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,1865 @@
1
+ """Plan, authorize and audit branch retirement without mutating Git refs.
2
+
3
+ The Controller owns the lifecycle decision and the execution grant. A consumer
4
+ owns the ref mutation and remains the only component that touches a ref; the
5
+ Controller re-observes the world instead of believing the consumer's account.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ import os
14
+ import re
15
+ import secrets
16
+ from collections.abc import Mapping
17
+ from dataclasses import asdict
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ from typing import Any
21
+ from uuid import UUID
22
+
23
+ from . import cleanup_evidence as e
24
+ from . import retirement_adapters as adapters
25
+ from . import retirement_execution as execution
26
+ from .git_backend import (
27
+ RepositoryInventory,
28
+ _git,
29
+ _text,
30
+ inventory,
31
+ is_ancestor,
32
+ public_refs,
33
+ resolve_commit,
34
+ )
35
+ from .models import (
36
+ ControllerError,
37
+ MetadataRecord,
38
+ MetadataSnapshot,
39
+ ReleaseIntent,
40
+ ReleaseReceipt,
41
+ WriterHolder,
42
+ )
43
+ from .policy import load_policy
44
+ from .resource_admission import canonical_holder_digest
45
+ from .semantics import integration_intent_abandoned
46
+ from .store import MetadataStore, now_iso
47
+ from .writer_lease import describe_holder, holder_is_live, resolve_holder
48
+
49
+
50
+ def _crash_point(name: str) -> None:
51
+ """The shared fault-injection hook, so a retirement crash is reproducible."""
52
+
53
+ from .lifecycle import _crash_point as hook
54
+
55
+ hook(name)
56
+
57
+ PLAN_SCHEMA_VERSION = 1
58
+ RECEIPT_SCHEMA_VERSION = 1
59
+ AUTHORIZATION_TTL_DEFAULT_SECONDS = 900
60
+ AUTHORIZATION_TTL_MAX_SECONDS = 3600
61
+ AUTHORIZED_RECEIPT_SCHEMA_VERSION = RECEIPT_SCHEMA_VERSION + 1
62
+ RECEIPT_PROVENANCE_AUTHORIZED = "AUTHORIZED_EXECUTION"
63
+ RECEIPT_PROVENANCE_OBSERVED = "CONTROLLER_OBSERVED"
64
+ RECEIPT_PROVENANCE_CLAIM = "CONSUMER_CLAIM"
65
+ OPERATIONS = frozenset({"local", "remote"})
66
+ REMOTE_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
67
+ PROTECTED_BRANCHES = frozenset({"main", "master", "trunk", "production"})
68
+ SPECIAL_PREFIXES = ("rescue/", "archive/", "recovery/")
69
+ TERMINAL_INTEGRATION_STATES = frozenset({"RELEASED"})
70
+ TERMINAL_RELEASE_STATES = frozenset({"RELEASED", "CANCELLED"})
71
+
72
+ # Only authority-bearing Controller state participates. Branch retirement plans and
73
+ # receipts are deliberately excluded so local and remote completion can be recorded
74
+ # independently without invalidating the other half of the same plan.
75
+ AUTHORITY_DIRECTORIES = (
76
+ "records",
77
+ "bindings",
78
+ "integration-intents",
79
+ "release-intents",
80
+ "release-plans",
81
+ "release-receipts",
82
+ "publication-receipts",
83
+ "reconciliation-receipts",
84
+ "writer-leases",
85
+ "transactions",
86
+ )
87
+ AUTHORITY_FILES = ("publication-lease.json",)
88
+
89
+
90
+ def _sha256_json(value: Mapping[str, Any]) -> str:
91
+ encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
92
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
93
+
94
+
95
+ def _plan_identity(plan: Mapping[str, Any]) -> dict[str, Any]:
96
+ """Return the immutable/OID-bound identity of a retirement decision.
97
+
98
+ Local and remote refs are expected to move from PRESENT_EXPECTED to ABSENT as
99
+ each operation completes, so observed states cannot be part of the plan id.
100
+ """
101
+
102
+ fields = (
103
+ "schema_version",
104
+ "repository_id",
105
+ "allocation_id",
106
+ "release_receipt_id",
107
+ "branch",
108
+ "expected_oid",
109
+ "remote",
110
+ "remote_ref",
111
+ "remote_tracking_ref",
112
+ "integrated_into",
113
+ "integrated_ref",
114
+ "expected_integrated_oid",
115
+ "classification",
116
+ "registration",
117
+ "controller_state_digest",
118
+ )
119
+ identity = {field: plan.get(field) for field in fields}
120
+ if "canonical_transition_evidence" in plan:
121
+ identity["canonical_transition_evidence"] = plan["canonical_transition_evidence"]
122
+ return identity
123
+
124
+
125
+ def _authority_relevant(
126
+ directory: str, value: Mapping[str, Any], *, allocation_id: str, branch: str
127
+ ) -> bool:
128
+ if value.get("allocation_id") == allocation_id:
129
+ return True
130
+ if directory == "bindings":
131
+ return value.get("allocation_id") == allocation_id
132
+ if directory == "integration-intents":
133
+ return (
134
+ allocation_id in value.get("source_allocations", [])
135
+ or branch in value.get("source_branches", [])
136
+ )
137
+ if directory == "release-intents":
138
+ return value.get("integration_allocation_id") == allocation_id
139
+ if directory in {"release-plans", "release-receipts"}:
140
+ return value.get("branch") == branch
141
+ if directory == "publication-receipts":
142
+ return any(
143
+ isinstance(item, dict) and item.get("source_allocation_id") == allocation_id
144
+ for item in value.get("source_results", [])
145
+ )
146
+ return False
147
+
148
+
149
+ def _state_digest(
150
+ store: MetadataStore, canonical_path: Path, *, allocation_id: str, branch: str
151
+ ) -> str:
152
+ digest = hashlib.sha256()
153
+ for directory_name in AUTHORITY_DIRECTORIES:
154
+ directory = store.root / directory_name
155
+ if not directory.is_dir():
156
+ continue
157
+ for path in sorted(directory.rglob("*.json")):
158
+ value = store._read_json(path)
159
+ if not _authority_relevant(
160
+ directory_name, value, allocation_id=allocation_id, branch=branch
161
+ ):
162
+ continue
163
+ relative = path.relative_to(store.root).as_posix().encode("utf-8")
164
+ digest.update(relative)
165
+ digest.update(b"\0")
166
+ digest.update(path.read_bytes())
167
+ digest.update(b"\0")
168
+ for file_name in AUTHORITY_FILES:
169
+ path = store.root / file_name
170
+ if path.is_file():
171
+ value = store._read_json(path)
172
+ if value.get("allocation_id") != allocation_id:
173
+ continue
174
+ digest.update(file_name.encode("utf-8"))
175
+ digest.update(b"\0")
176
+ digest.update(path.read_bytes())
177
+ digest.update(b"\0")
178
+ policy = canonical_path / ".agents" / "worktree-policy.toml"
179
+ digest.update(b"policy\0")
180
+ if policy.is_file():
181
+ digest.update(policy.read_bytes())
182
+ else:
183
+ digest.update(b"<default>")
184
+ return digest.hexdigest()
185
+
186
+
187
+ def _normalize_branch(repo: Path, branch: str) -> str:
188
+ value = branch.removeprefix("refs/heads/")
189
+ if not value or value.startswith("-") or "\x00" in value:
190
+ raise ControllerError("INVALID_BRANCH", "branch name is empty or unsafe")
191
+ result = _git(repo, "check-ref-format", "--branch", value, check=False)
192
+ if result.returncode != 0:
193
+ raise ControllerError("INVALID_BRANCH", f"invalid branch name: {branch}")
194
+ return value
195
+
196
+
197
+ def _normalize_integration_ref(remote: str, value: str) -> str:
198
+ if value.startswith("refs/"):
199
+ if not value.startswith(("refs/heads/", "refs/remotes/")):
200
+ raise ControllerError(
201
+ "INVALID_INTEGRATION_REF", "integrated_into must name a branch ref"
202
+ )
203
+ return value
204
+ prefix = f"{remote}/"
205
+ branch = value.removeprefix(prefix) if value.startswith(prefix) else value
206
+ if not branch or branch.startswith("-"):
207
+ raise ControllerError("INVALID_INTEGRATION_REF", "integrated_into is unsafe")
208
+ return f"refs/remotes/{remote}/{branch}"
209
+
210
+
211
+ def _ref_state(refs: Mapping[str, str], ref: str, expected_oid: str) -> str:
212
+ observed = refs.get(ref)
213
+ if observed is None:
214
+ return "ABSENT"
215
+ return "PRESENT_EXPECTED" if observed == expected_oid else "PRESENT_DRIFTED"
216
+
217
+
218
+ def _is_special_lifecycle_branch(branch: str) -> bool:
219
+ return branch in {"rescue", "archive", "recovery"} or branch.startswith(SPECIAL_PREFIXES)
220
+
221
+
222
+ def _upstream_state(repo: Path, branch: str, expected: str) -> tuple[str, str | None]:
223
+ output = _text(
224
+ repo,
225
+ "for-each-ref",
226
+ "--format=%(refname)%00%(upstream)",
227
+ f"refs/heads/{branch}",
228
+ )
229
+ if not output:
230
+ return "ABSENT_LOCAL", None
231
+ try:
232
+ ref_name, upstream = output.split("\0", 1)
233
+ except ValueError as exc:
234
+ raise ControllerError("GIT_PARSE_ERROR", "cannot parse branch upstream") from exc
235
+ if ref_name != f"refs/heads/{branch}":
236
+ raise ControllerError("GIT_PARSE_ERROR", "unexpected local branch result")
237
+ if not upstream:
238
+ return "NONE", None
239
+ if upstream == expected:
240
+ return "EXPECTED", upstream
241
+ return "OTHER", upstream
242
+
243
+
244
+ def _classify_integration(repo: Path, expected_oid: str, integrated_oid: str) -> str:
245
+ if is_ancestor(repo, expected_oid, integrated_oid):
246
+ return "ANCESTRY"
247
+ non_merges = _text(repo, "rev-list", "--no-merges", f"{integrated_oid}..{expected_oid}")
248
+ merges = _text(repo, "rev-list", "--merges", f"{integrated_oid}..{expected_oid}")
249
+ if not non_merges:
250
+ return "MERGE_ONLY" if merges else "UNIQUE"
251
+ cherry = _text(repo, "cherry", integrated_oid, expected_oid)
252
+ signs = {line[:1] for line in cherry.splitlines() if line}
253
+ if signs == {"-"}:
254
+ return "CHERRY_EQUIVALENT"
255
+ if signs == {"+"}:
256
+ return "UNIQUE"
257
+ return "MIXED"
258
+
259
+
260
+ def _transactions_for_allocation(store: MetadataStore, allocation_id: str) -> tuple[str, ...]:
261
+ # Same pending/terminal semantics as doctor and mutation admission: a
262
+ # journal closed by terminal evidence must not block retirement.
263
+ from .transaction_scope import pending
264
+
265
+ return tuple(
266
+ str(row["transaction_id"])
267
+ for row in pending(store, relevant=lambda row: row.get("allocation_id") == allocation_id)
268
+ )
269
+
270
+
271
+ def _release_identity(snapshot: Any, allocation_id: str | None, branch: str | None) -> tuple[Any, str]:
272
+ receipts = list(snapshot.release_receipts.values())
273
+ if allocation_id is not None:
274
+ matches = [item for item in receipts if item.allocation_id == allocation_id]
275
+ if not matches:
276
+ return None, "NO_RELEASE_RECEIPT"
277
+ else:
278
+ matches = [item for item in receipts if item.branch == branch]
279
+ if not matches:
280
+ return None, "HISTORICAL_UNREGISTERED"
281
+ identities = {(item.allocation_id, item.branch, item.released_head) for item in matches}
282
+ if len(identities) != 1:
283
+ return None, "AMBIGUOUS_RELEASE_HISTORY"
284
+ return max(matches, key=lambda item: (item.released_at, item.receipt_id)), "RELEASE_BACKED"
285
+
286
+
287
+ def _canonical_transition_identity(
288
+ repository: RepositoryInventory,
289
+ snapshot: MetadataSnapshot,
290
+ *,
291
+ allocation_id: str | None,
292
+ branch: str,
293
+ ) -> tuple[MetadataRecord | None, dict[str, Any] | None, str]:
294
+ canonical = next(item for item in repository.worktrees if item.canonical)
295
+ record = snapshot.records.get(snapshot.bindings.get(canonical.key, ""))
296
+ if record is None or (allocation_id is not None and record.allocation_id != allocation_id):
297
+ return None, None, "HISTORICAL_UNREGISTERED"
298
+ events = [
299
+ event
300
+ for event in record.history
301
+ if event.get("event") == "CANONICAL_TRANSITIONED" and event.get("old_branch") == branch
302
+ ]
303
+ if not events:
304
+ return None, None, "HISTORICAL_UNREGISTERED"
305
+ if len(events) != 1:
306
+ return record, None, "CANONICAL_TRANSITION_EVIDENCE_AMBIGUOUS"
307
+ event = events[0]
308
+ identities = ("old_owner", "old_task_key", "old_authority_key", "old_finalizer_record_id", "owner")
309
+ valid = (
310
+ event.get("from") == "HANDOFF_READY"
311
+ and event.get("to") == "ACTIVE"
312
+ and all(isinstance(event.get(key), str) and event[key].strip() for key in identities)
313
+ and all(
314
+ isinstance(event.get(key), str) and re.fullmatch(r"[0-9a-f]{40}", event[key])
315
+ for key in ("old_head", "main_before", "main_after")
316
+ )
317
+ and isinstance(event.get("old_scope_paths"), list)
318
+ and all(isinstance(path, str) for path in event["old_scope_paths"])
319
+ and isinstance(event.get("remote"), str)
320
+ and REMOTE_PATTERN.fullmatch(event["remote"]) is not None
321
+ )
322
+ try:
323
+ timestamp = datetime.fromisoformat(event.get("at", ""))
324
+ valid = valid and timestamp.tzinfo is not None
325
+ except (TypeError, ValueError):
326
+ valid = False
327
+ if not valid:
328
+ return record, None, "CANONICAL_TRANSITION_EVIDENCE_INVALID"
329
+ return record, {
330
+ "event_sha256": _sha256_json(event),
331
+ "old_head": event["old_head"],
332
+ "main_before": event["main_before"],
333
+ "main_after": event["main_after"],
334
+ "remote": event["remote"],
335
+ "old_owner": event["old_owner"],
336
+ "old_finalizer_record_id": event["old_finalizer_record_id"],
337
+ }, "CANONICAL_TRANSITION_BACKED"
338
+
339
+
340
+ def _integration_publication_verified(
341
+ snapshot: MetadataSnapshot,
342
+ record: MetadataRecord,
343
+ release_receipt: ReleaseReceipt,
344
+ *,
345
+ target_ref: str,
346
+ ) -> bool:
347
+ plan = snapshot.release_plans.get(release_receipt.plan_id)
348
+ if plan is None:
349
+ return False
350
+ if plan.publication_receipt_id is not None:
351
+ publication = snapshot.publication_receipts.get(plan.publication_receipt_id)
352
+ elif plan.trigger == "owner-release":
353
+ # Owner release preserves the original plan. Its typed acknowledgement
354
+ # can join the exact publication without rewriting either receipt.
355
+ publications = [
356
+ publication
357
+ for reconciliation in snapshot.reconciliation_receipts.values()
358
+ if reconciliation.repository_id == snapshot.repository_id
359
+ and reconciliation.allocation_id == record.allocation_id
360
+ and reconciliation.observed_record_version
361
+ == release_receipt.released_record_version
362
+ == record.record_version
363
+ and reconciliation.observed_record_lifecycle == "RELEASED"
364
+ and reconciliation.observed_receipt_status == "REMOTE_VERIFIED"
365
+ and reconciliation.result == "RELEASE_STATE_ACKNOWLEDGED"
366
+ and reconciliation.recovered_intent_state == "RELEASED"
367
+ and (publication := snapshot.publication_receipts.get(reconciliation.source_receipt_id))
368
+ is not None
369
+ and publication.intent_id is not None
370
+ and reconciliation.recovered_intent_id == publication.intent_id
371
+ and publication.lease_released_at is not None
372
+ ]
373
+ if len(publications) != 1:
374
+ return False
375
+ publication = publications[0]
376
+ else:
377
+ return False
378
+ return bool(
379
+ publication is not None
380
+ and plan.repository_id == release_receipt.repository_id == snapshot.repository_id
381
+ and plan.allocation_id == release_receipt.allocation_id == record.allocation_id
382
+ and plan.branch == release_receipt.branch
383
+ and plan.target_path == release_receipt.target_path
384
+ and plan.expected_head == release_receipt.released_head
385
+ and plan.expected_record_version == release_receipt.expected_record_version
386
+ and publication.repository_id == snapshot.repository_id
387
+ and publication.allocation_id == record.allocation_id
388
+ and publication.candidate_oid == release_receipt.released_head
389
+ and publication.finalizer_record_id == record.finalizer_record_id
390
+ and publication.target_ref == target_ref
391
+ and publication.status in {"REMOTE_VERIFIED", "RELEASED"}
392
+ )
393
+
394
+
395
+ def _release_intent_resolved(
396
+ snapshot: MetadataSnapshot,
397
+ record: MetadataRecord | None,
398
+ receipt: ReleaseReceipt,
399
+ intent: ReleaseIntent,
400
+ ) -> bool:
401
+ if intent.status in TERMINAL_RELEASE_STATES:
402
+ return True
403
+ plan = snapshot.release_plans.get(receipt.plan_id)
404
+ if record is None or plan is None:
405
+ return False
406
+ # An owner release need not name a pending intent. Match its completed
407
+ # receipt to the exact frozen authority instead of rewriting old history.
408
+ return bool(
409
+ receipt.result == "RELEASED"
410
+ and record.lifecycle == "RELEASED"
411
+ and plan.lifecycle == "HANDOFF_READY"
412
+ and record.role in {"feature", "audit"}
413
+ and plan.repository_id
414
+ == receipt.repository_id
415
+ == intent.repository_id
416
+ == snapshot.repository_id
417
+ and plan.allocation_id
418
+ == receipt.allocation_id
419
+ == intent.allocation_id
420
+ == record.allocation_id
421
+ and plan.branch == receipt.branch
422
+ and plan.target_path == receipt.target_path
423
+ and plan.expected_head == receipt.released_head == intent.expected_head
424
+ and plan.expected_record_version
425
+ == receipt.expected_record_version
426
+ == intent.expected_record_version
427
+ and receipt.released_record_version == record.record_version
428
+ and receipt.released_record_version == receipt.expected_record_version + 1
429
+ and plan.owner == intent.owner
430
+ and plan.authority_key == intent.authority_key == record.authority_key
431
+ and plan.role == intent.role == record.role
432
+ and intent.task_key == record.task_key
433
+ and intent.task_commit == record.task_commit
434
+ and intent.handoff_commit == record.handoff_commit == receipt.released_head
435
+ and intent.finalizer_commit == record.finalizer_commit == receipt.released_head
436
+ and intent.finalizer_record_id == record.finalizer_record_id
437
+ )
438
+
439
+
440
+ def _evaluate(
441
+ repository_path: Path,
442
+ *,
443
+ allocation_id: str | None,
444
+ branch: str | None,
445
+ remote: str,
446
+ integrated_into: str,
447
+ plan_id: str | None = None,
448
+ _locked_store: MetadataStore | None = None,
449
+ ) -> tuple[dict[str, Any], MetadataStore]:
450
+ repository = inventory(repository_path)
451
+ # A locked caller must hand over its serialized instance; a fresh store
452
+ # would re-open (or wrongly refuse) the flock the caller already holds.
453
+ store = _locked_store or MetadataStore(repository.common_dir)
454
+ snapshot = store._load_unlocked()
455
+ if snapshot.repository_id is None:
456
+ raise ControllerError("UNMANAGED_REPOSITORY", "Controller repository metadata is missing")
457
+ if REMOTE_PATTERN.fullmatch(remote) is None:
458
+ raise ControllerError("INVALID_REMOTE", f"remote name is unsafe: {remote}")
459
+ if _git(repository.canonical_path, "remote", "get-url", remote, check=False).returncode != 0:
460
+ raise ControllerError("INVALID_REMOTE", f"remote is not configured: {remote}")
461
+
462
+ normalized_requested_branch = (
463
+ _normalize_branch(repository.canonical_path, branch) if branch is not None else None
464
+ )
465
+ release_receipt, registration = _release_identity(
466
+ snapshot, allocation_id, normalized_requested_branch
467
+ )
468
+ transition_record = None
469
+ transition_evidence = None
470
+ if (
471
+ registration in {"HISTORICAL_UNREGISTERED", "NO_RELEASE_RECEIPT"}
472
+ and normalized_requested_branch is not None
473
+ ):
474
+ transition_record, transition_evidence, registration = _canonical_transition_identity(
475
+ repository, snapshot, allocation_id=allocation_id, branch=normalized_requested_branch
476
+ )
477
+ if release_receipt is not None:
478
+ normalized_branch = _normalize_branch(repository.canonical_path, release_receipt.branch)
479
+ resolved_allocation = release_receipt.allocation_id
480
+ expected_oid = release_receipt.released_head
481
+ release_receipt_id = release_receipt.receipt_id
482
+ elif transition_evidence is not None and transition_record is not None:
483
+ normalized_branch = str(normalized_requested_branch)
484
+ resolved_allocation = transition_record.allocation_id
485
+ expected_oid = transition_evidence["old_head"]
486
+ release_receipt_id = None
487
+ else:
488
+ if normalized_requested_branch is None:
489
+ normalized_branch = "<unknown>"
490
+ expected_oid = ""
491
+ else:
492
+ normalized_branch = normalized_requested_branch
493
+ ref = f"refs/heads/{normalized_branch}"
494
+ expected_oid = public_refs(repository.canonical_path).get(ref, "")
495
+ resolved_allocation = allocation_id
496
+ release_receipt_id = None
497
+
498
+ policy = load_policy(repository.canonical_path)
499
+ integrated_ref = _normalize_integration_ref(remote, integrated_into)
500
+ refs = public_refs(repository.canonical_path)
501
+ integrated_oid = refs.get(integrated_ref)
502
+ blockers: list[str] = []
503
+ if registration not in {"RELEASE_BACKED", "CANONICAL_TRANSITION_BACKED"}:
504
+ blockers.append(registration)
505
+ if transition_evidence is not None:
506
+ if (
507
+ remote != policy.publication_remote
508
+ or transition_evidence["remote"] != remote
509
+ or integrated_ref != _normalize_integration_ref(remote, policy.publication_branch)
510
+ ):
511
+ blockers.append("CANONICAL_TRANSITION_TARGET_MISMATCH")
512
+ try:
513
+ transition_head = transition_evidence["main_after"]
514
+ if (
515
+ integrated_oid is None
516
+ or not is_ancestor(repository.canonical_path, transition_evidence["old_head"], transition_head)
517
+ or not is_ancestor(repository.canonical_path, transition_evidence["main_before"], transition_head)
518
+ or not is_ancestor(repository.canonical_path, transition_head, integrated_oid)
519
+ ):
520
+ blockers.append("CANONICAL_TRANSITION_PUBLICATION_MISMATCH")
521
+ except ControllerError:
522
+ blockers.append("CANONICAL_TRANSITION_PUBLICATION_MISMATCH")
523
+ if normalized_branch in PROTECTED_BRANCHES or normalized_branch == policy.publication_branch:
524
+ blockers.append("PROTECTED_BRANCH")
525
+ if _is_special_lifecycle_branch(normalized_branch):
526
+ blockers.append("SPECIAL_LIFECYCLE_REF")
527
+ if integrated_oid is None:
528
+ blockers.append("INTEGRATION_REF_MISSING")
529
+ if not expected_oid:
530
+ blockers.append("EXPECTED_OID_MISSING")
531
+ classification = "HISTORICAL_UNREGISTERED"
532
+ elif integrated_oid is None:
533
+ resolve_commit(repository.canonical_path, expected_oid)
534
+ classification = "UNVERIFIED"
535
+ else:
536
+ resolve_commit(repository.canonical_path, expected_oid)
537
+ resolve_commit(repository.canonical_path, integrated_oid)
538
+ classification = _classify_integration(
539
+ repository.canonical_path, expected_oid, integrated_oid
540
+ )
541
+ if classification != "ANCESTRY":
542
+ blockers.append(f"CLASSIFICATION_{classification}")
543
+
544
+ local_ref = f"refs/heads/{normalized_branch}"
545
+ tracking_ref = f"refs/remotes/{remote}/{normalized_branch}"
546
+ local_state = _ref_state(refs, local_ref, expected_oid) if expected_oid else "UNKNOWN"
547
+ tracking_state = _ref_state(refs, tracking_ref, expected_oid) if expected_oid else "UNKNOWN"
548
+ if local_state == "PRESENT_DRIFTED":
549
+ blockers.append("LOCAL_OID_DRIFT")
550
+ if tracking_state == "PRESENT_DRIFTED":
551
+ blockers.append("UPSTREAM_OID_DRIFT")
552
+ upstream_state, upstream_ref = _upstream_state(
553
+ repository.canonical_path, normalized_branch, tracking_ref
554
+ )
555
+ if upstream_state == "OTHER":
556
+ blockers.append("UPSTREAM_TARGET_MISMATCH")
557
+
558
+ checked_out = sorted(
559
+ str(item.path) for item in repository.worktrees if item.branch == normalized_branch
560
+ )
561
+ if checked_out:
562
+ blockers.append("BRANCH_CHECKED_OUT")
563
+
564
+ record = snapshot.records.get(resolved_allocation or "")
565
+ if release_receipt is not None or transition_evidence is not None:
566
+ if record is None:
567
+ blockers.append("ALLOCATION_RECORD_MISSING")
568
+ else:
569
+ if transition_evidence is not None:
570
+ if (
571
+ record.role not in {"canonical", "control-plane"}
572
+ or record.lifecycle not in {"ACTIVE", "HANDOFF_READY"}
573
+ ):
574
+ blockers.append("CANONICAL_TRANSITION_ALLOCATION_INVALID")
575
+ else:
576
+ if record.lifecycle != "RELEASED":
577
+ blockers.append("ALLOCATION_NOT_RELEASED")
578
+ if record.role == "integration":
579
+ if remote != policy.publication_remote or not _integration_publication_verified(
580
+ snapshot,
581
+ record,
582
+ release_receipt,
583
+ target_ref=f"refs/heads/{policy.publication_branch}",
584
+ ):
585
+ blockers.append("INTEGRATION_PUBLICATION_NOT_VERIFIED")
586
+ elif record.role not in {"feature", "audit"}:
587
+ blockers.append("UNSUPPORTED_ALLOCATION_ROLE")
588
+ if record.external_bindings:
589
+ blockers.append("RUNTIME_BINDING_ACTIVE")
590
+ if record.recovery_ref or record.lifecycle in {"QUARANTINED", "RECOVERY_REQUIRED"}:
591
+ blockers.append("RECOVERY_STATE_EXCLUDED")
592
+ canonical_key = next(item.key for item in repository.worktrees if item.canonical)
593
+ if any(
594
+ value == resolved_allocation and (transition_evidence is None or key != canonical_key)
595
+ for key, value in snapshot.bindings.items()
596
+ ):
597
+ blockers.append("ALLOCATION_BOUND")
598
+ if resolved_allocation in snapshot.writer_leases:
599
+ blockers.append("WRITER_LEASE_ACTIVE")
600
+ if (
601
+ snapshot.publication_lease is not None
602
+ and snapshot.publication_lease.allocation_id == resolved_allocation
603
+ ):
604
+ blockers.append("PUBLICATION_LEASE_ACTIVE")
605
+ if any(
606
+ intent.allocation_id == resolved_allocation
607
+ and (release_receipt is None or not _release_intent_resolved(snapshot, record, release_receipt, intent))
608
+ for intent in snapshot.release_intents.values()
609
+ ):
610
+ blockers.append("RELEASE_INTENT_UNRESOLVED")
611
+ if any(
612
+ (intent.allocation_id == resolved_allocation or resolved_allocation in intent.source_allocations)
613
+ and intent.state not in TERMINAL_INTEGRATION_STATES
614
+ and not integration_intent_abandoned(intent)
615
+ for intent in snapshot.integration_intents.values()
616
+ ):
617
+ blockers.append("INTEGRATION_INTENT_UNRESOLVED")
618
+ if _transactions_for_allocation(store, resolved_allocation):
619
+ blockers.append("TRANSACTION_ACTIVE")
620
+
621
+ controller_digest = _state_digest(
622
+ store,
623
+ repository.canonical_path,
624
+ allocation_id=resolved_allocation or "",
625
+ branch=normalized_branch,
626
+ )
627
+ eligible = not blockers and classification == "ANCESTRY"
628
+ core: dict[str, Any] = {
629
+ "schema_version": PLAN_SCHEMA_VERSION,
630
+ "repository_id": snapshot.repository_id,
631
+ "allocation_id": resolved_allocation,
632
+ "release_receipt_id": release_receipt_id,
633
+ "branch": normalized_branch,
634
+ "expected_oid": expected_oid or None,
635
+ "remote": remote,
636
+ "remote_ref": f"refs/heads/{normalized_branch}",
637
+ "remote_tracking_ref": tracking_ref,
638
+ "integrated_into": integrated_into,
639
+ "integrated_ref": integrated_ref,
640
+ "expected_integrated_oid": integrated_oid,
641
+ "classification": classification,
642
+ "registration": registration,
643
+ "local_ref_state": local_state,
644
+ "remote_tracking_state": tracking_state,
645
+ "upstream_state": upstream_state,
646
+ "upstream_ref": upstream_ref,
647
+ "checked_out_paths": checked_out,
648
+ "controller_state_digest": controller_digest,
649
+ "eligible": eligible,
650
+ "blockers": sorted(set(blockers)),
651
+ }
652
+ if transition_evidence is not None:
653
+ core["canonical_transition_evidence"] = transition_evidence
654
+ observed_plan_id = _sha256_json(_plan_identity(core))
655
+ if plan_id is not None and observed_plan_id != plan_id:
656
+ core["eligible"] = False
657
+ core["blockers"] = sorted({*core["blockers"], "PLAN_STATE_DRIFT"})
658
+ core["plan_id"] = observed_plan_id
659
+ return core, store
660
+
661
+
662
+ def _finalizer_arguments(
663
+ plan: Mapping[str, Any], operation: str, *, repository_root: Path
664
+ ) -> list[str]:
665
+ from .retirement_adapters import arguments
666
+
667
+ return arguments(plan, operation, repository_root=repository_root)
668
+
669
+
670
+ def _payload(command: str, decision: Mapping[str, Any]) -> dict[str, Any]:
671
+ from .controller import envelope
672
+
673
+ payload = envelope(command)
674
+ payload["decision"] = dict(decision)
675
+ return payload
676
+
677
+
678
+ def plan_branch_retirement(
679
+ repository_path: Path,
680
+ *,
681
+ allocation_id: str | None = None,
682
+ branch: str | None = None,
683
+ remote: str,
684
+ integrated_into: str,
685
+ dry_run: bool = False,
686
+ ) -> dict[str, Any]:
687
+ if (allocation_id is None) == (branch is None):
688
+ raise ControllerError(
689
+ "INVALID_RETIREMENT_TARGET", "specify exactly one of allocation_id or branch"
690
+ )
691
+ repository = inventory(repository_path)
692
+ store = MetadataStore(repository.common_dir)
693
+ with store.lock(exclusive=not dry_run, create=not dry_run):
694
+ evaluated, _ = _evaluate(
695
+ repository_path,
696
+ allocation_id=allocation_id,
697
+ branch=branch,
698
+ remote=remote,
699
+ integrated_into=integrated_into,
700
+ _locked_store=store,
701
+ )
702
+ persisted = False
703
+ if evaluated["eligible"] and not dry_run:
704
+ persisted_plan = {**evaluated, "created_at": now_iso()}
705
+ path = store.branch_retirement_plans_dir / f"{evaluated['plan_id']}.json"
706
+ if path.exists():
707
+ current = store._read_json(path)
708
+ if _plan_identity(current) != _plan_identity(evaluated):
709
+ raise ControllerError("PLAN_MISMATCH", "retirement plan already differs")
710
+ else:
711
+ store._atomic_json(path, persisted_plan)
712
+ store.touch_repository()
713
+ persisted = True
714
+ decision = {
715
+ "code": (
716
+ "BRANCH_RETIREMENT_READY"
717
+ if evaluated["eligible"]
718
+ else "BRANCH_RETIREMENT_REPORT_ONLY"
719
+ if evaluated["registration"] == "HISTORICAL_UNREGISTERED"
720
+ or str(evaluated["classification"]) in {
721
+ "CHERRY_EQUIVALENT",
722
+ "MERGE_ONLY",
723
+ "UNIQUE",
724
+ "MIXED",
725
+ "HISTORICAL_UNREGISTERED",
726
+ }
727
+ else "BRANCH_RETIREMENT_BLOCKED"
728
+ ),
729
+ "persisted": persisted,
730
+ "dry_run": dry_run,
731
+ "plan": evaluated,
732
+ }
733
+ if evaluated["eligible"]:
734
+ decision["execution"] = {
735
+ "contract": execution.RECIPE_CONTRACT,
736
+ "schema_version": execution.RECIPE_SCHEMA_VERSION,
737
+ "grant": "branch-retirement-authorize",
738
+ "settle": "branch-retirement-complete",
739
+ "result_contract": execution.RESULT_CONTRACT,
740
+ "operations": {
741
+ operation: {
742
+ "ref": (
743
+ f"refs/heads/{evaluated['branch']}"
744
+ if operation == "local"
745
+ else str(evaluated["remote_tracking_ref"])
746
+ ),
747
+ "mutation": "expected-oid-cas-delete",
748
+ "expected_oid": evaluated["expected_oid"],
749
+ "integrated_ref": evaluated["integrated_ref"],
750
+ "expected_integrated_oid": evaluated["expected_integrated_oid"],
751
+ "tracking_ref": evaluated["remote_tracking_ref"],
752
+ "kind": execution.KIND_CAS_DELETE,
753
+ }
754
+ for operation in ("local", "remote")
755
+ },
756
+ }
757
+ decision["finalizer"] = {
758
+ "local": _finalizer_arguments(
759
+ evaluated, "local", repository_root=repository.canonical_path
760
+ ),
761
+ "remote": _finalizer_arguments(
762
+ evaluated, "remote", repository_root=repository.canonical_path
763
+ ),
764
+ }
765
+ return _payload("branch-retirement-plan", decision)
766
+
767
+
768
+ def _load_plan(store: MetadataStore, plan_id: str) -> dict[str, Any]:
769
+ if not re.fullmatch(r"[0-9a-f]{64}", plan_id):
770
+ raise ControllerError("INVALID_PLAN_ID", "retirement plan id must be sha256")
771
+ path = store.branch_retirement_plans_dir / f"{plan_id}.json"
772
+ if not path.is_file():
773
+ raise ControllerError("UNKNOWN_RETIREMENT_PLAN", "retirement plan does not exist")
774
+ plan = dict(store._read_json(path))
775
+ if plan.get("schema_version") != PLAN_SCHEMA_VERSION or plan.get("plan_id") != plan_id:
776
+ raise ControllerError("INVALID_METADATA", "retirement plan schema is invalid")
777
+ return plan
778
+
779
+
780
+ def _receipt_path(store: MetadataStore, plan_id: str, operation: str) -> Path:
781
+ return store.branch_retirement_receipts_dir / f"{plan_id}-{operation}.json"
782
+
783
+
784
+ def _completed_blockers(
785
+ repository_path: Path,
786
+ plan: Mapping[str, Any],
787
+ current: Mapping[str, Any],
788
+ *,
789
+ operation: str,
790
+ receipt: Mapping[str, Any],
791
+ ) -> list[str]:
792
+ """Revalidate a historical completion without changing its execution plan.
793
+
794
+ Only an integrated-ref fast-forward may differ, and only for an attested
795
+ operation whose ref remains absent. Pending execution still uses exact CAS.
796
+ """
797
+
798
+ blockers = set(current["blockers"])
799
+ plan_id = str(plan["plan_id"])
800
+ if (
801
+ _sha256_json(_plan_identity(plan)) != plan_id
802
+ or type(receipt.get("schema_version")) is not int
803
+ or receipt.get("schema_version")
804
+ not in {RECEIPT_SCHEMA_VERSION, AUTHORIZED_RECEIPT_SCHEMA_VERSION}
805
+ or receipt.get("receipt_id")
806
+ != hashlib.sha256(f"{plan_id}:{operation}".encode()).hexdigest()
807
+ or receipt.get("operation") != operation
808
+ or any(
809
+ receipt.get(key) != plan[key]
810
+ for key in ("plan_id", "branch", "expected_oid", "expected_integrated_oid")
811
+ )
812
+ ):
813
+ return sorted({*blockers, "RETIREMENT_RECEIPT_MISMATCH"})
814
+ state_key = "local_ref_state" if operation == "local" else "remote_tracking_state"
815
+ if current[state_key] != "ABSENT":
816
+ blockers.add("REF_REAPPEARED_AFTER_RETIREMENT")
817
+ if blockers == {"PLAN_STATE_DRIFT"}:
818
+ original_identity = _plan_identity(current)
819
+ original_identity["expected_integrated_oid"] = plan["expected_integrated_oid"]
820
+ if (
821
+ _sha256_json(original_identity) == plan_id
822
+ and isinstance(current["expected_integrated_oid"], str)
823
+ and is_ancestor(
824
+ repository_path,
825
+ str(plan["expected_integrated_oid"]),
826
+ current["expected_integrated_oid"],
827
+ )
828
+ ):
829
+ blockers.clear()
830
+ return sorted(blockers)
831
+
832
+
833
+
834
+ def branch_retirement_status(repository_path: Path, *, plan_id: str) -> dict[str, Any]:
835
+ repository = inventory(repository_path)
836
+ store = MetadataStore(repository.common_dir)
837
+ with store.lock(exclusive=False):
838
+ plan = _load_plan(store, plan_id)
839
+ current, _ = _evaluate(
840
+ repository_path,
841
+ allocation_id=str(plan["allocation_id"]),
842
+ branch=str(plan["branch"]),
843
+ remote=str(plan["remote"]),
844
+ integrated_into=str(plan["integrated_into"]),
845
+ plan_id=plan_id,
846
+ _locked_store=store,
847
+ )
848
+ receipts = {
849
+ operation: (
850
+ dict(store._read_json(_receipt_path(store, plan_id, operation)))
851
+ if _receipt_path(store, plan_id, operation).is_file()
852
+ else None
853
+ )
854
+ for operation in ("local", "remote")
855
+ }
856
+ if all(receipt is not None for receipt in receipts.values()):
857
+ blockers = sorted(
858
+ {
859
+ blocker
860
+ for operation, receipt in receipts.items()
861
+ for blocker in _completed_blockers(
862
+ repository.canonical_path,
863
+ plan,
864
+ current,
865
+ operation=operation,
866
+ receipt=receipt,
867
+ )
868
+ }
869
+ )
870
+ current = {**current, "blockers": blockers, "eligible": not blockers}
871
+ completed = [operation for operation, receipt in receipts.items() if receipt is not None]
872
+ drifted = "PLAN_STATE_DRIFT" in current["blockers"] or not current["eligible"]
873
+ if len(completed) == 2 and not drifted:
874
+ code = "BRANCH_RETIREMENT_COMPLETE"
875
+ elif len(completed) == 2:
876
+ code = "BRANCH_RETIREMENT_DRIFTED"
877
+ elif completed and drifted:
878
+ code = "BRANCH_RETIREMENT_PARTIAL_DRIFTED"
879
+ elif completed:
880
+ code = "BRANCH_RETIREMENT_PARTIAL"
881
+ elif drifted:
882
+ code = "BRANCH_RETIREMENT_DRIFTED"
883
+ else:
884
+ code = "BRANCH_RETIREMENT_READY"
885
+ return _payload(
886
+ "branch-retirement-status",
887
+ {
888
+ "code": code,
889
+ "plan_id": plan_id,
890
+ "completed_operations": completed,
891
+ "pending_operations": [item for item in ("local", "remote") if item not in completed],
892
+ "receipts": receipts,
893
+ "current": current,
894
+ },
895
+ )
896
+
897
+
898
+ #: Authority facts that must still hold when a retirement result is filed. Ref-derived
899
+ #: codes (EXPECTED_OID_MISSING, UPSTREAM_*, INTEGRATION_REF_MISSING) are what deleting a
900
+ #: branch legitimately produces, so they are not in this set.
901
+ AUTHORITY_BLOCKERS = frozenset(
902
+ {
903
+ "BRANCH_CHECKED_OUT",
904
+ "WRITER_LEASE_ACTIVE",
905
+ "ALLOCATION_BOUND",
906
+ "RUNTIME_BINDING_ACTIVE",
907
+ "PUBLICATION_LEASE_ACTIVE",
908
+ "RELEASE_INTENT_UNRESOLVED",
909
+ "INTEGRATION_INTENT_UNRESOLVED",
910
+ "TRANSACTION_ACTIVE",
911
+ "RECOVERY_STATE_EXCLUDED",
912
+ "ALLOCATION_NOT_RELEASED",
913
+ "ALLOCATION_RECORD_MISSING",
914
+ "UNSUPPORTED_ALLOCATION_ROLE",
915
+ "INTEGRATION_PUBLICATION_NOT_VERIFIED",
916
+ "CANONICAL_TRANSITION_EVIDENCE_INVALID",
917
+ "CANONICAL_TRANSITION_EVIDENCE_AMBIGUOUS",
918
+ "CANONICAL_TRANSITION_TARGET_MISMATCH",
919
+ "CANONICAL_TRANSITION_PUBLICATION_MISMATCH",
920
+ "CANONICAL_TRANSITION_ALLOCATION_INVALID",
921
+ "PROTECTED_BRANCH",
922
+ "SPECIAL_LIFECYCLE_REF",
923
+ "PLAN_STATE_DRIFT",
924
+ }
925
+ )
926
+
927
+ STALE_BLOCKERS = frozenset(
928
+ {
929
+ "PLAN_STATE_DRIFT",
930
+ "LOCAL_OID_DRIFT",
931
+ "UPSTREAM_OID_DRIFT",
932
+ }
933
+ )
934
+
935
+
936
+ def _stale_prefix(blockers: list[str]) -> bool:
937
+ return any(item.startswith("CLASSIFICATION_") for item in blockers)
938
+
939
+
940
+ def _classify(current: Mapping[str, Any], *, operation: str, receipt: object) -> tuple[str, list[str]]:
941
+ """One classifier for every pre-mutation question, so verbs cannot diverge.
942
+
943
+ ``PLAN_STATE_DRIFT`` and the digest-blind ``*_DRIFT`` blockers mean the world the
944
+ plan described is gone, which is a different answer from "something is occupying
945
+ it": a stale plan needs a new plan, a blocked one needs the occupancy removed.
946
+ """
947
+
948
+ blockers = list(current["blockers"])
949
+ state_key = "local_ref_state" if operation == "local" else "remote_tracking_state"
950
+ ref_state = str(current[state_key])
951
+ if receipt is not None:
952
+ return "RETIREMENT_OPERATION_COMPLETE", blockers
953
+ if ref_state == "ABSENT":
954
+ return "RETIREMENT_BLOCKED", sorted({*blockers, "ABSENT_UNATTESTED"})
955
+ if any(item in STALE_BLOCKERS for item in blockers) or _stale_prefix(blockers):
956
+ return "RETIREMENT_STALE", blockers
957
+ if blockers or not current["eligible"] or ref_state != "PRESENT_EXPECTED":
958
+ return "RETIREMENT_BLOCKED", blockers
959
+ return "RETIREMENT_EXECUTABLE", blockers
960
+
961
+
962
+ def _observation(repository: RepositoryInventory, current: Mapping[str, Any]) -> dict[str, Any]:
963
+ """The live Git facts the state digest provably cannot see.
964
+
965
+ ``controller_state_digest`` covers Controller authority and policy bytes; refs,
966
+ worktree occupancy and upstream configuration are Git state, reachable by any
967
+ plain ``git`` invocation without touching a digested file. An authorization
968
+ therefore binds this vector as well, and settle compares it.
969
+ """
970
+
971
+ refs = public_refs(repository.canonical_path)
972
+ branch = str(current["branch"])
973
+ watched = {f"refs/heads/{branch}": refs.get(f"refs/heads/{branch}")}
974
+ for ref in (current.get("remote_tracking_ref"), current.get("integrated_ref")):
975
+ if isinstance(ref, str) and ref:
976
+ watched[ref] = refs.get(ref)
977
+ return execution.normalize_observation(
978
+ {
979
+ "refs": watched,
980
+ "checked_out_paths": list(current["checked_out_paths"]),
981
+ "upstream_ref": current.get("upstream_ref"),
982
+ "upstream_target": current.get("upstream_state"),
983
+ "classification": current.get("classification"),
984
+ "blockers": list(current["blockers"]),
985
+ }
986
+ )
987
+
988
+
989
+ def _authorization_path(store: MetadataStore, authorization_id: str) -> Path:
990
+ return store.branch_retirement_authorizations_dir / f"{authorization_id}.json"
991
+
992
+
993
+ def outstanding_retirement_authorizations(
994
+ store: MetadataStore, *, branch: str | None = None, allocation_id: str | None = None
995
+ ) -> list[dict[str, Any]]:
996
+ """Every execution authorization the Controller has granted and not yet closed.
997
+
998
+ Callers under a repository lock only; the directory is small, so a full scan is
999
+ cheaper and safer than a secondary index that could disagree with the files.
1000
+ """
1001
+
1002
+ from .transaction_scope import is_atomic_temp
1003
+
1004
+ directory = store.branch_retirement_authorizations_dir
1005
+ if not directory.is_dir():
1006
+ return []
1007
+ result: list[dict[str, Any]] = []
1008
+ for path in sorted(directory.glob("*.json")):
1009
+ if is_atomic_temp(path.name) or path.name.endswith("-abandoned.json"):
1010
+ continue
1011
+ try:
1012
+ value = dict(store._read_json(path))
1013
+ except ControllerError:
1014
+ continue
1015
+ if value.get("contract") != execution.AUTHORIZATION_CONTRACT:
1016
+ continue
1017
+ if branch is not None and value.get("branch") != branch:
1018
+ continue
1019
+ if allocation_id is not None and value.get("allocation_id") != allocation_id:
1020
+ continue
1021
+ result.append(value)
1022
+ return result
1023
+
1024
+
1025
+ def retirement_occupancy_blocker(
1026
+ store: MetadataStore, *, branch: str | None = None, allocation_id: str | None = None
1027
+ ) -> ControllerError | None:
1028
+ """Refuse to re-occupy a branch the Controller has just authorized for deletion.
1029
+
1030
+ This is the fence half of the execution contract: an authorization is a promise
1031
+ that the branch is going away, so handing it to a new checkout or writer lease
1032
+ would make the authorized mutation destroy live work instead of a retired ref.
1033
+ """
1034
+
1035
+ outstanding = outstanding_retirement_authorizations(
1036
+ store, branch=branch, allocation_id=allocation_id
1037
+ )
1038
+ if not outstanding:
1039
+ return None
1040
+ scope = branch or allocation_id or "this target"
1041
+ return ControllerError(
1042
+ "RETIREMENT_EXECUTION_ACTIVE",
1043
+ f"a retirement execution authorization covers {scope!r} "
1044
+ f"({outstanding[0]['authorization_id']}); complete or abandon it first",
1045
+ )
1046
+
1047
+
1048
+ def authorize_branch_retirement(
1049
+ repository_path: Path,
1050
+ *,
1051
+ plan_id: str,
1052
+ operation: str,
1053
+ request_id: str,
1054
+ holder: str | None = None,
1055
+ client_id: str = "retirement-executor",
1056
+ session_id: str | None = None,
1057
+ run_id: str | None = None,
1058
+ kind: str = execution.KIND_CAS_DELETE,
1059
+ ttl_seconds: int = AUTHORIZATION_TTL_DEFAULT_SECONDS,
1060
+ ) -> dict[str, Any]:
1061
+ """Grant one single-use execution authorization for one operation of one plan.
1062
+
1063
+ Validation happens here, under the exclusive repository lock, and the grant is
1064
+ durable: the consumer's next step is the Git compare-and-delete, and the
1065
+ Controller's final check is ``branch-retirement-complete``, which re-observes
1066
+ the world itself rather than believing the consumer's account of it. A caller
1067
+ never asserts "I hold the lock" - WC takes the lock, and the token it returns
1068
+ is the only capability the grant carries.
1069
+ """
1070
+
1071
+ if operation not in OPERATIONS:
1072
+ raise ControllerError("INVALID_OPERATION", "operation must be local or remote")
1073
+ if kind != execution.KIND_CAS_DELETE:
1074
+ raise ControllerError(
1075
+ "INVALID_EXECUTION_KIND",
1076
+ f"{kind!r} is a reported outcome kind, not a grantable one; only CAS_DELETE is authorized",
1077
+ )
1078
+ if ttl_seconds <= 0 or ttl_seconds > AUTHORIZATION_TTL_MAX_SECONDS:
1079
+ raise ControllerError(
1080
+ "INVALID_AUTHORIZATION_TTL", f"ttl must be between 1 and {AUTHORIZATION_TTL_MAX_SECONDS} seconds"
1081
+ )
1082
+ try:
1083
+ identity = UUID(request_id)
1084
+ except ValueError as exc:
1085
+ raise ControllerError("INVALID_REQUEST_ID", "request id must be a UUID") from exc
1086
+ if str(identity) != request_id:
1087
+ raise ControllerError("INVALID_REQUEST_ID", "request id must be a canonical UUID")
1088
+
1089
+ repository = inventory(repository_path)
1090
+ store = MetadataStore(repository.common_dir)
1091
+ with store.lock(exclusive=True, create=False):
1092
+ plan = _load_plan(store, plan_id)
1093
+ current, _ = _evaluate(
1094
+ repository_path,
1095
+ allocation_id=str(plan["allocation_id"]),
1096
+ branch=str(plan["branch"]),
1097
+ remote=str(plan["remote"]),
1098
+ integrated_into=str(plan["integrated_into"]),
1099
+ plan_id=plan_id,
1100
+ _locked_store=store,
1101
+ )
1102
+ receipt_path = _receipt_path(store, plan_id, operation)
1103
+ receipt = dict(store._read_json(receipt_path)) if receipt_path.is_file() else None
1104
+ code, blockers = _classify(current, operation=operation, receipt=receipt)
1105
+ ref = (
1106
+ f"refs/heads/{current['branch']}"
1107
+ if operation == "local"
1108
+ else str(current["remote_tracking_ref"])
1109
+ )
1110
+ live = [
1111
+ item
1112
+ for item in outstanding_retirement_authorizations(store, branch=str(current["branch"]))
1113
+ if item.get("operation") == operation
1114
+ ]
1115
+ replayed = next((item for item in live if item.get("request_id") == request_id), None)
1116
+ if replayed is not None:
1117
+ return _payload(
1118
+ "branch-retirement-authorize",
1119
+ {
1120
+ "code": "RETIREMENT_AUTHORIZED",
1121
+ "idempotent": True,
1122
+ "plan_id": plan_id,
1123
+ "operation": operation,
1124
+ "authorization": replayed,
1125
+ "blockers": [],
1126
+ },
1127
+ )
1128
+ if code != "RETIREMENT_EXECUTABLE":
1129
+ return _payload(
1130
+ "branch-retirement-authorize",
1131
+ {
1132
+ "code": code,
1133
+ "idempotent": False,
1134
+ "plan_id": plan_id,
1135
+ "operation": operation,
1136
+ "authorization": None,
1137
+ "blockers": blockers,
1138
+ },
1139
+ )
1140
+ if live:
1141
+ return _payload(
1142
+ "branch-retirement-authorize",
1143
+ {
1144
+ "code": "RETIREMENT_BLOCKED",
1145
+ "idempotent": False,
1146
+ "plan_id": plan_id,
1147
+ "operation": operation,
1148
+ "authorization": None,
1149
+ "blockers": sorted({*blockers, "RETIREMENT_AUTHORIZATION_ACTIVE"}),
1150
+ "outstanding_authorization_id": live[0]["authorization_id"],
1151
+ },
1152
+ )
1153
+
1154
+ selector = holder or "self"
1155
+ if selector != "self" and not selector.startswith("ancestor:"):
1156
+ raise ControllerError(
1157
+ "INVALID_HOLDER",
1158
+ "a grant binds the calling process tree: use self or ancestor:<program>, never a "
1159
+ "detached pid that names somebody else's process",
1160
+ )
1161
+ resolved = (
1162
+ describe_holder(os.getpid(), client_id=client_id, session_id=session_id, run_id=run_id)
1163
+ if selector == "self"
1164
+ else resolve_holder(
1165
+ selector, client_id=client_id, session_id=session_id, run_id=run_id
1166
+ )
1167
+ )
1168
+ issued_at = now_iso()
1169
+ expires_at = (
1170
+ datetime.fromisoformat(issued_at)
1171
+ .fromtimestamp(
1172
+ datetime.fromisoformat(issued_at).timestamp() + ttl_seconds,
1173
+ tz=datetime.fromisoformat(issued_at).tzinfo,
1174
+ )
1175
+ .isoformat(timespec="seconds")
1176
+ )
1177
+ body: dict[str, Any] = {
1178
+ "schema_version": execution.AUTHORIZATION_SCHEMA_VERSION,
1179
+ "contract": execution.AUTHORIZATION_CONTRACT,
1180
+ "request_id": request_id,
1181
+ "plan_id": plan_id,
1182
+ "operation": operation,
1183
+ "kind": kind,
1184
+ "repository_id": current["repository_id"],
1185
+ "allocation_id": current["allocation_id"],
1186
+ "branch": current["branch"],
1187
+ "ref": ref,
1188
+ "expected_oid": current["expected_oid"],
1189
+ "integrated_ref": current["integrated_ref"],
1190
+ "expected_integrated_oid": current["expected_integrated_oid"],
1191
+ "tracking_ref": current["remote_tracking_ref"],
1192
+ "observed": _observation(repository, current),
1193
+ "controller_state_digest": current["controller_state_digest"],
1194
+ "holder_sha256": canonical_holder_digest(asdict(resolved)),
1195
+ "issued_at": issued_at,
1196
+ "expires_at": expires_at,
1197
+ "ttl_seconds": ttl_seconds,
1198
+ "holder": asdict(resolved),
1199
+ "token": secrets.token_hex(32),
1200
+ "minimum_revalidation": {
1201
+ "plan_id": plan_id,
1202
+ "operation": operation,
1203
+ "branch": current["branch"],
1204
+ "expected_oid": current["expected_oid"],
1205
+ "expected_integrated_oid": current["expected_integrated_oid"],
1206
+ "integrated_ref": current["integrated_ref"],
1207
+ "ref_state": current["local_ref_state" if operation == "local" else "remote_tracking_state"],
1208
+ "controller_state_digest": current["controller_state_digest"],
1209
+ },
1210
+ }
1211
+ body["authorization_id"] = _sha256_json(execution.identity(body))
1212
+ store._atomic_json(_authorization_path(store, body["authorization_id"]), body)
1213
+ _crash_point("retirement_authorization_issued")
1214
+ store.touch_repository()
1215
+ return _payload(
1216
+ "branch-retirement-authorize",
1217
+ {
1218
+ "code": "RETIREMENT_AUTHORIZED",
1219
+ "idempotent": False,
1220
+ "plan_id": plan_id,
1221
+ "operation": operation,
1222
+ "authorization_id": body["authorization_id"],
1223
+ "authorization": body,
1224
+ "blockers": [],
1225
+ },
1226
+ )
1227
+
1228
+
1229
+ def _load_authorization(store: MetadataStore, authorization_id: str) -> dict[str, Any]:
1230
+ if not re.fullmatch(r"[0-9a-f]{64}", authorization_id):
1231
+ raise ControllerError("INVALID_AUTHORIZATION_ID", "authorization id must be sha256")
1232
+ path = _authorization_path(store, authorization_id)
1233
+ if not path.is_file():
1234
+ raise ControllerError("UNKNOWN_RETIREMENT_AUTHORIZATION", "authorization does not exist")
1235
+ document = dict(store._read_json(path))
1236
+ if (
1237
+ document.get("schema_version") != execution.AUTHORIZATION_SCHEMA_VERSION
1238
+ or document.get("contract") != execution.AUTHORIZATION_CONTRACT
1239
+ or _sha256_json(execution.identity(document)) != document.get("authorization_id")
1240
+ or document.get("authorization_id") != authorization_id
1241
+ ):
1242
+ raise ControllerError("INVALID_METADATA", "retirement authorization content differs")
1243
+ return document
1244
+
1245
+
1246
+ def complete_branch_retirement(
1247
+ repository_path: Path,
1248
+ *,
1249
+ authorization_id: str,
1250
+ token: str,
1251
+ result_path: Path,
1252
+ ) -> dict[str, Any]:
1253
+ """Close an authorized retirement using the Controller's own observation.
1254
+
1255
+ The consumer's result document is treated as a claim about what it attempted,
1256
+ never as proof of what happened. Under the exclusive lock the Controller
1257
+ re-reads the refs, re-derives the authority digest, and compares the live
1258
+ observation against the vector the grant bound; only the single authorized
1259
+ delta (the target ref moving from its expected OID to absent) is accepted.
1260
+ """
1261
+
1262
+ try:
1263
+ raw = result_path.read_bytes()
1264
+ reported = json.loads(raw)
1265
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
1266
+ raise ControllerError("INVALID_EXECUTION_RESULT", f"cannot read result: {exc}") from exc
1267
+ if not isinstance(reported, dict):
1268
+ raise ControllerError("INVALID_EXECUTION_RESULT", "result must be a JSON object")
1269
+ result = execution.normalize_result(reported)
1270
+
1271
+ repository = inventory(repository_path)
1272
+ store = MetadataStore(repository.common_dir)
1273
+ with store.lock(exclusive=True, create=False):
1274
+ settled = _receipt_by_authorization(store, authorization_id)
1275
+ if settled is not None:
1276
+ receipt, plan_id, operation = settled
1277
+ if _authorization_path(store, authorization_id).is_file():
1278
+ # Crash between the receipt write and the grant close: the decision
1279
+ # is already recorded, so finishing the close needs no new one.
1280
+ _close_authorization(store, authorization_id)
1281
+ return _payload(
1282
+ "branch-retirement-complete",
1283
+ {
1284
+ "code": "RETIREMENT_EXECUTION_COMPLETE",
1285
+ "idempotent": True,
1286
+ "authorization_id": authorization_id,
1287
+ "plan_id": plan_id,
1288
+ "operation": operation,
1289
+ "receipt": receipt,
1290
+ "provenance": receipt.get("provenance", RECEIPT_PROVENANCE_CLAIM),
1291
+ },
1292
+ )
1293
+ grant = _load_authorization(store, authorization_id)
1294
+ if not hmac.compare_digest(str(grant["token"]), str(token)):
1295
+ raise ControllerError("AUTHORIZATION_TOKEN_MISMATCH", "execution token differs")
1296
+ for field in ("plan_id", "operation", "kind", "ref", "expected_oid", "expected_integrated_oid"):
1297
+ if result[field] != grant[field]:
1298
+ raise ControllerError(
1299
+ "EXECUTION_RESULT_MISMATCH", f"result {field} differs from the authorization"
1300
+ )
1301
+ if result["integrated_ref"] is not None and result["integrated_ref"] != grant["integrated_ref"]:
1302
+ raise ControllerError("EXECUTION_RESULT_MISMATCH", "result integrated_ref differs")
1303
+ plan = _load_plan(store, str(grant["plan_id"]))
1304
+ authorization_id_value = str(grant["authorization_id"])
1305
+ receipt_path = _receipt_path(store, str(grant["plan_id"]), str(grant["operation"]))
1306
+ if receipt_path.is_file():
1307
+ # A receipt without a closed grant is a crash between the two writes;
1308
+ # finishing the close is the only safe repair and needs no new decision.
1309
+ _close_authorization(store, authorization_id_value)
1310
+ return _payload(
1311
+ "branch-retirement-complete",
1312
+ {
1313
+ "code": "RETIREMENT_EXECUTION_COMPLETE",
1314
+ "idempotent": True,
1315
+ "authorization_id": authorization_id_value,
1316
+ "plan_id": grant["plan_id"],
1317
+ "operation": grant["operation"],
1318
+ "receipt": dict(store._read_json(receipt_path)),
1319
+ "provenance": RECEIPT_PROVENANCE_AUTHORIZED,
1320
+ "grant_expired_at_settle": False,
1321
+ },
1322
+ )
1323
+
1324
+ refs = public_refs(repository.canonical_path)
1325
+ target_before = refs.get(str(grant["ref"]))
1326
+ succeeded = execution.succeeded(result)
1327
+ if succeeded:
1328
+ if target_before is not None:
1329
+ raise ControllerError(
1330
+ "EXECUTION_OBSERVATION_MISMATCH",
1331
+ f"the authorized ref still exists at {target_before}",
1332
+ )
1333
+ else:
1334
+ abandon = _abandon(
1335
+ store,
1336
+ grant,
1337
+ classification="EXECUTION_REPORTED_FAILURE",
1338
+ reason=str(result["outcome"]),
1339
+ repository=repository,
1340
+ )
1341
+ return _payload(
1342
+ "branch-retirement-complete",
1343
+ {
1344
+ "code": "RETIREMENT_EXECUTION_ABORTED",
1345
+ "idempotent": False,
1346
+ "authorization_id": authorization_id_value,
1347
+ "plan_id": grant["plan_id"],
1348
+ "operation": grant["operation"],
1349
+ "evidence": abandon,
1350
+ "blockers": [],
1351
+ },
1352
+ )
1353
+
1354
+ current, _ = _evaluate(
1355
+ repository_path,
1356
+ allocation_id=str(plan["allocation_id"]),
1357
+ branch=str(plan["branch"]),
1358
+ remote=str(plan["remote"]),
1359
+ integrated_into=str(plan["integrated_into"]),
1360
+ plan_id=None,
1361
+ _locked_store=store,
1362
+ )
1363
+ live_observation = _observation(repository, current)
1364
+ blockers = _observation_delta(grant["observed"], live_observation, authorized_ref=str(grant["ref"]))
1365
+ if str(current["controller_state_digest"]) != str(grant["controller_state_digest"]):
1366
+ blockers = sorted({*blockers, "PLAN_STATE_DRIFT"})
1367
+ if blockers:
1368
+ abandon = _abandon(
1369
+ store,
1370
+ grant,
1371
+ classification="EXECUTION_OBSERVATION_MISMATCH",
1372
+ reason=",".join(blockers),
1373
+ repository=repository,
1374
+ )
1375
+ return _payload(
1376
+ "branch-retirement-complete",
1377
+ {
1378
+ "code": "RETIREMENT_EXECUTION_REFUSED",
1379
+ "idempotent": False,
1380
+ "authorization_id": authorization_id_value,
1381
+ "plan_id": grant["plan_id"],
1382
+ "operation": grant["operation"],
1383
+ "blockers": blockers,
1384
+ "observed": live_observation,
1385
+ "evidence": abandon,
1386
+ },
1387
+ )
1388
+
1389
+ receipt = {
1390
+ "schema_version": RECEIPT_SCHEMA_VERSION + 1,
1391
+ "receipt_id": hashlib.sha256(
1392
+ f"{grant['plan_id']}:{grant['operation']}".encode()
1393
+ ).hexdigest(),
1394
+ "plan_id": grant["plan_id"],
1395
+ "operation": grant["operation"],
1396
+ "branch": grant["branch"],
1397
+ "expected_oid": grant["expected_oid"],
1398
+ "expected_integrated_oid": grant["expected_integrated_oid"],
1399
+ "finalizer_version": result["executor"]["version"],
1400
+ "finalizer_result": result["outcome"],
1401
+ "summary_sha256": hashlib.sha256(raw).hexdigest(),
1402
+ "recorded_at": now_iso(),
1403
+ "authorization_id": authorization_id_value,
1404
+ "provenance": RECEIPT_PROVENANCE_AUTHORIZED,
1405
+ "grant_expired_at_settle": grant_expired(grant),
1406
+ "observed_ref": grant["ref"],
1407
+ "observed_at_settle": target_before,
1408
+ "executor_name": result["executor"]["name"],
1409
+ }
1410
+ store._atomic_json(receipt_path, receipt)
1411
+ _crash_point("retirement_receipt_written")
1412
+ _close_authorization(store, authorization_id_value)
1413
+ store.touch_repository()
1414
+ return _payload(
1415
+ "branch-retirement-complete",
1416
+ {
1417
+ "code": "RETIREMENT_EXECUTION_COMPLETE",
1418
+ "idempotent": False,
1419
+ "authorization_id": authorization_id_value,
1420
+ "plan_id": grant["plan_id"],
1421
+ "operation": grant["operation"],
1422
+ "receipt": receipt,
1423
+ "provenance": RECEIPT_PROVENANCE_AUTHORIZED,
1424
+ "grant_expired_at_settle": receipt["grant_expired_at_settle"],
1425
+ "blockers": [],
1426
+ },
1427
+ )
1428
+
1429
+
1430
+ def _receipt_by_authorization(
1431
+ store: MetadataStore, authorization_id: str
1432
+ ) -> tuple[dict[str, Any], str, str] | None:
1433
+ """Find a settled retirement from its grant id, so replay answers after closure."""
1434
+
1435
+ directory = store.branch_retirement_receipts_dir
1436
+ if not directory.is_dir():
1437
+ return None
1438
+ for receipt_file in sorted(directory.glob("*.json")):
1439
+ try:
1440
+ receipt = dict(store._read_json(receipt_file))
1441
+ except ControllerError:
1442
+ continue
1443
+ if receipt.get("authorization_id") == authorization_id:
1444
+ return receipt, str(receipt["plan_id"]), str(receipt["operation"])
1445
+ return None
1446
+
1447
+
1448
+ def _observation_delta(
1449
+ bound: object, live: Mapping[str, Any], *, authorized_ref: str
1450
+ ) -> list[str]:
1451
+ """Blockers for the live Git facts the authorized deletion must not have touched.
1452
+
1453
+ Only refs and occupancy are compared. ``classification`` and ``blockers`` are
1454
+ derived from the very ref the grant authorized deleting, so they legitimately
1455
+ change the moment it goes; comparing them would refuse every honest settlement.
1456
+ An occupied path is different: it means a live checkout is dangling at a ref
1457
+ that is already gone.
1458
+ """
1459
+
1460
+ if not isinstance(bound, Mapping):
1461
+ return ["OBSERVATION_UNREADABLE"]
1462
+ blockers: list[str] = []
1463
+ bound_refs = bound.get("refs") or {}
1464
+ live_refs = live.get("refs") or {}
1465
+ for ref, oid in sorted(bound_refs.items()):
1466
+ if ref == authorized_ref:
1467
+ continue
1468
+ if live_refs.get(ref) != oid:
1469
+ blockers.append("OBSERVATION_REF_MOVED")
1470
+ if live.get("checked_out_paths"):
1471
+ blockers.append("OBSERVATION_CHECKOUT_APPEARED")
1472
+ return sorted(set(blockers))
1473
+
1474
+
1475
+ def grant_expired(grant: Mapping[str, Any], *, at: str | None = None) -> bool:
1476
+ """Expiry means the claim needs verification, never that the work is done.
1477
+
1478
+ An expired grant still fences occupancy and still has to be settled or abandoned
1479
+ explicitly; the only thing that ends a retirement is the Controller's own
1480
+ observation of the authorized delta.
1481
+ """
1482
+
1483
+ stamp = at or now_iso()
1484
+ try:
1485
+ return datetime.fromisoformat(str(grant["expires_at"])) < datetime.fromisoformat(stamp)
1486
+ except (KeyError, TypeError, ValueError) as exc:
1487
+ raise ControllerError(
1488
+ "INVALID_METADATA", "retirement authorization expiry is unreadable"
1489
+ ) from exc
1490
+
1491
+
1492
+ def _close_authorization(store: MetadataStore, authorization_id: str) -> None:
1493
+ store.remove_branch_retirement_authorization(authorization_id)
1494
+
1495
+
1496
+ def _live_refs(repository: RepositoryInventory, grant: Mapping[str, Any]) -> dict[str, str | None]:
1497
+ """Every ref the grant bound, read straight from Git at the moment of writing."""
1498
+
1499
+ refs = public_refs(repository.canonical_path)
1500
+ watched = {str(grant["ref"]): refs.get(str(grant["ref"]))}
1501
+ for key in ("integrated_ref", "tracking_ref"):
1502
+ value = grant.get(key)
1503
+ if isinstance(value, str) and value:
1504
+ watched[value] = refs.get(value)
1505
+ return {key: watched[key] for key in sorted(watched)}
1506
+
1507
+
1508
+ def _abandon(
1509
+ store: MetadataStore,
1510
+ grant: Mapping[str, Any],
1511
+ *,
1512
+ classification: str,
1513
+ reason: str,
1514
+ repository: RepositoryInventory,
1515
+ ) -> dict[str, str]:
1516
+ """Close one authorization with immutable evidence; never mint a receipt here.
1517
+
1518
+ A ref that is gone without the Controller having observed the authorized
1519
+ transition is an attention item, not a completed retirement: absence alone does
1520
+ not say who caused it.
1521
+ """
1522
+
1523
+ _crash_point("retirement_abandon_started")
1524
+ evidence = e.write(
1525
+ store.branch_retirement_authorizations_dir,
1526
+ f"{grant['authorization_id']}-abandoned.json",
1527
+ {
1528
+ "schema_version": execution.AUTHORIZATION_SCHEMA_VERSION,
1529
+ "contract": execution.AUTHORIZATION_CONTRACT,
1530
+ "authorization_id": grant["authorization_id"],
1531
+ "plan_id": grant["plan_id"],
1532
+ "operation": grant["operation"],
1533
+ "branch": grant["branch"],
1534
+ "ref": grant["ref"],
1535
+ "classification": classification,
1536
+ "reason": reason,
1537
+ "bound": grant["observed"],
1538
+ "live_refs": _live_refs(repository, grant),
1539
+ "reconciled_at": now_iso(),
1540
+ },
1541
+ )
1542
+ _crash_point("retirement_abandon_evidence_written")
1543
+ _close_authorization(store, str(grant["authorization_id"]))
1544
+ return evidence
1545
+
1546
+
1547
+ def branch_retirement_executions(
1548
+ repository_path: Path,
1549
+ *,
1550
+ action: str,
1551
+ authorization_id: str | None = None,
1552
+ token: str | None = None,
1553
+ reason: str | None = None,
1554
+ ) -> dict[str, Any]:
1555
+ """Inspect or retire an outstanding execution authorization.
1556
+
1557
+ ``abandon`` never mints a completion receipt. An authorization whose ref is
1558
+ already gone without the Controller having observed the authorized transition
1559
+ becomes immutable evidence and a visible attention item, not a blessed
1560
+ retirement - absence alone does not prove who caused it.
1561
+ """
1562
+
1563
+ if action not in {"list", "inspect", "abandon"}:
1564
+ raise ControllerError("INVALID_ACTION", "action must be list, inspect or abandon")
1565
+ repository = inventory(repository_path)
1566
+ store = MetadataStore(repository.common_dir)
1567
+ exclusive = action == "abandon"
1568
+ with store.lock(exclusive=exclusive, create=False):
1569
+ if action == "list":
1570
+ return _payload(
1571
+ "branch-retirement-executions",
1572
+ {
1573
+ "code": "RETIREMENT_EXECUTIONS_LISTED",
1574
+ "authorizations": [
1575
+ {
1576
+ "authorization_id": item["authorization_id"],
1577
+ "plan_id": item["plan_id"],
1578
+ "operation": item["operation"],
1579
+ "branch": item["branch"],
1580
+ "ref": item["ref"],
1581
+ "expires_at": item["expires_at"],
1582
+ "holder_sha256": item["holder_sha256"],
1583
+ }
1584
+ for item in outstanding_retirement_authorizations(store)
1585
+ ],
1586
+ },
1587
+ )
1588
+ if authorization_id is None:
1589
+ raise ControllerError("INVALID_REQUEST", "authorization id is required")
1590
+ grant = _load_authorization(store, authorization_id)
1591
+ if action == "inspect":
1592
+ current, _ = _evaluate(
1593
+ repository_path,
1594
+ allocation_id=str(grant["allocation_id"]),
1595
+ branch=str(grant["branch"]),
1596
+ remote=_remote_from_tracking(str(grant["tracking_ref"])),
1597
+ integrated_into=str(grant["integrated_ref"]),
1598
+ _locked_store=store,
1599
+ )
1600
+ return _payload(
1601
+ "branch-retirement-executions",
1602
+ {
1603
+ "code": "RETIREMENT_EXECUTION_INSPECTED",
1604
+ "authorization": {
1605
+ key: value for key, value in grant.items() if key != "token"
1606
+ },
1607
+ "token_presented": token is not None,
1608
+ "holder_dead": not holder_is_live(_grant_holder(grant)),
1609
+ "observed_now": _observation(repository, current),
1610
+ "ref_present_now": public_refs(repository.canonical_path).get(str(grant["ref"]))
1611
+ is not None,
1612
+ },
1613
+ )
1614
+ if token is None:
1615
+ raise ControllerError(
1616
+ "AUTHORIZATION_TOKEN_REQUIRED",
1617
+ "abandoning a grant needs the execution token: holder liveness is not authority, "
1618
+ "because flock dies with the process while the mutation may not have run at all",
1619
+ )
1620
+ if not hmac.compare_digest(str(grant["token"]), str(token)):
1621
+ raise ControllerError("AUTHORIZATION_TOKEN_MISMATCH", "execution token differs")
1622
+ present = public_refs(repository.canonical_path).get(str(grant["ref"])) is not None
1623
+ classification = "ABANDONED_NO_EFFECT" if present else "ABANDONED_UNATTESTED_REMOVAL"
1624
+ evidence = _abandon(
1625
+ store,
1626
+ grant,
1627
+ classification=classification,
1628
+ reason=reason or "operator abandon",
1629
+ repository=repository,
1630
+ )
1631
+ return _payload(
1632
+ "branch-retirement-executions",
1633
+ {
1634
+ "code": "RETIREMENT_EXECUTION_ABANDONED",
1635
+ "authorization_id": authorization_id,
1636
+ "classification": classification,
1637
+ "receipt_filed": False,
1638
+ "evidence": evidence,
1639
+ },
1640
+ )
1641
+
1642
+
1643
+ def _remote_from_tracking(tracking_ref: str) -> str:
1644
+ parts = tracking_ref.split("/")
1645
+ return parts[2] if len(parts) > 3 and parts[0] == "refs" and parts[1] == "remotes" else "origin"
1646
+
1647
+
1648
+ def _grant_holder(grant: Mapping[str, Any]) -> WriterHolder:
1649
+ holder = grant.get("holder")
1650
+ if not isinstance(holder, dict):
1651
+ raise ControllerError("INVALID_METADATA", "retirement authorization has no holder")
1652
+ return WriterHolder(
1653
+ client_id=str(holder["client_id"]),
1654
+ pid=int(holder["pid"]),
1655
+ boot_id=str(holder["boot_id"]),
1656
+ start_ticks=int(holder["start_ticks"]),
1657
+ session_id=holder.get("session_id"),
1658
+ run_id=holder.get("run_id"),
1659
+ )
1660
+
1661
+
1662
+ def verify_branch_retirement(
1663
+ repository_path: Path, *, plan_id: str, operation: str
1664
+ ) -> dict[str, Any]:
1665
+ """The published pre-mutation verdict for one retirement operation.
1666
+
1667
+ A consumer obtains its decision here instead of re-deriving eligibility,
1668
+ and binds ``minimum_revalidation`` in the compare-and-delete that follows.
1669
+ Observation happens under the shared repository lock, so verify must run
1670
+ before the consumer takes its own exclusive retirement execution lock: a
1671
+ shared request against an already-held exclusive descriptor blocks.
1672
+ ``branch-retirement-authorize`` is the versioned follow-up that turns this
1673
+ verdict into a single-use, Controller-observed execution grant.
1674
+ """
1675
+
1676
+ if operation not in OPERATIONS:
1677
+ raise ControllerError("INVALID_OPERATION", "operation must be local or remote")
1678
+ repository = inventory(repository_path)
1679
+ store = MetadataStore(repository.common_dir)
1680
+ with store.lock(exclusive=False):
1681
+ plan = _load_plan(store, plan_id)
1682
+ current, _ = _evaluate(
1683
+ repository_path,
1684
+ allocation_id=str(plan["allocation_id"]),
1685
+ branch=str(plan["branch"]),
1686
+ remote=str(plan["remote"]),
1687
+ integrated_into=str(plan["integrated_into"]),
1688
+ plan_id=plan_id,
1689
+ _locked_store=store,
1690
+ )
1691
+ receipt_path = _receipt_path(store, plan_id, operation)
1692
+ receipt = dict(store._read_json(receipt_path)) if receipt_path.is_file() else None
1693
+ outstanding = [
1694
+ item
1695
+ for item in outstanding_retirement_authorizations(
1696
+ store, branch=str(current["branch"]), allocation_id=None
1697
+ )
1698
+ if item.get("operation") == operation
1699
+ ]
1700
+ if receipt is not None:
1701
+ current = {
1702
+ **current,
1703
+ "blockers": _completed_blockers(
1704
+ repository.canonical_path, plan, current, operation=operation, receipt=receipt
1705
+ ),
1706
+ }
1707
+ blockers = list(current["blockers"])
1708
+ if outstanding:
1709
+ blockers = sorted({*blockers, "RETIREMENT_AUTHORIZATION_ACTIVE"})
1710
+ code, blockers = _classify(
1711
+ {**current, "blockers": blockers}, operation=operation, receipt=receipt
1712
+ )
1713
+ minimum_revalidation = {
1714
+ "plan_id": plan_id,
1715
+ "operation": operation,
1716
+ "branch": current["branch"],
1717
+ "expected_oid": current["expected_oid"],
1718
+ "expected_integrated_oid": current["expected_integrated_oid"],
1719
+ "integrated_ref": current["integrated_ref"],
1720
+ "ref_state": current[
1721
+ "local_ref_state" if operation == "local" else "remote_tracking_state"
1722
+ ],
1723
+ "controller_state_digest": current["controller_state_digest"],
1724
+ }
1725
+ return _payload(
1726
+ "branch-retirement-verify",
1727
+ {
1728
+ "code": code,
1729
+ "plan_id": plan_id,
1730
+ "operation": operation,
1731
+ "blockers": blockers,
1732
+ "receipt": receipt,
1733
+ "minimum_revalidation": minimum_revalidation,
1734
+ },
1735
+ )
1736
+
1737
+
1738
+ def record_branch_retirement(
1739
+ repository_path: Path,
1740
+ *,
1741
+ plan_id: str,
1742
+ operation: str,
1743
+ summary_path: Path | None = None,
1744
+ result_path: Path | None = None,
1745
+ ) -> dict[str, Any]:
1746
+ """File one retirement result the Controller can actually observe.
1747
+
1748
+ A result document - neutral or translated from a legacy consumer summary - is
1749
+ a claim about an attempt. The receipt is only filed when the Controller's own
1750
+ read of Git agrees that the ref the plan named is gone. That closes the door
1751
+ where a well-shaped file with no mutation behind it became governance history.
1752
+ A result filed while an execution authorization is outstanding is refused: an
1753
+ authorized run settles through ``branch-retirement-complete``.
1754
+ """
1755
+
1756
+ if operation not in OPERATIONS:
1757
+ raise ControllerError("INVALID_OPERATION", "operation must be local or remote")
1758
+ if (summary_path is None) == (result_path is None):
1759
+ raise ControllerError(
1760
+ "INVALID_REQUEST", "present exactly one of --summary-file or --result-file"
1761
+ )
1762
+ if result_path is not None:
1763
+ try:
1764
+ raw = result_path.read_bytes()
1765
+ reported = json.loads(raw)
1766
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
1767
+ raise ControllerError("INVALID_EXECUTION_RESULT", f"cannot read result: {exc}") from exc
1768
+ if not isinstance(reported, dict):
1769
+ raise ControllerError("INVALID_EXECUTION_RESULT", "result must be a JSON object")
1770
+ result = execution.normalize_result(reported)
1771
+ else:
1772
+ assert summary_path is not None
1773
+ raw, summary = adapters.load_summary(summary_path)
1774
+ result = execution.normalize_result(adapters.from_summary(summary))
1775
+
1776
+ repository = inventory(repository_path)
1777
+ store = MetadataStore(repository.common_dir)
1778
+ with store.lock(exclusive=True, create=False):
1779
+ plan = _load_plan(store, plan_id)
1780
+ if result["plan_id"] != plan_id or result["operation"] != operation:
1781
+ raise ControllerError(
1782
+ "EXECUTION_RESULT_MISMATCH", "result names a different plan or operation"
1783
+ )
1784
+ for field in ("expected_oid", "expected_integrated_oid"):
1785
+ if result[field] != plan[field]:
1786
+ raise ControllerError(
1787
+ "EXECUTION_RESULT_MISMATCH", f"result {field} differs from the plan"
1788
+ )
1789
+ authorized_ref = (
1790
+ str(plan["remote_ref"]) if operation == "local" else str(plan["remote_tracking_ref"])
1791
+ )
1792
+ if result["ref"] != authorized_ref:
1793
+ raise ControllerError("EXECUTION_RESULT_MISMATCH", "result ref differs from the plan")
1794
+ if not execution.succeeded(result):
1795
+ raise ControllerError(
1796
+ "EXECUTION_RESULT_MISMATCH", f"result outcome {result['outcome']} is not a completion"
1797
+ )
1798
+ outstanding = [
1799
+ item
1800
+ for item in outstanding_retirement_authorizations(store, branch=str(plan["branch"]))
1801
+ if item.get("operation") == operation
1802
+ ]
1803
+ if outstanding:
1804
+ raise ControllerError(
1805
+ "RETIREMENT_EXECUTION_ACTIVE",
1806
+ "an execution authorization covers this operation; settle it with "
1807
+ f"branch-retirement-complete for {outstanding[0]['authorization_id']}",
1808
+ )
1809
+ observed = public_refs(repository.canonical_path).get(result["ref"])
1810
+ if observed is not None:
1811
+ raise ControllerError(
1812
+ "EXECUTION_OBSERVATION_MISMATCH",
1813
+ f"the Controller still observes {result['ref']} at {observed}; nothing was retired",
1814
+ )
1815
+ # Deleting a ref that a checkout still points at orphans that checkout, and
1816
+ # git gives update-ref no occupancy guard, so the authority state has to be
1817
+ # re-read here rather than assumed from the plan.
1818
+ current, _ = _evaluate(
1819
+ repository_path,
1820
+ allocation_id=str(plan["allocation_id"]),
1821
+ branch=str(plan["branch"]),
1822
+ remote=str(plan["remote"]),
1823
+ integrated_into=str(plan["integrated_into"]),
1824
+ plan_id=plan_id,
1825
+ _locked_store=store,
1826
+ )
1827
+ moved = sorted(AUTHORITY_BLOCKERS.intersection(current["blockers"]))
1828
+ if moved:
1829
+ raise ControllerError(
1830
+ "EXECUTION_GOVERNANCE_MOVED",
1831
+ f"authority changed after the plan: {', '.join(moved)}",
1832
+ )
1833
+ path = _receipt_path(store, plan_id, operation)
1834
+ if path.is_file():
1835
+ receipt = dict(store._read_json(path))
1836
+ idempotent = True
1837
+ else:
1838
+ receipt = {
1839
+ "schema_version": RECEIPT_SCHEMA_VERSION,
1840
+ "receipt_id": hashlib.sha256(f"{plan_id}:{operation}".encode()).hexdigest(),
1841
+ "plan_id": plan_id,
1842
+ "operation": operation,
1843
+ "branch": plan["branch"],
1844
+ "expected_oid": plan["expected_oid"],
1845
+ "expected_integrated_oid": plan["expected_integrated_oid"],
1846
+ "finalizer_version": result["executor"]["version"],
1847
+ "finalizer_result": result["outcome"],
1848
+ "summary_sha256": hashlib.sha256(raw).hexdigest(),
1849
+ "recorded_at": now_iso(),
1850
+ "provenance": RECEIPT_PROVENANCE_OBSERVED,
1851
+ "result_contract": result["contract"],
1852
+ "executor_name": result["executor"]["name"],
1853
+ }
1854
+ store._atomic_json(path, receipt)
1855
+ store.touch_repository()
1856
+ idempotent = False
1857
+ return _payload(
1858
+ "branch-retirement-record",
1859
+ {
1860
+ "code": "BRANCH_RETIREMENT_RESULT_RECORDED",
1861
+ "idempotent": idempotent,
1862
+ "provenance": receipt.get("provenance", RECEIPT_PROVENANCE_CLAIM),
1863
+ "receipt": receipt,
1864
+ },
1865
+ )