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,365 @@
1
+ """Advisory worktree classification and reviewed release routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import stat
6
+ from dataclasses import asdict, replace
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .controller import _environment_first, _repository_payload, envelope
12
+ from .git_backend import inventory
13
+ from .lifecycle import RELEASABLE_STATES, build_release_plan, governance_release_plan_id, release
14
+ from .lifecycle_observation import observe_repository_orphans, observe_worktree
15
+ from .models import (
16
+ ControllerError,
17
+ MetadataRecord,
18
+ MetadataSnapshot,
19
+ RepositoryInventory,
20
+ WorktreeGitState,
21
+ WorktreePolicy,
22
+ )
23
+ from .mount_diagnostics import diagnose_mounts
24
+ from .policy import load_policy
25
+ from .registration_prune import apply_prune, build_prune_plan
26
+ from .semantics import record_for as _record_for
27
+ from .store import MetadataStore
28
+
29
+
30
+ def _candidate_diagnostics(
31
+ repository: RepositoryInventory,
32
+ item: WorktreeGitState,
33
+ requested_diagnostics: dict[str, Any],
34
+ *,
35
+ namespace_pid: int | None,
36
+ ) -> dict[str, Any]:
37
+ """Probe each registered checkout's own mounts without another Git inventory."""
38
+ reason = "CANDIDATE_PRUNABLE" if item.prunable else "CANDIDATE_PATH_UNAVAILABLE"
39
+ if not item.prunable:
40
+ try:
41
+ if stat.S_ISDIR(item.path.lstat().st_mode):
42
+ if item.path == repository.requested_path:
43
+ return requested_diagnostics
44
+ return diagnose_mounts(
45
+ replace(repository, requested_path=item.path), namespace_pid=namespace_pid
46
+ )
47
+ except OSError:
48
+ pass
49
+ return {
50
+ "environment": {"code": "UNKNOWN", "facts": [reason]},
51
+ "namespace": {"comparison": "UNKNOWN", "pid": namespace_pid},
52
+ }
53
+
54
+
55
+ def _candidate(
56
+ item: WorktreeGitState,
57
+ record: MetadataRecord | None,
58
+ snapshot: MetadataSnapshot,
59
+ policy: WorktreePolicy,
60
+ diagnostics: dict[str, Any],
61
+ now_at: datetime,
62
+ ) -> dict[str, Any]:
63
+ allocation_id = record.allocation_id if record else None
64
+ writer = snapshot.writer_leases.get(allocation_id) if allocation_id else None
65
+ publication = snapshot.publication_lease
66
+ if publication is not None and publication.allocation_id != allocation_id:
67
+ publication = None
68
+ blockers: list[str] = []
69
+ if item.canonical:
70
+ blockers.append("CANONICAL_PROTECTED")
71
+ if item.prunable:
72
+ blockers.append("PRUNABLE_REGISTRATION")
73
+ if record is None:
74
+ blockers.append("METADATA_MISSING")
75
+ if item.detached:
76
+ blockers.append("DETACHED_WORKTREE")
77
+ if item.locked:
78
+ blockers.append("LOCKED_WORKTREE")
79
+ if not item.clean and not item.prunable:
80
+ blockers.append("DIRTY_WORKTREE")
81
+ if record is not None:
82
+ if record.lifecycle not in RELEASABLE_STATES:
83
+ blockers.append("LIFECYCLE_NOT_RELEASABLE")
84
+ if record.external_bindings:
85
+ blockers.append("EXTERNAL_BINDING")
86
+ if writer is not None:
87
+ blockers.append("WRITER_LEASE_PRESENT")
88
+ if publication is not None:
89
+ blockers.append("PUBLICATION_LEASE_PRESENT")
90
+ observation = observe_worktree(
91
+ item,
92
+ record,
93
+ snapshot,
94
+ policy,
95
+ now_at=now_at,
96
+ namespace_healthy=diagnostics["environment"]["code"] == "HEALTHY",
97
+ )
98
+ return {
99
+ "allocation_id": allocation_id,
100
+ "path": str(item.path),
101
+ "branch": item.branch,
102
+ "head": item.head,
103
+ "record_version": record.record_version if record else None,
104
+ "lifecycle": record.lifecycle if record else None,
105
+ "owner": record.owner if record else None,
106
+ "session": observation["session"],
107
+ "dirty": None if item.prunable else not item.clean,
108
+ "lease": {
109
+ "writer": {
110
+ "lease_id": writer.lease_id,
111
+ "owner": writer.owner,
112
+ "holder_pid": writer.holder.pid,
113
+ "session_id": writer.holder.session_id,
114
+ "run_id": writer.holder.run_id,
115
+ "renewed_at": writer.renewed_at,
116
+ "expires_at": writer.expires_at,
117
+ }
118
+ if writer
119
+ else None,
120
+ "publication": {"lease_id": publication.lease_id} if publication else None,
121
+ },
122
+ "external_bindings": list(record.external_bindings) if record else [],
123
+ "lifecycle_observation": observation,
124
+ "stale_facts": observation["stale_facts"],
125
+ "orphan_facts": observation["orphan_facts"],
126
+ "filesystem_health": diagnostics["environment"],
127
+ "namespace_health": {
128
+ "comparison": diagnostics["namespace"]["comparison"],
129
+ "pid": diagnostics["namespace"]["pid"],
130
+ },
131
+ "classification": "BLOCKED" if blockers else "REVIEW_REQUIRED",
132
+ "candidate_reason": blockers[0] if blockers else "RELEASE_PREFLIGHT_REQUIRED",
133
+ "blocking_facts": list(blockers),
134
+ "blockers": blockers or ["RELEASE_PREFLIGHT_REQUIRED"],
135
+ "gc_plan_id": None,
136
+ }
137
+
138
+
139
+ def gc_plan(
140
+ repository_path: Path,
141
+ *,
142
+ target: Path | None = None,
143
+ owner_release: str | None = None,
144
+ release_intent_id: str | None = None,
145
+ namespace_pid: int | None = None,
146
+ ) -> dict[str, Any]:
147
+ """Classify registered worktrees; preflight only an explicitly selected target."""
148
+ repository = inventory(repository_path)
149
+ diagnostics = diagnose_mounts(repository, namespace_pid=namespace_pid)
150
+ policy = load_policy(repository.canonical_path)
151
+ try:
152
+ snapshot = MetadataStore(repository.common_dir).load()
153
+ except ControllerError as exc:
154
+ raise _environment_first(exc, diagnostics["environment"]) from exc
155
+ result = envelope("gc-plan")
156
+ result["mount_diagnostics"] = diagnostics
157
+ result["environment_diagnosis"] = diagnostics["environment"]
158
+ result["repository"] = _repository_payload(repository, policy, snapshot)
159
+ result["orphan_observations"] = observe_repository_orphans(repository, snapshot)
160
+ now_at = datetime.now(UTC)
161
+ candidates = [
162
+ _candidate(
163
+ item,
164
+ _record_for(item, snapshot),
165
+ snapshot,
166
+ policy,
167
+ _candidate_diagnostics(repository, item, diagnostics, namespace_pid=namespace_pid),
168
+ now_at,
169
+ )
170
+ for item in repository.worktrees
171
+ ]
172
+ result["candidates"] = candidates
173
+ if target is None:
174
+ result["decision"] = {
175
+ "code": "GC_CLASSIFIED",
176
+ "total": len(candidates),
177
+ "blocked": sum(row["classification"] == "BLOCKED" for row in candidates),
178
+ "review_required": sum(
179
+ row["classification"] == "REVIEW_REQUIRED" for row in candidates
180
+ ),
181
+ "reason": "advisory inventory; each release requires an exact target preflight",
182
+ }
183
+ return result
184
+ selected = target.expanduser().resolve(strict=False)
185
+ row = next((candidate for candidate in candidates if Path(candidate["path"]) == selected), None)
186
+ if row is None:
187
+ raise ControllerError("UNREGISTERED_WORKTREE", f"GC target is not registered: {selected}")
188
+ result["candidate"] = row
189
+ item = next(item for item in repository.worktrees if item.path == selected)
190
+ if item.prunable:
191
+ if release_intent_id is not None:
192
+ result["decision"] = {"code": "GC_BLOCKED", "blockers": ["INVALID_RELEASE_INTENT"]}
193
+ return result
194
+ record = _record_for(item, snapshot)
195
+ if record is None:
196
+ result["decision"] = {"code": "GC_BLOCKED", "blockers": ["METADATA_MISSING"]}
197
+ return result
198
+ try:
199
+ prune_plan = build_prune_plan(repository, snapshot, policy, item, record, owner_release)
200
+ except ControllerError as exc:
201
+ row["blockers"] = [exc.code]
202
+ row["blocking_facts"] = list(row["blockers"])
203
+ row["candidate_reason"] = exc.code
204
+ result["decision"] = {
205
+ "code": "GC_BLOCKED",
206
+ "blockers": row["blockers"],
207
+ "reason": str(exc),
208
+ }
209
+ return result
210
+ row["classification"] = "PREFLIGHT_READY"
211
+ row["blockers"] = []
212
+ row["blocking_facts"] = []
213
+ row["candidate_reason"] = "MISSING_NO_CHANGE_AUDIT"
214
+ row["gc_plan_id"] = prune_plan["gc_plan_id"]
215
+ result["registration_prune_plan"] = prune_plan
216
+ result["decision"] = {
217
+ "code": "GC_PREFLIGHT_READY",
218
+ "gc_plan_id": prune_plan["gc_plan_id"],
219
+ "allocation_id": record.allocation_id,
220
+ "reason": "missing audit registration has owner and retained-ref evidence",
221
+ }
222
+ return result
223
+ if row["classification"] == "BLOCKED":
224
+ result["decision"] = {"code": "GC_BLOCKED", "blockers": row["blockers"]}
225
+ return result
226
+ if owner_release is None and release_intent_id is None:
227
+ row["blockers"] = ["OWNER_RELEASE_OR_INTENT_REQUIRED"]
228
+ row["blocking_facts"] = list(row["blockers"])
229
+ row["candidate_reason"] = row["blockers"][0]
230
+ result["decision"] = {"code": "GC_REVIEW_REQUIRED", "blockers": row["blockers"]}
231
+ return result
232
+ try:
233
+ release_plan = build_release_plan(
234
+ repository_path,
235
+ target=selected,
236
+ expected_record_version=row["record_version"],
237
+ owner_release=owner_release,
238
+ release_intent_id=release_intent_id,
239
+ )
240
+ except ControllerError as exc:
241
+ row["classification"] = "BLOCKED"
242
+ row["blockers"] = [exc.code]
243
+ row["blocking_facts"] = list(row["blockers"])
244
+ row["candidate_reason"] = exc.code
245
+ result["decision"] = {"code": "GC_BLOCKED", "blockers": row["blockers"], "reason": str(exc)}
246
+ return result
247
+ row["classification"] = "PREFLIGHT_READY"
248
+ row["blockers"] = []
249
+ row["blocking_facts"] = []
250
+ row["candidate_reason"] = "EXISTING_RELEASE_PREFLIGHT_PASSED"
251
+ row["gc_plan_id"] = governance_release_plan_id(release_plan)
252
+ result["release_plan"] = asdict(release_plan)
253
+ result["decision"] = {
254
+ "code": "GC_PREFLIGHT_READY",
255
+ "gc_plan_id": row["gc_plan_id"],
256
+ "allocation_id": row["allocation_id"],
257
+ "reason": "existing release preflight passed without mutation",
258
+ }
259
+ return result
260
+
261
+
262
+ def _replay_reviewed_release(
263
+ repository_path: Path,
264
+ *,
265
+ target: Path,
266
+ expected_gc_plan_id: str,
267
+ owner_release: str | None,
268
+ release_intent_id: str | None,
269
+ ) -> dict[str, Any] | None:
270
+ """The existing receipt when the exact reviewed plan already completed elsewhere.
271
+
272
+ A background pass or an interrupted earlier apply may have executed the very plan
273
+ that was reviewed. Only a completed receipt whose ReleasePlan hashes to the reviewed
274
+ GC identity qualifies; release() then re-verifies that evidence (released record,
275
+ retained branch, teardown receipt) before answering ALREADY_RELEASED. A release made
276
+ from any other plan stays GC_PLAN_DRIFT, which names the receipt that did happen.
277
+ """
278
+
279
+ repository = inventory(repository_path)
280
+ snapshot = MetadataStore(repository.common_dir).load()
281
+ selected = target.expanduser().resolve(strict=False)
282
+ completed = [
283
+ (receipt, snapshot.release_plans.get(receipt.plan_id))
284
+ for receipt in snapshot.release_receipts.values()
285
+ if receipt.result == "RELEASED" and Path(receipt.target_path) == selected
286
+ ]
287
+ matches = [
288
+ (receipt, plan)
289
+ for receipt, plan in completed
290
+ if plan is not None and governance_release_plan_id(plan) == expected_gc_plan_id
291
+ ]
292
+ if len(matches) != 1:
293
+ if completed:
294
+ raise ControllerError(
295
+ "GC_PLAN_DRIFT",
296
+ "GC target was released by a different plan: "
297
+ + ", ".join(sorted(receipt.receipt_id for receipt, _ in completed)),
298
+ )
299
+ return None
300
+ _receipt, plan = matches[0]
301
+ payload = release(
302
+ repository_path,
303
+ target=selected,
304
+ expected_record_version=plan.expected_record_version,
305
+ owner_release=owner_release,
306
+ release_intent_id=release_intent_id,
307
+ )
308
+ payload["command"] = "gc-apply"
309
+ payload["decision"]["gc_plan_id"] = expected_gc_plan_id
310
+ return payload
311
+
312
+
313
+ def gc_apply(
314
+ repository_path: Path,
315
+ *,
316
+ target: Path,
317
+ expected_gc_plan_id: str,
318
+ owner_release: str | None = None,
319
+ release_intent_id: str | None = None,
320
+ ) -> dict[str, Any]:
321
+ """Revalidate review identity and delegate the only removal to release()."""
322
+ pruned = apply_prune(
323
+ repository_path,
324
+ target=target,
325
+ expected_gc_plan_id=expected_gc_plan_id,
326
+ owner_release=owner_release,
327
+ )
328
+ if pruned is not None:
329
+ return pruned
330
+ try:
331
+ planned = gc_plan(
332
+ repository_path,
333
+ target=target,
334
+ owner_release=owner_release,
335
+ release_intent_id=release_intent_id,
336
+ )
337
+ except ControllerError as exc:
338
+ if exc.code in {"INVALID_TARGET", "UNREGISTERED_WORKTREE"}:
339
+ replayed = _replay_reviewed_release(
340
+ repository_path,
341
+ target=target,
342
+ expected_gc_plan_id=expected_gc_plan_id,
343
+ owner_release=owner_release,
344
+ release_intent_id=release_intent_id,
345
+ )
346
+ if replayed is not None:
347
+ return replayed
348
+ raise ControllerError("GC_PLAN_DRIFT", "GC target changed since review") from exc
349
+ raise
350
+ if planned["decision"]["code"] != "GC_PREFLIGHT_READY":
351
+ raise ControllerError("GC_PLAN_DRIFT", "GC target no longer passes release preflight")
352
+ observed = planned["decision"]["gc_plan_id"]
353
+ if observed != expected_gc_plan_id:
354
+ raise ControllerError("GC_PLAN_DRIFT", "GC plan identity changed since review")
355
+ receipt = release(
356
+ repository_path,
357
+ target=target,
358
+ expected_record_version=planned["candidate"]["record_version"],
359
+ owner_release=owner_release,
360
+ release_intent_id=release_intent_id,
361
+ expected_governance_plan_id=expected_gc_plan_id,
362
+ )
363
+ receipt["command"] = "gc-apply"
364
+ receipt["decision"]["gc_plan_id"] = expected_gc_plan_id
365
+ return receipt
@@ -0,0 +1,294 @@
1
+ """Pre-deletion, allocation-scoped hardlink topology and exact unlink effects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import errno
7
+ import os
8
+ import stat
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from . import preservation as p
13
+ from .models import ControllerError
14
+
15
+ CONTRACT = "cleanup-hardlink-topology/v1"
16
+ EXTERNAL_CONTRACT = "cleanup-hardlink-topology/v2"
17
+ MUTATION_CONTRACT = "cleanup-induced-hardlink-mutation/v1"
18
+ EXTERNAL_MUTATION_CONTRACT = "cleanup-induced-hardlink-mutation/v2"
19
+
20
+
21
+ def _blocked(message: str) -> ControllerError:
22
+ return ControllerError("HARDLINK_TOPOLOGY_BLOCKED", message)
23
+
24
+
25
+ def _effects(group: dict[str, Any], resources: list[str]) -> dict[str, Any]:
26
+ owners = {m[0] for m in group["members"]}
27
+ classification = (
28
+ "single-link"
29
+ if len(group["members"]) == 1
30
+ else "same-resource"
31
+ if len(owners) == 1
32
+ else "typed-preserved"
33
+ if ".venv" in owners
34
+ else "typed-typed"
35
+ )
36
+ surviving = group["members"]
37
+ steps = []
38
+ for resource in resources:
39
+ removed = [m for m in surviving if m[0] == resource]
40
+ if removed:
41
+ surviving = [m for m in surviving if m[0] != resource]
42
+ steps.append(
43
+ {
44
+ "resource": resource,
45
+ "deleted_links": removed,
46
+ "surviving_paths": surviving,
47
+ "expected_nlink": len(surviving) + group.get("external_links", 0),
48
+ }
49
+ )
50
+ return {"classification": classification, "expected_steps": steps}
51
+
52
+
53
+ def inventory(root: Path, resources: list[str]) -> dict[str, Any]:
54
+ """Record every non-directory inode and all its in-scope names; hash opaque bytes.
55
+
56
+ Names outside the exact cleanup resources are never searched or authorized.
57
+ Version 2 records their link-count remainder. Every unlink of such an inode
58
+ requires the pinned inode's unchanged bytes and exact metadata decrement.
59
+ Directory identity remains bound by the FD-relative deletion primitive.
60
+ """
61
+ if len(resources) != len(set(resources)):
62
+ raise _blocked("invalid or overlapping cleanup resources")
63
+ for name in resources:
64
+ p.source_relative_path(name)
65
+ if any(name != other and Path(name).is_relative_to(Path(other)) for other in resources):
66
+ raise _blocked("invalid or overlapping cleanup resources")
67
+ groups: dict[str, dict[str, Any]] = {}
68
+ anchors = {}
69
+ for resource in resources:
70
+ with p._absolute_source(root / resource) as chain:
71
+ tree = p.scan_tree(chain[-1], chain)
72
+ tree.require_complete()
73
+ anchors[resource] = {
74
+ "worktree": chain[-len(Path(resource).parts) - 1].identity,
75
+ "resource": chain[-1].identity,
76
+ }
77
+ for row in tree.manifest:
78
+ identity = tree.identities[row["path"]]
79
+ if stat.S_ISDIR(identity[2]):
80
+ continue
81
+ key = f"{identity[0]}:{identity[1]}"
82
+ content = {k: row[k] for k in ("content_sha256", "target") if k in row}
83
+ member = [resource, row["path"]]
84
+ if key in groups:
85
+ group = groups[key]
86
+ if group["identity"] != identity or group["content"] != content:
87
+ raise _blocked("inode identity changed during topology scan")
88
+ group["members"].append(member)
89
+ else:
90
+ groups[key] = {"identity": identity, "content": content, "members": [member]}
91
+ external = any(g["identity"][8] > len(g["members"]) for g in groups.values())
92
+ for group in groups.values():
93
+ group["members"].sort()
94
+ if group["identity"][8] < len(group["members"]):
95
+ raise _blocked("hardlink topology is unstable")
96
+ if external:
97
+ group["external_links"] = group["identity"][8] - len(group["members"])
98
+ group.update(_effects(group, resources))
99
+ payload = {
100
+ "contract": EXTERNAL_CONTRACT if external else CONTRACT,
101
+ "resources": resources,
102
+ "anchors": anchors,
103
+ "groups": groups,
104
+ }
105
+ return {**payload, "sha256": p._sha(payload)}
106
+
107
+
108
+ def validate(graph: dict[str, Any]) -> None:
109
+ if graph.get("contract") not in {CONTRACT, EXTERNAL_CONTRACT} or graph.get("sha256") != p._sha(
110
+ {k: v for k, v in graph.items() if k != "sha256"}
111
+ ):
112
+ raise _blocked("topology evidence changed")
113
+ for key, group in graph["groups"].items():
114
+ identity = group["identity"]
115
+ members = group["members"]
116
+ external = group.get("external_links", 0)
117
+ if (
118
+ key != f"{identity[0]}:{identity[1]}"
119
+ or type(external) is not int
120
+ or external < 0
121
+ or (graph["contract"] == CONTRACT and "external_links" in group)
122
+ or (graph["contract"] == EXTERNAL_CONTRACT and "external_links" not in group)
123
+ or len(members) + external != identity[8]
124
+ or len({tuple(m) for m in members}) != len(members)
125
+ or any(m[0] not in graph["resources"] for m in members)
126
+ ):
127
+ raise _blocked("incomplete topology evidence")
128
+ if any(group.get(k) != v for k, v in _effects(group, graph["resources"]).items()):
129
+ raise _blocked("invalid expected link-count effects")
130
+
131
+
132
+ def verify(root: Path, expected: dict[str, Any]) -> None:
133
+ validate(expected)
134
+ try:
135
+ actual = inventory(root, expected["resources"])
136
+ except OSError as exc:
137
+ if exc.errno in {errno.ENOENT, errno.ENOTDIR, errno.ELOOP, errno.ESTALE}:
138
+ raise ControllerError(
139
+ "RESOURCE_PLAN_MISMATCH", "resource path identity changed"
140
+ ) from exc
141
+ raise
142
+ if actual["anchors"] != expected["anchors"]:
143
+ raise ControllerError("RESOURCE_PLAN_MISMATCH", "resource directory identity changed")
144
+ if actual != expected:
145
+ raise _blocked("topology changed since the last recorded baseline")
146
+
147
+
148
+ def transition(
149
+ before: dict[str, Any],
150
+ after: dict[str, Any],
151
+ resource: str,
152
+ external_survivors: dict[str, Any] | None = None,
153
+ ) -> dict[str, Any]:
154
+ """Accept only the exact removal of one recorded resource's inode names."""
155
+ validate(before)
156
+ validate(after)
157
+ if resource not in before["resources"] or after["resources"] != [
158
+ r for r in before["resources"] if r != resource
159
+ ]:
160
+ raise _blocked("unexpected cleanup action or resource set")
161
+ if after["anchors"] != {k: v for k, v in before["anchors"].items() if k != resource}:
162
+ raise _blocked("surviving directory identity changed")
163
+ expected_keys = set()
164
+ mutations = {}
165
+ proofs = external_survivors or {}
166
+ expected_proofs = set()
167
+ for key, old in before["groups"].items():
168
+ members = [m for m in old["members"] if m[0] != resource]
169
+ removed = len(old["members"]) - len(members)
170
+ external = old.get("external_links", 0)
171
+ if external and removed:
172
+ expected_proofs.add(key)
173
+ if not members and not external:
174
+ continue
175
+ if members:
176
+ expected_keys.add(key)
177
+ current = after["groups"].get(key)
178
+ else:
179
+ current = proofs.get(key)
180
+ if current is None or current["content"] != old["content"]:
181
+ raise _blocked("surviving path, inode, content or symlink literal changed")
182
+ if members and (
183
+ current["members"] != members or current.get("external_links", 0) != external
184
+ ):
185
+ raise _blocked("surviving hardlink membership changed")
186
+ wanted = list(old["identity"])
187
+ wanted[8] -= removed
188
+ if removed:
189
+ wanted[5] = current["identity"][5]
190
+ if current["identity"] != wanted:
191
+ raise _blocked("unrecorded metadata or link-count mutation")
192
+ if (
193
+ external
194
+ and removed
195
+ and proofs.get(key) != {"identity": current["identity"], "content": current["content"]}
196
+ ):
197
+ raise _blocked("pinned external inode verification is missing or differs")
198
+ if removed:
199
+ mutations[key] = {
200
+ "removed_links": removed,
201
+ "before": old["identity"],
202
+ "after": current["identity"],
203
+ "surviving_paths": members,
204
+ }
205
+ if set(after["groups"]) != expected_keys:
206
+ raise _blocked("unexpected surviving inode set")
207
+ if set(proofs) != expected_proofs:
208
+ raise _blocked("unexpected or missing pinned external inode evidence")
209
+ return {
210
+ "contract": mutation_contract(before),
211
+ "completed_resource": resource,
212
+ "before_sha256": before["sha256"],
213
+ "after_sha256": after["sha256"],
214
+ "mutations": mutations,
215
+ **({"external_survivors": proofs} if before["contract"] == EXTERNAL_CONTRACT else {}),
216
+ }
217
+
218
+
219
+ def mutation_contract(graph: dict[str, Any]) -> str:
220
+ return (
221
+ EXTERNAL_MUTATION_CONTRACT if graph["contract"] == EXTERNAL_CONTRACT else MUTATION_CONTRACT
222
+ )
223
+
224
+
225
+ def external_contents(graph: dict[str, Any], resource: str) -> dict[str, dict[str, Any]]:
226
+ validate(graph)
227
+ return {
228
+ path: group["content"]
229
+ for group in graph["groups"].values()
230
+ if group.get("external_links", 0)
231
+ for name, path in group["members"]
232
+ if name == resource
233
+ }
234
+
235
+
236
+ def pinned_content(pin: int, expected: list[int]) -> dict[str, Any]:
237
+ """Observe the authorized inode, including after its last in-scope name is gone.
238
+
239
+ The kernel FD is the only handle: no external pathname is discovered, followed,
240
+ opened or changed. FIFO endpoints remain unopened. This is an observation at
241
+ the unlink boundary, not a claim that external writers are globally excluded.
242
+ """
243
+ if p._identity(os.fstat(pin)) != expected:
244
+ raise _blocked("pinned inode changed before content verification")
245
+ if stat.S_ISREG(expected[2]):
246
+ fd = os.open(f"/proc/self/fd/{pin}", os.O_RDONLY | os.O_CLOEXEC | os.O_NOATIME)
247
+ try:
248
+ if p._identity(os.fstat(fd)) != expected:
249
+ raise _blocked("pinned content identity changed")
250
+ size, digest = p._transfer(fd)
251
+ if size != expected[3] or p._identity(os.fstat(fd)) != expected:
252
+ raise _blocked("pinned bytes changed during verification")
253
+ content = {"content_sha256": digest}
254
+ finally:
255
+ os.close(fd)
256
+ elif stat.S_ISLNK(expected[2]):
257
+ content = {"target": os.readlink("", dir_fd=pin)}
258
+ elif stat.S_ISFIFO(expected[2]):
259
+ content = {}
260
+ else:
261
+ raise _blocked("unsupported pinned inode type")
262
+ if p._identity(os.fstat(pin)) != expected:
263
+ raise _blocked("pinned inode changed during content verification")
264
+ return content
265
+
266
+
267
+ def identities(graph: dict[str, Any], resource: str) -> dict[str, list[int]]:
268
+ validate(graph)
269
+ return {
270
+ path: group["identity"]
271
+ for group in graph["groups"].values()
272
+ for name, path in group["members"]
273
+ if name == resource
274
+ }
275
+
276
+
277
+ def source_baseline(
278
+ manifest: dict[str, Any], graph: dict[str, Any], *, resource: str = ".venv"
279
+ ) -> dict[str, Any]:
280
+ """Overlay a previously verified, recorded graph; never changes the receipt."""
281
+ expected = copy.deepcopy(manifest)
282
+ current = identities(graph, resource)
283
+ if resource not in graph["resources"] or set(current) != {
284
+ name for name, identity in manifest["source_identities"].items() if not stat.S_ISDIR(identity[2])
285
+ }:
286
+ raise _blocked("preserved source topology coverage is incomplete")
287
+ for path, identity in current.items():
288
+ old = expected["source_identities"].get(path)
289
+ if old is None or any(
290
+ a != b for i, (a, b) in enumerate(zip(old, identity, strict=True)) if i not in {5, 8}
291
+ ):
292
+ raise _blocked("preserved source identity cannot be rebased")
293
+ expected["source_identities"][path] = identity
294
+ return expected