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,928 @@
1
+ """Owner-authorized closeout of checkouts already contained in publication.
2
+
3
+ Closeout changes lifecycle metadata only. It neither invents task completion nor
4
+ rewrites historical task evidence. Physical removal remains the guarded release path.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import uuid
12
+ from dataclasses import replace
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .git_backend import (
18
+ REMOTE_NAME_PATTERN,
19
+ _git,
20
+ inventory,
21
+ is_ancestor,
22
+ resolve_commit,
23
+ validate_branch_name,
24
+ )
25
+ from .models import (
26
+ ACTIVE_AUTHORITY_STATES,
27
+ ControllerError,
28
+ MetadataRecord,
29
+ MetadataSnapshot,
30
+ RepositoryInventory,
31
+ WorktreeGitState,
32
+ )
33
+ from .policy import load_policy
34
+ from .store import MetadataStore, record_from_dict, record_to_dict
35
+
36
+ CONTRACT = "delivered-checkout-closeout/v1"
37
+ STATE = "RELEASE_READY"
38
+ EVENT = "DELIVERED_CHECKOUT_CLOSED"
39
+ ARCHIVE_CONTRACT = "retained-checkout-archive/v1"
40
+ ARCHIVE_STATE = "ARCHIVED"
41
+ ARCHIVE_EVENT = "RETAINED_CHECKOUT_ARCHIVED"
42
+ NO_CHANGE_CONTRACT = "no-change-cleanup-closeout/v1"
43
+ NO_CHANGE_RECOVERY_EVENT = "NO_CHANGE_CLEANUP_RECONCILED"
44
+
45
+
46
+ def _no_change_fail(message: str) -> ControllerError:
47
+ return ControllerError("NO_CHANGE_CLOSEOUT_INVALID", message)
48
+
49
+
50
+ def _no_change_time(value: str) -> datetime:
51
+ result = datetime.fromisoformat(value)
52
+ if result.tzinfo is None:
53
+ raise _no_change_fail("no-change evidence requires an aware timestamp")
54
+ return result
55
+
56
+
57
+ def _no_change_transition(original: MetadataRecord, event: dict[str, Any]) -> MetadataRecord:
58
+ if (original.lifecycle not in {"ACTIVE", "RETAINED"}
59
+ or original.role not in {"feature", "audit"}
60
+ or event.get("event") != "NO_CHANGE_COMPLETED"
61
+ or event.get("from") != original.lifecycle
62
+ or event.get("to") != "NO_CHANGE_COMPLETED"
63
+ or event.get("owner") != original.owner):
64
+ raise _no_change_fail("no-change completion is not the exact original transition")
65
+ if _no_change_time(event["at"]) < _no_change_time(original.last_reviewed_at):
66
+ raise _no_change_fail("no-change completion predates the original record")
67
+ return replace(original, lifecycle="NO_CHANGE_COMPLETED",
68
+ record_version=original.record_version + 1,
69
+ last_reviewed_at=event["at"], history=(*original.history, event))
70
+
71
+
72
+ def _no_change_identity(receipt: dict, original: MetadataRecord,
73
+ item: WorktreeGitState, repository_id: str | None) -> None:
74
+ expected = {
75
+ "kind": "no_change_completion", "schema_version": 1,
76
+ "repository_id": repository_id, "allocation_id": original.allocation_id,
77
+ "task_key": original.task_key, "authority_key": original.authority_key,
78
+ "role": original.role, "owner": original.owner, "branch": item.branch,
79
+ "head": item.head, "base_commit": original.base_commit,
80
+ "worktree_clean": True, "handoff_required": False, "task_completed": True,
81
+ "source_cleanup_evidence": [], "external_bindings": [],
82
+ }
83
+ if (any(receipt.get(key) != value for key, value in expected.items())
84
+ or (receipt.get("contract") == NO_CHANGE_CONTRACT and receipt.get("target_path") != str(item.path))
85
+ or (original.base_commit is not None and original.base_commit != item.head)
86
+ or original.handoff_commit or original.finalizer_record_id
87
+ or original.finalizer_commit or original.external_bindings):
88
+ raise _no_change_fail("no-change completion identity changed")
89
+
90
+
91
+ def _completion_document(store: MetadataStore, ref: dict[str, str]) -> dict:
92
+ from .cleanup_evidence import matches_reference, read
93
+
94
+ if (set(ref) != {"receipt_id", "path", "sha256"}
95
+ or str(uuid.UUID(ref["receipt_id"])) != ref["receipt_id"]
96
+ or not matches_reference(ref, store.completion_receipts_dir / f"{ref['receipt_id']}.json")):
97
+ raise _no_change_fail("completion reference differs from its exact canonical identity")
98
+ value, _ = read(Path(ref["path"]), ref["sha256"])
99
+ if value.get("receipt_id") != ref["receipt_id"]:
100
+ raise _no_change_fail("completion receipt identity changed")
101
+ return value
102
+
103
+
104
+ def _legacy_no_change(store: MetadataStore, item: WorktreeGitState, record: MetadataRecord,
105
+ repository_id: str | None, ref: dict[str, str]) -> MetadataRecord:
106
+ from .cleanup_evidence import read
107
+
108
+ event = record.history[-1]
109
+ if (record.lifecycle != "NO_CHANGE_COMPLETED" or event.get("no_change_completion_ref")
110
+ or event.get("event") != "NO_CHANGE_COMPLETED" or record.record_version < 2
111
+ or sum(row.get("event") == "NO_CHANGE_COMPLETED" for row in record.history) != 1
112
+ or any(row.get("event") == NO_CHANGE_RECOVERY_EVENT for row in record.history)):
113
+ raise _no_change_fail("only one unreconciled legacy completion transition is eligible")
114
+ original = replace(record, lifecycle=event["from"], record_version=record.record_version - 1,
115
+ last_reviewed_at=record.history[-2]["at"] if len(record.history) > 1 else record.created_at,
116
+ history=record.history[:-1])
117
+ if _no_change_transition(original, event) != record:
118
+ raise _no_change_fail("legacy completion is not the exact +1 transition")
119
+ receipt = _completion_document(store, ref)
120
+ if receipt.get("contract") or "before_record" in receipt:
121
+ raise _no_change_fail("new completion evidence cannot be reclassified as legacy")
122
+ _no_change_identity(receipt, original, item, repository_id)
123
+ matches = []
124
+ for path in sorted(store.completion_receipts_dir.glob("*.json")):
125
+ candidate, _ = read(path)
126
+ if (candidate.get("kind") == "no_change_completion"
127
+ and candidate.get("allocation_id") == record.allocation_id):
128
+ matches.append(candidate.get("receipt_id"))
129
+ if matches != [ref["receipt_id"]]:
130
+ raise _no_change_fail("legacy completion evidence is missing or ambiguous")
131
+ times = [_no_change_time(value) for value in (
132
+ original.last_reviewed_at, receipt["completed_at"], event["at"],
133
+ )]
134
+ if not times[0] <= times[1] <= times[2]:
135
+ raise _no_change_fail("legacy completion precedes the original recorded transition")
136
+ return original
137
+
138
+
139
+ def verify_no_change_cleanup(store: MetadataStore, item: WorktreeGitState,
140
+ record: MetadataRecord, repository_id: str | None) -> list[dict[str, str]]:
141
+ """Bridge only frozen normal transitions; cleanup keeps its original exact CAS."""
142
+ from .preserved_cleanup import cleanup_receipts
143
+
144
+ try:
145
+ event = record.history[-1]
146
+ receipt = _completion_document(store, event["no_change_completion_ref"])
147
+ if receipt.get("contract") != NO_CHANGE_CONTRACT or record.lifecycle != "NO_CHANGE_COMPLETED":
148
+ raise _no_change_fail("no-change closeout contract or lifecycle changed")
149
+ if receipt.get("kind") == "no_change_completion":
150
+ original = record_from_dict(receipt["before_record"])
151
+ if (_no_change_transition(original, event) != record
152
+ or {key: value for key, value in event.items() if key != "no_change_completion_ref"}
153
+ != receipt["completion_event"]):
154
+ raise _no_change_fail("completion after-record changed")
155
+ _no_change_identity(receipt, original, item, repository_id)
156
+ if not (_no_change_time(original.last_reviewed_at)
157
+ <= _no_change_time(receipt["completed_at"]) <= _no_change_time(event["at"])):
158
+ raise _no_change_fail("completion timestamp differs from its transition")
159
+ refs = receipt["preservation_cleanup_receipts"]
160
+ elif receipt.get("kind") == "no_change_cleanup_recovery":
161
+ plan = receipt["plan"]
162
+ legacy = record_from_dict(plan["record"])
163
+ exact_event = {"at": receipt["reconciled_at"], "event": NO_CHANGE_RECOVERY_EVENT,
164
+ "from": "NO_CHANGE_COMPLETED", "to": "NO_CHANGE_COMPLETED",
165
+ "owner": legacy.owner, "no_change_completion_ref": event["no_change_completion_ref"]}
166
+ expected = replace(legacy, record_version=legacy.record_version + 1,
167
+ last_reviewed_at=event["at"], history=(*legacy.history, event))
168
+ if (event != exact_event or event.get("event") != NO_CHANGE_RECOVERY_EVENT
169
+ or event.get("from") != "NO_CHANGE_COMPLETED" or event.get("to") != "NO_CHANGE_COMPLETED"
170
+ or event.get("owner") != legacy.owner or expected != record
171
+ or event["at"] != receipt["reconciled_at"]
172
+ or _no_change_time(event["at"]) < _no_change_time(legacy.last_reviewed_at)
173
+ or receipt["plan_id"] != _digest(plan)
174
+ or plan["repository_id"] != repository_id or plan["target_path"] != str(item.path)):
175
+ raise _no_change_fail("reconciliation after-record or plan changed")
176
+ original = _legacy_no_change(store, item, legacy, repository_id, plan["completion_ref"])
177
+ if record_to_dict(original) != plan["cleanup_record"]:
178
+ raise _no_change_fail("original cleanup record changed")
179
+ refs = plan["preservation_cleanup_receipts"]
180
+ else:
181
+ raise _no_change_fail("unsupported no-change completion evidence")
182
+ cleanup_receipts(store, item, original, repository_id, expected_refs=refs)
183
+ # Verified zero-effect refusals still activate release's runtime/writer guards.
184
+ return cleanup_receipts(store, item, original, repository_id, include_no_action_refs=True)
185
+ except ControllerError:
186
+ raise
187
+ except (KeyError, TypeError, ValueError, IndexError, OSError) as exc:
188
+ raise _no_change_fail("no-change closeout evidence is unavailable or changed") from exc
189
+
190
+
191
+ def reconcile_no_change_completion(repository_path: Path, *, target: Path, owner: str,
192
+ expected_record_version: int, completion_id: str,
193
+ completion_sha256: str, cleanup_ids: tuple[str, ...],
194
+ cleanup_sha256: tuple[str, ...], dry_run: bool,
195
+ expected_plan_id: str | None = None) -> dict[str, Any]:
196
+ try:
197
+ return _reconcile_no_change_completion(
198
+ repository_path, target=target, owner=owner,
199
+ expected_record_version=expected_record_version,
200
+ completion_id=completion_id, completion_sha256=completion_sha256,
201
+ cleanup_ids=cleanup_ids, cleanup_sha256=cleanup_sha256,
202
+ dry_run=dry_run, expected_plan_id=expected_plan_id,
203
+ )
204
+ except ControllerError:
205
+ raise
206
+ except (KeyError, TypeError, ValueError, IndexError, OSError) as exc:
207
+ raise _no_change_fail("reconciliation identity or evidence is unavailable or malformed") from exc
208
+
209
+
210
+ def _reconcile_no_change_completion(repository_path: Path, *, target: Path, owner: str,
211
+ expected_record_version: int, completion_id: str,
212
+ completion_sha256: str, cleanup_ids: tuple[str, ...],
213
+ cleanup_sha256: tuple[str, ...], dry_run: bool,
214
+ expected_plan_id: str | None) -> dict[str, Any]:
215
+ from .cleanup_evidence import read, write
216
+ from .controller import status
217
+ from .lifecycle import _release_transaction
218
+ from .preserved_cleanup import cleanup_receipts
219
+ from .semantics import assert_record_version as _cas
220
+ from .semantics import managed_record as _managed_record
221
+
222
+ def reference(identity: str, digest: str) -> dict[str, str]:
223
+ if str(uuid.UUID(identity)) != identity:
224
+ raise _no_change_fail("canonical completion receipt ID required")
225
+ return {"receipt_id": identity, "path": str(store.completion_receipts_dir / f"{identity}.json"), "sha256": digest}
226
+
227
+ store = MetadataStore(inventory(repository_path).common_dir)
228
+ with store.lock(exclusive=not dry_run):
229
+ repository = inventory(repository_path)
230
+ snapshot = store._load_unlocked()
231
+ item, record = _managed_record(repository, snapshot, target)
232
+ if record.owner != owner:
233
+ raise ControllerError("OWNER_MISMATCH", "completion reconciliation owner does not match")
234
+ _eligible(repository, snapshot, item, record, state="NO_CHANGE_COMPLETED", require_publication=False)
235
+ if _release_transaction(store, target_path=str(item.path), allocation_id=record.allocation_id):
236
+ raise ControllerError("INTERRUPTED_TRANSACTION", "pending transaction blocks reconciliation")
237
+ if not cleanup_ids or len(cleanup_ids) != len(cleanup_sha256) or len(set(cleanup_ids)) != len(cleanup_ids):
238
+ raise _no_change_fail("distinct paired successful cleanup receipt IDs and SHA256 values required")
239
+ refs = sorted((reference(identity, digest) for identity, digest in zip(cleanup_ids, cleanup_sha256, strict=True)),
240
+ key=lambda ref: ref["receipt_id"])
241
+ completion_ref = reference(completion_id, completion_sha256)
242
+ request = {"owner": owner, "expected_record_version": expected_record_version,
243
+ "completion_ref": completion_ref, "cleanup_refs": refs}
244
+ if record.history[-1].get("event") == NO_CHANGE_RECOVERY_EVENT:
245
+ completed = _completion_document(store, record.history[-1]["no_change_completion_ref"])
246
+ if completed["plan"]["request"] != request:
247
+ raise _no_change_fail("reconciliation replay differs from the original request")
248
+ if expected_plan_id is not None and expected_plan_id != completed["plan_id"]:
249
+ raise _no_change_fail("reconciliation replay differs from the original plan")
250
+ verify_no_change_cleanup(store, item, record, snapshot.repository_id)
251
+ decision = {"code": "NO_CHANGE_COMPLETION_ALREADY_RECONCILED",
252
+ "receipt": record.history[-1]["no_change_completion_ref"], "record_version": record.record_version}
253
+ else:
254
+ _cas(record, expected_record_version)
255
+ original = _legacy_no_change(store, item, record, snapshot.repository_id, completion_ref)
256
+ cleanup_receipts(store, item, original, snapshot.repository_id, expected_refs=refs)
257
+ plan = {"contract": NO_CHANGE_CONTRACT, "request": request,
258
+ "repository_id": snapshot.repository_id, "target_path": str(item.path),
259
+ "record": record_to_dict(record), "cleanup_record": record_to_dict(original),
260
+ "completion_ref": completion_ref, "preservation_cleanup_receipts": refs}
261
+ plan_id = _digest(plan)
262
+ if dry_run:
263
+ decision = {"code": "NO_CHANGE_COMPLETION_RECOVERY_READY", "plan": plan, "plan_id": plan_id}
264
+ else:
265
+ if expected_plan_id != plan_id:
266
+ raise ControllerError("RESOURCE_PLAN_MISMATCH", "reconciliation requires the exact current dry-run plan")
267
+ receipt_id = str(uuid.uuid5(uuid.UUID(record.allocation_id), plan_id))
268
+ path = store.completion_receipts_dir / f"{receipt_id}.json"
269
+ from .store import now_iso
270
+
271
+ receipt = {"contract": NO_CHANGE_CONTRACT, "kind": "no_change_cleanup_recovery",
272
+ "receipt_id": receipt_id, "plan_id": plan_id, "plan": plan}
273
+ if path.exists():
274
+ existing, digest = read(path)
275
+ if any(existing.get(key) != value for key, value in receipt.items()) or set(existing) != {*receipt, "reconciled_at"}:
276
+ raise _no_change_fail("existing reconciliation receipt differs")
277
+ receipt = existing
278
+ ref = {"receipt_id": receipt_id, "path": str(path), "sha256": digest}
279
+ else:
280
+ receipt["reconciled_at"] = now_iso()
281
+ if _no_change_time(receipt["reconciled_at"]) < _no_change_time(record.last_reviewed_at):
282
+ raise _no_change_fail("reconciliation predates the legacy completion")
283
+ if not path.exists():
284
+ ref = {"receipt_id": receipt_id, **write(store.completion_receipts_dir, path.name, receipt)}
285
+ event = {"at": receipt["reconciled_at"], "event": NO_CHANGE_RECOVERY_EVENT,
286
+ "from": record.lifecycle, "to": record.lifecycle, "owner": record.owner,
287
+ "no_change_completion_ref": ref}
288
+ updated = replace(record, record_version=record.record_version + 1,
289
+ last_reviewed_at=event["at"], history=(*record.history, event))
290
+ verify_no_change_cleanup(store, item, updated, snapshot.repository_id)
291
+ store.replace_record(updated)
292
+ decision = {"code": "NO_CHANGE_COMPLETION_RECONCILED", "receipt": ref, "record_version": updated.record_version}
293
+ result = status(repository_path)
294
+ result.update(command="reconcile", decision=decision)
295
+ return result
296
+
297
+
298
+ def _digest(value: dict[str, Any]) -> str:
299
+ return hashlib.sha256(
300
+ json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
301
+ ).hexdigest()
302
+
303
+
304
+ def _publication(repository: RepositoryInventory) -> dict[str, str]:
305
+ policy = load_policy(repository.canonical_path)
306
+ if not policy.present or policy.policy_schema_version < 2:
307
+ raise ControllerError("MISSING_PUBLICATION_POLICY", "closeout requires publication policy")
308
+ if REMOTE_NAME_PATTERN.fullmatch(policy.publication_remote) is None:
309
+ raise ControllerError("INVALID_REMOTE", "closeout requires a configured remote name")
310
+ validate_branch_name(repository.canonical_path, policy.publication_branch)
311
+ ref = f"refs/heads/{policy.publication_branch}"
312
+ result = _git(
313
+ repository.canonical_path,
314
+ "ls-remote",
315
+ "--exit-code",
316
+ policy.publication_remote,
317
+ ref,
318
+ check=False,
319
+ timeout=30,
320
+ )
321
+ rows = result.stdout.decode("utf-8", "replace").splitlines()
322
+ fields = rows[0].split() if len(rows) == 1 else []
323
+ if result.returncode or len(fields) != 2 or fields[1] != ref:
324
+ raise ControllerError(
325
+ "PUBLICATION_UNVERIFIED", "live publication ref could not be verified"
326
+ )
327
+ tracking = f"refs/remotes/{policy.publication_remote}/{policy.publication_branch}"
328
+ oid = resolve_commit(repository.canonical_path, tracking)
329
+ if fields[0] != oid:
330
+ raise ControllerError(
331
+ "PUBLICATION_TRACKING_STALE", "publication tracking ref needs refresh"
332
+ )
333
+ return {"remote": policy.publication_remote, "ref": ref, "tracking_ref": tracking, "oid": oid}
334
+
335
+
336
+ def _eligible(
337
+ repository: RepositoryInventory,
338
+ snapshot: MetadataSnapshot,
339
+ item: WorktreeGitState,
340
+ record: MetadataRecord,
341
+ *,
342
+ state: str,
343
+ require_publication: bool = True,
344
+ allow_registered_venv: bool = False,
345
+ for_typed_cleanup: bool = False,
346
+ ) -> dict[str, str]:
347
+ from .lifecycle import (
348
+ _effective_intent_state,
349
+ _typed_runtime_blockers,
350
+ _typed_runtime_evidence,
351
+ _validate_release_environment,
352
+ )
353
+
354
+ if for_typed_cleanup and (state != "RETAINED" or require_publication):
355
+ raise ControllerError("INVALID_LIFECYCLE", "typed cleanup does not authorize publication")
356
+ if item.canonical or item.detached or item.locked or item.prunable:
357
+ raise ControllerError(
358
+ "CLOSEOUT_TARGET_INVALID", "closeout requires an attached linked checkout"
359
+ )
360
+ if record.role not in {"feature", "audit"} or record.lifecycle != state:
361
+ raise ControllerError(
362
+ "INVALID_LIFECYCLE", f"closeout requires {state} feature/audit lifecycle"
363
+ )
364
+ if (
365
+ state == "RETAINED"
366
+ and not require_publication
367
+ and any(
368
+ other.allocation_id != record.allocation_id
369
+ and other.lifecycle in ACTIVE_AUTHORITY_STATES
370
+ and (other.task_key == record.task_key or other.authority_key == record.authority_key)
371
+ for other in snapshot.records.values()
372
+ )
373
+ ):
374
+ raise ControllerError(
375
+ "DUPLICATE_AUTHORITY", "another active task or authority blocks archive"
376
+ )
377
+ if not item.clean:
378
+ raise ControllerError("DIRTY_WORKTREE", "closeout requires a clean checkout")
379
+ if record.handoff_commit or record.finalizer_record_id or record.finalizer_commit:
380
+ raise ControllerError(
381
+ "HANDOFF_EVIDENCE_PRESENT", "frozen handoffs require their existing release flow"
382
+ )
383
+ if record.quarantine_reason or record.recovery_ref or record.recovery_allocation_id:
384
+ raise ControllerError(
385
+ "RECOVERY_STATE", "recovery lineage requires its existing recovery flow"
386
+ )
387
+ if record.external_bindings or record.allocation_id in snapshot.writer_leases:
388
+ raise ControllerError("MANAGED_REFERENCE", "bindings and writer leases block closeout")
389
+ if (
390
+ snapshot.publication_lease
391
+ and snapshot.publication_lease.allocation_id == record.allocation_id
392
+ ):
393
+ raise ControllerError("PUBLICATION_LEASE_HELD", "publication is in progress")
394
+ registered_paths = tuple(
395
+ r["path"] for r in record.disposable_resources if r["state"] == "registered"
396
+ )
397
+ if any(
398
+ r["state"] != "cleaned"
399
+ and not (allow_registered_venv and r["state"] == "registered" and r["path"] == ".venv")
400
+ for r in record.disposable_resources
401
+ ):
402
+ raise ControllerError(
403
+ "DISPOSABLE_RESOURCE_PRESENT", "registered resources must be cleaned first"
404
+ )
405
+ # Formal abandonment leaves historical source references in the intent. Archive
406
+ # also accepts reconciled release evidence; both require the frozen source handoff.
407
+ if any(
408
+ intent.allocation_id == record.allocation_id
409
+ or (
410
+ record.allocation_id in intent.source_allocations
411
+ and (
412
+ not (
413
+ (effective_state := _effective_intent_state(snapshot, intent)) == "ABANDONED"
414
+ or (not require_publication and effective_state == "RELEASED")
415
+ )
416
+ or not is_ancestor(
417
+ repository.canonical_path,
418
+ intent.source_handoffs[intent.source_allocations.index(record.allocation_id)],
419
+ item.head,
420
+ )
421
+ )
422
+ )
423
+ for intent in snapshot.integration_intents.values()
424
+ ) or any(
425
+ other.lifecycle != "RELEASED" and record.allocation_id in other.source_allocations
426
+ for other in snapshot.records.values()
427
+ ):
428
+ raise ControllerError(
429
+ "INTEGRATION_OBLIGATION", "integration lineage still references this allocation"
430
+ )
431
+ if any(
432
+ intent.allocation_id == record.allocation_id
433
+ and intent.status in {"PENDING", "BLOCKED_RETRYABLE"}
434
+ for intent in snapshot.release_intents.values()
435
+ ):
436
+ raise ControllerError(
437
+ "RELEASE_INTENT_PRESENT", "an existing release intent must be resolved first"
438
+ )
439
+ if resolve_commit(repository.canonical_path, f"refs/heads/{item.branch}") != item.head:
440
+ raise ControllerError(
441
+ "MISSING_RETAINED_REF", "the checkout branch no longer retains its HEAD"
442
+ )
443
+ if not require_publication:
444
+ for oid in (record.base_commit, record.task_commit):
445
+ if oid and not is_ancestor(repository.canonical_path, oid, item.head):
446
+ raise ControllerError(
447
+ "TASK_LINEAGE_UNREACHABLE",
448
+ "the retained branch no longer contains recorded task lineage",
449
+ )
450
+ publication: dict[str, str] = {}
451
+ if require_publication:
452
+ publication = _publication(repository)
453
+ for oid in (item.head, record.base_commit, record.task_commit):
454
+ if oid and (
455
+ not is_ancestor(repository.canonical_path, oid, item.head)
456
+ or not is_ancestor(repository.canonical_path, oid, publication["oid"])
457
+ ):
458
+ raise ControllerError(
459
+ "DELIVERABLE_NOT_PUBLISHED", "HEAD or historical task lineage is not published"
460
+ )
461
+ blockers = _typed_runtime_blockers(_typed_runtime_evidence(item, record, snapshot))
462
+ if blockers:
463
+ raise ControllerError(blockers[0]["code"], blockers[0]["message"])
464
+ if not for_typed_cleanup:
465
+ _validate_release_environment(
466
+ repository,
467
+ item,
468
+ registered_resource_paths=registered_paths if allow_registered_venv else (),
469
+ )
470
+ return publication
471
+
472
+
473
+ def _archive_receipt_binding(
474
+ store: MetadataStore,
475
+ record: MetadataRecord,
476
+ repository_id: str | None,
477
+ *,
478
+ target: Path,
479
+ branch: str | None,
480
+ head: str,
481
+ ) -> dict[str, Any]:
482
+ """Verify the immutable archive identity without reading preserved payloads."""
483
+ try:
484
+ event = record.history[-1]
485
+ receipt_id = event["archive_receipt_id"]
486
+ if str(uuid.UUID(receipt_id)) != receipt_id:
487
+ raise ValueError("invalid receipt id")
488
+ receipt = store._read_json(store.completion_receipts_dir / f"{receipt_id}.json")
489
+ plan = receipt["plan"]
490
+ original = record_from_dict(plan["record"])
491
+ expected = replace(
492
+ original,
493
+ lifecycle=ARCHIVE_STATE,
494
+ record_version=original.record_version + 1,
495
+ last_reviewed_at=event["at"],
496
+ history=(*original.history, event),
497
+ )
498
+ if (
499
+ record.lifecycle != ARCHIVE_STATE
500
+ or receipt["contract"] != ARCHIVE_CONTRACT
501
+ or receipt["receipt_id"] != receipt_id
502
+ or receipt["plan_id"] != _digest(plan)
503
+ or event["event"] != ARCHIVE_EVENT
504
+ or event["from"] != "RETAINED"
505
+ or event["to"] != ARCHIVE_STATE
506
+ or event["owner"] != original.owner
507
+ or event["archive_plan_id"] != receipt["plan_id"]
508
+ or event["archive_receipt_sha256"]
509
+ != _digest({key: value for key, value in receipt.items() if key != "after_record"})
510
+ or plan["repository_id"] != repository_id
511
+ or original.lifecycle != "RETAINED"
512
+ or plan["owner_release"] != original.owner
513
+ or not plan["actor"].strip()
514
+ or plan["target_path"] != str(target)
515
+ or plan["head"] != head
516
+ or plan["branch"] != branch
517
+ or plan["task_completed"] is not False
518
+ or plan["integration_ready"] is not False
519
+ or plan["publication_asserted"] is not False
520
+ or record_to_dict(record) != receipt["after_record"]
521
+ or record_to_dict(expected) != receipt["after_record"]
522
+ ):
523
+ raise ValueError("archive evidence changed")
524
+ except (KeyError, TypeError, ValueError, IndexError, OSError, ControllerError) as exc:
525
+ raise ControllerError(
526
+ "RETAINED_ARCHIVE_INVALID", "archive receipt or checkout identity changed"
527
+ ) from exc
528
+ return receipt
529
+
530
+
531
+ def verify_archive_receipt(
532
+ store: MetadataStore,
533
+ item: WorktreeGitState,
534
+ record: MetadataRecord,
535
+ repository_id: str | None,
536
+ ) -> dict[str, Any]:
537
+ """An archived task remains unfinished; its clean HEAD stays on its branch."""
538
+ if not item.clean:
539
+ raise ControllerError(
540
+ "RETAINED_ARCHIVE_INVALID", "archive receipt or checkout identity changed"
541
+ )
542
+ receipt = _archive_receipt_binding(
543
+ store, record, repository_id, target=item.path, branch=item.branch, head=item.head
544
+ )
545
+ original = record_from_dict(receipt["plan"]["record"])
546
+ from .preserved_cleanup import cleanup_receipts
547
+
548
+ cleanup_receipts(
549
+ store,
550
+ item,
551
+ original,
552
+ repository_id,
553
+ expected_refs=receipt["plan"].get("preservation_cleanup_receipts", []),
554
+ )
555
+ return receipt
556
+
557
+
558
+ def validate_archive_release(
559
+ repository: RepositoryInventory,
560
+ snapshot: MetadataSnapshot,
561
+ item: WorktreeGitState,
562
+ record: MetadataRecord,
563
+ ) -> None:
564
+ verify_archive_receipt(
565
+ MetadataStore(repository.common_dir), item, record, snapshot.repository_id
566
+ )
567
+ _eligible(
568
+ repository,
569
+ snapshot,
570
+ item,
571
+ record,
572
+ state=ARCHIVE_STATE,
573
+ require_publication=False,
574
+ allow_registered_venv=True,
575
+ )
576
+
577
+
578
+ def verify_receipt(
579
+ store: MetadataStore,
580
+ item: WorktreeGitState,
581
+ record: MetadataRecord,
582
+ repository_id: str | None,
583
+ ) -> dict[str, Any]:
584
+ """Validate immutable closeout evidence without network or lifecycle mutation."""
585
+ try:
586
+ event = record.history[-1]
587
+ receipt_id = event["closeout_receipt_id"]
588
+ if str(uuid.UUID(receipt_id)) != receipt_id:
589
+ raise ValueError("invalid receipt id")
590
+ receipt = store._read_json(store.completion_receipts_dir / f"{receipt_id}.json")
591
+ plan = receipt["plan"]
592
+ before, after = plan["record"], receipt["after_record"]
593
+ original = record_from_dict(before)
594
+ expected = replace(
595
+ original,
596
+ lifecycle=STATE,
597
+ record_version=original.record_version + 1,
598
+ last_reviewed_at=event["at"],
599
+ history=(*original.history, event),
600
+ )
601
+ if (
602
+ record.lifecycle != STATE
603
+ or not item.clean
604
+ or receipt["contract"] != CONTRACT
605
+ or receipt["receipt_id"] != receipt_id
606
+ or receipt["plan_id"] != _digest(plan)
607
+ or event["event"] != EVENT
608
+ or event["from"] != original.lifecycle
609
+ or event["to"] != STATE
610
+ or event["owner"] != original.owner
611
+ or event["closeout_plan_id"] != receipt["plan_id"]
612
+ or event["closeout_receipt_sha256"]
613
+ != _digest({key: value for key, value in receipt.items() if key != "after_record"})
614
+ or plan["repository_id"] != repository_id
615
+ or original.lifecycle not in {"RETAINED", "PRESERVED"}
616
+ or plan["owner_release"] != original.owner
617
+ or not plan["actor"].strip()
618
+ or plan["target_path"] != str(item.path)
619
+ or plan["head"] != item.head
620
+ or plan["branch"] != item.branch
621
+ or plan["task_completed"] is not False
622
+ or record_to_dict(record) != after
623
+ or record_to_dict(expected) != after
624
+ ):
625
+ raise ValueError("closeout evidence changed")
626
+ except (KeyError, TypeError, ValueError, IndexError, OSError, ControllerError) as exc:
627
+ raise ControllerError(
628
+ "DELIVERED_CLOSEOUT_INVALID", "closeout receipt or checkout identity changed"
629
+ ) from exc
630
+ return receipt
631
+
632
+
633
+ def validate_release(
634
+ repository: RepositoryInventory,
635
+ snapshot: MetadataSnapshot,
636
+ item: WorktreeGitState,
637
+ record: MetadataRecord,
638
+ ) -> None:
639
+ receipt = verify_receipt(
640
+ MetadataStore(repository.common_dir), item, record, snapshot.repository_id
641
+ )
642
+ publication = _eligible(
643
+ repository, snapshot, item, record, state=STATE, allow_registered_venv=True
644
+ )
645
+ previous = receipt["plan"]["publication"]
646
+ if any(
647
+ previous[key] != publication[key] for key in ("remote", "ref", "tracking_ref")
648
+ ) or not is_ancestor(repository.canonical_path, previous["oid"], publication["oid"]):
649
+ raise ControllerError(
650
+ "PUBLICATION_DRIFT", "publication no longer extends the closeout evidence"
651
+ )
652
+
653
+
654
+ def close_delivered(
655
+ repository_path: Path,
656
+ *,
657
+ target: Path,
658
+ expected_record_version: int,
659
+ owner_release: str,
660
+ actor: str,
661
+ dry_run: bool = False,
662
+ plan_id: str | None = None,
663
+ ) -> dict[str, Any]:
664
+ from .controller import envelope
665
+ from .lifecycle import _updated_record
666
+ from .semantics import assert_record_version as _cas
667
+ from .semantics import managed_record as _managed_record
668
+
669
+ if not actor.strip():
670
+ raise ControllerError("INVALID_ACTOR", "closeout requires a named governance actor")
671
+ if not dry_run and not plan_id:
672
+ raise ControllerError(
673
+ "CLOSEOUT_PLAN_REQUIRED", "execute only an exact reviewed closeout plan"
674
+ )
675
+ initial = inventory(repository_path)
676
+ store = MetadataStore(initial.common_dir)
677
+ with store.lock(exclusive=not dry_run):
678
+ repository = inventory(repository_path)
679
+ snapshot = store._load_unlocked()
680
+ item, record = _managed_record(repository, snapshot, target)
681
+ if record.owner != owner_release:
682
+ raise ControllerError(
683
+ "OWNER_MISMATCH", "closeout requires the recorded owner's release authority"
684
+ )
685
+ if record.lifecycle == STATE and not dry_run:
686
+ receipt = verify_receipt(store, item, record, snapshot.repository_id)
687
+ if (
688
+ receipt["plan_id"] != plan_id
689
+ or receipt["plan"]["actor"] != actor.strip()
690
+ or receipt["plan"]["record"]["record_version"] != expected_record_version
691
+ ):
692
+ raise ControllerError(
693
+ "CLOSEOUT_PLAN_DRIFT", "retry does not match the recorded closeout"
694
+ )
695
+ code = "ALREADY_CLOSED"
696
+ else:
697
+ _cas(record, expected_record_version)
698
+ if record.lifecycle not in {"RETAINED", "PRESERVED"}:
699
+ raise ControllerError(
700
+ "INVALID_LIFECYCLE", "only retained or legacy preserved checkouts qualify"
701
+ )
702
+ if record.lifecycle == "PRESERVED":
703
+ from .source_cleanup import verify_completed
704
+
705
+ if verify_completed(store, item, record):
706
+ raise ControllerError(
707
+ "PRESERVATION_FLOW_REQUIRED",
708
+ "verified archived source must use its existing preservation release flow",
709
+ )
710
+ publication = _eligible(
711
+ repository,
712
+ snapshot,
713
+ item,
714
+ record,
715
+ state=record.lifecycle,
716
+ allow_registered_venv=record.lifecycle == "RETAINED",
717
+ )
718
+ plan = {
719
+ "contract": CONTRACT,
720
+ "repository_id": snapshot.repository_id,
721
+ "target_path": str(item.path),
722
+ "head": item.head,
723
+ "branch": item.branch,
724
+ "record": record_to_dict(record),
725
+ "publication": publication,
726
+ "owner_release": owner_release,
727
+ "actor": actor.strip(),
728
+ "task_completed": False,
729
+ }
730
+ identity = _digest(plan)
731
+ if dry_run:
732
+ result = envelope("close-delivered")
733
+ result["decision"] = {
734
+ "code": "CLOSEOUT_READY",
735
+ "plan_id": identity,
736
+ "allocation_id": record.allocation_id,
737
+ "record_version": record.record_version,
738
+ "reason": "checkout and historical commits are contained in live publication",
739
+ }
740
+ result["closeout_plan"] = plan
741
+ return result
742
+ if plan_id != identity:
743
+ raise ControllerError("CLOSEOUT_PLAN_DRIFT", "closeout facts changed after review")
744
+ receipt_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{CONTRACT}:{identity}"))
745
+ path = store.completion_receipts_dir / f"{receipt_id}.json"
746
+ if path.exists():
747
+ receipt = store._read_json(path)
748
+ if receipt.get("plan") != plan or receipt.get("plan_id") != identity:
749
+ raise ControllerError(
750
+ "CLOSEOUT_PLAN_DRIFT", "existing closeout evidence differs"
751
+ )
752
+ updated = record_from_dict(receipt["after_record"])
753
+ verify_receipt(store, item, updated, snapshot.repository_id)
754
+ else:
755
+ receipt = {
756
+ "contract": CONTRACT,
757
+ "receipt_id": receipt_id,
758
+ "plan_id": identity,
759
+ "plan": plan,
760
+ }
761
+ updated = _updated_record(record, EVENT, lifecycle=STATE)
762
+ event = {
763
+ **updated.history[-1],
764
+ "closeout_receipt_id": receipt_id,
765
+ "closeout_plan_id": identity,
766
+ "closeout_receipt_sha256": _digest(receipt),
767
+ }
768
+ updated = replace(updated, history=(*updated.history[:-1], event))
769
+ receipt["after_record"] = record_to_dict(updated)
770
+ store.write_completion_receipt(receipt_id, receipt)
771
+ # Git may be changed by a process outside the Controller's repository lock.
772
+ current, latest = _managed_record(
773
+ inventory(repository_path), store._load_unlocked(), target
774
+ )
775
+ if current != item or latest != record:
776
+ raise ControllerError(
777
+ "CLOSEOUT_PLAN_DRIFT", "checkout changed before metadata transition"
778
+ )
779
+ store.replace_record(updated)
780
+ code = "CHECKOUT_CLOSED"
781
+ result = envelope("close-delivered")
782
+ result["decision"] = {
783
+ "code": code,
784
+ "reason": "published checkout closed; ordinary guarded release remains required",
785
+ "allocation_id": record.allocation_id,
786
+ "record_version": receipt["after_record"]["record_version"],
787
+ "plan_id": receipt["plan_id"],
788
+ "receipt_id": receipt["receipt_id"],
789
+ "task_completed": False,
790
+ }
791
+ return result
792
+
793
+
794
+ def archive_retained(
795
+ repository_path: Path,
796
+ *,
797
+ target: Path,
798
+ expected_record_version: int,
799
+ owner_release: str,
800
+ actor: str,
801
+ dry_run: bool = False,
802
+ plan_id: str | None = None,
803
+ ) -> dict[str, Any]:
804
+ """Freeze a clean, unfinished retained task for guarded physical release."""
805
+ from .controller import envelope
806
+ from .lifecycle import _updated_record
807
+ from .semantics import assert_record_version as _cas
808
+ from .semantics import managed_record as _managed_record
809
+
810
+ if not actor.strip():
811
+ raise ControllerError("INVALID_ACTOR", "archive requires a named governance actor")
812
+ if not dry_run and not plan_id:
813
+ raise ControllerError(
814
+ "ARCHIVE_PLAN_REQUIRED", "execute only an exact reviewed archive plan"
815
+ )
816
+ initial = inventory(repository_path)
817
+ store = MetadataStore(initial.common_dir)
818
+ with store.lock(exclusive=not dry_run):
819
+ repository = inventory(repository_path)
820
+ snapshot = store._load_unlocked()
821
+ item, record = _managed_record(repository, snapshot, target)
822
+ if record.owner != owner_release:
823
+ raise ControllerError("OWNER_MISMATCH", "archive requires the recorded owner")
824
+ if record.lifecycle == ARCHIVE_STATE and not dry_run:
825
+ receipt = verify_archive_receipt(store, item, record, snapshot.repository_id)
826
+ if (
827
+ receipt["plan_id"] != plan_id
828
+ or receipt["plan"]["actor"] != actor.strip()
829
+ or receipt["plan"]["record"]["record_version"] != expected_record_version
830
+ ):
831
+ raise ControllerError("ARCHIVE_PLAN_DRIFT", "retry does not match the archive")
832
+ code = "ALREADY_ARCHIVED"
833
+ else:
834
+ _cas(record, expected_record_version)
835
+ from .transaction_scope import conflicts
836
+
837
+ if conflicts(store, allocation=record.allocation_id, target=item.path):
838
+ raise ControllerError(
839
+ "INTERRUPTED_TRANSACTION", "archive target has an unresolved transaction"
840
+ )
841
+ _eligible(
842
+ repository,
843
+ snapshot,
844
+ item,
845
+ record,
846
+ state="RETAINED",
847
+ require_publication=False,
848
+ allow_registered_venv=True,
849
+ )
850
+ from .lifecycle import _release_resource_actions
851
+
852
+ _release_resource_actions(repository, item, record)
853
+ from .preserved_cleanup import cleanup_receipts
854
+
855
+ cleanup_refs = cleanup_receipts(store, item, record, snapshot.repository_id)
856
+ plan = {
857
+ "contract": ARCHIVE_CONTRACT,
858
+ "repository_id": snapshot.repository_id,
859
+ "target_path": str(item.path),
860
+ "head": item.head,
861
+ "branch": item.branch,
862
+ "record": record_to_dict(record),
863
+ "owner_release": owner_release,
864
+ "actor": actor.strip(),
865
+ "task_completed": False,
866
+ "integration_ready": False,
867
+ "publication_asserted": False,
868
+ **({"preservation_cleanup_receipts": cleanup_refs} if cleanup_refs else {}),
869
+ }
870
+ identity = _digest(plan)
871
+ if dry_run:
872
+ result = envelope("archive-retained")
873
+ result["decision"] = {
874
+ "code": "ARCHIVE_READY",
875
+ "plan_id": identity,
876
+ "allocation_id": record.allocation_id,
877
+ "record_version": record.record_version,
878
+ "reason": "clean unfinished checkout is retained by its exact branch; no delivery is asserted",
879
+ }
880
+ result["archive_plan"] = plan
881
+ return result
882
+ if plan_id != identity:
883
+ raise ControllerError("ARCHIVE_PLAN_DRIFT", "archive facts changed after review")
884
+ receipt_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{ARCHIVE_CONTRACT}:{identity}"))
885
+ path = store.completion_receipts_dir / f"{receipt_id}.json"
886
+ if path.exists():
887
+ receipt = store._read_json(path)
888
+ if receipt.get("plan") != plan or receipt.get("plan_id") != identity:
889
+ raise ControllerError("ARCHIVE_PLAN_DRIFT", "existing archive evidence differs")
890
+ updated = record_from_dict(receipt["after_record"])
891
+ verify_archive_receipt(store, item, updated, snapshot.repository_id)
892
+ else:
893
+ receipt = {
894
+ "contract": ARCHIVE_CONTRACT,
895
+ "receipt_id": receipt_id,
896
+ "plan_id": identity,
897
+ "plan": plan,
898
+ }
899
+ updated = _updated_record(record, ARCHIVE_EVENT, lifecycle=ARCHIVE_STATE)
900
+ event = {
901
+ **updated.history[-1],
902
+ "archive_receipt_id": receipt_id,
903
+ "archive_plan_id": identity,
904
+ "archive_receipt_sha256": _digest(receipt),
905
+ }
906
+ updated = replace(updated, history=(*updated.history[:-1], event))
907
+ receipt["after_record"] = record_to_dict(updated)
908
+ store.write_completion_receipt(receipt_id, receipt)
909
+ current, latest = _managed_record(
910
+ inventory(repository_path), store._load_unlocked(), target
911
+ )
912
+ if current != item or latest != record:
913
+ raise ControllerError(
914
+ "ARCHIVE_PLAN_DRIFT", "checkout changed before metadata transition"
915
+ )
916
+ store.replace_record(updated)
917
+ code = "ARCHIVED"
918
+ result = envelope("archive-retained")
919
+ result["decision"] = {
920
+ "code": code,
921
+ "reason": "unfinished task and branch preserved; guarded release remains required",
922
+ "allocation_id": record.allocation_id,
923
+ "record_version": receipt["after_record"]["record_version"],
924
+ "plan_id": receipt["plan_id"],
925
+ "receipt_id": receipt["receipt_id"],
926
+ "task_completed": False,
927
+ }
928
+ return result