diff --git a/docs/reviews/history_delete_http500_fix_v1_evidence.md b/docs/reviews/history_delete_http500_fix_v1_evidence.md index 92c81a5..36a1f3b 100644 --- a/docs/reviews/history_delete_http500_fix_v1_evidence.md +++ b/docs/reviews/history_delete_http500_fix_v1_evidence.md @@ -44,3 +44,12 @@ exposing SQL or paths. The rollout-specific backup hashes, deployed commit, causal operation, generation/ACK identifiers and restart/non-resurrection results are appended after the controlled private rollout. No public tag or release is created. + +During the first post-delete hardware exchange, the desktop correctly rejected +Android's still-live snapshot with `causal protection refuses legacy/live +replay`. This exposed a missing full-envelope dominance rule: Android publishes +its half before consuming the desktop tombstone in the same conversation. The +production full-generation consumer now filters only live facts already +dominated by causal state applied from that complete envelope; standalone +snapshot imports retain their strict rejection. A regression exercises this +ordering and proves the tombstone and absent session remain unchanged. diff --git a/tests/test_session_exchange_v3.py b/tests/test_session_exchange_v3.py index 45755fe..c9cd341 100644 --- a/tests/test_session_exchange_v3.py +++ b/tests/test_session_exchange_v3.py @@ -11,6 +11,8 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] IMPORTER = ROOT / "tools/import_mobile_export.py" EXPORTER = ROOT / "tools/export_pc_mobile.py" +sys.path.insert(0, str(ROOT / "tools")) +import import_mobile_export from test_mobile_import_variable_sets import SCHEMA V11_SCHEMA = SCHEMA.replace("PRAGMA user_version=10;", "") + """ @@ -163,6 +165,29 @@ def main(): "SELECT target_id,current_revision_id,deleted FROM sync_causal_state " "WHERE target_kind='session' ORDER BY target_id" ).fetchall() == revisions_before + + # A full generation applies causal operations before its mobile + # snapshot. The older live fact is dominated rather than rejected + # or reinserted; standalone import keeps the stricter collision. + con.execute("DELETE FROM sessions WHERE session_id='se_v3'") + con.execute( + "UPDATE sync_causal_state SET current_revision_id='del_fixture'," + "deleted=1,operation_id='del_fixture' WHERE target_kind='session' " + "AND target_id='se_v3'" + ) + report = import_mobile_export.apply_payload( + con, + import_mobile_export.load_payload(artifact), + complete_causal_envelope=True, + ) + assert report["sessions_imported"] == 0 + assert con.execute( + "SELECT COUNT(*) FROM sessions WHERE session_id='se_v3'" + ).fetchone()[0] == 0 + assert tuple(con.execute( + "SELECT current_revision_id,deleted FROM sync_causal_state " + "WHERE target_kind='session' AND target_id='se_v3'" + ).fetchone()) == ("del_fixture", 1) exported = root / "pc-v3.json" run([sys.executable, str(EXPORTER), str(exported), "--database", str(db)]) roundtrip = json.loads(exported.read_text()) diff --git a/tests/test_sync_generation_exchange.py b/tests/test_sync_generation_exchange.py index 14c84dd..48d7f85 100644 --- a/tests/test_sync_generation_exchange.py +++ b/tests/test_sync_generation_exchange.py @@ -210,7 +210,10 @@ class GenerationTest(unittest.TestCase): filename=name+".json";(directory/filename).write_text("{}") artifacts.append({"logical_name":name,"format":"test","version":1,"filename":f"generations/{GEN}/{filename}","size":2,"sha256":"0"*64,"required":True}) manifest={"format":generation.MANIFEST,"version":1,"generation_id":GEN,"run_id":RUN,"producer":{"peer_id":PEER_A,"kind":"android"},"consumer_peer_id":PEER_B,"generated_at":"2026-09-17T10:00:00+02:00","parent_generation_id":None,"artifacts":artifacts} - def write_fact(db,_payload,_trace=False):db.execute("INSERT INTO facts VALUES('partial','must roll back')");return {} + def write_fact(db, _payload, _trace=False, complete_causal_envelope=False): + self.assertTrue(complete_causal_envelope) + db.execute("INSERT INTO facts VALUES('partial','must roll back')") + return {} with mock.patch.object(generation,"validate_published",return_value=(manifest,"a"*64)), \ mock.patch.object(generation.causal_delete_exchange,"load",return_value={"operations":[]}), \ mock.patch.object(generation.execution_draft_exchange,"load",return_value={}), \ diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index f189f52..4a86b58 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -1960,7 +1960,30 @@ def import_body( report["body_imported"] += 1 -def apply_payload(connection, payload, trace_exercises=False): +def causal_snapshot_view(payload, protected): + """Return the live subset of a snapshot after its causal envelope.""" + filtered = dict(payload) + filtered["exercises"] = [ + value for value in payload["exercises"] + if ("exercise", value["exercise_id"]) not in protected + ] + filtered["sessions"] = [ + value for value in payload["sessions"] + if ("session", value["session_id"]) not in protected + ] + filtered["body_observations"] = [] + for value in payload["body_observations"]: + if ("body_observation", value["observation_id"]) in protected: + continue + if payload["version"] == 4 and value["session_id"] is not None and \ + ("session", value["session_id"]) in protected: + value = dict(value) + value["session_id"] = None + filtered["body_observations"].append(value) + return filtered + + +def apply_payload(connection, payload, trace_exercises=False, complete_causal_envelope=False): """Apply a validated artifact without owning commit or rollback.""" connection.row_factory = sqlite3.Row report = { @@ -1988,9 +2011,17 @@ def apply_payload(connection, payload, trace_exercises=False): {("body_observation", value["observation_id"]) for value in payload["body_observations"]} | {("exercise", value["exercise_id"]) for value in payload["exercises"]}) collision = protected & incoming - if collision: + if collision and not complete_causal_envelope: kind, identity = sorted(collision)[0] raise ImportFailure(f"causal protection refuses legacy/live replay: {kind}/{identity}") + if complete_causal_envelope: + # WHY: Android publishes its snapshot before consuming this run's + # desktop half, so that snapshot may still contain objects deleted + # by the causal artifact already applied above. CONTRACT: only the + # complete generation consumer may discard those dominated live + # facts. INVARIANT: the durable tombstone remains authoritative and + # ordinary standalone imports continue to reject the collision. + payload = causal_snapshot_view(payload, protected) has_explicit_max = any("max_weight_kg" in entry for session in payload["sessions"] for entry in session["exercises"]) if has_explicit_max and schema_version < 9: raise ImportFailure("max_weight_kg exige le schéma desktop v9") diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index f80cf5d..efb4f50 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -633,7 +633,7 @@ def consume_desktop(database: Path, directory: Path) -> dict: causal_delete_exchange.apply_operation(db, operation) import_exercise_profile_state.apply_profile_state(db, profile, allow_pending=True) import_equipment_definitions.apply_definitions(db, definitions, complete_causal_envelope=True) - import_mobile_export.apply_payload(db, history) + import_mobile_export.apply_payload(db, history, complete_causal_envelope=True) import_exercise_profile_state.apply_profile_state(db, profile) import_exercise_aliases.apply_aliases(db, aliases) import_equipment_associations.apply_associations(db, association_payload, reserved, mobile_occurrences)