Honor causal tombstones in full generation snapshots

This commit is contained in:
fy59 2026-09-18 23:18:01 +02:00
parent 68af06b470
commit 2c0fa714c3
5 changed files with 72 additions and 4 deletions

View file

@ -44,3 +44,12 @@ exposing SQL or paths.
The rollout-specific backup hashes, deployed commit, causal operation, The rollout-specific backup hashes, deployed commit, causal operation,
generation/ACK identifiers and restart/non-resurrection results are appended generation/ACK identifiers and restart/non-resurrection results are appended
after the controlled private rollout. No public tag or release is created. 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.

View file

@ -11,6 +11,8 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
IMPORTER = ROOT / "tools/import_mobile_export.py" IMPORTER = ROOT / "tools/import_mobile_export.py"
EXPORTER = ROOT / "tools/export_pc_mobile.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 from test_mobile_import_variable_sets import SCHEMA
V11_SCHEMA = SCHEMA.replace("PRAGMA user_version=10;", "") + """ 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 " "SELECT target_id,current_revision_id,deleted FROM sync_causal_state "
"WHERE target_kind='session' ORDER BY target_id" "WHERE target_kind='session' ORDER BY target_id"
).fetchall() == revisions_before ).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" exported = root / "pc-v3.json"
run([sys.executable, str(EXPORTER), str(exported), "--database", str(db)]) run([sys.executable, str(EXPORTER), str(exported), "--database", str(db)])
roundtrip = json.loads(exported.read_text()) roundtrip = json.loads(exported.read_text())

View file

@ -210,7 +210,10 @@ class GenerationTest(unittest.TestCase):
filename=name+".json";(directory/filename).write_text("{}") 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}) 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} 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)), \ 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.causal_delete_exchange,"load",return_value={"operations":[]}), \
mock.patch.object(generation.execution_draft_exchange,"load",return_value={}), \ mock.patch.object(generation.execution_draft_exchange,"load",return_value={}), \

View file

@ -1960,7 +1960,30 @@ def import_body(
report["body_imported"] += 1 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.""" """Apply a validated artifact without owning commit or rollback."""
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
report = { report = {
@ -1988,9 +2011,17 @@ def apply_payload(connection, payload, trace_exercises=False):
{("body_observation", value["observation_id"]) for value in payload["body_observations"]} | {("body_observation", value["observation_id"]) for value in payload["body_observations"]} |
{("exercise", value["exercise_id"]) for value in payload["exercises"]}) {("exercise", value["exercise_id"]) for value in payload["exercises"]})
collision = protected & incoming collision = protected & incoming
if collision: if collision and not complete_causal_envelope:
kind, identity = sorted(collision)[0] kind, identity = sorted(collision)[0]
raise ImportFailure(f"causal protection refuses legacy/live replay: {kind}/{identity}") 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"]) 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: if has_explicit_max and schema_version < 9:
raise ImportFailure("max_weight_kg exige le schéma desktop v9") raise ImportFailure("max_weight_kg exige le schéma desktop v9")

View file

@ -633,7 +633,7 @@ def consume_desktop(database: Path, directory: Path) -> dict:
causal_delete_exchange.apply_operation(db, operation) causal_delete_exchange.apply_operation(db, operation)
import_exercise_profile_state.apply_profile_state(db, profile, allow_pending=True) import_exercise_profile_state.apply_profile_state(db, profile, allow_pending=True)
import_equipment_definitions.apply_definitions(db, definitions, complete_causal_envelope=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_profile_state.apply_profile_state(db, profile)
import_exercise_aliases.apply_aliases(db, aliases) import_exercise_aliases.apply_aliases(db, aliases)
import_equipment_associations.apply_associations(db, association_payload, reserved, mobile_occurrences) import_equipment_associations.apply_associations(db, association_payload, reserved, mobile_occurrences)