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,
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.

View file

@ -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())

View file

@ -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={}), \

View file

@ -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")

View file

@ -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)