worktree-controller 1.30.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. worktree_controller/__init__.py +3 -0
  2. worktree_controller/activation_layout.py +483 -0
  3. worktree_controller/branch_retirement.py +1865 -0
  4. worktree_controller/canonical_refresh.py +150 -0
  5. worktree_controller/canonical_transition.py +324 -0
  6. worktree_controller/cleanup_attempts.py +442 -0
  7. worktree_controller/cleanup_evidence.py +75 -0
  8. worktree_controller/cleanup_recovery.py +912 -0
  9. worktree_controller/cli.py +1907 -0
  10. worktree_controller/controller.py +1974 -0
  11. worktree_controller/convergence.py +893 -0
  12. worktree_controller/delivered_closeout.py +928 -0
  13. worktree_controller/disposable.py +677 -0
  14. worktree_controller/environment.py +1038 -0
  15. worktree_controller/frozen_recovery.py +291 -0
  16. worktree_controller/git_backend.py +682 -0
  17. worktree_controller/governance.py +365 -0
  18. worktree_controller/hardlinks.py +294 -0
  19. worktree_controller/integration_cleanup.py +92 -0
  20. worktree_controller/integration_conflict.py +117 -0
  21. worktree_controller/integration_retry.py +526 -0
  22. worktree_controller/keyed_document_scope.py +162 -0
  23. worktree_controller/lifecycle.py +8992 -0
  24. worktree_controller/lifecycle_history.py +358 -0
  25. worktree_controller/lifecycle_observation.py +234 -0
  26. worktree_controller/metadata_codec.py +1694 -0
  27. worktree_controller/metadata_relocation.py +131 -0
  28. worktree_controller/models.py +546 -0
  29. worktree_controller/mount_diagnostics.py +324 -0
  30. worktree_controller/multiple_cleanup_recovery.py +730 -0
  31. worktree_controller/namespace_migration.py +1412 -0
  32. worktree_controller/native_session_recovery.py +171 -0
  33. worktree_controller/owner_actions.py +34 -0
  34. worktree_controller/policy.py +333 -0
  35. worktree_controller/preservation.py +983 -0
  36. worktree_controller/preserved_cleanup.py +849 -0
  37. worktree_controller/publication_execution.py +361 -0
  38. worktree_controller/reconciler.py +430 -0
  39. worktree_controller/recovery_guidance.py +205 -0
  40. worktree_controller/registration_backfill.py +166 -0
  41. worktree_controller/registration_prune.py +391 -0
  42. worktree_controller/rendering.py +179 -0
  43. worktree_controller/resource_admission.py +1736 -0
  44. worktree_controller/retirement_adapters.py +130 -0
  45. worktree_controller/retirement_execution.py +234 -0
  46. worktree_controller/semantics.py +459 -0
  47. worktree_controller/session.py +505 -0
  48. worktree_controller/session_admission.py +265 -0
  49. worktree_controller/source_cleanup.py +515 -0
  50. worktree_controller/source_preservation.py +692 -0
  51. worktree_controller/source_recovery.py +592 -0
  52. worktree_controller/store.py +1228 -0
  53. worktree_controller/tool_cli_contract.json +1791 -0
  54. worktree_controller/transaction_scope.py +876 -0
  55. worktree_controller/writer_lease.py +662 -0
  56. worktree_controller/writer_session.py +887 -0
  57. worktree_controller-1.30.4.dist-info/METADATA +3149 -0
  58. worktree_controller-1.30.4.dist-info/RECORD +61 -0
  59. worktree_controller-1.30.4.dist-info/WHEEL +4 -0
  60. worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
  61. worktree_controller-1.30.4.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,730 @@
