Reject replay after post-correction authority drift

This commit is contained in:
2026-10-07 08:38:35 +00:00
parent e6ca9a7ff9
commit 3db086a7fd
2 changed files with 25 additions and 5 deletions
@@ -694,7 +694,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
except (TypeError, json.JSONDecodeError):
stored_fingerprint = {"authority": existing_run["request_fingerprint"], "request": ""}
latest_basis_run = conn.execute(
"SELECT id, correction_of_run_id FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1",
"SELECT id, correction_of_run_id, request_fingerprint FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1",
(basis,),
).fetchone()
run_in_correction_chain = False
@@ -714,9 +714,16 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
if parent_id
else None
)
try:
latest_authority_fingerprint = json.loads(
latest_basis_run["request_fingerprint"]
).get("authority") if latest_basis_run else None
except (TypeError, json.JSONDecodeError, AttributeError):
latest_authority_fingerprint = None
immutable_replay_run = bool(
run_in_correction_chain
and latest_basis_run
and latest_authority_fingerprint == authority_fingerprint
and (
latest_basis_run["id"] != existing_run["id"]
or existing_run["correction_of_run_id"]
+17 -4
View File
@@ -97,22 +97,28 @@ def test_f002_correction_replay_preserves_full_initial_response(tmp_path):
def test_f006_initial_replay_survives_multiple_later_corrections(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")
service.create_manual_asset(
"현금", 100, "2026-06-30", "cash", db_path=db_path
"현금 A", 100, "2026-06-30", "cash", db_path=db_path
)
asset_id = service.list_manual_assets(db_path=db_path)["manual_assets"][0]["id"]
service.create_manual_asset(
"현금 B", 200, "2026-06-30", "cash", db_path=db_path
)
assets = {
item["name"]: item["id"]
for item in service.list_manual_assets(db_path=db_path)["manual_assets"]
}
first = service.close_asset_snapshot(
"2026-06-30", idempotency_key="multi-initial", db_path=db_path
)
assert first["success"] is True
service.update_manual_asset(asset_id, valuation_amount=110, db_path=db_path)
service.update_manual_asset(assets["현금 A"], valuation_amount=110, db_path=db_path)
first_correction = service.close_asset_snapshot(
"2026-06-30", correction_reason="first correction",
idempotency_key="multi-correction-1", db_path=db_path,
)
assert first_correction["success"] is True
service.update_manual_asset(asset_id, valuation_amount=120, db_path=db_path)
service.update_manual_asset(assets["현금 A"], valuation_amount=120, db_path=db_path)
second_correction = service.close_asset_snapshot(
"2026-06-30", correction_reason="second correction",
idempotency_key="multi-correction-2", db_path=db_path,
@@ -124,6 +130,13 @@ def test_f006_initial_replay_survives_multiple_later_corrections(tmp_path):
)
assert replay == first
service.update_manual_asset(assets["현금 B"], valuation_amount=220, db_path=db_path)
drifted_replay = service.close_asset_snapshot(
"2026-06-30", idempotency_key="multi-initial", db_path=db_path
)
assert drifted_replay["success"] is False
assert drifted_replay["error_code"] == "idempotency_conflict"
def test_f003_zero_row_conditional_runs_are_timeseries_authority_with_aliases(tmp_path):
db_path = str(tmp_path / "ledger.sqlite3")