From a7c249d526e82041457a35f50fae5c31d7620a42 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 16 Oct 2026 19:27:46 +0800 Subject: [PATCH 1/2] fix: surface snapshot schema admission failures during startup --- app/application.ml | 1 + ...026-10-16-snapshot-admission-failure-ui.md | 72 +++++++++++ logseq_db_storage/lib/admission.ml | 7 +- logseq_db_storage/lib/admission.mli | 5 +- .../lib/effect_runner/effect_runner.ml | 1 + logseq_db_worker/lib/pure_reducer/core.ml | 7 ++ logseq_db_worker/test/test_pure_reducer.ml | 113 +++++++++++++++++- logseq_overlay_db/lib/mirror.ml | 15 ++- .../test/test_overlay_storage.ml | 66 ++++++++++ .../lib/effect_runner/protocol/e2ee.ml | 8 +- logseq_sync/lib/pure_reducer/core.ml | 7 ++ logseq_sync/test/runner_contract.ml | 39 ++++-- test/application_view_test.ml | 23 +++- 13 files changed, 345 insertions(+), 19 deletions(-) create mode 100644 docs/agent-guide/implemented/bugfix/2026-10-16-snapshot-admission-failure-ui.md diff --git a/app/application.ml b/app/application.ml index bc4be9d6..a8976146 100644 --- a/app/application.ml +++ b/app/application.ml @@ -2560,6 +2560,7 @@ let diagnostic_phase_rows ~snapshot ~(graph : Logseq_db_worker.graph_state) = ; "Startup phase", startup_phase_name startup.phase ; "Graph phase", graph_phase_name graph.phase ] + @ Option.fold ~none:[] ~some:(fun message -> [ "Last error", message ]) snapshot.last_error ;; let format_bytes bytes = diff --git a/docs/agent-guide/implemented/bugfix/2026-10-16-snapshot-admission-failure-ui.md b/docs/agent-guide/implemented/bugfix/2026-10-16-snapshot-admission-failure-ui.md new file mode 100644 index 00000000..7bf12922 --- /dev/null +++ b/docs/agent-guide/implemented/bugfix/2026-10-16-snapshot-admission-failure-ui.md @@ -0,0 +1,72 @@ +# Snapshot admission failure feedback + +## Problem + +The downloaded graph snapshot is schema 65.29, below admission's minimum 65.33. +Snapshot activation rejects it before decryption, but the worker publishes only a +Diagnostic that the LUI service ignores. Startup remains Bootstrapping indefinitely. +The loading route and Diagnostics do not tell the user why opening stopped. + +## Decision + +Retain actual/minimum versions in the existing Unsupported_schema error at its +admission owner. Preserve a fixed, content-free rejection message through snapshot +preparation. Route the failed worker completion through the existing scoped +Snapshot_activation_failed event. Use the existing startup Failed page and render +the current snapshot error in Diagnostics. Do not migrate, delete, lower admission +requirements, change cloud data, or promise recovery. No spec/ or Dune edits. Cold-bootstrap Retry reselects its current graph when no +sync view exists; new graph selection clears obsolete failure facts. + +The user explicitly approved the actual/minimum payload in lib/admission.mli. +No other protected interface or build definitions changed. + +## Alternatives considered + +### Timeout detection + +A timeout guesses at the cause after production already returned a deterministic +error. Propagate that completion instead. Do not duplicate version constants in UI +or parse private snapshot metadata outside the admission owner for presentation. + +## Acceptance criteria + +- Current activation failure exits Bootstrapping and publishes the exact safe error. +- The loading route displays actual and minimum schema versions supplied by admission. +- Diagnostics exposes the current failure when diagnostic groups are empty. +- Success, explicit retry, duplicate completions, graph switches and late completions + preserve ownership and do not restore an obsolete failure. +- Tests are RED before implementation and GREEN afterward. Identify each production + owner first; use public pure worker events/completions for its propagation defect. + Admission message retention requires the public snapshot-preparation boundary; + Diagnostics visibility requires the existing application presentation boundary. +- Simulator acceptance uses an isolated synthetic graph/application and retains the + original Invest installation, process, snapshot and evidence unchanged. + +## Risks + +- Generic parse/SQLite errors may contain paths or data: expose only owner-authored, + fixed error messages and numeric schema metadata, never arbitrary parser strings. +- Experimental Simulator native restamping is not full cross-ABI validation. +- This UI repair does not make schema 65.29 compatible or verify iPhone recovery. + +## Consequences + +- A rejected snapshot produces a visible terminal error instead of indefinite + loading. Diagnostics includes its safe current reason even with empty groups. +- Parser/SQLite exception text is discarded at snapshot import; AES failure and + decrypted Transit parse errors also use fixed text at the E2EE decoder boundary. + Independent review identified this additional privacy boundary; its two real + public-runner regressions failed before sanitization and passed afterward. + Version rejection + is formatted from numeric metadata owned by admission. +- Retry repeats the opening attempt and may fail again with the same compatibility + error. It does not upgrade data. Choosing another graph clears the obsolete error. +- Complete native build and tests passed: 882 Alcotest cases and 87 custom checks. + Initial transport tests could not bind loopback ports inside the shell sandbox; + the full suite passed after the local-port run received approval. +- Two actual Simulator XCTest scenarios passed (19.433 seconds, zero failures): + error/retry/Diagnostics and switching to a supported schema snapshot. Their + acceptance uses synthetic authentication/transport with production DB/Core/Runner + and Application. It does not exercise cloud sync or prove iPhone recovery. +- The probe is an experimental host-restamped ARM64 object with + cross_abi_validated=false; it is not full iOS cross-compilation acceptance. diff --git a/logseq_db_storage/lib/admission.ml b/logseq_db_storage/lib/admission.ml index 9b7147cb..5f81eb90 100644 --- a/logseq_db_storage/lib/admission.ml +++ b/logseq_db_storage/lib/admission.ml @@ -14,7 +14,10 @@ type observed = } type error = - | Unsupported_schema + | Unsupported_schema of + { actual : Graph_types.schema_version + ; minimum : Graph_types.schema_version + } | Remote_graph | Ambiguous_sync_state | Unsupported_value @@ -83,7 +86,7 @@ let synced_facts graph_id (observed : observed) = let admit ~target (observed : observed) = if not (schema_is_supported observed.schema) - then Error Unsupported_schema + then Error (Unsupported_schema { actual = observed.schema; minimum = minimum_schema }) else if not observed.codec_lossless then Error Unsupported_value else if not observed.required_schema_present diff --git a/logseq_db_storage/lib/admission.mli b/logseq_db_storage/lib/admission.mli index ba90ad4d..880b356f 100644 --- a/logseq_db_storage/lib/admission.mli +++ b/logseq_db_storage/lib/admission.mli @@ -14,7 +14,10 @@ type observed = } type error = - | Unsupported_schema + | Unsupported_schema of + { actual : Graph_types.schema_version + ; minimum : Graph_types.schema_version + } | Remote_graph | Ambiguous_sync_state | Unsupported_value diff --git a/logseq_db_worker/lib/effect_runner/effect_runner.ml b/logseq_db_worker/lib/effect_runner/effect_runner.ml index 69b6f347..4ed23940 100644 --- a/logseq_db_worker/lib/effect_runner/effect_runner.ml +++ b/logseq_db_worker/lib/effect_runner/effect_runner.ml @@ -1588,6 +1588,7 @@ let handle_sync_worker_effect t = function ~expected_checksum:None ~expected_rows:(Sync.staged_artifact_expected_rows request.artifact) with + | Error (Overlay.Snapshot_parse_error message) -> Error (effect_error message) | Error _ -> Error (effect_error "The snapshot activation could not start.") | Ok prepared -> Fun.protect diff --git a/logseq_db_worker/lib/pure_reducer/core.ml b/logseq_db_worker/lib/pure_reducer/core.ml index 16020670..6e358507 100644 --- a/logseq_db_worker/lib/pure_reducer/core.ml +++ b/logseq_db_worker/lib/pure_reducer/core.ml @@ -458,6 +458,13 @@ let step state event = | Error error -> let message = Logseq_db_worker_contract.Error.message error in (match worker_effect with + | Some (Sync.Activate_snapshot request) -> + translate_sync + (Sync.step + state.sync_core + (Sync.Snapshot_activation_failed + { scope = Sync.effect_scope_of_graph request.scope; message })) + state | Some (Sync.Apply_authoritative_batch request) -> let transitioned = translate_sync diff --git a/logseq_db_worker/test/test_pure_reducer.ml b/logseq_db_worker/test/test_pure_reducer.ml index 71feb55e..b5dff081 100644 --- a/logseq_db_worker/test/test_pure_reducer.ml +++ b/logseq_db_worker/test/test_pure_reducer.ml @@ -979,10 +979,121 @@ let test_upload_recovery_failure () = (Core.upload_recovery_current stale.next fresh) ;; +(* The worker owns translating its runner's terminal result into Sync state. + Drive a complete cold bootstrap using only public events and issued tickets. *) +let snapshot_activation () = + let graph : Sync.graph = + { graph_id = uuid "71111111-1111-4111-8111-111111111111" + ; name = "Synthetic schema fixture" + ; schema = { major = 65; minor = 29; exact = true } + ; encrypted = false + } + in + let auth = Core.step (initial ()) + (Sync_event (Sync.Account_authenticated { user_id = Some "synthetic" })) in + let catalog = List.find_map (function + | Core.Run_sync (Sync.Request (ticket, Sync.Fetch_catalog _)) -> + Some (Core.step auth.next (Sync_event + (Sync.Runner_completed (Sync.Completion (ticket, Ok [graph]))))) + | _ -> None) auth.effects |> Option.get in + let selected = Core.step catalog.next (Sync_event (Sync.Graph_selected graph.graph_id)) in + let missing, scope = List.find_map (function + | Core.Run_worker (Core.Request (ticket, + Core.Handle_sync_worker_effect (Sync.Inspect_mirror request))) -> + Some (Core.step selected.next (Runner_completed + (Sync_worker_effect_completed (ticket, Ok + { event = Some (Sync.Mirror_inspected (Mirror_absent request.scope)) + ; lifecycle = Lifecycle_unchanged }))), request.scope) + | _ -> None) selected.effects |> Option.get in + let baseline = List.find_map (function + | Core.Run_sync (Sync.Request (ticket, Sync.Fetch_snapshot_baseline _)) -> + Some (Core.step missing.next (Sync_event (Sync.Runner_completed + (Sync.Completion (ticket, Ok {|{"type":"pull/ok","t":42}|}))))) + | _ -> None) missing.effects |> Option.get in + let download = List.find_map (function + | Core.Run_sync (Sync.Request (ticket, Sync.Fetch_snapshot_metadata _)) -> + Some (Core.step baseline.next (Sync_event (Sync.Runner_completed + (Sync.Completion (ticket, Ok {|{"ok":true,"url":"https://example.invalid/snapshot"}|}))))) + | _ -> None) baseline.effects |> Option.get in + let activation = List.find_map (function + | Core.Run_sync (Sync.Request (ticket, Sync.Download_snapshot _)) -> + let artifact = Sync.staged_artifact ~id:"synthetic" ~scope + ~path:"/tmp/synthetic-schema.snapshot" ~expected_rows:1 in + Some (Core.step download.next (Sync_event (Sync.Runner_completed + (Sync.Completion (ticket, Ok artifact))))) + | _ -> None) download.effects |> Option.get in + let error = Logseq_db_worker.Error.create ~code:Closed_session + ~message:"Graph schema 65.29 is not supported. Journal requires schema 65.33 or later." + ~details:[] |> Result.get_ok in + let failure, success = List.find_map (function + | Core.Run_worker (Core.Request (ticket, + Core.Handle_sync_worker_effect (Sync.Activate_snapshot request))) -> + Some (Core.Runner_completed (Sync_worker_effect_completed (ticket, Error error)), + Core.Runner_completed (Sync_worker_effect_completed (ticket, Ok + { event = Some (Sync.Snapshot_activated {scope = request.scope}) + ; lifecycle = Lifecycle_unchanged }))) + | _ -> None) activation.effects |> Option.get in + activation, failure, success, Logseq_db_worker.Error.message error +;; + +let test_snapshot_failure_is_terminal_and_retryable () = + let activation, failure, _, message = snapshot_activation () in + let failed = Core.step activation.next failure in + let snapshot = (Core.view failed.next).sync.snapshot in + Alcotest.(check bool) "failure terminates bootstrap" true + (snapshot.startup.failure = Some Sync.During_bootstrap && snapshot.sync_phase = Sync.Failed); + Alcotest.(check (option string)) "specific reason retained" (Some message) snapshot.last_error; + Alcotest.(check bool) "failure published to UI" true + (List.exists (function Core.Publish (Sync_output (Sync.State_changed state)) -> + state.snapshot.last_error = Some message | _ -> false) failed.effects); + let duplicate = Core.step failed.next failure in + Alcotest.(check bool) "duplicate completion inert" true + (Core.equal_view (Core.view failed.next) (Core.view duplicate.next) && duplicate.effects = []); + let retry = Core.step failed.next (Sync_event Sync.Online_recovery_requested) in + Alcotest.(check (option string)) "explicit retry clears failure" None + (Core.view retry.next).sync.snapshot.last_error; + let late = Core.step retry.next failure in + Alcotest.(check bool) "old failure cannot poison retry" true + (Core.equal_view (Core.view retry.next) (Core.view late.next) && late.effects = []) +;; + +let test_snapshot_success_and_late_switch_completions () = + let activation, failure, success, _ = snapshot_activation () in + let succeeded = Core.step activation.next success in + let attaching = List.find_map (function + | Core.Run_worker (Core.Request (ticket, + Core.Handle_sync_worker_effect (Sync.Inspect_mirror request))) -> + Some (Core.step succeeded.next (Runner_completed (Sync_worker_effect_completed + (ticket, Ok { event = Some (Sync.Mirror_inspected (Mirror_available request)) + ; lifecycle = Lifecycle_unchanged })))) + | _ -> None) succeeded.effects |> Option.get in + Alcotest.(check bool) "success proceeds to attachment" true + (List.exists (function Core.Run_worker (Core.Request (_, + Core.Handle_sync_worker_effect (Sync.Attach_graph _))) -> true | _ -> false) attaching.effects); + Alcotest.(check (option string)) "success has no failure" None + (Core.view succeeded.next).sync.snapshot.last_error; + let switched = Core.step activation.next (Sync_event Sync.Graph_picker_requested) in + List.iter (fun completion -> + let late = Core.step switched.next completion in + Alcotest.(check bool) "retired graph keeps selected state" true + ((Core.view switched.next).sync = (Core.view late.next).sync); + Alcotest.(check bool) "retired graph keeps lifecycle" true + ((Core.view switched.next).graph = (Core.view late.next).graph); + let work = List.filter (function + | Core.Run_worker (Core.Request (_, Core.Handle_sync_worker_effect (Sync.Detach_graph _))) -> false + | _ -> true) late.effects in + Alcotest.(check (list string)) "retired graph emits only pending close cleanup" [] + (List.map Core.instruction_diagnostic work)) + [failure; success] +;; + let () = Alcotest.run "logseq db worker pure reducer" - [ ( "asset session" + [ ( "snapshot failure", [ + Alcotest.test_case "terminal failure and explicit retry" `Quick test_snapshot_failure_is_terminal_and_retryable; + Alcotest.test_case "success and late graph switch completions" `Quick test_snapshot_success_and_late_switch_completions ]) + ; ( "asset session" , [ Alcotest.test_case "bounded upload recovery" `Quick test_upload_recovery ; Alcotest.test_case "upload recovery backpressure" diff --git a/logseq_overlay_db/lib/mirror.ml b/logseq_overlay_db/lib/mirror.ml index 9003f7f2..fb419ac8 100644 --- a/logseq_overlay_db/lib/mirror.ml +++ b/logseq_overlay_db/lib/mirror.ml @@ -119,9 +119,18 @@ let inspect_database database_path graph_id = ~storage_schema:startup.schema with | Ok admitted -> Ok (database, admitted.schema, callbacks) - | Error _ -> + | Error error -> ignore (Storage.close callbacks); - Error "snapshot graph admission failed") + Error + (match error with + | Unsupported_schema { actual; minimum } -> + Printf.sprintf + "Graph schema %d.%d is not supported. Journal requires schema %d.%d or later." + actual.major actual.minor minimum.major minimum.minor + | Remote_graph -> "The downloaded graph has an unexpected remote identity." + | Ambiguous_sync_state -> "The downloaded graph has an invalid synchronization identity." + | Unsupported_value -> "The downloaded graph contains unsupported values." + | Corrupt_storage -> "The downloaded graph storage is corrupt.")) | Error _, _ | _, Error _ -> ignore (Storage.close callbacks); Error "snapshot SQLite storage is corrupt") @@ -137,7 +146,7 @@ let stage ~root ~graph_id ~snapshot_path ~expected_rows = Error message in match import_snapshot_rows ~snapshot_path ~expected_rows database_path with - | Error message -> cleanup message + | Error _ -> cleanup "The downloaded graph snapshot could not be imported." | Ok () -> (match inspect_database database_path graph_id with | Error message -> cleanup message diff --git a/logseq_overlay_db/test/test_overlay_storage.ml b/logseq_overlay_db/test/test_overlay_storage.ml index c2bc8f9b..8c4d2991 100644 --- a/logseq_overlay_db/test/test_overlay_storage.ml +++ b/logseq_overlay_db/test/test_overlay_storage.ml @@ -208,6 +208,64 @@ let snapshot_input_validation_is_fail_closed () = "invalid snapshot input allocated staging resources") ;; +(* Admission has no reducer boundary: exercise the public preparation operation + on a real synthetic framed snapshot rather than injecting a parse error. *) +let snapshot_rejection_preserves_schema_versions () = + let module Storage = Logseq_db_storage.Logseq_sqlite_storage in + let module Session = Logseq_db_storage.Storage_session in + List.iter (fun (major, minor) -> + T.with_temp_directory "overlay-old-schema-" (fun root -> + let source = Filename.concat root "source" in + Unix.mkdir source 0o700; + let database_path = T.seed_mirror source in + let connection = Storage.open_database database_path |> T.require_ok ~behavior:"open synthetic schema" in + let db = Storage.restore_database connection |> T.require_ok ~behavior:"restore synthetic schema" in + let session = Session.create ~tail:(Datascript.Storage.restore_tail_groups (Storage.datascript_storage connection)) + ~callbacks:(Storage.connection_callbacks connection) in + Fun.protect ~finally:(fun () -> ignore (Session.close session)) (fun () -> + let staged = Session.stage_transact session ~authoritative_before:db + [Datascript.Add (Ident "logseq.kv/schema-version", "kv/value", + Map [Keyword "major", Int64 (Int64.of_int major); Keyword "minor", Int64 (Int64.of_int minor)])] + |> T.require_ok ~behavior:"stage old synthetic schema" in + Session.commit_staged session staged |> T.require_ok ~behavior:"commit old synthetic schema"); + let path = Filename.concat root "snapshot.transit" in + let expected_rows = T.write_snapshot_from_database ~database_path ~snapshot_path:path in + let support = Filename.concat root "target" in + Unix.mkdir support 0o700; + let dependencies, _, inspection = absent_inspection ~behavior:"old schema admission" support in + match Database.prepare_snapshot_activation dependencies inspection ~path + ~applied_server_cursor:(server_cursor 0) ~expected_checksum:None ~expected_rows with + | Error (Snapshot_parse_error message) -> + Alcotest.(check string) "version evidence preserved at admission boundary" + (Printf.sprintf "Graph schema %d.%d is not supported. Journal requires schema 65.33 or later." major minor) + message + | Error _ -> Alcotest.fail "old schema returned a different error category" + | Ok prepared -> Database.cancel_snapshot_activation prepared; + Alcotest.fail "unsupported schema was admitted")) + [65, 29; 64, 200] +;; + +let snapshot_import_error_does_not_disclose_payload () = + T.with_temp_directory "overlay-invalid-payload-" (fun root -> + let support = Filename.concat root "target" in + Unix.mkdir support 0o700; + let dependencies, _, inspection = absent_inspection ~behavior:"safe import rejection" support in + let path = Filename.concat root "snapshot.transit" in + let payload = {|{"SENSITIVE-synthetic-body":invalid}|} in + let header = Bytes.create 4 in + Bytes.set_int32_be header 0 (Int32.of_int (String.length payload)); + Out_channel.with_open_bin path (fun channel -> + output_bytes channel header; output_string channel payload); + match Database.prepare_snapshot_activation dependencies inspection ~path + ~applied_server_cursor:(server_cursor 0) ~expected_checksum:None ~expected_rows:1 with + | Error (Snapshot_parse_error message) -> + Alcotest.(check string) "raw parser payload is not user-facing" + "The downloaded graph snapshot could not be imported." message + | Error _ -> Alcotest.fail "malformed snapshot returned another category" + | Ok prepared -> Database.cancel_snapshot_activation prepared; + Alcotest.fail "malformed snapshot was admitted") +;; + let snapshot_activation_installs_queryable_mirror () = let behavior = "snapshot activation installs one queryable mirror" in T.with_temp_directory "overlay-snapshot-activation-" (fun root -> @@ -2706,6 +2764,14 @@ let cases = "snapshot input validation is fail closed" `Quick snapshot_input_validation_is_fail_closed + ; Alcotest.test_case + "snapshot rejection preserves actual and minimum versions" + `Quick + snapshot_rejection_preserves_schema_versions + ; Alcotest.test_case + "snapshot import error excludes raw payload" + `Quick + snapshot_import_error_does_not_disclose_payload ; Alcotest.test_case "snapshot activation installs a queryable mirror" `Quick diff --git a/logseq_sync/lib/effect_runner/protocol/e2ee.ml b/logseq_sync/lib/effect_runner/protocol/e2ee.ml index aa038281..bd4da370 100644 --- a/logseq_sync/lib/effect_runner/protocol/e2ee.ml +++ b/logseq_sync/lib/effect_runner/protocol/e2ee.ml @@ -102,7 +102,11 @@ let decrypt_value ~crypto ~graph_key source = match protect "decode protected value" (fun () -> Codec.of_string source) with | Error _ -> Ok (Transit.String source) | Ok (Transit.Array [ Transit.Binary iv; Transit.Binary ciphertext ]) -> - bind (crypto.decrypt_aes_gcm ~key:graph_key ~iv ~ciphertext) (fun plaintext -> - protect "decode decrypted value" (fun () -> Codec.of_string plaintext)) + (match crypto.decrypt_aes_gcm ~key:graph_key ~iv ~ciphertext with + | Error _ -> Error "The graph value could not be decrypted." + | Ok plaintext -> + (match protect "decode decrypted value" (fun () -> Codec.of_string plaintext) with + | Ok value -> Ok value + | Error _ -> Error "The decrypted graph value is not valid Transit.")) | Ok plaintext -> Ok plaintext ;; diff --git a/logseq_sync/lib/pure_reducer/core.ml b/logseq_sync/lib/pure_reducer/core.ml index a7eb9c4b..14fe76eb 100644 --- a/logseq_sync/lib/pure_reducer/core.ml +++ b/logseq_sync/lib/pure_reducer/core.ml @@ -1303,6 +1303,9 @@ let select_graph_transition core ~persist graph_id = { core.public_state.snapshot.startup with awaiting_selection = false ; restoring_local = true + ; bootstrapping = false + ; awaiting_e2ee_password = false + ; failure = None ; graph_generation } in @@ -1310,6 +1313,8 @@ let select_graph_transition core ~persist graph_id = { core.public_state.snapshot with selected_graph = Some graph_id ; applied_server_t = None + ; sync_phase = Offline + ; last_error = None ; startup } in @@ -2842,6 +2847,8 @@ let step core event = { next; effects = [ Run request ] }) | Online_recovery_requested -> (match core.current_graph_scope, core.public_state.snapshot.startup.failure with + | Some scope, Some During_bootstrap when core.sync_view = None -> + select_graph_transition core ~persist:false scope.graph_id | Some scope, Some (During_local_restore | During_e2ee) when not core.public_state.snapshot.startup.awaiting_e2ee_password -> let startup = diff --git a/logseq_sync/test/runner_contract.ml b/logseq_sync/test/runner_contract.ml index f4f99ac8..95159923 100644 --- a/logseq_sync/test/runner_contract.ml +++ b/logseq_sync/test/runner_contract.ml @@ -410,7 +410,8 @@ let protected_run runner ~tasks ~posted core operation scope action = | _ -> fail "protected request did not resolve through Core output" ;; -let test_cached_wrapped_key_is_unlocked_before_protected_value_decryption () = +let test_cached_wrapped_key_is_unlocked_before_protected_value_decryption + ?(malformed_plaintext = false) ?(decryption_failure = false) () = with_support (fun support -> Eio_main.run (fun environment -> Eio.Switch.run (fun sw -> @@ -447,7 +448,11 @@ let test_cached_wrapped_key_is_unlocked_before_protected_value_decryption () = Error "encryption was not expected") ~decrypt_aes_gcm:(fun ~key ~iv:_ ~ciphertext:_ -> if String.equal key raw_key - then + then if decryption_failure + then Error "SENSITIVE-synthetic-crypto-error" + else if malformed_plaintext + then Ok {|"~XSENSITIVE-synthetic-body"|} + else Ok (Transit_native.Transit.Json.to_string (Transit_core.Json.String "plaintext")) @@ -502,18 +507,23 @@ let test_cached_wrapped_key_is_unlocked_before_protected_value_decryption () = | [ event ] -> (Core.step before.next event).next | _ -> fail "missing key completion" in - Alcotest.(check bool) - "returned handle decrypts with the raw graph key" - true - (protected_run + let result = protected_run runner ~tasks ~posted unlocked "decrypt-cached" (Core.graph_key_handle_scope handle) - (Core.Decrypt_value (handle, ciphertext)) - = Ok (Core.Decrypted_value "plaintext"))))) + (Core.Decrypt_value (handle, ciphertext)) in + if decryption_failure then + Alcotest.(check bool) "crypto error excludes sensitive detail" true + (result = Error (Core.Effect_failed "The graph value could not be decrypted.")) + else if malformed_plaintext then + Alcotest.(check bool) "decrypted parser error excludes plaintext" true + (result = Error (Core.Effect_failed "The decrypted graph value is not valid Transit.")) + else + Alcotest.(check bool) "returned handle decrypts with the raw graph key" true + (result = Ok (Core.Decrypted_value "plaintext"))))) ;; let test_cached_wrapped_key_unlock_failure_is_fail_closed () = @@ -931,7 +941,18 @@ let scenarios = ; Alcotest.test_case "cached wrapped key is unlocked before AES" `Quick - test_cached_wrapped_key_is_unlocked_before_protected_value_decryption + (test_cached_wrapped_key_is_unlocked_before_protected_value_decryption + ~malformed_plaintext:false) + ; Alcotest.test_case + "decrypted parser error excludes plaintext" + `Quick + (test_cached_wrapped_key_is_unlocked_before_protected_value_decryption + ~malformed_plaintext:true) + ; Alcotest.test_case + "crypto error excludes sensitive detail" + `Quick + (test_cached_wrapped_key_is_unlocked_before_protected_value_decryption + ~decryption_failure:true) ; Alcotest.test_case "cached wrapped key unlock failure is fail-closed" `Quick diff --git a/test/application_view_test.ml b/test/application_view_test.ml index bf33f3c9..428bb901 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -117,6 +117,26 @@ let test_missing_snapshot_keeps_graph_lifecycle_visible () = (Application.diagnostic_phase_rows ~snapshot:None ~graph) ;; +let test_diagnostic_phase_rows_retain_current_error () = + let message = "Graph schema 65.29 is not supported. Journal requires schema 65.33 or later." in + let snapshot : Service.snapshot = + { sync_phase = Failed; catalog = []; selected_graph = None; applied_server_t = None + ; timeline_presentation_pending = false; local_deletion = None; last_error = Some message + ; startup = { authenticated = true; catalog_loading = false; awaiting_selection = false + ; restoring_local = false; bootstrapping = true; awaiting_e2ee_password = false + ; failure = Some During_bootstrap; account_generation = 1; graph_generation = 1 + ; presentation_generation = 1 } } in + let graph : Logseq_db_worker.graph_state = + { generation = 1; graph_id = None; phase = Graph_closed; error = None } in + let rows = Application.diagnostic_phase_rows ~snapshot:(Some snapshot) ~graph in + Alcotest.(check (option string)) "current error visible without diagnostic groups" + (Some message) (List.assoc_opt "Last error" rows); + let cleared = Application.diagnostic_phase_rows + ~snapshot:(Some {snapshot with last_error = None}) ~graph in + Alcotest.(check bool) "retry does not retain obsolete error" false + (List.mem_assoc "Last error" cleared) +;; + let admission_inspection : Logseq_db_worker.Protocol.v2_admission_inspection = { active_records = 3 ; active_bytes = 1536 @@ -5488,7 +5508,8 @@ let () = Journal_bridge.register hooks) else Alcotest.run "application view" - [ ( "direct delete", List.map (fun scenario -> + [ ( "diagnostic failure", [ Alcotest.test_case "current error and clearing" `Quick test_diagnostic_phase_rows_retain_current_error ]) + ; ( "direct delete", List.map (fun scenario -> Alcotest.test_case scenario `Quick (fun () -> run_favorites_native_visibility ~delete_scenario:scenario ())) ["success"; "failure"]) ; ( "status list", List.map (fun scenario -> From 0cfa3fb1367c48839c34cfbacd7a634811dbdb51 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 16 Oct 2026 20:12:12 +0800 Subject: [PATCH 2/2] fix: prepare encryption password challenge after cached key miss --- ...026-10-16-snapshot-admission-failure-ui.md | 53 ++++++-- logseq_sync/lib/pure_reducer/core.ml | 28 ++++- logseq_sync/test/core_contract.ml | 118 +++++++++++++++++- .../local_restore_failure_reconciliation.ml | 28 +++-- logseq_sync/test/runner_contract.ml | 13 +- logseq_sync/test/transport_contract.ml | 2 +- 6 files changed, 205 insertions(+), 37 deletions(-) diff --git a/docs/agent-guide/implemented/bugfix/2026-10-16-snapshot-admission-failure-ui.md b/docs/agent-guide/implemented/bugfix/2026-10-16-snapshot-admission-failure-ui.md index 7bf12922..6d63d36c 100644 --- a/docs/agent-guide/implemented/bugfix/2026-10-16-snapshot-admission-failure-ui.md +++ b/docs/agent-guide/implemented/bugfix/2026-10-16-snapshot-admission-failure-ui.md @@ -1,4 +1,4 @@ -# Snapshot admission failure feedback +# Graph startup failure and unlock recovery ## Problem @@ -6,6 +6,9 @@ The downloaded graph snapshot is schema 65.29, below admission's minimum 65.33. Snapshot activation rejects it before decryption, but the worker publishes only a Diagnostic that the LUI service ignores. Startup remains Bootstrapping indefinitely. The loading route and Diagnostics do not tell the user why opening stopped. +Separately, a first cached wrapped-key miss enters Failed even though the existing +online preparation could immediately obtain a valid password challenge; users +have to press Retry before reaching the encryption-password form. ## Decision @@ -14,12 +17,20 @@ admission owner. Preserve a fixed, content-free rejection message through snapsh preparation. Route the failed worker completion through the existing scoped Snapshot_activation_failed event. Use the existing startup Failed page and render the current snapshot error in Diagnostics. Do not migrate, delete, lower admission -requirements, change cloud data, or promise recovery. No spec/ or Dune edits. Cold-bootstrap Retry reselects its current graph when no -sync view exists; new graph selection clears obsolete failure facts. +requirements, change cloud data, or promise recovery. No spec/ or Dune edits. +Cold-bootstrap Retry reselects its current graph when no sync view exists; new graph selection clears obsolete failure facts. The user explicitly approved the actual/minimum payload in lib/admission.mli. No other protected interface or build definitions changed. +The user additionally requested that an unavailable cached wrapped key begin the +existing online key preparation automatically, once per open attempt. The Core +owns this transition: it must obtain both encrypted materials before publishing a +password challenge; network/authentication failure stays visible with explicit +Retry and graph-selection exits. Cancellation, graph re-entry and late results +must not resurrect an obsolete challenge. A wrong password remains retryable. +This reuses existing events and errors without another interface change. + ## Alternatives considered ### Timeout detection @@ -33,6 +44,13 @@ or parse private snapshot metadata outside the admission owner for presentation. - Current activation failure exits Bootstrapping and publishes the exact safe error. - The loading route displays actual and minimum schema versions supplied by admission. - Diagnostics exposes the current failure when diagnostic groups are empty. +- A cached-key miss starts one online preparation without Retry; neither material + alone advertises a password challenge. Remote network/authentication failure is + visible and does not automatically retry; explicit Retry and graph selection work. +- Cancellation/re-entry retires old key-access tickets, including account-material + and private-unlock work scoped to the graph challenge. Duplicate recovery does + not start unrelated sync inspection. Wrong-password resubmission remains usable. +- Same-account catalog reconciliation preserves an E2EE preparation failure. - Success, explicit retry, duplicate completions, graph switches and late completions preserve ownership and do not restore an obsolete failure. - Tests are RED before implementation and GREEN afterward. Identify each production @@ -46,6 +64,9 @@ or parse private snapshot metadata outside the admission owner for presentation. - Generic parse/SQLite errors may contain paths or data: expose only owner-authored, fixed error messages and numeric schema metadata, never arbitrary parser strings. +- Cached-key failures lack a typed reason at the Core boundary; automatic online + recovery is therefore bounded to the failed local key-access operation, without + parsing error strings. Remote preparation failure must never loop automatically. - Experimental Simulator native restamping is not full cross-ABI validation. - This UI repair does not make schema 65.29 compatible or verify iPhone recovery. @@ -57,16 +78,30 @@ or parse private snapshot metadata outside the admission owner for presentation. decrypted Transit parse errors also use fixed text at the E2EE decoder boundary. Independent review identified this additional privacy boundary; its two real public-runner regressions failed before sanitization and passed afterward. - Version rejection - is formatted from numeric metadata owned by admission. + Version rejection is formatted from numeric metadata owned by admission. +- A missing cached key now directly starts one online material preparation. A + remote failure remains visible with explicit recovery, and same-account catalog + reconciliation cannot discard it. New selection retires old key-access tickets; + challenge work using shared account credentials still carries its graph scope. +- Opening the graph picker alone retains the current session. Selecting that same + graph resumes its valid password challenge and previous error; it must remain + editable and resubmittable. Choosing a different graph discards the old challenge. - Retry repeats the opening attempt and may fail again with the same compatibility error. It does not upgrade data. Choosing another graph clears the obsolete error. -- Complete native build and tests passed: 882 Alcotest cases and 87 custom checks. +- Complete native build and tests passed: 898 Alcotest cases and 87 custom checks. Initial transport tests could not bind loopback ports inside the shell sandbox; the full suite passed after the local-port run received approval. -- Two actual Simulator XCTest scenarios passed (19.433 seconds, zero failures): - error/retry/Diagnostics and switching to a supported schema snapshot. Their - acceptance uses synthetic authentication/transport with production DB/Core/Runner +- Five actual Simulator XCTest scenarios passed (79.998 seconds, zero failures): + direct password/wrong-password/current-session resume; bounded network/auth + failure recovery; switching graphs before late key material; schema rejection/ + Retry/Diagnostics; and switching to a supported snapshot. The first UI run's + expectation that selecting the current graph clears its error was corrected to + the existing session-preservation contract, then all five scenarios passed. + Acceptance uses synthetic authentication/transport with production DB/Core/Runner and Application. It does not exercise cloud sync or prove iPhone recovery. +- Fifteen new Core regressions cover automatic preparation, missing-material + guards, bounded failure and success/error completions after cancel/re-entry. + Existing runner/transport fixtures consume the new automatic transition; no + new duplicate regression coverage was added outside the owning pure reducer. - The probe is an experimental host-restamped ARM64 object with cross_abi_validated=false; it is not full iOS cross-compilation acceptance. diff --git a/logseq_sync/lib/pure_reducer/core.ml b/logseq_sync/lib/pure_reducer/core.ml index 14fe76eb..76b5b07f 100644 --- a/logseq_sync/lib/pure_reducer/core.ml +++ b/logseq_sync/lib/pure_reducer/core.ml @@ -973,7 +973,12 @@ let issue_request : type a. t -> a runner_request -> t * runner_effect = fun core request -> let ticket = { id = core.next_effect_id - ; scope = scope_of_request request + ; scope = + (match request, core.snapshot_scope with + | (Fetch_e2ee_user_keys _ | Unlock_private_key _), Some graph -> + (* Shared account material is requested for this graph's challenge. *) + effect_scope_of_graph graph + | _ -> scope_of_request request) ; kind = request_kind request } in @@ -1139,7 +1144,7 @@ let authenticate_new_account core user_id = let clear_reconciliation_failure (snapshot : snapshot) = match snapshot.startup.failure with - | Some During_local_restore -> snapshot + | Some (During_local_restore | During_e2ee) -> snapshot | None | Some _ -> { snapshot with startup = { snapshot.startup with failure = None } @@ -1295,6 +1300,16 @@ let graph_in_catalog graphs graph_id = graphs ;; +let is_graph_key_access (Pending_effect ticket) = + match ticket.kind with + | Load_and_unlock_graph_key_kind + | Fetch_e2ee_graph_key_kind + | Fetch_e2ee_user_keys_kind + | Unlock_private_key_kind + | Fetch_and_unlock_graph_key_kind -> true + | _ -> false +;; + let select_graph_transition core ~persist graph_id = match core.user_id, graph_in_catalog core.public_state.snapshot.catalog graph_id with | Some user_id, Some graph -> @@ -1324,6 +1339,7 @@ let select_graph_transition core ~persist graph_id = set_snapshot { core with cached_selected_graph = Some graph_id + ; pending_effects = List.filter (fun pending -> not (is_graph_key_access pending)) core.pending_effects ; selected_graph_value = Some graph ; current_graph_scope = Some scope ; graph_key = None @@ -2564,7 +2580,10 @@ let consume_completion core = function } During_catalog message - | Load_and_unlock_graph_key_kind -> fail core During_local_restore message + | Load_and_unlock_graph_key_kind -> + (match core.current_graph_scope with + | Some scope -> begin_e2ee_key_access core scope + | None -> unchanged core) | Unlock_private_key_kind -> (* A rejected password leaves the encrypted challenge available for another attempt. No password or unlocked key is retained. *) @@ -2849,6 +2868,9 @@ let step core event = (match core.current_graph_scope, core.public_state.snapshot.startup.failure with | Some scope, Some During_bootstrap when core.sync_view = None -> select_graph_transition core ~persist:false scope.graph_id + | Some _, _ + when core.public_state.snapshot.startup.awaiting_e2ee_password + || List.exists is_graph_key_access core.pending_effects -> unchanged core | Some scope, Some (During_local_restore | During_e2ee) when not core.public_state.snapshot.startup.awaiting_e2ee_password -> let startup = diff --git a/logseq_sync/test/core_contract.ml b/logseq_sync/test/core_contract.ml index c2bc18f2..98f52f55 100644 --- a/logseq_sync/test/core_contract.ml +++ b/logseq_sync/test/core_contract.ml @@ -603,7 +603,7 @@ let warm_encrypted_graph_online_recovery_attaches_existing_mirror () = inspected.effects |> Option.get in - let recovery = Core.step failed.next Core.Online_recovery_requested in + let recovery = failed in let user_key_fetch = List.find_map (function @@ -690,7 +690,7 @@ let e2ee_password_retry_recovers_after_failed_unlock () = missing.effects |> Option.get in - let recovery = Core.step failed_cache.next Core.Online_recovery_requested in + let recovery = failed_cache in let user_key_fetch = List.find_map (function @@ -879,7 +879,7 @@ let encrypted_graph_recovery_fetches_and_unlocks_key_before_bootstrap () = missing.effects |> Option.get in - let recovery = Core.step failed.next Core.Online_recovery_requested in + let recovery = failed in let user_key_fetch = List.find_map (function @@ -5053,7 +5053,7 @@ let e2ee_preparation_fixture () = Some (Core.step absent.next (Core.Runner_completed (Core.Completion (ticket, Error (Core.Effect_failed "synthetic absent cache"))))) | _ -> None) absent.effects |> Option.get in - Core.step cached.next Core.Online_recovery_requested, scope + cached, scope ;; let e2ee_finish_graph_fetch preparation (result : (string, Core.effect_error) result) = @@ -5180,3 +5180,113 @@ let scenarios = snapshot_metadata_open_fields_keep_declared_constraints ] ;; + + +(* A missing wrapped-key cache is a valid local-access failure. Core owns whether + to fetch the challenge or advertise a terminal failure; no runner/UI replica. *) +let e2ee_auto_preparation () = + let selected, scope = selected_graph encrypted_graph in + let absent = Core.step selected.next (Core.Mirror_inspected (Mirror_absent scope)) in + let completion = List.find_map (function + | Core.Run (Core.Request (ticket, Core.Load_and_unlock_graph_key _)) -> + Some (Core.Runner_completed (Core.Completion + (ticket, Error (Core.Effect_failed "wrappedGraphKeyUnavailable")))) + | _ -> None) absent.effects |> Option.get in + Core.step absent.next completion, scope, completion +;; + +let e2ee_missing_cache_prepares_without_retry () = + let preparation, scope, missing = e2ee_auto_preparation () in + let snapshot = (Core.state preparation.next).snapshot in + Alcotest.(check bool) "cache miss is not a terminal UI failure" true + (snapshot.startup.failure = None && snapshot.last_error = None); + Alcotest.(check bool) "no password prompt before materials exist" false + snapshot.startup.awaiting_e2ee_password; + Alcotest.(check int) "one automatic graph-key fetch" 1 + (List.length (List.filter (function + | Core.Run (Core.Request (_, Core.Fetch_e2ee_graph_key actual)) -> actual = scope + | _ -> false) preparation.effects)); + let repeated = Core.step preparation.next Core.Online_recovery_requested in + Alcotest.(check int) "recovery during preparation is inert" 0 + (List.length repeated.effects); + let duplicate = Core.step preparation.next missing in + Alcotest.(check int) "duplicate cache completion starts no work" 0 + (List.length duplicate.effects); + let user_fetch = e2ee_finish_graph_fetch preparation (Ok "wrapped") in + Alcotest.(check bool) "graph key alone cannot advertise password" false + (Core.state user_fetch.next).snapshot.startup.awaiting_e2ee_password; + let prompt = e2ee_finish_user_fetch user_fetch (Ok "private-package") in + Alcotest.(check bool) "both materials directly advertise password" true + (Core.state prompt.next).snapshot.startup.awaiting_e2ee_password; + Alcotest.(check bool) "password prompt has no obsolete failure" true + ((Core.state prompt.next).snapshot.startup.failure = None) +;; + +let e2ee_auto_preparation_failure_is_bounded fetch_user message () = + let preparation, _, _ = e2ee_auto_preparation () in + let failed = if fetch_user then + e2ee_finish_user_fetch (e2ee_finish_graph_fetch preparation (Ok "wrapped")) + (Error (Core.Effect_failed message)) + else e2ee_finish_graph_fetch preparation (Error (Core.Effect_failed message)) in + let snapshot = (Core.state failed.next).snapshot in + Alcotest.(check bool) "remote error is visible" true + (snapshot.startup.failure = Some Core.During_e2ee && snapshot.last_error = Some message); + Alcotest.(check bool) "missing challenge never shows password" false + snapshot.startup.awaiting_e2ee_password; + Alcotest.(check bool) "failure does not automatically retry" false + (List.exists (function Core.Run _ -> true | _ -> false) failed.effects); + let blocked = Core.step failed.next (Core.E2ee_password_submitted "unused") in + Alcotest.(check int) "no unusable password submission" 0 (List.length blocked.effects); + let retry = Core.step failed.next Core.Online_recovery_requested in + Alcotest.(check bool) "explicit Retry can reacquire materials" true + (List.exists (function Core.Run (Core.Request (_, Core.Fetch_e2ee_graph_key _)) -> true + | _ -> false) retry.effects); + let picker = Core.step failed.next Core.Graph_picker_requested in + Alcotest.(check bool) "failure can return to graph selection" true + (Core.state picker.next).snapshot.startup.awaiting_selection +;; + +let e2ee_late_preparation_cannot_reopen_challenge stage cancel result () = + let preparation, scope, _ = e2ee_auto_preparation () in + let flight, complete = + if stage = 0 then preparation, (fun origin -> + let finished = e2ee_finish_graph_fetch { preparation with next = origin } result in + finished) + else let user_fetch = e2ee_finish_graph_fetch preparation (Ok "old-wrapped") in + if stage = 1 then user_fetch, (fun origin -> + e2ee_finish_user_fetch { user_fetch with next = origin } result) + else let prompt = e2ee_finish_user_fetch user_fetch (Ok "old-package") in + let unlocking = Core.step prompt.next (Core.E2ee_password_submitted "synthetic") in + let completion = List.find_map (function + | Core.Run (Core.Request (ticket, Core.Unlock_private_key _)) -> + Some (Core.Runner_completed (Core.Completion (ticket, + match result with Ok _ -> Ok () | Error e -> Error e))) + | _ -> None) unlocking.effects |> Option.get in + unlocking, (fun origin -> Core.step origin completion) + in + let left = Core.step flight.next + (if cancel then Core.Graph_picker_requested else Core.Graph_selected scope.graph_id) in + let before = Core.state left.next in + let late = complete left.next in + Alcotest.(check bool) "late completion preserves current public state" true + (before = Core.state late.next); + Alcotest.(check int) "late completion starts no next phase" 0 (List.length late.effects); + let submit = Core.step late.next (Core.E2ee_password_submitted "abandoned") in + Alcotest.(check int) "obsolete challenge cannot submit" 0 (List.length submit.effects) +;; + +let scenarios = scenarios @ + [ Alcotest.test_case "E2EE missing cache auto-prepares without Retry" `Quick + e2ee_missing_cache_prepares_without_retry + ; Alcotest.test_case "E2EE automatic graph-key network failure is bounded" `Quick + (e2ee_auto_preparation_failure_is_bounded false "synthetic network unavailable") + ; Alcotest.test_case "E2EE automatic user-key authentication failure is bounded" `Quick + (e2ee_auto_preparation_failure_is_bounded true "synthetic authentication required") + ] @ List.concat_map (fun stage -> List.concat_map (fun cancel -> + List.map (fun success -> Alcotest.test_case + (Printf.sprintf "E2EE late stage %d %s %s is fenced" stage + (if cancel then "cancel" else "reentry") (if success then "success" else "error")) + `Quick (e2ee_late_preparation_cannot_reopen_challenge stage cancel + (if success then Ok "late-material" else Error (Core.Effect_failed "late error")))) + [true; false]) [true; false]) [0;1;2] +;; diff --git a/logseq_sync/test/local_restore_failure_reconciliation.ml b/logseq_sync/test/local_restore_failure_reconciliation.ml index 7d5cc614..fe04aa98 100644 --- a/logseq_sync/test/local_restore_failure_reconciliation.ml +++ b/logseq_sync/test/local_restore_failure_reconciliation.ml @@ -34,15 +34,15 @@ let complete core ticket result = Core.step core (Core.Runner_completed (Core.Completion (ticket, result))) ;; -let check_local_failure label core = +let check_preparation_failure label core = let snapshot = (Core.state core).snapshot in Alcotest.(check bool) - (label ^ ": local restore failure") + (label ^ ": E2EE preparation failure") true - (snapshot.startup.failure = Some Core.During_local_restore); + (snapshot.startup.failure = Some Core.During_e2ee); Alcotest.(check (option string)) (label ^ ": diagnostic") - (Some "wrappedGraphKeyUnavailable") + (Some "synthetic key preparation offline") snapshot.last_error; Alcotest.(check bool) (label ^ ": selected graph") @@ -67,10 +67,9 @@ let check_catalog_only label effects = effects) ;; -(* A warm restore can fail before same-account authentication completes. - Authentication and catalog success must preserve the local restore failure: - neither event retries key loading or opens the graph. Clearing the failure - leaves startup waiting indefinitely and removes the explicit recovery action. *) +(* A warm cache miss starts online preparation automatically. If it fails before + same-account authentication completes, reconciliation must preserve that visible + failure and its explicit recovery action rather than leave startup waiting. *) let preserves_local_restore_failure_during_reconciliation () = let user_id = "cached-account" in let restoring = Core.step (initial ()) (Core.Restore_local_account { user_id }) in @@ -108,14 +107,19 @@ let preserves_local_restore_failure_during_reconciliation () = key_ticket (Error (Core.Effect_failed "wrappedGraphKeyUnavailable")) in - check_local_failure "key load failed" failed.next; + let graph_key_ticket = require_instruction "automatic E2EE recovery" + (function Core.Run (Core.Request (ticket, Core.Fetch_e2ee_graph_key _)) -> Some (ticket : string Core.effect_ticket) + | _ -> None) failed.effects in + let failed = complete failed.next graph_key_ticket + (Error (Core.Effect_failed "synthetic key preparation offline")) in + check_preparation_failure "remote preparation failed" failed.next; let authenticated = Core.step failed.next (Core.Account_authenticated { user_id = Some user_id }) in let startup = (Core.state authenticated.next).snapshot.startup in Alcotest.(check bool) "same account authenticated" true startup.authenticated; Alcotest.(check bool) "catalog reconciliation started" true startup.catalog_loading; - check_local_failure "same-account authentication" authenticated.next; + check_preparation_failure "same-account authentication" authenticated.next; check_catalog_only "authentication" authenticated.effects; let fetch_ticket = require_instruction @@ -131,7 +135,7 @@ let preserves_local_restore_failure_during_reconciliation () = let snapshot = (Core.state refreshed.next).snapshot in Alcotest.(check bool) "catalog loading finished" false snapshot.startup.catalog_loading; Alcotest.(check bool) "catalog refreshed" true (snapshot.catalog = [ graph ]); - check_local_failure "catalog success" refreshed.next; + check_preparation_failure "catalog success" refreshed.next; check_catalog_only "catalog success" refreshed.effects; let recovery = Core.step refreshed.next Core.Online_recovery_requested in let recovery_scope = @@ -160,7 +164,7 @@ let preserves_local_restore_failure_during_reconciliation () = let scenario = Alcotest.test_case - "local restore failure survives same-account reconciliation" + "E2EE preparation failure survives same-account reconciliation" `Quick preserves_local_restore_failure_during_reconciliation ;; diff --git a/logseq_sync/test/runner_contract.ml b/logseq_sync/test/runner_contract.ml index 95159923..787e8358 100644 --- a/logseq_sync/test/runner_contract.ml +++ b/logseq_sync/test/runner_contract.ml @@ -588,16 +588,13 @@ let test_cached_wrapped_key_unlock_failure_is_fail_closed () = in Alcotest.check Alcotest.bool - "unlock failure waits for explicit E2EE recovery" + "unusable cached key starts bounded online preparation" true - ((Core.state failed.next).snapshot.startup.failure - = Some Core.During_local_restore - && failed.effects - = [ Core.Publish (Core.State_changed (Core.state failed.next)) ]); - let recovery = Core.step failed.next Core.Online_recovery_requested in + ((Core.state failed.next).snapshot.startup.failure = None); + let recovery = failed in Alcotest.check Alcotest.bool - "explicit recovery requests E2EE authorization" + "automatic recovery requests E2EE authorization" true (List.exists (function @@ -771,7 +768,7 @@ let private_key_unlock_and_sign_out () = missing.effects |> Option.get in - let recovery = Core.step failed.next Online_recovery_requested in + let recovery = failed in let user_key_fetch = List.find_map (function diff --git a/logseq_sync/test/transport_contract.ml b/logseq_sync/test/transport_contract.ml index 0ec3b5d9..26b7df50 100644 --- a/logseq_sync/test/transport_contract.ml +++ b/logseq_sync/test/transport_contract.ml @@ -3902,7 +3902,7 @@ let unlock_classification_effect origin = Some (Core.step absent.next (Core.Runner_completed (Core.Completion (ticket, Error (Core.Effect_failed "synthetic cached key absent"))))) | _ -> None) absent.effects |> Option.get in - let keys = Core.step cached.next Core.Online_recovery_requested in + let keys = cached in let request = List.find_map (function | Core.Run (Core.Request (_, Core.Fetch_e2ee_graph_key _) as request) -> Some request | _ -> None) keys.effects |> Option.get in