1
+ """Resume only a proven completed prefix's untouched suffix, with immutable evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ import uuid
8
+ from dataclasses import replace
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from . import cleanup_evidence as e
13
+ from . import preservation as p
14
+ from .git_backend import inventory
15
+ from .models import ControllerError, MetadataRecord
16
+ from .preserved_cleanup import (
17
+ _document,
18
+ common_source_root,
19
+ completed_actions,
20
+ coverage,
21
+ source_absent,
22
+ )
23
+ from .store import MetadataStore, now_iso, record_from_dict, record_to_dict
24
+
25
+ CONTRACT = "multiple-preservation-cleanup-recovery/v1"
26
+ CLEANUP_CONTRACT = "legacy-preserved-state-multiple-cleanup/v1"
27
+ IDENTITY_KEYS = (
28
+ "repository_id", "allocation_id", "target_path", "expected_head",
29
+ "expected_record_version", "owner", "authority_key",
30
+ )
31
+
32
+
33
+ def _blocked(message: str) -> ControllerError:
34
+ return ControllerError("MULTIPLE_CLEANUP_RECOVERY_BLOCKED", message)
35
+
36
+
37
+ def _uuid(value: str) -> None:
38
+ try:
39
+ valid = str(uuid.UUID(value)) == value
40
+ except (ValueError, TypeError, AttributeError):
41
+ valid = False
42
+ if not valid:
43
+ raise _blocked("canonical recovery transaction identity required")
44
+
45
+
46
+ def context(transaction: str, transaction_sha256: str, receipt_sha256: str) -> dict[str, Any]:
47
+ _uuid(transaction)
48
+ if any(not isinstance(value, str) or len(value) != 64 or set(value) - set("0123456789abcdef")
49
+ for value in (transaction_sha256, receipt_sha256)):
50
+ raise _blocked("trusted original journal and failed receipt digests required")
51
+ return {
52
+ "contract": CONTRACT,
53
+ "original_transaction_id": transaction,
54
+ "original_transaction_sha256": transaction_sha256,
55
+ "original_cleanup_receipt_sha256": receipt_sha256,
56
+ "old_transaction_remains_immutable": True,
57
+ "completed_action_will_replay": False,
58
+ }
59
+
60
+
61
+ def _identity(plan: dict[str, Any]) -> dict[str, Any]:
62
+ return {key: plan[key] for key in IDENTITY_KEYS}
63
+
64
+
65
+ def _plan_hash(plan: dict[str, Any]) -> bool:
66
+ from .lifecycle import _release_plan_id
67
+
68
+ return plan.get("plan_id") == _release_plan_id({key: value for key, value in plan.items() if key != "plan_id"})
69
+
70
+
71
+ def _action_files(store: MetadataStore, transaction: str, count: int) -> None:
72
+ with p._absolute_directory(store.cleanup_actions_dir / transaction) as chain:
73
+ names = set(os.listdir(chain[-1].fd))
74
+ required = {f"{step:04d}-{phase}.json" for step in range(count) for phase in ("before", "after")}
75
+ if names != required:
76
+ raise _blocked("pending or unbound actions have evidence, or completed action files are incomplete")
77
+
78
+
79
+ def _generation_metadata(bound: dict[str, Any]) -> list[dict[str, Any]]:
80
+ """Freeze completed official generations without reading unreferenced payloads."""
81
+ for key in ("repository_id", "allocation_id"):
82
+ _uuid(bound[key])
83
+ allocation = p.evidence_root() / bound["repository_id"] / bound["allocation_id"]
84
+ result = []
85
+ with p._absolute_directory(allocation) as chain:
86
+ for name in sorted(os.listdir(chain[-1].fd)):
87
+ _uuid(name)
88
+ with p._absolute_directory(allocation / name) as generation:
89
+ directory = generation[-1]
90
+ try:
91
+ os.stat("receipt.json", dir_fd=directory.fd, follow_symlinks=False)
92
+ except FileNotFoundError:
93
+ continue # Incomplete copies retain the existing no-authority rule.
94
+ files = []
95
+ for filename in ("plan.json", "receipt.json", "manifest.json"):
96
+ try:
97
+ os.stat(filename, dir_fd=directory.fd, follow_symlinks=False)
98
+ except FileNotFoundError:
99
+ if filename != "manifest.json":
100
+ raise _blocked("completed generation metadata is missing") from None
101
+ continue
102
+ metadata, digest = _document(directory, filename)
103
+ if filename == "receipt.json" and (
104
+ metadata.get("receipt_id") != name
105
+ or metadata.get("generation") != str(allocation / name)
106
+ or metadata.get("generation_identity") != directory.identity
107
+ ):
108
+ raise _blocked("completed generation receipt or original directory identity changed")
109
+ files.append({"path": str(allocation / name / filename), "sha256": digest})
110
+ result.append({"generation": name, "files": files})
111
+ return result
112
+
113
+
114
+ def _frozen_authority(record: dict[str, Any]) -> MetadataRecord:
115
+ frozen = record_from_dict(record["allocation_record"])
116
+ baseline = record["plan"]
117
+ if (frozen.allocation_id != baseline["allocation_id"] or frozen.owner != baseline["owner"]
118
+ or frozen.authority_key != baseline["authority_key"]
119
+ or frozen.record_version != baseline["expected_record_version"]
120
+ or not frozen.history or frozen.history[-1].get("to") != frozen.lifecycle
121
+ or record.get("generation_metadata") != _generation_metadata(_identity(baseline))):
122
+ raise _blocked("frozen allocation lineage or complete generation metadata set changed")
123
+ for generation in record["generation_metadata"]:
124
+ for ref in generation["files"]:
125
+ e.read(Path(ref["path"]), ref["sha256"])
126
+ return frozen
127
+
128
+
129
+ def _released_record(frozen: MetadataRecord, current: MetadataRecord,
130
+ release_plan: dict[str, Any]) -> bool:
131
+ event = current.history[-1]
132
+ if (set(event) != {"at", "event", "from", "to", "owner"}
133
+ or event.get("event") != "RELEASED" or event.get("from") != frozen.lifecycle
134
+ or event.get("to") != "RELEASED" or event.get("owner") != "unassigned"
135
+ or release_plan.get("lifecycle") != frozen.lifecycle
136
+ or release_plan.get("role") != frozen.role
137
+ or release_plan.get("expected_record_version") != frozen.record_version):
138
+ return False
139
+ resources = [dict(resource) for resource in frozen.disposable_resources]
140
+ cleaned = {action["resource_id"] for action in release_plan["resource_actions"]}
141
+ for resource in resources:
142
+ if resource["resource_id"] in cleaned:
143
+ resource["state"] = "cleaned"
144
+ expected = replace(frozen, lifecycle="RELEASED", owner="unassigned",
145
+ record_version=frozen.record_version + 1, last_reviewed_at=event["at"],
146
+ history=(*frozen.history, event), disposable_resources=tuple(resources))
147
+ return record_to_dict(current) == record_to_dict(expected)
148
+
149
+
150
+ def _released(store: MetadataStore, transaction: str) -> bool:
151
+ from .transaction_scope import release_evidence_terminal
152
+
153
+ if not release_evidence_terminal(store, transaction):
154
+ return False
155
+ journal, _ = e.read(store.transactions_dir / f"{transaction}.json")
156
+ cleanup = journal["cleanup_plan"]
157
+ terminals = list((store.cleanup_recoveries_dir / transaction).glob("*/complete.json"))
158
+ if len(terminals) != 1:
159
+ return False
160
+ terminal, _ = e.read(terminals[0])
161
+ baseline, _ = e.read(terminals[0].parent / "record.json", terminal["record_sha256"])
162
+ frozen = _frozen_authority(baseline)
163
+ recovery = baseline["plan"]["recovery"]
164
+ if (terminal.get("contract") != CONTRACT or terminal.get("original_transaction_id") != transaction
165
+ or recovery["original_transaction_id"] != transaction):
166
+ return False
167
+ e.read(store.transactions_dir / f"{transaction}.json", recovery["original_transaction_sha256"])
168
+ e.read(store.completion_receipts_dir / f"{transaction}.json", recovery["original_cleanup_receipt_sha256"])
169
+ record = record_from_dict(store._read_json(store.records_dir / f"{cleanup['allocation_id']}.json"))
170
+ releases = [e.read(path)[0] for path in store.release_receipts_dir.glob("*.json")]
171
+ matches = [receipt for receipt in releases if (
172
+ receipt.get("allocation_id") == record.allocation_id
173
+ and receipt.get("released_record_version") == record.record_version
174
+ and receipt.get("result") == "RELEASED"
175
+ )]
176
+ if len(matches) != 1:
177
+ return False
178
+ release = matches[0]
179
+ release_plan, _ = e.read(store.release_plans_dir / f"{release['plan_id']}.json")
180
+ if (not _plan_hash(release_plan)
181
+ or record.authority_key != cleanup["authority_key"]
182
+ or release.get("released_head") != cleanup["expected_head"]
183
+ or release.get("branch") != journal["branch"]
184
+ or release.get("released_record_version") != release.get("expected_record_version", -1) + 1
185
+ or any(release_plan.get(key) != cleanup[key] for key in IDENTITY_KEYS if key != "expected_record_version")
186
+ or any(release_plan.get(key) != release.get(key) for key in (
187
+ "repository_id", "allocation_id", "target_path", "branch", "expected_record_version"))
188
+ or release_plan.get("expected_head") != release["released_head"]
189
+ or not record.history or record.history[-1].get("event") != "RELEASED"
190
+ or record.history[-1].get("from") != release_plan.get("lifecycle")
191
+ or record.history[-1].get("to") != "RELEASED"):
192
+ return False
193
+ if release["expected_record_version"] == cleanup["expected_record_version"]:
194
+ return _released_record(frozen, record, release_plan)
195
+ # A retained checkout may advance exactly once through an archive whose
196
+ # frozen cleanup references bind this successful recovery receipt.
197
+ from .delivered_closeout import ARCHIVE_CONTRACT, ARCHIVE_EVENT, ARCHIVE_STATE, _digest
198
+
199
+ if (release["expected_record_version"] != cleanup["expected_record_version"] + 1
200
+ or release_plan.get("lifecycle") != ARCHIVE_STATE or len(record.history) < 2):
201
+ return False
202
+ event = record.history[-2]
203
+ archive, _ = e.read(store.completion_receipts_dir / f"{event['archive_receipt_id']}.json")
204
+ archived_plan = archive["plan"]
205
+ original = record_from_dict(archived_plan["record"])
206
+ archived = replace(original, lifecycle=ARCHIVE_STATE, record_version=original.record_version + 1,
207
+ last_reviewed_at=event["at"], history=(*original.history, event))
208
+ completion_path = store.completion_receipts_dir / f"{terminal['completion_receipt_id']}.json"
209
+ references = archived_plan.get("preservation_cleanup_receipts", [])
210
+ if (archive.get("contract") != ARCHIVE_CONTRACT or archive.get("receipt_id") != event["archive_receipt_id"]
211
+ or archive.get("plan_id") != _digest(archived_plan) or event.get("event") != ARCHIVE_EVENT
212
+ or event.get("from") != "RETAINED" or event.get("to") != ARCHIVE_STATE
213
+ or event.get("owner") != cleanup["owner"] or event.get("archive_plan_id") != archive["plan_id"]
214
+ or event.get("archive_receipt_sha256") != _digest({key: value for key, value in archive.items() if key != "after_record"})
215
+ or archive.get("after_record") != record_to_dict(archived)
216
+ or list(archived.history) != list(record.history[:-1])
217
+ or record_to_dict(original) != record_to_dict(frozen)
218
+ or original.record_version != cleanup["expected_record_version"] or original.lifecycle != "RETAINED"
219
+ or original.owner != cleanup["owner"] or original.authority_key != cleanup["authority_key"]
220
+ or original.allocation_id != cleanup["allocation_id"]
221
+ or archived_plan.get("repository_id") != cleanup["repository_id"]
222
+ or archived_plan.get("target_path") != cleanup["target_path"]
223
+ or archived_plan.get("head") != cleanup["expected_head"] or archived_plan.get("branch") != journal["branch"]
224
+ or archived_plan.get("owner_release") != cleanup["owner"]
225
+ or any(archived_plan.get(key) is not False for key in ("task_completed", "integration_ready", "publication_asserted"))
226
+ or not any(ref.get("receipt_id") == terminal["completion_receipt_id"]
227
+ and ref.get("sha256") == terminal["completion_receipt_sha256"]
228
+ and e.matches_reference(ref, completion_path) for ref in references)):
229
+ return False
230
+ for ref in references:
231
+ e.read(Path(ref["path"]), ref["sha256"])
232
+ return _released_record(archived, record, release_plan)
233
+
234
+
235
+ def _source_absent(store: MetadataStore, source_plan: dict[str, Any], target: Path,
236
+ transaction: str) -> bool:
237
+ if os.path.lexists(target):
238
+ return source_absent(source_plan, target)
239
+ return _released(store, transaction)
240
+
241
+
242
+ def _common_root(store: MetadataStore, plans: list[dict[str, Any]], target: Path,
243
+ transaction: str) -> dict[str, int]:
244
+ if os.path.lexists(target):
245
+ return common_source_root(plans, target)
246
+ if not _released(store, transaction):
247
+ raise _blocked("absent checkout has no exact official release evidence")
248
+ pins = []
249
+ for plan in plans:
250
+ relative = p.source_relative_path(Path(plan["source"]).relative_to(target).as_posix())
251
+ if "source_ancestor_identities" in plan:
252
+ pin = plan["source_ancestor_identities"].get(str(target))
253
+ elif len(Path(relative).parts) == 1:
254
+ pin = plan["source_anchor"]["worktree"]
255
+ else:
256
+ pin = None
257
+ if pin is None or set(pin) != {"device", "inode", "file_type"} or pin["file_type"] != stat.S_IFDIR:
258
+ raise _blocked("missing or invalid original checkout root pin")
259
+ pins.append(pin)
260
+ if not pins or any(pin != pins[0] for pin in pins):
261
+ raise _blocked("preservation generations have different original checkout roots")
262
+ return pins[0]
263
+
264
+
265
+ def _proofset(plan: dict[str, Any], target: Path) -> list[dict[str, Any]]:
266
+ proofs = plan["preservation_evidences"]
267
+ names = [row["source_relative"] for row in proofs]
268
+ if (not isinstance(proofs, list) or len(proofs) < 2 or names != sorted(set(names))
269
+ or plan.get("preservation_evidence") != proofs[0]["evidence"]):
270
+ raise _blocked("the complete ordered multiple preservation proofset is required")
271
+ preserved = []
272
+ for row in proofs:
273
+ relative = p.source_relative_path(row["source_relative"])
274
+ if any(Path(relative) in Path(other).parents for other in names if other != relative):
275
+ raise _blocked("preserved sources overlap")
276
+ evidence = row["evidence"]
277
+ verified, source_plan = coverage(
278
+ Path(evidence["receipt_path"]), evidence["receipt_sha256"],
279
+ identity=_identity(plan), verify_source=False,
280
+ )
281
+ if verified != evidence or Path(source_plan["source"]).relative_to(target).as_posix() != relative:
282
+ raise _blocked("original preservation proof identity changed")
283
+ preserved.append(source_plan)
284
+ return preserved
285
+
286
+
287
+ def details(
288
+ store: MetadataStore, recovery: dict[str, Any],
289
+ ) -> tuple[dict[str, Any], list[dict[str, Any]], set[str], dict[str, Any]]:
290
+ """Verify historical bytes and prefix effects; current admission is checked separately."""
291
+ try:
292
+ expected = context(recovery["original_transaction_id"], recovery["original_transaction_sha256"],
293
+ recovery["original_cleanup_receipt_sha256"])
294
+ if any(recovery.get(key) != value for key, value in expected.items()):
295
+ raise _blocked("recovery history differs from its supported contract")
296
+ if set(recovery) - (set(expected) | {"recovery_id", "recovery_record_sha256"}):
297
+ raise _blocked("unknown recovery history assertions are not supported")
298
+ if ("recovery_id" in recovery) != ("recovery_record_sha256" in recovery):
299
+ raise _blocked("executing recovery requires both exact immutable record bindings")
300
+ if "recovery_id" in recovery:
301
+ _uuid(recovery["recovery_id"])
302
+ digest = recovery["recovery_record_sha256"]
303
+ if not isinstance(digest, str) or len(digest) != 64 or set(digest) - set("0123456789abcdef"):
304
+ raise _blocked("executing recovery record digest is invalid")
305
+ transaction = recovery["original_transaction_id"]
306
+ journal, _ = e.read(store.transactions_dir / f"{transaction}.json",
307
+ recovery["original_transaction_sha256"])
308
+ receipt, _ = e.read(store.completion_receipts_dir / f"{transaction}.json",
309
+ recovery["original_cleanup_receipt_sha256"])
310
+ plan = receipt["plan"]
311
+ target = Path(plan["target_path"])
312
+ identity = _identity(plan)
313
+ paths = plan["cleanup_paths"]
314
+ actions = receipt["actions"]
315
+ prefix = [row["path"] for row in actions if row["status"] == "COMPLETED"]
316
+ shape = [{"path": name, "status": "COMPLETED"} for name in prefix] + [
317
+ {"path": name, "status": "PENDING"} for name in paths[len(prefix):]
318
+ ]
319
+ rows = plan["resources"]
320
+ planned_paths = [row["path"] for row in rows if row["cleanup_action"] == "remove-tree"] + [
321
+ row["path"] for row in rows if row["cleanup_action"] == "remove-preserved-tree"
322
+ ]
323
+ if (receipt.get("receipt_id") != transaction or receipt.get("result") != "FAILED_OR_PARTIAL"
324
+ or receipt.get("contract") != CLEANUP_CONTRACT or plan.get("contract") != CLEANUP_CONTRACT
325
+ or not _plan_hash(plan) or plan.get("blockers") != [] or not target.is_absolute()
326
+ or not paths or paths != planned_paths or len(set(paths)) != len(paths)
327
+ or not prefix or len(prefix) >= len(paths) or prefix != paths[:len(prefix)]
328
+ or actions != shape or journal.get("actions") != shape
329
+ or journal.get("transaction_id") != transaction or journal.get("command") != "resource-clean"
330
+ or journal.get("phase") != "PREPARED" or journal.get("schema_version") not in {1, 2, 3, 4}
331
+ or journal.get("cleanup_plan") != plan or not isinstance(journal.get("branch"), str)
332
+ or not journal["branch"] or receipt.get("failure") != journal.get("failure")
333
+ or any(journal.get(key) != identity[key] for key in IDENTITY_KEYS[:5])
334
+ or identity["repository_id"] != store._read_json(store.root / "repo.json")["repository_id"]):
335
+ raise _blocked("only an exact COMPLETED prefix and untouched PENDING suffix are supported")
336
+ for name in paths:
337
+ p.source_relative_path(name)
338
+ proofs = tuple(receipt["action_evidence"])
339
+ if len(proofs) != len(prefix):
340
+ raise _blocked("every completed original action needs exact before and after evidence")
341
+ done, topology = completed_actions(
342
+ store, transaction, proofs, initial_topology=plan["hardlink_topology"], cleanup_paths=paths,
343
+ )
344
+ _action_files(store, transaction, len(prefix))
345
+ if done != set(prefix):
346
+ raise _blocked("pending actions have evidence or original action files are incomplete")
347
+ preserved = _proofset(plan, target)
348
+ preserved_rows = {row["path"]: row for row in rows if row["cleanup_action"] == "remove-preserved-tree"}
349
+ if set(preserved_rows) != {row["source_relative"] for row in plan["preservation_evidences"]}:
350
+ raise _blocked("original resources differ from the full preservation proofset")
351
+ for proof, source_plan in zip(plan["preservation_evidences"], preserved, strict=True):
352
+ row = preserved_rows[proof["source_relative"]]
353
+ if row.get("classification") != "exact-preserved" or row.get("evidence") != proof["evidence"]:
354
+ raise _blocked("original resource preservation binding changed")
355
+ if proof["source_relative"] in done and not _source_absent(store, source_plan, target, transaction):
356
+ raise _blocked("completed preserved source reappeared")
357
+ if any(os.path.lexists(target / name) for name in done):
358
+ raise _blocked("completed original resource reappeared; replay is forbidden")
359
+ root = _common_root(store, preserved, target, transaction)
360
+ if any(anchor["worktree"] != root for anchor in plan["hardlink_topology"]["anchors"].values()):
361
+ raise _blocked("original graph is bound to another checkout root")
362
+ return receipt, preserved, done, topology
363
+ except ControllerError:
364
+ raise
365
+ except (OSError, ValueError, TypeError, KeyError, IndexError, StopIteration) as exc:
366
+ raise _blocked("original multi cleanup evidence is unavailable or malformed") from exc
367
+
368
+
369
+ def original(store: MetadataStore, recovery: dict[str, Any], *, target: Path | None = None,
370
+ allocation_id: str | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
371
+ receipt, preserved, _, _ = details(store, recovery)
372
+ _guard(store, recovery)
373
+ if ((target is not None and receipt["plan"]["target_path"] != str(target))
374
+ or (allocation_id is not None and receipt["plan"]["allocation_id"] != allocation_id)):
375
+ raise _blocked("original cleanup belongs to another target or allocation")
376
+ return receipt, preserved[0]
377
+
378
+
379
+ def _guard(store: MetadataStore, recovery: dict[str, Any], *, allow_successor: bool = True) -> None:
380
+ from .transaction_scope import is_atomic_temp, terminal_state
381
+
382
+ parent = store.cleanup_recoveries_dir / recovery["original_transaction_id"]
383
+ if recovery.get("recovery_id"):
384
+ directory = _directory(store, recovery["original_transaction_id"], recovery["recovery_id"])
385
+ record, _ = load_record(store, directory / "record.json", recovery["recovery_record_sha256"])
386
+ if record["plan"]["recovery"] != context(recovery["original_transaction_id"],
387
+ recovery["original_transaction_sha256"], recovery["original_cleanup_receipt_sha256"]):
388
+ raise _blocked("executing recovery does not bind its immutable baseline")
389
+ elif list(parent.glob("*/started.json")):
390
+ raise _blocked("started recovery cannot replay; exact terminal reconciliation is required")
391
+ for path in store.transactions_dir.iterdir():
392
+ if is_atomic_temp(path.name) or path.stem == recovery["original_transaction_id"]:
393
+ continue
394
+ if not path.is_file() or path.suffix != ".json":
395
+ raise _blocked("unknown transaction entry blocks recovery")
396
+ other, _ = e.read(path)
397
+ if (allow_successor and recovery.get("recovery_id") and other.get("command") == "resource-clean"
398
+ and other.get("cleanup_plan", {}).get("recovery") == recovery):
399
+ continue
400
+ if terminal_state(store, path.stem, verify_generations=True) is None:
401
+ raise _blocked("another interrupted transaction requires its own recovery")
402
+
403
+
404
+ def _inputs(plan: dict[str, Any]) -> dict[str, Any]:
405
+ proofs = plan["preservation_evidences"]
406
+ return {
407
+ "preservation_receipt": Path(proofs[0]["evidence"]["receipt_path"]),
408
+ "preservation_receipt_sha256": proofs[0]["evidence"]["receipt_sha256"],
409
+ "additional_preservation_receipts": tuple(Path(row["evidence"]["receipt_path"]) for row in proofs[1:]),
410
+ "additional_preservation_receipt_sha256": tuple(row["evidence"]["receipt_sha256"] for row in proofs[1:]),
411
+ }
412
+
413
+
414
+ def _plan_locked(repo: Path, store: MetadataStore, target: Path, owner: str, version: int,
415
+ recovery: dict[str, Any]) -> dict[str, Any]:
416
+ from .lifecycle import _typed_cleanup_plan_locked
417
+ from .semantics import managed_record as _managed_record
418
+
419
+ old, _, done, topology = details(store, recovery)
420
+ _guard(store, recovery)
421
+ repository = inventory(repo)
422
+ snapshot = store._load_unlocked()
423
+ item, record = _managed_record(repository, snapshot, target)
424
+ current = _typed_cleanup_plan_locked(repository, snapshot, store, item, record,
425
+ owner=owner, expected_record_version=version, recovery=recovery, **_inputs(old["plan"]))
426
+ expected = old["plan"]
427
+ suffix = expected["cleanup_paths"][len(done):]
428
+ old_rows = {row["path"]: row for row in expected["resources"]}
429
+ if (current["blockers"] or _identity(current) != _identity(expected)
430
+ or item.branch != store._read_json(store.transactions_dir / f"{recovery['original_transaction_id']}.json")["branch"]
431
+ or current.get("contract") != CLEANUP_CONTRACT or current["cleanup_paths"] != suffix
432
+ or current.get("preservation_evidences") != expected["preservation_evidences"]
433
+ or current["hardlink_topology"] != topology
434
+ or any(row != old_rows.get(row["path"]) for row in current["resources"] if row["path"] in suffix)):
435
+ raise _blocked("current resource, topology, identity or runtime guards differ from the proven suffix")
436
+ return current
437
+
438
+
439
+ def plan(repo: Path, *, target: Path, owner: str, version: int,
440
+ recovery: dict[str, Any]) -> dict[str, Any]:
441
+ store = MetadataStore(inventory(repo).common_dir)
442
+ with store.lock(exclusive=False):
443
+ return _plan_locked(repo, store, target, owner, version, recovery)
444
+
445
+
446
+ def _directory(store: MetadataStore, transaction: str, recovery_id: str) -> Path:
447
+ _uuid(transaction)
448
+ _uuid(recovery_id)
449
+ return store.cleanup_recoveries_dir / transaction / recovery_id
450
+
451
+
452
+ def prepare(repo: Path, *, target: Path, owner: str, version: int, recovery: dict[str, Any],
453
+ dry_run: bool, expected_plan_id: str | None = None) -> dict[str, Any]:
454
+ from .controller import status
455
+
456
+ store = MetadataStore(inventory(repo).common_dir)
457
+ with store.lock(exclusive=not dry_run):
458
+ current = _plan_locked(repo, store, target, owner, version, recovery)
459
+ decision = {"code": "MULTIPLE_CLEANUP_RECOVERABLE", "plan": current, "dry_run": dry_run}
460
+ if not dry_run:
461
+ if expected_plan_id != current["plan_id"]:
462
+ raise _blocked("exact fresh recovery dry-run plan required")
463
+ parent = store.cleanup_recoveries_dir / recovery["original_transaction_id"]
464
+ if parent.exists() and list(parent.glob("*/record.json")):
465
+ raise _blocked("an immutable recovery baseline already exists")
466
+ recovery_id = str(uuid.uuid4())
467
+ directory = _directory(store, recovery["original_transaction_id"], recovery_id)
468
+ old, _, done, topology = details(store, recovery)
469
+ allocation_record = store._load_unlocked().records[current["allocation_id"]]
470
+ record = {
471
+ "contract": CONTRACT, "result": "MULTIPLE_CLEANUP_RECOVERABLE",
472
+ "recovery_id": recovery_id, "plan": current,
473
+ "original_plan_id": old["plan"]["plan_id"],
474
+ "original_preservation_evidences": old["plan"]["preservation_evidences"],
475
+ "completed_resources": old["plan"]["cleanup_paths"][:len(done)],
476
+ "remaining_resources": current["cleanup_paths"],
477
+ "prefix_topology_sha256": topology["sha256"],
478
+ "allocation_record": record_to_dict(allocation_record),
479
+ "generation_metadata": _generation_metadata(_identity(current)),
480
+ "old_transaction_remains_immutable": True, "completed_action_will_replay": False,
481
+ "timestamp": now_iso(),
482
+ }
483
+ decision.update(recovery_id=recovery_id, recovery_receipt=e.write(directory, "record.json", record))
484
+ result = status(repo)
485
+ result.update(command="resource-recover", decision=decision)
486
+ return result
487
+
488
+
489
+ def load_record(store: MetadataStore, recovery_path: Path,
490
+ recovery_sha: str) -> tuple[dict[str, Any], Path]:
491
+ try:
492
+ return _load_record(store, recovery_path, recovery_sha)
493
+ except ControllerError:
494
+ raise
495
+ except (OSError, ValueError, TypeError, KeyError, IndexError, StopIteration) as exc:
496
+ raise _blocked("immutable multiple recovery record is unavailable or malformed") from exc
497
+
498
+
499
+ def _load_record(store: MetadataStore, recovery_path: Path,
500
+ recovery_sha: str) -> tuple[dict[str, Any], Path]:
501
+ record, _ = e.read(recovery_path, recovery_sha)
502
+ _frozen_authority(record)
503
+ recovery = record["plan"]["recovery"]
504
+ directory = _directory(store, recovery["original_transaction_id"], record["recovery_id"])
505
+ old, _, done, topology = details(store, recovery)
506
+ baseline = record["plan"]
507
+ suffix = old["plan"]["cleanup_paths"][len(done):]
508
+ old_rows = {row["path"]: row for row in old["plan"]["resources"]}
509
+ if (not e.matches_reference({"path": str(recovery_path), "sha256": recovery_sha}, directory / "record.json")
510
+ or record.get("contract") != CONTRACT or record.get("result") != "MULTIPLE_CLEANUP_RECOVERABLE"
511
+ or record.get("old_transaction_remains_immutable") is not True
512
+ or record.get("completed_action_will_replay") is not False
513
+ or record.get("original_plan_id") != old["plan"]["plan_id"]
514
+ or record.get("original_preservation_evidences") != old["plan"]["preservation_evidences"]
515
+ or record.get("completed_resources") != old["plan"]["cleanup_paths"][:len(done)]
516
+ or record.get("remaining_resources") != old["plan"]["cleanup_paths"][len(done):]
517
+ or record.get("prefix_topology_sha256") != topology["sha256"]
518
+ or not _plan_hash(baseline) or baseline.get("contract") != CLEANUP_CONTRACT
519
+ or _identity(baseline) != _identity(old["plan"])
520
+ or baseline.get("cleanup_paths") != suffix or baseline.get("hardlink_topology") != topology
521
+ or baseline.get("preservation_evidences") != old["plan"]["preservation_evidences"]
522
+ or baseline.get("preservation_evidence") != old["plan"]["preservation_evidence"]
523
+ or baseline.get("blockers") != []
524
+ or any(row != old_rows.get(row["path"]) for row in baseline["resources"] if row["path"] in suffix)):
525
+ raise _blocked("invalid immutable multiple recovery record")
526
+ return record, directory
527
+
528
+
529
+ def recovered_receipt(store: MetadataStore, receipt: dict[str, Any],
530
+ bound: dict[str, Any] | None = None) -> list[dict[str, Any]]:
531
+ """Verify a successful suffix receipt against its original full proofset and prefix."""
532
+ try:
533
+ return _recovered_receipt(store, receipt, bound)
534
+ except ControllerError:
535
+ raise
536
+ except (OSError, ValueError, TypeError, KeyError, IndexError, StopIteration) as exc:
537
+ raise _blocked("multiple recovery completion is unavailable or malformed") from exc
538
+
539
+
540
+ def _recovered_receipt(store: MetadataStore, receipt: dict[str, Any],
541
+ bound: dict[str, Any] | None = None) -> list[dict[str, Any]]:
542
+ successor = receipt["plan"]
543
+ executing = successor["recovery"]
544
+ record_sha = executing["recovery_record_sha256"]
545
+ directory = _directory(store, executing["original_transaction_id"], executing["recovery_id"])
546
+ record, _ = load_record(store, directory / "record.json", record_sha)
547
+ baseline = record["plan"]
548
+ expected_recovery = {**baseline["recovery"], "recovery_id": record["recovery_id"],
549
+ "recovery_record_sha256": record_sha}
550
+ old, preserved, done, topology = details(store, expected_recovery)
551
+ suffix = old["plan"]["cleanup_paths"][len(done):]
552
+ post = receipt["post_scan"]
553
+ if (receipt.get("contract") != CLEANUP_CONTRACT or receipt.get("result") != "CLEANED"
554
+ or successor.get("contract") != CLEANUP_CONTRACT or executing != expected_recovery
555
+ or _identity(successor) != _identity(old["plan"])
556
+ or (bound is not None and _identity(successor) != bound)
557
+ or successor.get("blockers") != [] or not _plan_hash(successor)
558
+ or successor.get("preservation_evidences") != old["plan"]["preservation_evidences"]
559
+ or successor.get("preservation_evidence") != old["plan"]["preservation_evidence"]
560
+ or successor.get("hardlink_topology") != topology or successor.get("cleanup_paths") != suffix
561
+ or receipt.get("actions") != [{"path": name, "status": "COMPLETED"} for name in suffix]
562
+ or len(receipt.get("action_evidence", [])) != len(suffix)
563
+ or any(successor.get(key) != value for key, value in baseline.items() if key not in {"plan_id", "recovery", "runtime_evidence", "warnings"})
564
+ or post.get("contract") != CLEANUP_CONTRACT or post.get("recovery") != expected_recovery
565
+ or _identity(post) != _identity(successor) or not _plan_hash(post)
566
+ or post.get("preservation_evidences") != old["plan"]["preservation_evidences"]
567
+ or post.get("preservation_evidence") != old["plan"]["preservation_evidence"]
568
+ or post.get("blockers") != [] or post.get("cleanup_paths") != []):
569
+ raise _blocked("successful suffix completion differs from its immutable recovery baseline")
570
+ completed, final = completed_actions(store, receipt["receipt_id"], tuple(receipt["action_evidence"]),
571
+ initial_topology=topology, cleanup_paths=suffix)
572
+ _action_files(store, receipt["receipt_id"], len(suffix))
573
+ if completed != set(suffix) or final != post.get("hardlink_topology") or final["resources"]:
574
+ raise _blocked("suffix action chain is incomplete or its final scan changed")
575
+ target = Path(successor["target_path"])
576
+ if any(os.path.lexists(target / name) for name in old["plan"]["cleanup_paths"]):
577
+ raise _blocked("a completed cleanup resource reappeared")
578
+ for source_plan in preserved:
579
+ if not _source_absent(store, source_plan, target, executing["original_transaction_id"]):
580
+ raise _blocked("completed preservation source or ancestors changed")
581
+ return preserved
582
+
583
+
584
+ def _append_terminal(store: MetadataStore, record: dict[str, Any], directory: Path,
585
+ record_sha: str, receipt: dict[str, Any], receipt_sha: str) -> dict[str, str]:
586
+ recovered_receipt(store, receipt)
587
+ executing = receipt["plan"]["recovery"]
588
+ _guard(store, executing, allow_successor=False)
589
+ started, started_sha = e.read(directory / "started.json")
590
+ if (started.get("contract") != CONTRACT or started.get("phase") != "ATTEMPT_STARTED"
591
+ or started.get("record_sha256") != record_sha or started.get("recovery_id") != record["recovery_id"]):
592
+ raise _blocked("completion requires the exact immutable started recovery")
593
+ value = {
594
+ "contract": CONTRACT, "result": "CLEANED",
595
+ "original_transaction_id": record["plan"]["recovery"]["original_transaction_id"],
596
+ "record_sha256": record_sha,
597
+ "started": {"path": str(directory / "started.json"), "sha256": started_sha},
598
+ "completion_receipt_id": receipt["receipt_id"], "completion_receipt_sha256": receipt_sha,
599
+ "completed_action_will_replay": False, "timestamp": now_iso(),
600
+ }
601
+ path = directory / "complete.json"
602
+ if path.exists():
603
+ existing, digest = e.read(path)
604
+ if any(existing.get(key) != item for key, item in value.items() if key != "timestamp"):
605
+ raise _blocked("existing terminal evidence differs")
606
+ return {"path": str(path), "sha256": digest}
607
+ return e.write(directory, "complete.json", value)
608
+
609
+
610
+ def resume(repo: Path, *, recovery_path: Path, recovery_sha: str, dry_run: bool) -> dict[str, Any]:
611
+ from .controller import status
612
+ from .lifecycle import cleanup_typed_disposable
613
+
614
+ store = MetadataStore(inventory(repo).common_dir)
615
+ with store.lock(exclusive=not dry_run):
616
+ record, directory = load_record(store, recovery_path, recovery_sha)
617
+ baseline = record["plan"]
618
+ executing = {**baseline["recovery"], "recovery_id": record["recovery_id"],
619
+ "recovery_record_sha256": recovery_sha}
620
+ if (directory / "started.json").exists():
621
+ _guard(store, executing)
622
+ successful = []
623
+ for path in store.completion_receipts_dir.glob("*.json"):
624
+ receipt, digest = e.read(path)
625
+ if receipt.get("plan", {}).get("recovery") == executing and receipt.get("result") == "CLEANED":
626
+ recovered_receipt(store, receipt)
627
+ successful.append((receipt, digest))
628
+ if len(successful) != 1:
629
+ raise _blocked("started recovery cannot replay; partial or unknown effects require separate recovery")
630
+ receipt, digest = successful[0]
631
+ reference = {"path": str(directory / "complete.json")}
632
+ if not dry_run:
633
+ reference = _append_terminal(store, record, directory, recovery_sha, receipt, digest)
634
+ verified_completion(store, executing["original_transaction_id"])
635
+ else:
636
+ _guard(store, executing, allow_successor=False)
637
+ if (directory / "complete.json").exists():
638
+ verified_completion(store, executing["original_transaction_id"])
639
+ decision = {"code": "MULTIPLE_RECOVERY_COMPLETION_READY" if dry_run and not (directory / "complete.json").exists()
640
+ else "MULTIPLE_RECOVERY_CLEANED", "dry_run": dry_run,
641
+ "receipt_id": receipt["receipt_id"], "recovery_completion": reference,
642
+ "completed_action_will_replay": False}
643
+ else:
644
+ current = _plan_locked(repo, store, Path(baseline["target_path"]), baseline["owner"],
645
+ baseline["expected_record_version"], baseline["recovery"])
646
+ allocation = store._load_unlocked().records[baseline["allocation_id"]]
647
+ if record_to_dict(allocation) != record["allocation_record"]:
648
+ raise _blocked("live allocation record differs from the frozen recovery authority")
649
+ if p._sha(current) != p._sha(baseline):
650
+ raise _blocked("current state differs from the immutable recovery plan")
651
+ if dry_run:
652
+ decision = {"code": "MULTIPLE_CLEANUP_RECOVERABLE", "plan": current, "dry_run": True}
653
+ else:
654
+ e.write(directory, "started.json", {"contract": CONTRACT, "record_sha256": recovery_sha,
655
+ "recovery_id": record["recovery_id"], "phase": "ATTEMPT_STARTED", "timestamp": now_iso()})
656
+ decision = None
657
+ if decision is None:
658
+ options = dict(target=Path(baseline["target_path"]), owner=baseline["owner"],
659
+ expected_record_version=baseline["expected_record_version"], recovery=executing, **_inputs(baseline))
660
+ planned = cleanup_typed_disposable(repo, **options, dry_run=True)
661
+ if planned["decision"]["plan"]["blockers"]:
662
+ raise _blocked("recovery execution preflight is blocked; started evidence retained")
663
+ cleaned = cleanup_typed_disposable(repo, **options, dry_run=False,
664
+ expected_plan_id=planned["decision"]["plan"]["plan_id"])
665
+ receipt_id = cleaned["decision"]["receipt_id"]
666
+ if not receipt_id:
667
+ raise _blocked("recovery has no exact successful successor receipt")
668
+ with store.lock(exclusive=True):
669
+ receipt, digest = e.read(store.completion_receipts_dir / f"{receipt_id}.json")
670
+ reference = _append_terminal(store, record, directory, recovery_sha, receipt, digest)
671
+ verified_completion(store, executing["original_transaction_id"])
672
+ decision = {"code": "MULTIPLE_RECOVERY_CLEANED", "receipt_id": receipt_id,
673
+ "recovery_id": record["recovery_id"], "recovery_completion": reference,
674
+ "completed_action_will_replay": False}
675
+ result = status(repo)
676
+ result.update(command="resource-recover", decision=decision)
677
+ return result
678
+
679
+
680
+ def verified_completion(store: MetadataStore, transaction: str,
681
+ bound: dict[str, Any] | None = None) -> list[dict[str, Any]]:
682
+ try:
683
+ return _verified_completion(store, transaction, bound)
684
+ except ControllerError:
685
+ raise
686
+ except (OSError, ValueError, TypeError, KeyError, IndexError, StopIteration) as exc:
687
+ raise _blocked("multiple recovery terminal is unavailable or malformed") from exc
688
+
689
+
690
+ def _verified_completion(store: MetadataStore, transaction: str,
691
+ bound: dict[str, Any] | None = None) -> list[dict[str, Any]]:
692
+ parent = store.cleanup_recoveries_dir / transaction
693
+ terminals = list(parent.glob("*/complete.json"))
694
+ if len(terminals) != 1:
695
+ raise _blocked("a unique immutable multiple recovery completion is required")
696
+ terminal, _ = e.read(terminals[0])
697
+ record, directory = load_record(store, terminals[0].parent / "record.json", terminal["record_sha256"])
698
+ recovery = record["plan"]["recovery"]
699
+ if (terminal.get("contract") != CONTRACT or terminal.get("result") != "CLEANED"
700
+ or terminal.get("original_transaction_id") != transaction
701
+ or recovery["original_transaction_id"] != transaction
702
+ or terminal.get("completed_action_will_replay") is not False
703
+ or not e.matches_reference(terminal["started"], directory / "started.json")):
704
+ raise _blocked("invalid multiple recovery terminal binding")
705
+ started, _ = e.read(directory / "started.json", terminal["started"]["sha256"])
706
+ if (started.get("contract") != CONTRACT or started.get("phase") != "ATTEMPT_STARTED"
707
+ or started.get("record_sha256") != terminal["record_sha256"]
708
+ or started.get("recovery_id") != record["recovery_id"]):
709
+ raise _blocked("terminal does not bind the exact started recovery")
710
+ _uuid(terminal["completion_receipt_id"])
711
+ receipt, _ = e.read(store.completion_receipts_dir / f"{terminal['completion_receipt_id']}.json",
712
+ terminal["completion_receipt_sha256"])
713
+ if receipt.get("receipt_id") != terminal["completion_receipt_id"]:
714
+ raise _blocked("terminal successor receipt identity differs")
715
+ return recovered_receipt(store, receipt, bound)
716
+
717
+
718
+ def completed(store: MetadataStore, transaction: str, *, verify_generations: bool = False) -> bool:
719
+ # Full generation verification remains mandatory for both classification modes.
720
+ parent = store.cleanup_recoveries_dir / transaction
721
+ terminals = list(parent.glob("*/complete.json"))
722
+ if not terminals:
723
+ return False
724
+ if len(terminals) != 1:
725
+ raise _blocked("ambiguous recovery completion")
726
+ terminal, _ = e.read(terminals[0])
727
+ if terminal.get("contract") != CONTRACT:
728
+ return False
729
+ verified_completion(store, transaction)
730
+ return True