Skip to content

Commit 2378a52

Browse files
committed
fix(recovery): harden journal conformance evidence
1 parent a9d41b4 commit 2378a52

6 files changed

Lines changed: 472 additions & 268 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
- Retain durable settlement records for contradictory retryable 4xx envelopes,
2525
and report heartbeat transport failures with their same-key retry or stop
2626
disposition.
27+
- Quarantine unsupported or structurally invalid journal records without
28+
aborting replay, keep serialization failures best-effort, and report exact
29+
native test evidence to the shared conformance runner.
2730

2831
## [0.5.1] - 2026-07-27
2932

runcycles/journal.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,12 @@ def to_json(self) -> str:
131131
@classmethod
132132
def from_json(cls, raw: str) -> PendingCommitRecord:
133133
data = json.loads(raw)
134-
reservation_id = data["reservation_id"]
134+
if not isinstance(data, dict):
135+
raise ValueError("journal record must be a JSON object")
136+
version = data.get("version")
137+
if not isinstance(version, int) or isinstance(version, bool) or version != _RECORD_VERSION:
138+
raise ValueError(f"unsupported journal version: {version!r}")
139+
reservation_id = data.get("reservation_id")
135140
mode = data.get("mode", "commit")
136141
if not isinstance(reservation_id, str) or not reservation_id:
137142
raise ValueError("journal record missing reservation_id")
@@ -141,15 +146,27 @@ def from_json(cls, raw: str) -> PendingCommitRecord:
141146
raise ValueError("commit-mode journal record missing commit_body")
142147
if mode == "event" and not isinstance(data.get("event_fallback_body"), dict):
143148
raise ValueError("event-mode journal record missing event_fallback_body")
149+
for body_key in ("commit_body", "event_fallback_body"):
150+
if data.get(body_key) is not None and not isinstance(data[body_key], dict):
151+
raise ValueError(f"journal record has invalid {body_key}")
152+
if "base_url" in data and not isinstance(data["base_url"], str):
153+
raise ValueError("journal record has invalid base_url")
154+
recorded_at_raw = data.get("recorded_at_ms", 0)
155+
if not isinstance(recorded_at_raw, int) or isinstance(recorded_at_raw, bool) or recorded_at_raw < 0:
156+
raise ValueError("journal record has invalid recorded_at_ms")
144157
not_before_raw = data.get("not_before_ms")
158+
if not_before_raw is not None and (
159+
not isinstance(not_before_raw, int) or isinstance(not_before_raw, bool) or not_before_raw < 0
160+
):
161+
raise ValueError("journal record has invalid not_before_ms")
145162
return cls(
146163
reservation_id=reservation_id,
147164
base_url=data.get("base_url", ""),
148165
mode=mode,
149166
commit_body=data.get("commit_body"),
150167
event_fallback_body=data.get("event_fallback_body"),
151-
recorded_at_ms=int(data.get("recorded_at_ms", 0)),
152-
not_before_ms=int(not_before_raw) if not_before_raw is not None else None,
168+
recorded_at_ms=recorded_at_raw,
169+
not_before_ms=not_before_raw,
153170
)
154171

155172

@@ -186,14 +203,14 @@ def record(self, entry: PendingCommitRecord) -> None:
186203
tmp.write_text(entry.to_json(), encoding="utf-8")
187204
_restrict_permissions(tmp, 0o600)
188205
tmp.replace(target)
189-
except OSError:
206+
except Exception:
190207
try:
191208
tmp.unlink(missing_ok=True)
192209
except OSError:
193210
pass
194211
raise
195212
logger.debug("Journaled pending commit: id=%s, path=%s", entry.reservation_id, target)
196-
except OSError:
213+
except Exception:
197214
logger.warning(
198215
"Failed to journal pending commit (continuing without durability): id=%s",
199216
entry.reservation_id,
@@ -260,9 +277,7 @@ def load_pending(self, base_url: str) -> list[PendingCommitRecord]:
260277
standard_path,
261278
)
262279
else:
263-
existing = PendingCommitRecord.from_json(
264-
standard_path.read_text(encoding="utf-8")
265-
)
280+
existing = PendingCommitRecord.from_json(standard_path.read_text(encoding="utf-8"))
266281
if existing.reservation_id == entry.reservation_id:
267282
path.unlink(missing_ok=True)
268283
duplicate_of_standard = True

