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,1228 @@
1
+ """Versioned, locked, atomic repository-local metadata storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import fcntl
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import stat
11
+ import tempfile
12
+ import uuid
13
+ from collections.abc import Callable, Iterator, Mapping
14
+ from contextlib import contextmanager, suppress
15
+ from datetime import datetime, timedelta, timezone
16
+ from pathlib import Path, PurePosixPath
17
+ from typing import Any, TypeVar
18
+
19
+ from . import metadata_codec as _codec
20
+ from .models import (
21
+ METADATA_SCHEMA_VERSION,
22
+ SUPPORTED_METADATA_SCHEMA_VERSIONS,
23
+ ControllerError,
24
+ IntegrationIntent,
25
+ IntegrationReconciliationReceipt,
26
+ LifecycleEvent,
27
+ MetadataRecord,
28
+ MetadataSnapshot,
29
+ PreparedCandidateReceipt,
30
+ PublicationLease,
31
+ PublicationReceipt,
32
+ ReconciliationReceipt,
33
+ ReleaseIntent,
34
+ ReleasePlan,
35
+ ReleaseReceipt,
36
+ WriterLease,
37
+ WriterReconciliationReceipt,
38
+ )
39
+
40
+ # Preserve the store import interface while codecs stay independent of disk access.
41
+ INTEGRATION_INTENT_CONTRACT_VERSION = _codec.INTEGRATION_INTENT_CONTRACT_VERSION
42
+ INTEGRATION_INTENT_SCHEMA_VERSION = _codec.INTEGRATION_INTENT_SCHEMA_VERSION
43
+ INTEGRATION_RECONCILIATION_RECEIPT_SCHEMA_VERSION = _codec.INTEGRATION_RECONCILIATION_RECEIPT_SCHEMA_VERSION
44
+ LIFECYCLE_EVENT_SCHEMA_VERSION = _codec.LIFECYCLE_EVENT_SCHEMA_VERSION
45
+ PREPARED_CANDIDATE_RECEIPT_SCHEMA_VERSION = _codec.PREPARED_CANDIDATE_RECEIPT_SCHEMA_VERSION
46
+ PUBLICATION_LEASE_SCHEMA_VERSION = _codec.PUBLICATION_LEASE_SCHEMA_VERSION
47
+ PUBLICATION_RECEIPT_SCHEMA_VERSION = _codec.PUBLICATION_RECEIPT_SCHEMA_VERSION
48
+ PUBLICATION_RECEIPT_STATES = _codec.PUBLICATION_RECEIPT_STATES
49
+ RECONCILIATION_RECEIPT_SCHEMA_VERSION = _codec.RECONCILIATION_RECEIPT_SCHEMA_VERSION
50
+ RELEASE_INTENT_SCHEMA_VERSION = _codec.RELEASE_INTENT_SCHEMA_VERSION
51
+ RELEASE_PLAN_SCHEMA_VERSION = _codec.RELEASE_PLAN_SCHEMA_VERSION
52
+ RELEASE_RECEIPT_SCHEMA_VERSION = _codec.RELEASE_RECEIPT_SCHEMA_VERSION
53
+ TEARDOWN_RECEIPT_RESULTS = _codec.TEARDOWN_RECEIPT_RESULTS
54
+ TEARDOWN_RECEIPT_SCHEMA_VERSION = _codec.TEARDOWN_RECEIPT_SCHEMA_VERSION
55
+ WRITER_LEASE_SCHEMA_VERSION = _codec.WRITER_LEASE_SCHEMA_VERSION
56
+ WRITER_RECEIPT_SCHEMA_VERSION = _codec.WRITER_RECEIPT_SCHEMA_VERSION
57
+ _disposable_resources = _codec._disposable_resources
58
+ _nonempty_string = _codec._nonempty_string
59
+ _nonnegative_int = _codec._nonnegative_int
60
+ _optional_oid = _codec._optional_oid
61
+ _optional_recovery_ref = _codec._optional_recovery_ref
62
+ _optional_sha256 = _codec._optional_sha256
63
+ _optional_string = _codec._optional_string
64
+ _optional_uuid = _codec._optional_uuid
65
+ _positive_int = _codec._positive_int
66
+ _required_oid = _codec._required_oid
67
+ _required_sha256 = _codec._required_sha256
68
+ _scope_digest_value = _codec._scope_digest_value
69
+ _scope_paths = _codec._scope_paths
70
+ _source_allocations = _codec._source_allocations
71
+ _string_list = _codec._string_list
72
+ _validate_timestamp = _codec._validate_timestamp
73
+ integration_intent_from_dict = _codec.integration_intent_from_dict
74
+ integration_intent_to_dict = _codec.integration_intent_to_dict
75
+ integration_reconciliation_receipt_from_dict = _codec.integration_reconciliation_receipt_from_dict
76
+ integration_reconciliation_receipt_to_dict = _codec.integration_reconciliation_receipt_to_dict
77
+ lifecycle_event_from_dict = _codec.lifecycle_event_from_dict
78
+ lifecycle_event_to_dict = _codec.lifecycle_event_to_dict
79
+ prepared_candidate_receipt_from_dict = _codec.prepared_candidate_receipt_from_dict
80
+ prepared_candidate_receipt_to_dict = _codec.prepared_candidate_receipt_to_dict
81
+ publication_lease_from_dict = _codec.publication_lease_from_dict
82
+ publication_lease_to_dict = _codec.publication_lease_to_dict
83
+ publication_receipt_from_dict = _codec.publication_receipt_from_dict
84
+ publication_receipt_to_dict = _codec.publication_receipt_to_dict
85
+ reconciliation_receipt_from_dict = _codec.reconciliation_receipt_from_dict
86
+ reconciliation_receipt_to_dict = _codec.reconciliation_receipt_to_dict
87
+ record_from_dict = _codec.record_from_dict
88
+ record_to_dict = _codec.record_to_dict
89
+ release_intent_from_dict = _codec.release_intent_from_dict
90
+ release_intent_to_dict = _codec.release_intent_to_dict
91
+ release_plan_from_dict = _codec.release_plan_from_dict
92
+ release_plan_to_dict = _codec.release_plan_to_dict
93
+ release_receipt_from_dict = _codec.release_receipt_from_dict
94
+ release_receipt_to_dict = _codec.release_receipt_to_dict
95
+ validate_teardown_receipt = _codec.validate_teardown_receipt
96
+ writer_lease_from_dict = _codec.writer_lease_from_dict
97
+ writer_lease_to_dict = _codec.writer_lease_to_dict
98
+ writer_receipt_to_dict = _codec.writer_receipt_to_dict
99
+
100
+ # Persisted timestamps use the existing UTC+08:00 offset without requiring OS tzdata.
101
+ TIMEZONE = timezone(timedelta(hours=8))
102
+ ROOT_NAME = "worktree-controller"
103
+ VERSION_NAME = "v1"
104
+ # A linked registration's identity: a token written into Git's own administrative
105
+ # directory for that worktree and remembered under the metadata root. Git removes
106
+ # the administrative directory with the worktree, so a later registration that
107
+ # reuses the same name (and therefore the same worktree key) cannot carry the token.
108
+ REGISTRATION_CONTRACT = "worktree-registration/v1"
109
+ REGISTRATION_MARKER = "worktree-controller-registration"
110
+ _REGISTRATION_MARKER_LIMIT = 4096
111
+
112
+
113
+ _Decoded = TypeVar("_Decoded")
114
+ # Commands loading several snapshots reuse decodes until file identity changes.
115
+ _DECODE_CACHE: dict[tuple[str, str], tuple[tuple[int, int, int, int], Any]] | None = None
116
+ _HISTORY_CACHE: dict[
117
+ tuple[str, str], tuple[dict[str, tuple[int, int, int, int]], dict[str, Any]]
118
+ ] | None = None
119
+ # CLI decoding retains its byte-reader error contract even without reuse.
120
+ _BYTE_READS = False
121
+
122
+
123
+ @contextmanager
124
+ def decode_cache(*, reuse: bool = True) -> Iterator[None]:
125
+ """Reuse decoded metadata within one command; nothing survives the command.
126
+
127
+ Metadata files are only ever replaced atomically, so an unchanged (device,
128
+ inode, size, mtime) identity is the same content. Decoded values are frozen
129
+ dataclasses and are shared read-only. Single-snapshot commands can disable
130
+ reuse while retaining the same byte decoding and error details.
131
+ """
132
+ global _DECODE_CACHE, _HISTORY_CACHE, _BYTE_READS
133
+ previous, history_previous, bytes_previous = _DECODE_CACHE, _HISTORY_CACHE, _BYTE_READS
134
+ _DECODE_CACHE = ({} if previous is None else previous) if reuse else None
135
+ _HISTORY_CACHE = ({} if history_previous is None else history_previous) if reuse else None
136
+ _BYTE_READS = True
137
+ try:
138
+ yield
139
+ finally:
140
+ _DECODE_CACHE = previous
141
+ _HISTORY_CACHE = history_previous
142
+ _BYTE_READS = bytes_previous
143
+
144
+
145
+ def _identity(info: os.stat_result) -> tuple[int, int, int, int]:
146
+ return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns)
147
+
148
+
149
+ def _listing_error(directory: Path, exc: OSError) -> ControllerError:
150
+ if exc.errno in {errno.EACCES, errno.EPERM}:
151
+ return ControllerError(
152
+ "FILESYSTEM_PERMISSION",
153
+ f"metadata directory cannot be listed with current filesystem permissions: "
154
+ f"{directory} ({errno.errorcode.get(exc.errno, 'UNKNOWN')})",
155
+ )
156
+ return ControllerError("INVALID_METADATA", f"cannot list metadata directory {directory}: {exc}")
157
+
158
+
159
+ def _json_entries(directory: Path) -> list[os.DirEntry[str]]:
160
+ """Flat ``*.json`` entries from a fresh scan on every load.
161
+
162
+ A missing directory has no entries. Any other listing failure is incomplete
163
+ evidence and fails closed: an unreadable directory must never look empty.
164
+ """
165
+ try:
166
+ with os.scandir(directory) as entries:
167
+ return sorted(
168
+ (entry for entry in entries if entry.name.endswith(".json")),
169
+ key=lambda entry: entry.name,
170
+ )
171
+ except FileNotFoundError:
172
+ return []
173
+ except OSError as exc:
174
+ raise _listing_error(directory, exc) from exc
175
+
176
+
177
+ def _glob_json(directory: Path) -> list[Path]:
178
+ """``sorted(directory.glob("*.json"))`` without silently dropping listing errors."""
179
+ return [
180
+ Path(entry.path) for entry in _json_entries(directory) if not entry.name.startswith(".")
181
+ ]
182
+
183
+
184
+ def _glob_nested_json(directory: Path) -> list[Path]:
185
+ """``sorted(directory.glob("*/*.json"))`` without silently dropping listing errors."""
186
+ try:
187
+ with os.scandir(directory) as entries:
188
+ children = sorted(
189
+ entry.name
190
+ for entry in entries
191
+ if not entry.name.startswith(".") and entry.is_dir(follow_symlinks=True)
192
+ )
193
+ except FileNotFoundError:
194
+ return []
195
+ except OSError as exc:
196
+ raise _listing_error(directory, exc) from exc
197
+ return sorted(path for name in children for path in _glob_json(directory / name))
198
+
199
+
200
+ def now_iso() -> str:
201
+ return datetime.now(TIMEZONE).isoformat(timespec="seconds")
202
+
203
+
204
+ class MetadataStore:
205
+ """Repository-local controller state. Read methods never create files."""
206
+
207
+ def __init__(self, common_dir: Path, *, _migration_root: Path | None = None) -> None:
208
+ # Only the explicit layout preflight inspects the caller-supplied old root.
209
+ # Runtime commands never discover or select a previous namespace.
210
+ self.root = _migration_root or common_dir / ROOT_NAME / VERSION_NAME
211
+ self._migration_view = _migration_root is not None
212
+ if self.root.parent.parent != common_dir or self.root.name != VERSION_NAME:
213
+ raise ControllerError(
214
+ "INVALID_METADATA", "metadata root is outside its Git common directory"
215
+ )
216
+ self.repo_file = self.root / "repo.json"
217
+ self.records_dir = self.root / "records"
218
+ self.bindings_dir = self.root / "bindings"
219
+ self.transactions_dir = self.root / "transactions"
220
+ self.teardown_receipts_dir = self.root / "teardown-receipts"
221
+ self.completion_receipts_dir = self.root / "completion-receipts"
222
+ self.integration_intents_dir = self.root / "integration-intents"
223
+ self.prepared_candidate_receipts_dir = self.root / "prepared-candidate-receipts"
224
+ self.integration_reconciliation_receipts_dir = (
225
+ self.root / "integration-reconciliation-receipts"
226
+ )
227
+ self.lifecycle_events_dir = self.root / "lifecycle-events"
228
+ self.lifecycle_event_acks_dir = self.root / "lifecycle-event-acks"
229
+ self.release_intents_dir = self.root / "release-intents"
230
+ self.release_plans_dir = self.root / "release-plans"
231
+ self.publication_receipts_dir = self.root / "publication-receipts"
232
+ self.release_receipts_dir = self.root / "release-receipts"
233
+ self.registration_prune_receipts_dir = self.root / "registration-prune-receipts"
234
+ self.branch_retirement_plans_dir = self.root / "branch-retirement-plans"
235
+ self.branch_retirement_authorizations_dir = self.root / "branch-retirement-authorizations"
236
+ self.branch_retirement_receipts_dir = self.root / "branch-retirement-receipts"
237
+ self.reconciliation_receipts_dir = self.root / "reconciliation-receipts"
238
+ self.writer_leases_dir = self.root / "writer-leases"
239
+ self.writer_receipts_dir = self.root / "writer-reconciliation-receipts"
240
+ self.cleanup_actions_dir = self.root / "cleanup-actions"
241
+ self.cleanup_recoveries_dir = self.root / "cleanup-recoveries"
242
+ self.native_session_recoveries_dir = self.root / "native-session-recoveries"
243
+ self.session_closeouts_dir = self.root / "session-closeouts"
244
+ self.binding_registrations_dir = self.root / "binding-registrations"
245
+ self.publication_lease_file = self.root / "publication-lease.json"
246
+ self.lock_file = self.root / "repo.lock"
247
+ self._flock_depth = 0
248
+
249
+ @property
250
+ def flock_held(self) -> bool:
251
+ """True only while this process actually holds the repository flock."""
252
+ return self._flock_depth > 0
253
+
254
+ @contextmanager
255
+ def lock(self, *, exclusive: bool, create: bool = False) -> Iterator[None]:
256
+ """Serialize repository access; writers fail closed on any lock anomaly.
257
+
258
+ The lock file is the arbiter for every writer, so an exclusive request
259
+ without a usable regular-file lock never proceeds. A malformed lock
260
+ and a filesystem access failure both fail closed for every caller, with
261
+ separate error codes so doctor can identify the responsible layer.
262
+ Only a genuinely missing lock keeps the shared read path open, which
263
+ lets doctor diagnose the state it cannot lock. While a flock is
264
+ actually held ``_flock_depth`` records it, so terminal-evidence
265
+ classifiers can refuse to judge state they do not serialize against.
266
+ """
267
+ def access_error(exc: OSError) -> ControllerError:
268
+ if exc.errno in {errno.EACCES, errno.EPERM, errno.EROFS}:
269
+ return ControllerError(
270
+ "FILESYSTEM_PERMISSION",
271
+ f"repository lock cannot be accessed with current filesystem permissions: "
272
+ f"{self.lock_file} ({errno.errorcode.get(exc.errno, 'UNKNOWN')})",
273
+ )
274
+ return ControllerError(
275
+ "REPOSITORY_LOCK_ACCESS_UNKNOWN",
276
+ f"repository lock access could not be classified: {self.lock_file} "
277
+ f"({errno.errorcode.get(exc.errno, 'UNKNOWN')})",
278
+ )
279
+
280
+ if create:
281
+ try:
282
+ self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
283
+ except OSError as exc:
284
+ raise access_error(exc) from exc
285
+ descriptor: int | None = None
286
+ try:
287
+ lock_mode = os.lstat(self.lock_file).st_mode
288
+ except FileNotFoundError:
289
+ lock_mode = None
290
+ except OSError as exc:
291
+ raise access_error(exc) from exc
292
+ if lock_mode is not None and not stat.S_ISREG(lock_mode):
293
+ raise ControllerError(
294
+ "REPOSITORY_LOCK_INVALID",
295
+ f"repository lock is not a regular file: {self.lock_file}",
296
+ )
297
+ if create or lock_mode is not None:
298
+ flags = os.O_RDWR | (os.O_CREAT if create else 0) | os.O_NOFOLLOW | os.O_CLOEXEC
299
+ try:
300
+ candidate = os.open(self.lock_file, flags, 0o600)
301
+ except OSError as exc:
302
+ if exc.errno not in {errno.ELOOP, errno.EISDIR}:
303
+ raise access_error(exc) from exc
304
+ raise ControllerError(
305
+ "REPOSITORY_LOCK_INVALID",
306
+ f"repository lock is no longer a regular file: {self.lock_file}",
307
+ ) from exc
308
+ try:
309
+ candidate_mode = os.fstat(candidate).st_mode
310
+ except OSError as exc:
311
+ os.close(candidate)
312
+ raise access_error(exc) from exc
313
+ if stat.S_ISREG(candidate_mode):
314
+ descriptor = candidate
315
+ else:
316
+ os.close(candidate)
317
+ raise ControllerError(
318
+ "REPOSITORY_LOCK_INVALID",
319
+ f"repository lock is not a regular file: {self.lock_file}",
320
+ )
321
+ elif exclusive:
322
+ raise ControllerError(
323
+ "MISSING_REPOSITORY_LOCK",
324
+ f"repository lock file is missing: mutations fail closed until {self.lock_file} "
325
+ "is recreated by a bootstrap command (adopt, acquire, enqueue-integration)",
326
+ )
327
+ if descriptor is None:
328
+ yield
329
+ return
330
+ held = False
331
+ try:
332
+ try:
333
+ fcntl.flock(descriptor, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
334
+ except OSError as exc:
335
+ raise access_error(exc) from exc
336
+ held = True
337
+ self._flock_depth += 1
338
+ try:
339
+ yield
340
+ finally:
341
+ self._flock_depth -= 1
342
+ finally:
343
+ try:
344
+ if held:
345
+ try:
346
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
347
+ except OSError as exc:
348
+ raise access_error(exc) from exc
349
+ finally:
350
+ os.close(descriptor)
351
+
352
+ def _decoded(
353
+ self, path: Path | os.DirEntry[str], decode: Callable[[Mapping[str, Any]], _Decoded]
354
+ ) -> _Decoded:
355
+ cache = _DECODE_CACHE
356
+ if cache is None and not _BYTE_READS:
357
+ return decode(self._read_json(path))
358
+ entry = isinstance(path, os.DirEntry)
359
+ key = (decode.__qualname__, os.fspath(path)) if cache is not None else None
360
+ cached = cache.get(key) if cache is not None else None
361
+ if cached is not None:
362
+ try:
363
+ # Entries come from this load's scan and are never retained in the cache.
364
+ info = path.stat() if entry else os.stat(path)
365
+ if _identity(info) == cached[0]:
366
+ return cached[1]
367
+ except OSError:
368
+ pass
369
+ try:
370
+ with open(path, "rb") as handle:
371
+ identity = _identity(os.fstat(handle.fileno()))
372
+ raw = handle.read().decode("utf-8")
373
+ value = json.loads(raw)
374
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
375
+ raise ControllerError("INVALID_METADATA", f"cannot read {os.fspath(path)}: {exc}") from exc
376
+ if not isinstance(value, dict):
377
+ raise ControllerError("INVALID_METADATA", f"metadata must be an object: {os.fspath(path)}")
378
+ decoded = decode(value)
379
+ if cache is not None and key is not None:
380
+ cache[key] = (identity, decoded)
381
+ return decoded
382
+
383
+ def _read_json(self, path: Path | os.DirEntry[str]) -> Mapping[str, Any]:
384
+ try:
385
+ with open(path, encoding="utf-8") as handle:
386
+ raw = handle.read()
387
+ value = json.loads(raw)
388
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
389
+ raise ControllerError("INVALID_METADATA", f"cannot read {os.fspath(path)}: {exc}") from exc
390
+ if not isinstance(value, dict):
391
+ raise ControllerError("INVALID_METADATA", f"metadata must be an object: {os.fspath(path)}")
392
+ return value
393
+
394
+ def _history(
395
+ self,
396
+ directory: Path,
397
+ decode: Callable[[Mapping[str, Any]], _Decoded],
398
+ identity: Callable[[_Decoded], str],
399
+ kind: str,
400
+ ) -> dict[str, _Decoded]:
401
+ entries = _json_entries(directory)
402
+ cache = _HISTORY_CACHE
403
+ key = (decode.__qualname__, os.fspath(directory))
404
+ cached = cache.get(key) if cache is not None else None
405
+ if cached is not None:
406
+ identities, values = cached
407
+ try:
408
+ if len(entries) == len(identities) and all(
409
+ _identity(path.stat()) == identities.get(path.name) for path in entries
410
+ ):
411
+ # Neither directory entries nor the caller's mutable mapping are retained.
412
+ return values.copy()
413
+ except OSError:
414
+ pass
415
+ values = {}
416
+ identities = {}
417
+ for path in entries:
418
+ value = self._decoded(path, decode)
419
+ identifier = identity(value)
420
+ if path.name != f"{identifier}.json":
421
+ message = (
422
+ f"record filename does not match allocation: {path.path}"
423
+ if kind == "record" else f"{kind} filename is invalid"
424
+ )
425
+ raise ControllerError("INVALID_METADATA", message)
426
+ if identifier in values:
427
+ message = (
428
+ f"duplicate allocation: {identifier}"
429
+ if kind == "record" else f"{kind} filename is invalid"
430
+ )
431
+ raise ControllerError("INVALID_METADATA", message)
432
+ values[identifier] = value
433
+ if cache is not None and _DECODE_CACHE is not None:
434
+ identities[path.name] = _DECODE_CACHE[(decode.__qualname__, path.path)][0]
435
+ if cache is not None:
436
+ cache[key] = (identities, values.copy())
437
+ return values
438
+
439
+ def _atomic_json(
440
+ self, path: Path, payload: Mapping[str, Any], *, compact: bool = False
441
+ ) -> None:
442
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
443
+ encoded = (json.dumps(
444
+ payload, ensure_ascii=False, indent=None if compact else 2, sort_keys=True,
445
+ separators=(",", ":") if compact else None,
446
+ ) + "\n").encode("utf-8")
447
+ descriptor, temporary_name = tempfile.mkstemp(
448
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
449
+ )
450
+ temporary = Path(temporary_name)
451
+ try:
452
+ os.fchmod(descriptor, 0o600)
453
+ with os.fdopen(descriptor, "wb", closefd=True) as handle:
454
+ handle.write(encoded)
455
+ handle.flush()
456
+ os.fsync(handle.fileno())
457
+ os.replace(temporary, path)
458
+ directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
459
+ try:
460
+ os.fsync(directory)
461
+ finally:
462
+ os.close(directory)
463
+ except BaseException:
464
+ with suppress(FileNotFoundError):
465
+ temporary.unlink()
466
+ raise
467
+
468
+ def _load_unlocked(self) -> MetadataSnapshot:
469
+ warnings: list[dict[str, str]] = []
470
+ repository_id: str | None = None
471
+ records: dict[str, MetadataRecord] = {}
472
+ bindings: dict[str, str] = {}
473
+ integration_intents: dict[str, IntegrationIntent] = {}
474
+ prepared_candidate_receipts: dict[str, PreparedCandidateReceipt] = {}
475
+ integration_reconciliation_receipts: dict[str, IntegrationReconciliationReceipt] = {}
476
+ release_intents: dict[str, ReleaseIntent] = {}
477
+ release_plans: dict[str, ReleasePlan] = {}
478
+ publication_lease: PublicationLease | None = None
479
+ publication_receipts: dict[str, PublicationReceipt] = {}
480
+ release_receipts: dict[str, ReleaseReceipt] = {}
481
+ reconciliation_receipts: dict[str, ReconciliationReceipt] = {}
482
+ if self.repo_file.exists():
483
+ repo = self._read_json(self.repo_file)
484
+ if (
485
+ set(repo)
486
+ != {
487
+ "schema_version",
488
+ "repository_id",
489
+ "created_at",
490
+ "updated_at",
491
+ }
492
+ or repo.get("schema_version") not in SUPPORTED_METADATA_SCHEMA_VERSIONS
493
+ ):
494
+ raise ControllerError("INVALID_METADATA", "repo.json schema is invalid")
495
+ repository_id = _nonempty_string(repo.get("repository_id"), "repository_id")
496
+ try:
497
+ uuid.UUID(repository_id)
498
+ except ValueError as exc:
499
+ raise ControllerError("INVALID_METADATA", "repository_id must be a UUID") from exc
500
+ _validate_timestamp(repo.get("created_at"), "created_at")
501
+ _validate_timestamp(repo.get("updated_at"), "updated_at")
502
+ elif self.root.exists():
503
+ warnings.append({"code": "MISSING_REPO_METADATA", "message": "repo.json is missing"})
504
+
505
+ if self.records_dir.exists():
506
+ records = self._history(
507
+ self.records_dir, record_from_dict, lambda record: record.allocation_id, "record"
508
+ )
509
+
510
+ if self.bindings_dir.exists():
511
+ for path in _glob_json(self.bindings_dir):
512
+ binding = self._read_json(path)
513
+ if set(binding) != {"schema_version", "allocation_id"}:
514
+ raise ControllerError("INVALID_METADATA", f"binding schema is invalid: {path}")
515
+ if binding.get("schema_version") not in SUPPORTED_METADATA_SCHEMA_VERSIONS:
516
+ raise ControllerError("INVALID_METADATA", f"binding version is invalid: {path}")
517
+ worktree_key = path.stem
518
+ allocation_id = _nonempty_string(binding.get("allocation_id"), "allocation_id")
519
+ bindings[worktree_key] = allocation_id
520
+ for worktree_key, allocation_id in self._drifted_bindings(bindings).items():
521
+ # The registration this binding was written for is gone: the Git
522
+ # administrative directory now belongs to another worktree.
523
+ del bindings[worktree_key]
524
+ warnings.append(
525
+ {
526
+ "code": "BINDING_IDENTITY_DRIFT",
527
+ "message": "a different Git registration reuses this bound worktree key",
528
+ "worktree_key": worktree_key,
529
+ "allocation_id": allocation_id,
530
+ }
531
+ )
532
+
533
+ if self.publication_lease_file.exists():
534
+ publication_lease = publication_lease_from_dict(
535
+ self._read_json(self.publication_lease_file)
536
+ )
537
+
538
+ if self.integration_intents_dir.exists():
539
+ allocations: set[str] = set()
540
+ for path in _glob_json(self.integration_intents_dir):
541
+ intent = self._decoded(path, integration_intent_from_dict)
542
+ if path.stem != intent.intent_id or intent.intent_id in integration_intents:
543
+ raise ControllerError(
544
+ "INVALID_METADATA", "integration intent filename or identity is invalid"
545
+ )
546
+ if intent.allocation_id is not None:
547
+ if intent.allocation_id in allocations:
548
+ raise ControllerError(
549
+ "INVALID_METADATA", "multiple intents bind one allocation"
550
+ )
551
+ allocations.add(intent.allocation_id)
552
+ integration_intents[intent.intent_id] = intent
553
+
554
+ if self.prepared_candidate_receipts_dir.exists():
555
+ prepared_candidate_receipts = self._history(
556
+ self.prepared_candidate_receipts_dir, prepared_candidate_receipt_from_dict,
557
+ lambda receipt: receipt.receipt_id, "prepared candidate receipt"
558
+ )
559
+
560
+ if self.integration_reconciliation_receipts_dir.exists():
561
+ for path in _glob_json(self.integration_reconciliation_receipts_dir):
562
+ receipt = self._decoded(path, integration_reconciliation_receipt_from_dict)
563
+ if (
564
+ path.stem != receipt.receipt_id
565
+ or receipt.receipt_id in integration_reconciliation_receipts
566
+ ):
567
+ raise ControllerError(
568
+ "INVALID_METADATA",
569
+ "integration reconciliation receipt filename is invalid",
570
+ )
571
+ integration_reconciliation_receipts[receipt.receipt_id] = receipt
572
+
573
+ if self.lifecycle_events_dir.exists():
574
+ event_ids: set[str] = set()
575
+ event_sequences: set[int] = set()
576
+ for path in _glob_json(self.lifecycle_events_dir):
577
+ event = self._decoded(path, lifecycle_event_from_dict)
578
+ if (
579
+ path.stem != event.event_id
580
+ or event.event_id in event_ids
581
+ or event.sequence < 1
582
+ or event.sequence in event_sequences
583
+ ):
584
+ raise ControllerError(
585
+ "INVALID_METADATA", "lifecycle event identity or sequence is invalid"
586
+ )
587
+ event_ids.add(event.event_id)
588
+ event_sequences.add(event.sequence)
589
+ if self.lifecycle_event_acks_dir.exists():
590
+ for path in _glob_nested_json(self.lifecycle_event_acks_dir):
591
+ ack = self._read_json(path)
592
+ if set(ack) != {"schema_version", "event_id", "consumer", "acked_at"}:
593
+ raise ControllerError("INVALID_METADATA", "lifecycle event ack is invalid")
594
+ if ack.get("schema_version") != LIFECYCLE_EVENT_SCHEMA_VERSION:
595
+ raise ControllerError(
596
+ "INVALID_METADATA", "lifecycle event ack version is invalid"
597
+ )
598
+ event_id = _optional_uuid(ack.get("event_id"), "event_id")
599
+ consumer = _nonempty_string(ack.get("consumer"), "consumer")
600
+ if event_id is None or path.stem != event_id:
601
+ raise ControllerError(
602
+ "INVALID_METADATA", "lifecycle event ack identity is invalid"
603
+ )
604
+ if path.parent.name != hashlib.sha256(consumer.encode("utf-8")).hexdigest():
605
+ raise ControllerError(
606
+ "INVALID_METADATA", "lifecycle event ack consumer differs"
607
+ )
608
+ _validate_timestamp(ack.get("acked_at"), "acked_at")
609
+
610
+ if self.publication_receipts_dir.exists():
611
+ publication_receipts = self._history(
612
+ self.publication_receipts_dir, publication_receipt_from_dict,
613
+ lambda receipt: receipt.receipt_id, "publication receipt"
614
+ )
615
+
616
+ if self.release_intents_dir.exists():
617
+ for path in _glob_json(self.release_intents_dir):
618
+ intent = self._decoded(path, release_intent_from_dict)
619
+ if path.stem != intent.intent_id or intent.intent_id in release_intents:
620
+ raise ControllerError("INVALID_METADATA", "release intent filename is invalid")
621
+ release_intents[intent.intent_id] = intent
622
+
623
+ if self.release_plans_dir.exists():
624
+ release_plans = self._history(
625
+ self.release_plans_dir, release_plan_from_dict, lambda plan: plan.plan_id,
626
+ "release plan"
627
+ )
628
+
629
+ if self.release_receipts_dir.exists():
630
+ release_receipts = self._history(
631
+ self.release_receipts_dir, release_receipt_from_dict,
632
+ lambda receipt: receipt.receipt_id, "release receipt"
633
+ )
634
+
635
+ if self.reconciliation_receipts_dir.exists():
636
+ for path in _glob_json(self.reconciliation_receipts_dir):
637
+ receipt = self._decoded(path, reconciliation_receipt_from_dict)
638
+ if path.stem != receipt.receipt_id or receipt.receipt_id in reconciliation_receipts:
639
+ raise ControllerError(
640
+ "INVALID_METADATA", "reconciliation receipt filename is invalid"
641
+ )
642
+ reconciliation_receipts[receipt.receipt_id] = receipt
643
+
644
+ if self.teardown_receipts_dir.exists():
645
+ for path in _glob_json(self.teardown_receipts_dir):
646
+ receipt = validate_teardown_receipt(self._read_json(path))
647
+ if path.stem != receipt["receipt_id"]:
648
+ raise ControllerError(
649
+ "INVALID_METADATA", f"teardown receipt filename is invalid: {path}"
650
+ )
651
+
652
+ writer_leases: dict[str, WriterLease] = {}
653
+ if self.writer_leases_dir.exists():
654
+ for path in _glob_json(self.writer_leases_dir):
655
+ value = self._read_json(path)
656
+ if self._migration_view and value.get("schema_version") == 1:
657
+ from .activation_layout import lease_bytes
658
+
659
+ # The explicit, locked layout preflight converts a read view
660
+ # only. Normal runtime parsing remains schema 2; the actual
661
+ # lease bytes and all managed-runtime gates remain unchanged.
662
+ value = json.loads(lease_bytes(json.dumps(value).encode()))
663
+ lease = writer_lease_from_dict(value)
664
+ if path.stem != lease.allocation_id or lease.allocation_id in writer_leases:
665
+ raise ControllerError("INVALID_METADATA", "writer lease filename is invalid")
666
+ writer_leases[lease.allocation_id] = lease
667
+
668
+ return MetadataSnapshot(
669
+ repository_id=repository_id,
670
+ records=records,
671
+ bindings=bindings,
672
+ integration_intents=integration_intents,
673
+ prepared_candidate_receipts=prepared_candidate_receipts,
674
+ integration_reconciliation_receipts=integration_reconciliation_receipts,
675
+ release_intents=release_intents,
676
+ release_plans=release_plans,
677
+ publication_lease=publication_lease,
678
+ publication_receipts=publication_receipts,
679
+ release_receipts=release_receipts,
680
+ reconciliation_receipts=reconciliation_receipts,
681
+ writer_leases=writer_leases,
682
+ warnings=tuple(warnings),
683
+ )
684
+
685
+ def load(self) -> MetadataSnapshot:
686
+ with self.lock(exclusive=False):
687
+ return self._load_unlocked()
688
+
689
+ @property
690
+ def common_dir(self) -> Path:
691
+ return self.root.parent.parent
692
+
693
+ def _registration_admin(self, worktree_key: str, admin_dir: object) -> Path:
694
+ """The Git administrative directory that derives ``worktree_key``."""
695
+ parts = PurePosixPath(admin_dir).parts if isinstance(admin_dir, str) else ()
696
+ if (
697
+ len(parts) != 2
698
+ or parts[0] != "worktrees"
699
+ or parts[1] in {".", ".."}
700
+ or admin_dir != "/".join(parts)
701
+ ):
702
+ raise ControllerError("INVALID_METADATA", "registration admin_dir is invalid")
703
+ digest = hashlib.sha256(str(admin_dir).encode("utf-8")).hexdigest()
704
+ if worktree_key != f"wt_{digest}":
705
+ raise ControllerError("INVALID_METADATA", "registration admin_dir differs from its key")
706
+ return self.common_dir / parts[0] / parts[1]
707
+
708
+ def _registrations(self) -> dict[str, dict[str, str]]:
709
+ registrations: dict[str, dict[str, str]] = {}
710
+ for path in _glob_json(self.binding_registrations_dir):
711
+ value = self._read_json(path)
712
+ if (
713
+ set(value) != {"schema_version", "contract", "allocation_id", "admin_dir", "token"}
714
+ or value.get("schema_version") not in SUPPORTED_METADATA_SCHEMA_VERSIONS
715
+ or value.get("contract") != REGISTRATION_CONTRACT
716
+ ):
717
+ raise ControllerError("INVALID_METADATA", f"registration schema is invalid: {path}")
718
+ key = path.stem
719
+ if not key.startswith("wt_") or len(key) != 67:
720
+ raise ControllerError("INVALID_METADATA", f"registration filename is invalid: {path}")
721
+ self._registration_admin(key, value.get("admin_dir"))
722
+ registrations[key] = {
723
+ "allocation_id": _nonempty_string(value.get("allocation_id"), "allocation_id"),
724
+ "admin_dir": str(value["admin_dir"]),
725
+ "token": str(_optional_uuid(value.get("token"), "token") or ""),
726
+ }
727
+ if not registrations[key]["token"]:
728
+ raise ControllerError("INVALID_METADATA", f"registration token is missing: {path}")
729
+ return registrations
730
+
731
+ def _marker_matches(self, admin: Path, expected: Mapping[str, str]) -> bool:
732
+ marker = admin / REGISTRATION_MARKER
733
+ try:
734
+ descriptor = os.open(marker, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
735
+ except OSError:
736
+ return False
737
+ try:
738
+ if not stat.S_ISREG(os.fstat(descriptor).st_mode):
739
+ return False
740
+ with os.fdopen(descriptor, "rb", closefd=False) as handle:
741
+ raw = handle.read(_REGISTRATION_MARKER_LIMIT + 1)
742
+ except OSError:
743
+ return False
744
+ finally:
745
+ os.close(descriptor)
746
+ if len(raw) > _REGISTRATION_MARKER_LIMIT:
747
+ return False
748
+ try:
749
+ value = json.loads(raw.decode("utf-8"))
750
+ except (UnicodeError, json.JSONDecodeError):
751
+ return False
752
+ return value == {
753
+ "contract": REGISTRATION_CONTRACT,
754
+ "allocation_id": expected["allocation_id"],
755
+ "token": expected["token"],
756
+ }
757
+
758
+ def _drifted_bindings(self, bindings: Mapping[str, str]) -> dict[str, str]:
759
+ """Bindings whose live Git registration is not the one they were written for.
760
+
761
+ A binding without a matching registration document predates registration
762
+ identity (or belongs to the canonical checkout) and keeps its previous
763
+ meaning. An administrative directory that no longer exists is the existing
764
+ orphan-binding case, which interrupted release recovery still relies on.
765
+ """
766
+ drifted: dict[str, str] = {}
767
+ for key, registration in self._registrations().items():
768
+ allocation_id = bindings.get(key)
769
+ if allocation_id is None or allocation_id != registration["allocation_id"]:
770
+ continue # Stale document from an older runtime's release; not authority.
771
+ admin = self._registration_admin(key, registration["admin_dir"])
772
+ if not os.path.lexists(admin):
773
+ continue
774
+ if not self._marker_matches(admin, registration):
775
+ drifted[key] = allocation_id
776
+ return drifted
777
+
778
+ def _write_registration(self, worktree_key: str, allocation_id: str, admin: Path) -> None:
779
+ try:
780
+ relative = admin.relative_to(self.common_dir).as_posix()
781
+ except ValueError as exc:
782
+ raise ControllerError(
783
+ "INVALID_METADATA", "registration is outside the Git common directory"
784
+ ) from exc
785
+ self._registration_admin(worktree_key, relative)
786
+ if admin.is_symlink() or not admin.is_dir():
787
+ raise ControllerError("BLOCK_DRIFT", "Git worktree administrative directory is missing")
788
+ token = str(uuid.uuid4())
789
+ self._atomic_json(
790
+ admin / REGISTRATION_MARKER,
791
+ {"contract": REGISTRATION_CONTRACT, "allocation_id": allocation_id, "token": token},
792
+ )
793
+ self._atomic_json(
794
+ self.binding_registrations_dir / f"{worktree_key}.json",
795
+ {
796
+ "schema_version": METADATA_SCHEMA_VERSION,
797
+ "contract": REGISTRATION_CONTRACT,
798
+ "allocation_id": allocation_id,
799
+ "admin_dir": relative,
800
+ "token": token,
801
+ },
802
+ )
803
+
804
+ def initialize_repository(self) -> str:
805
+ if self.repo_file.exists():
806
+ snapshot = self._load_unlocked()
807
+ if snapshot.repository_id is None:
808
+ raise ControllerError("INVALID_METADATA", "repo.json is missing")
809
+ return snapshot.repository_id
810
+ timestamp = now_iso()
811
+ repository_id = str(uuid.uuid4())
812
+ self._atomic_json(
813
+ self.repo_file,
814
+ {
815
+ "schema_version": METADATA_SCHEMA_VERSION,
816
+ "repository_id": repository_id,
817
+ "created_at": timestamp,
818
+ "updated_at": timestamp,
819
+ },
820
+ )
821
+ return repository_id
822
+
823
+ def write_record(
824
+ self, worktree_key: str, record: MetadataRecord, *, registration: Path | None = None
825
+ ) -> None:
826
+ """Bind ``record`` to a worktree key.
827
+
828
+ ``registration`` is the linked worktree's Git administrative directory. When
829
+ given, the binding is tied to that exact registration before it is written,
830
+ so a later registration reusing the name cannot inherit it.
831
+ """
832
+ if not worktree_key.startswith("wt_") or len(worktree_key) != 67:
833
+ raise ControllerError("INVALID_METADATA", "invalid worktree key")
834
+ # One chokepoint for every new governed occupancy, so a retirement grant
835
+ # cannot be re-occupied by any verb that funnels through here. Updating a
836
+ # record whose binding already exists is not occupancy and stays allowed.
837
+ if not (self.bindings_dir / f"{worktree_key}.json").exists():
838
+ # A record carries no branch, so the fence here is the allocation half;
839
+ # the branch half is refused by acquire's own BRANCH_EXISTS rule and
840
+ # caught at settle by the grant's observation vector.
841
+ from .branch_retirement import retirement_occupancy_blocker
842
+
843
+ blocked = retirement_occupancy_blocker(self, allocation_id=record.allocation_id)
844
+ if blocked is not None:
845
+ raise blocked
846
+ if registration is not None:
847
+ # Written before the binding: an interruption leaves an unreferenced
848
+ # registration, never a binding that claims an unverified registration.
849
+ self._write_registration(worktree_key, record.allocation_id, registration)
850
+ self._atomic_json(self.records_dir / f"{record.allocation_id}.json", record_to_dict(record))
851
+ self._atomic_json(
852
+ self.bindings_dir / f"{worktree_key}.json",
853
+ {
854
+ "schema_version": METADATA_SCHEMA_VERSION,
855
+ "allocation_id": record.allocation_id,
856
+ },
857
+ )
858
+ repo = dict(self._read_json(self.repo_file))
859
+ repo["schema_version"] = METADATA_SCHEMA_VERSION
860
+ repo["updated_at"] = now_iso()
861
+ self._atomic_json(self.repo_file, repo)
862
+
863
+ def replace_record(self, record: MetadataRecord) -> None:
864
+ path = self.records_dir / f"{record.allocation_id}.json"
865
+ if not path.is_file():
866
+ raise ControllerError("BLOCK_DRIFT", "metadata record disappeared")
867
+ self._atomic_json(path, record_to_dict(record))
868
+ self.touch_repository()
869
+
870
+ def remove_binding(self, worktree_key: str) -> None:
871
+ path = self.bindings_dir / f"{worktree_key}.json"
872
+ try:
873
+ path.unlink()
874
+ except FileNotFoundError as exc:
875
+ raise ControllerError("BLOCK_DRIFT", "metadata binding disappeared") from exc
876
+ directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
877
+ try:
878
+ os.fsync(directory)
879
+ finally:
880
+ os.close(directory)
881
+ registration = self.binding_registrations_dir / f"{worktree_key}.json"
882
+ if registration.exists():
883
+ # After the binding: an interruption leaves a document no binding uses.
884
+ registration.unlink()
885
+ directory = os.open(registration.parent, os.O_RDONLY | os.O_DIRECTORY)
886
+ try:
887
+ os.fsync(directory)
888
+ finally:
889
+ os.close(directory)
890
+ self.touch_repository()
891
+
892
+ def touch_repository(self) -> None:
893
+ repo = dict(self._read_json(self.repo_file))
894
+ repo["schema_version"] = METADATA_SCHEMA_VERSION
895
+ repo["updated_at"] = now_iso()
896
+ self._atomic_json(self.repo_file, repo)
897
+
898
+ def write_writer_lease(self, lease: WriterLease) -> None:
899
+ """Persist one writer lease, keyed by allocation so single-writer is structural."""
900
+ self.writer_leases_dir.mkdir(parents=True, exist_ok=True)
901
+ self._atomic_json(
902
+ self.writer_leases_dir / f"{lease.allocation_id}.json", writer_lease_to_dict(lease)
903
+ )
904
+
905
+ def remove_writer_lease(self, allocation_id: str) -> bool:
906
+ path = self.writer_leases_dir / f"{allocation_id}.json"
907
+ if not path.exists():
908
+ return False
909
+ path.unlink()
910
+ return True
911
+
912
+ def write_writer_receipt(self, receipt: WriterReconciliationReceipt) -> None:
913
+ self.writer_receipts_dir.mkdir(parents=True, exist_ok=True)
914
+ self._atomic_json(
915
+ self.writer_receipts_dir / f"{receipt.receipt_id}.json", writer_receipt_to_dict(receipt)
916
+ )
917
+
918
+ def write_publication_lease(self, lease: PublicationLease) -> None:
919
+ if self.publication_lease_file.exists():
920
+ raise ControllerError("PUBLICATION_LEASE_HELD", "main publication lease is occupied")
921
+ self._atomic_json(self.publication_lease_file, publication_lease_to_dict(lease))
922
+ self.touch_repository()
923
+
924
+ def write_integration_intent(self, intent: IntegrationIntent) -> None:
925
+ path = self.integration_intents_dir / f"{intent.intent_id}.json"
926
+ if path.exists():
927
+ raise ControllerError("INTENT_EXISTS", "integration intent already exists")
928
+ self._atomic_json(path, integration_intent_to_dict(intent))
929
+ self.touch_repository()
930
+
931
+ def replace_integration_intent(self, intent: IntegrationIntent) -> None:
932
+ path = self.integration_intents_dir / f"{intent.intent_id}.json"
933
+ if not path.is_file():
934
+ raise ControllerError("BLOCK_DRIFT", "integration intent disappeared")
935
+ self._atomic_json(path, integration_intent_to_dict(intent))
936
+ self.touch_repository()
937
+
938
+ def write_prepared_candidate_receipt(self, receipt: PreparedCandidateReceipt) -> None:
939
+ path = self.prepared_candidate_receipts_dir / f"{receipt.receipt_id}.json"
940
+ if path.exists():
941
+ current = prepared_candidate_receipt_from_dict(self._read_json(path))
942
+ if current != receipt:
943
+ raise ControllerError("RECEIPT_MISMATCH", "prepared receipt already differs")
944
+ return
945
+ self._atomic_json(path, prepared_candidate_receipt_to_dict(receipt))
946
+ self.touch_repository()
947
+
948
+ def write_integration_reconciliation_receipt(
949
+ self, receipt: IntegrationReconciliationReceipt
950
+ ) -> None:
951
+ path = self.integration_reconciliation_receipts_dir / f"{receipt.receipt_id}.json"
952
+ if path.exists():
953
+ current = integration_reconciliation_receipt_from_dict(self._read_json(path))
954
+ if current != receipt:
955
+ raise ControllerError(
956
+ "RECEIPT_MISMATCH", "integration reconciliation receipt already differs"
957
+ )
958
+ return
959
+ self._atomic_json(path, integration_reconciliation_receipt_to_dict(receipt))
960
+ self.touch_repository()
961
+
962
+ def write_lifecycle_event(self, event: LifecycleEvent) -> None:
963
+ path = self.lifecycle_events_dir / f"{event.event_id}.json"
964
+ if path.exists():
965
+ current = lifecycle_event_from_dict(self._read_json(path))
966
+ if current != event:
967
+ raise ControllerError("EVENT_MISMATCH", "lifecycle event already differs")
968
+ return
969
+ self._atomic_json(path, lifecycle_event_to_dict(event))
970
+ self.touch_repository()
971
+
972
+ def _event_ack_path(self, consumer: str, event_id: str) -> Path:
973
+ normalized = _nonempty_string(consumer, "consumer")
974
+ if len(normalized) > 256:
975
+ raise ControllerError("INVALID_CONSUMER", "event consumer is too long")
976
+ directory = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
977
+ return self.lifecycle_event_acks_dir / directory / f"{event_id}.json"
978
+
979
+ def list_lifecycle_events(self, consumer: str) -> tuple[LifecycleEvent, ...]:
980
+ with self.lock(exclusive=False):
981
+ events = (
982
+ [
983
+ lifecycle_event_from_dict(self._read_json(path))
984
+ for path in _glob_json(self.lifecycle_events_dir)
985
+ ]
986
+ if self.lifecycle_events_dir.exists()
987
+ else []
988
+ )
989
+ return tuple(
990
+ event
991
+ for event in sorted(events, key=lambda item: (item.sequence, item.event_id))
992
+ if not self._event_ack_path(consumer, event.event_id).exists()
993
+ )
994
+
995
+ def acknowledge_lifecycle_event(self, consumer: str, event_id: str) -> bool:
996
+ try:
997
+ uuid.UUID(event_id)
998
+ except ValueError as exc:
999
+ raise ControllerError("INVALID_EVENT", "event id must be a UUID") from exc
1000
+ event_path = self.lifecycle_events_dir / f"{event_id}.json"
1001
+ if not event_path.is_file():
1002
+ raise ControllerError("MISSING_EVENT", "lifecycle event is missing")
1003
+ lifecycle_event_from_dict(self._read_json(event_path))
1004
+ path = self._event_ack_path(consumer, event_id)
1005
+ if path.exists():
1006
+ ack = self._read_json(path)
1007
+ if ack.get("event_id") != event_id or ack.get("consumer") != consumer.strip():
1008
+ raise ControllerError("EVENT_ACK_MISMATCH", "event acknowledgement differs")
1009
+ return False
1010
+ self._atomic_json(
1011
+ path,
1012
+ {
1013
+ "schema_version": LIFECYCLE_EVENT_SCHEMA_VERSION,
1014
+ "event_id": event_id,
1015
+ "consumer": consumer.strip(),
1016
+ "acked_at": now_iso(),
1017
+ },
1018
+ )
1019
+ self.touch_repository()
1020
+ return True
1021
+
1022
+ def write_release_intent(self, intent: ReleaseIntent) -> None:
1023
+ path = self.release_intents_dir / f"{intent.intent_id}.json"
1024
+ if path.exists():
1025
+ current = release_intent_from_dict(self._read_json(path))
1026
+ if current != intent:
1027
+ raise ControllerError("INTENT_MISMATCH", "release intent already differs")
1028
+ return
1029
+ self._atomic_json(path, release_intent_to_dict(intent))
1030
+ self.touch_repository()
1031
+
1032
+ def replace_release_intent(self, intent: ReleaseIntent) -> None:
1033
+ path = self.release_intents_dir / f"{intent.intent_id}.json"
1034
+ if not path.is_file():
1035
+ raise ControllerError("BLOCK_DRIFT", "release intent disappeared")
1036
+ self._atomic_json(path, release_intent_to_dict(intent))
1037
+ self.touch_repository()
1038
+
1039
+ def write_release_plan(self, plan: ReleasePlan) -> None:
1040
+ path = self.release_plans_dir / f"{plan.plan_id}.json"
1041
+ if path.exists():
1042
+ current = release_plan_from_dict(self._read_json(path))
1043
+ if current != plan:
1044
+ raise ControllerError("PLAN_MISMATCH", "release plan already differs")
1045
+ return
1046
+ self._atomic_json(path, release_plan_to_dict(plan))
1047
+ self.touch_repository()
1048
+
1049
+ def remove_release_plan(self, plan_id: str) -> bool:
1050
+ path = self.release_plans_dir / f"{plan_id}.json"
1051
+ try:
1052
+ path.unlink()
1053
+ except FileNotFoundError:
1054
+ return False
1055
+ return True
1056
+
1057
+ def write_publication_receipt(self, receipt: PublicationReceipt) -> None:
1058
+ path = self.publication_receipts_dir / f"{receipt.receipt_id}.json"
1059
+ if path.exists():
1060
+ current = publication_receipt_from_dict(self._read_json(path))
1061
+ if current != receipt:
1062
+ raise ControllerError("RECEIPT_MISMATCH", "publication receipt already differs")
1063
+ return
1064
+ self._atomic_json(path, publication_receipt_to_dict(receipt))
1065
+ self.touch_repository()
1066
+
1067
+ def write_release_receipt(self, receipt: ReleaseReceipt) -> None:
1068
+ path = self.release_receipts_dir / f"{receipt.receipt_id}.json"
1069
+ if path.exists():
1070
+ current = release_receipt_from_dict(self._read_json(path))
1071
+ if current != receipt:
1072
+ raise ControllerError("RECEIPT_MISMATCH", "release receipt already differs")
1073
+ return
1074
+ self._atomic_json(path, release_receipt_to_dict(receipt))
1075
+ self.touch_repository()
1076
+
1077
+ def write_registration_prune_receipt(self, receipt_id: str, payload: Mapping[str, Any]) -> None:
1078
+ path = self.registration_prune_receipts_dir / f"{receipt_id}.json"
1079
+ if path.exists():
1080
+ if dict(self._read_json(path)) != dict(payload):
1081
+ raise ControllerError("RECEIPT_MISMATCH", "prune receipt already differs")
1082
+ return
1083
+ self._atomic_json(path, payload)
1084
+ self.touch_repository()
1085
+
1086
+ def write_reconciliation_receipt(self, receipt: ReconciliationReceipt) -> None:
1087
+ path = self.reconciliation_receipts_dir / f"{receipt.receipt_id}.json"
1088
+ if path.exists():
1089
+ current = reconciliation_receipt_from_dict(self._read_json(path))
1090
+ if current != receipt:
1091
+ raise ControllerError("RECEIPT_MISMATCH", "reconciliation receipt already differs")
1092
+ return
1093
+ self._atomic_json(path, reconciliation_receipt_to_dict(receipt))
1094
+ self.touch_repository()
1095
+
1096
+ def remove_publication_lease(self, lease_id: str) -> None:
1097
+ if not self.publication_lease_file.is_file():
1098
+ raise ControllerError("BLOCK_DRIFT", "publication lease disappeared")
1099
+ current = publication_lease_from_dict(self._read_json(self.publication_lease_file))
1100
+ if current.lease_id != lease_id:
1101
+ raise ControllerError("LEASE_MISMATCH", "publication lease id changed")
1102
+ self.publication_lease_file.unlink()
1103
+ directory = os.open(self.publication_lease_file.parent, os.O_RDONLY | os.O_DIRECTORY)
1104
+ try:
1105
+ os.fsync(directory)
1106
+ finally:
1107
+ os.close(directory)
1108
+ self.touch_repository()
1109
+
1110
+ def remove_branch_retirement_authorization(self, authorization_id: str) -> None:
1111
+ path = self.branch_retirement_authorizations_dir / f"{authorization_id}.json"
1112
+ if not path.is_file():
1113
+ raise ControllerError("BLOCK_DRIFT", "retirement authorization disappeared")
1114
+ path.unlink()
1115
+ directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
1116
+ try:
1117
+ os.fsync(directory)
1118
+ finally:
1119
+ os.close(directory)
1120
+ self.touch_repository()
1121
+
1122
+ def write_transaction(self, transaction_id: str, payload: Mapping[str, Any]) -> None:
1123
+ try:
1124
+ uuid.UUID(transaction_id)
1125
+ except ValueError as exc:
1126
+ raise ControllerError("INVALID_TRANSACTION", "transaction id must be a UUID") from exc
1127
+ self._atomic_json(self.transactions_dir / f"{transaction_id}.json", payload)
1128
+
1129
+ def write_teardown_receipt(self, receipt_id: str, payload: Mapping[str, Any]) -> None:
1130
+ try:
1131
+ uuid.UUID(receipt_id)
1132
+ except ValueError as exc:
1133
+ raise ControllerError("INVALID_METADATA", "teardown receipt id must be a UUID") from exc
1134
+ validated = validate_teardown_receipt(payload)
1135
+ if validated["receipt_id"] != receipt_id:
1136
+ raise ControllerError("INVALID_METADATA", "teardown receipt filename does not match")
1137
+ self._atomic_json(self.teardown_receipts_dir / f"{receipt_id}.json", validated)
1138
+
1139
+ def write_completion_receipt(self, receipt_id: str, payload: Mapping[str, Any]) -> None:
1140
+ try:
1141
+ uuid.UUID(receipt_id)
1142
+ except ValueError as exc:
1143
+ raise ControllerError(
1144
+ "INVALID_METADATA", "completion receipt id must be a UUID"
1145
+ ) from exc
1146
+ if payload.get("receipt_id") != receipt_id:
1147
+ raise ControllerError("INVALID_METADATA", "completion receipt id does not match")
1148
+ self._atomic_json(self.completion_receipts_dir / f"{receipt_id}.json", dict(payload))
1149
+
1150
+ def load_teardown_receipt(self, receipt_id: str) -> dict[str, Any]:
1151
+ try:
1152
+ uuid.UUID(receipt_id)
1153
+ except ValueError as exc:
1154
+ raise ControllerError("INVALID_METADATA", "teardown receipt id must be a UUID") from exc
1155
+ path = self.teardown_receipts_dir / f"{receipt_id}.json"
1156
+ if not path.is_file():
1157
+ raise ControllerError("BLOCK_DRIFT", "teardown receipt disappeared")
1158
+ receipt = validate_teardown_receipt(self._read_json(path))
1159
+ if receipt["receipt_id"] != receipt_id:
1160
+ raise ControllerError("INVALID_METADATA", "teardown receipt filename does not match")
1161
+ return receipt
1162
+
1163
+ def remove_transaction(self, transaction_id: str) -> None:
1164
+ path = self.transactions_dir / f"{transaction_id}.json"
1165
+ try:
1166
+ path.unlink()
1167
+ except FileNotFoundError as exc:
1168
+ raise ControllerError("BLOCK_DRIFT", "transaction journal disappeared") from exc
1169
+ directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
1170
+ try:
1171
+ os.fsync(directory)
1172
+ finally:
1173
+ os.close(directory)
1174
+
1175
+
1176
+ def new_record(
1177
+ *,
1178
+ task_key: str,
1179
+ authority_key: str,
1180
+ module: str,
1181
+ role: str,
1182
+ owner: str,
1183
+ lifecycle: str,
1184
+ base_commit: str | None = None,
1185
+ task_commit: str | None = None,
1186
+ scope_paths: tuple[str, ...] = (),
1187
+ source_allocations: tuple[str, ...] = (),
1188
+ external_bindings: tuple[str, ...] = (),
1189
+ initial_event: str = "ADOPTED",
1190
+ ) -> MetadataRecord:
1191
+ timestamp = now_iso()
1192
+ event = {
1193
+ "at": timestamp,
1194
+ "event": initial_event,
1195
+ "from": None,
1196
+ "to": lifecycle,
1197
+ "owner": owner,
1198
+ }
1199
+ return record_from_dict(
1200
+ {
1201
+ "schema_version": METADATA_SCHEMA_VERSION,
1202
+ "record_version": 1,
1203
+ "allocation_id": str(uuid.uuid4()),
1204
+ "task_key": task_key,
1205
+ "authority_key": authority_key,
1206
+ "module": module,
1207
+ "role": role,
1208
+ "owner": owner,
1209
+ "lifecycle": lifecycle,
1210
+ "created_at": timestamp,
1211
+ "last_reviewed_at": timestamp,
1212
+ "handoff_commit": None,
1213
+ "finalizer_record_id": None,
1214
+ "base_commit": base_commit,
1215
+ "task_commit": task_commit,
1216
+ "finalizer_commit": None,
1217
+ "scope_paths": list(scope_paths),
1218
+ "source_allocations": list(source_allocations),
1219
+ "quarantine_expected_commit": None,
1220
+ "quarantine_observed_commit": None,
1221
+ "quarantine_reason": None,
1222
+ "recovery_allocation_id": None,
1223
+ "recovery_ref": None,
1224
+ "disposable_resources": [],
1225
+ "external_bindings": list(external_bindings),
1226
+ "history": [event],
1227
+ }
1228
+ )