scripts/recovery_conformance_adapter.py

Lines changed: 36 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -10,73 +10,45 @@
1010

1111
ROOT = Path(__file__).resolve().parents[1]
1212

13-
OBSERVATIONS = {
14-
"CR-CORE-001": (["commit", "commit_same_key"], [
15-
"settlement_occurs_at_most_once", "retry_uses_original_idempotency_key"]),
16-
"CR-CORE-002": (["commit", "event_same_key"], [
17-
"event_carries_original_subject_action_actual", "settlement_occurs_at_most_once"]),
18-
"CR-CORE-003": (["extend", "extend_same_key", "commit"], [
19-
"heartbeat_failure_reports_reservation_and_disposition",
20-
"guarded_action_continues_under_warn_policy", "final_settlement_is_attempted"]),
21-
"CR-CORE-004": (["commit", "commit_same_key"], [
22-
"only_schema_valid_expected_status_is_terminal_success",
23-
"ambiguous_success_retains_original_idempotency_key"]),
24-
"CR-DURABLE-001": (["commit", "commit_same_key_after_restart"], [
25-
"journal_write_precedes_first_settlement_request",
26-
"unresolved_record_survives_restart", "successful_replay_removes_record",
27-
"settlement_occurs_at_most_once"]),
28-
"CR-DURABLE-002": (["commit_same_key_after_restart", "event_same_key_after_restart"], [
29-
"event_mode_is_persisted_before_event_attempt", "successful_event_removes_record",
30-
"settlement_occurs_at_most_once"]),
31-
"CR-DURABLE-003": (["commit", "commit_same_key_after_retry_after"], [
32-
"no_retry_before_persisted_not_before", "successful_replay_removes_record"]),
33-
"CR-DURABLE-004": (["commit_same_key_after_restart"], [
34-
"new_tenant_credential_finds_record", "old_api_key_is_not_stored"]),
35-
"CR-DURABLE-005": ([], [
36-
"corrupt_record_is_quarantined", "other_valid_records_still_replay",
37-
"corruption_is_reported"]),
38-
"CR-DURABLE-006": (["concurrent_commit_same_key"], [
39-
"settlement_occurs_at_most_once", "terminal_record_is_removed"]),
40-
"CR-DURABLE-007": ([
41-
"commit_first_identifier", "commit_second_identifier",
42-
"commit_first_identifier_same_key_after_restart",
43-
"commit_second_identifier_same_key_after_restart",
44-
], [
45-
"standard_filename_is_sha256_of_exact_utf8_identifier",
46-
"distinct_identifiers_never_share_a_journal_file",
47-
"matching_legacy_record_migrates_without_deleting_collision",
48-
"both_settlements_occur_at_most_once",
49-
]),
50-
"CR-BOUNDARY-001": ([], [
51-
"sdk_does_not_claim_ledger_convergence", "application_checkpoint_is_required"]),
52-
}
53-
5413
TESTS = {
55-
"CR-CORE-001": "tests/test_retry.py::TestCommitRetryEngine::test_retries_until_success",
56-
"CR-CORE-002": "tests/test_journal.py::TestLifecycleEventFallbackWiring::test_expired_commit_schedules_event",
57-
"CR-CORE-003": "tests/test_lifecycle.py::TestSyncLifecycleExecution::test_heartbeat_exception_does_not_crash",
14+
"CR-CORE-001": ("tests/test_retry.py::TestCommitRetryEngine::test_retries_until_success",),
15+
"CR-CORE-002": ("tests/test_journal.py::TestLifecycleEventFallbackWiring::test_expired_commit_schedules_event",),
16+
"CR-CORE-003": ("tests/test_lifecycle.py::TestSyncLifecycleExecution::test_heartbeat_exception_does_not_crash",),
5817
"CR-CORE-004": (
5918
"tests/test_journal.py::TestLifecycleEventFallbackWiring::"
60-
"test_protocol_invalid_2xx_is_ambiguous_and_keeps_same_key"
19+
"test_protocol_invalid_2xx_is_ambiguous_and_keeps_same_key",
6120
),
6221
"CR-DURABLE-001": (
6322
"tests/test_journal.py::TestLifecycleEventFallbackWiring::"
64-
"test_journal_write_precedes_first_commit_and_success_discards"
23+
"test_journal_write_precedes_first_commit_and_success_discards",
24+
"tests/test_journal.py::TestSyncReplay::test_replays_pending_commit_on_set_client",
25+
"tests/test_retry.py::TestCommitRetryEngine::test_retries_until_success",
6526
),
6627
"CR-DURABLE-002": (
67-
"tests/test_journal.py::TestSyncEngineDurability::"
68-
"test_expired_then_event_transient_continues_in_event_mode"
28+
"tests/test_journal.py::TestSyncEngineDurability::test_expired_then_event_transient_continues_in_event_mode",
29+
"tests/test_journal.py::TestSyncReplay::test_replays_event_mode_entry",
30+
),
31+
"CR-DURABLE-003": (
32+
"tests/test_journal.py::TestRateLimitedRetry::test_429_commit_is_transient_and_honors_retry_after",
33+
"tests/test_journal.py::TestRateLimitedRetry::test_replay_restores_future_retry_after_floor",
34+
"tests/test_journal.py::TestRateLimitedRetry::test_429_then_success_discards_journal",
6935
),
70-
"CR-DURABLE-003": "tests/test_journal.py::TestRateLimitedRetry::test_replay_restores_future_retry_after_floor",
7136
"CR-DURABLE-004": (
72-
"tests/test_journal.py::TestAuthFailureRetention::test_replay_survives_api_key_rotation_with_tenant"
37+
"tests/test_journal.py::TestAuthFailureRetention::test_replay_survives_api_key_rotation_with_tenant",
38+
),
39+
"CR-DURABLE-005": (
40+
"tests/test_journal.py::TestCommitJournal::"
41+
"test_corrupt_and_unsupported_records_are_quarantined_without_blocking_valid",
42+
),
43+
"CR-DURABLE-006": (
44+
"tests/test_journal.py::TestSyncReplay::test_concurrent_replay_workers_reuse_one_key_and_remove_record",
7345
),
74-
"CR-DURABLE-005": "tests/test_journal.py::TestCommitJournal::test_corrupt_file_renamed_and_skipped",
75-
"CR-DURABLE-006": "tests/test_journal.py::TestSyncReplay::test_replay_happens_once_per_directory",
7646
"CR-DURABLE-007": (
77-
"tests/test_journal.py::TestCommitJournal::test_colliding_legacy_ids_are_distinct_and_migrate_safely"
47+
"tests/test_journal.py::TestCommitJournal::test_colliding_legacy_ids_are_distinct_and_migrate_safely",
48+
),
49+
"CR-BOUNDARY-001": (
50+
"tests/test_lifecycle.py::TestSyncLifecycleExecution::test_missing_actual_surfaces_without_settlement",
7851
),
79-
"CR-BOUNDARY-001": "tests/test_lifecycle.py::TestEvaluateActual::test_no_fallback_raises",
8052
}
8153

8254

@@ -94,7 +66,7 @@ def main() -> int:
9466
return 2
9567

9668
completed = subprocess.run(
97-
[sys.executable, "-m", "pytest", "-q", TESTS[scenario_id]],
69+
[sys.executable, "-m", "pytest", "-q", *TESTS[scenario_id]],
9870
cwd=ROOT,
9971
text=True,
10072
capture_output=True,
@@ -104,14 +76,15 @@ def main() -> int:
10476
print(completed.stdout, file=sys.stderr, end="")
10577
if completed.stderr:
10678
print(completed.stderr, file=sys.stderr, end="")
107-
requests, assertions = OBSERVATIONS[scenario_id]
108-
json.dump({
109-
"scenario_id": scenario_id,
110-
"passed": completed.returncode == 0,
111-
"observed_requests": requests,
112-
"assertions": assertions,
113-
"diagnostic": f"native pytest exit code {completed.returncode}",
114-
}, sys.stdout)
79+
json.dump(
80+
{
81+
"scenario_id": scenario_id,
82+
"passed": completed.returncode == 0,
83+
"native_tests": list(TESTS[scenario_id]),
84+
"diagnostic": f"native pytest exit code {completed.returncode}",
85+
},
86+
sys.stdout,
87+
)
11588
return 0
11689

11790

0 commit comments

Comments
 (0)