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,1038 @@
1
+ """Release-time inspection for ignored artifacts and Python environments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import hashlib
7
+ import os
8
+ import re
9
+ import stat
10
+ import time
11
+ from dataclasses import asdict, dataclass
12
+ from pathlib import Path, PurePosixPath
13
+ from typing import Any
14
+
15
+ from .git_backend import head_paths, tracked_paths
16
+ from .models import ControllerError, RepositoryInventory, WriterHolder
17
+ from .writer_lease import boot_id
18
+
19
+ SAFE_CACHE_PARTS = frozenset(
20
+ {
21
+ "__pycache__",
22
+ ".pytest_cache",
23
+ ".ruff_cache",
24
+ ".mypy_cache",
25
+ ".pyright",
26
+ ".hypothesis",
27
+ }
28
+ )
29
+ SAFE_CACHE_SUFFIXES = (".pyc", ".pyo")
30
+ REFERENCE_NAMES = frozenset({"direct_url.json"})
31
+ REFERENCE_SUFFIXES = (".pth",)
32
+ MAX_REFERENCE_FILE_BYTES = 1_048_576
33
+ MAX_REFERENCE_FILES = 20_000
34
+ MAX_VENV_ENTRIES = 500_000
35
+ MAX_RUNTIME_PROCESSES = 16_384
36
+ MAX_RUNTIME_FDS = 200_000
37
+ MAX_RUNTIME_MAP_BYTES = 256 * 1_048_576
38
+ CACHEDIR_SIGNATURE = b"Signature: 8a477f597d28d172789f06886806bc55"
39
+
40
+
41
+ def unsafe_ignored_paths(paths: tuple[str, ...]) -> tuple[str, ...]:
42
+ unsafe: list[str] = []
43
+ for raw in paths:
44
+ path = PurePosixPath(raw)
45
+ if any(part in SAFE_CACHE_PARTS for part in path.parts):
46
+ continue
47
+ if path.name.endswith(SAFE_CACHE_SUFFIXES):
48
+ continue
49
+ unsafe.append(raw)
50
+ return tuple(unsafe)
51
+
52
+
53
+ def _candidate_reference_file(path: Path) -> bool:
54
+ name = path.name
55
+ return (
56
+ name in REFERENCE_NAMES
57
+ or name.endswith(REFERENCE_SUFFIXES)
58
+ or name.startswith("__editable__")
59
+ )
60
+
61
+
62
+ def _environment_roots(
63
+ repository: RepositoryInventory, excluded_roots: frozenset[Path]
64
+ ) -> tuple[Path, ...]:
65
+ roots: set[Path] = set()
66
+ for worktree in repository.worktrees:
67
+ if worktree.path.exists():
68
+ for venv_name in (".venv", "backend/.venv"):
69
+ candidate = worktree.path / venv_name
70
+ if candidate.is_dir() and not candidate.is_symlink():
71
+ resolved = candidate.resolve(strict=True)
72
+ if resolved not in excluded_roots:
73
+ roots.add(resolved)
74
+ return tuple(sorted(roots))
75
+
76
+
77
+ def editable_references(
78
+ repository: RepositoryInventory,
79
+ target: Path,
80
+ *,
81
+ exclude_environments: tuple[Path, ...] = (),
82
+ ) -> tuple[dict[str, str], ...]:
83
+ target_bytes = os.fsencode(target.resolve(strict=True))
84
+ excluded_roots = frozenset(
85
+ path.resolve(strict=True) for path in exclude_environments if path.exists()
86
+ )
87
+ matches: list[dict[str, str]] = []
88
+ inspected = 0
89
+ def scan_error(error: OSError) -> None:
90
+ raise ControllerError(
91
+ "ENVIRONMENT_SCAN_ERROR", f"cannot enumerate environment: {error.filename}"
92
+ ) from error
93
+
94
+ for root in _environment_roots(repository, excluded_roots):
95
+ for directory, directories, files in os.walk(root, followlinks=False, onerror=scan_error):
96
+ directories[:] = [
97
+ name for name in directories if not (Path(directory) / name).is_symlink()
98
+ ]
99
+ for name in files:
100
+ path = Path(directory) / name
101
+ if not _candidate_reference_file(path):
102
+ continue
103
+ inspected += 1
104
+ if inspected > MAX_REFERENCE_FILES:
105
+ raise ControllerError(
106
+ "ENVIRONMENT_SCAN_LIMIT", "editable reference scan exceeded its bound"
107
+ )
108
+ payload = _reference_payload(path)
109
+ if target_bytes in payload:
110
+ matches.append({"environment": str(root), "file": str(path)})
111
+ return tuple(matches)
112
+
113
+
114
+ def _reference_payload(path: Path) -> bytes:
115
+ """An unexamined candidate is uncertainty, never evidence of no dependency."""
116
+ try:
117
+ # Do not follow links or wait on a FIFO/device disguised as a .pth file.
118
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
119
+ with os.fdopen(descriptor, "rb") as stream:
120
+ info = os.fstat(stream.fileno())
121
+ if not stat.S_ISREG(info.st_mode):
122
+ raise ControllerError(
123
+ "ENVIRONMENT_SCAN_ERROR", f"environment reference is not a regular file: {path}"
124
+ )
125
+ if info.st_size > MAX_REFERENCE_FILE_BYTES:
126
+ raise ControllerError(
127
+ "ENVIRONMENT_SCAN_LIMIT", f"environment reference exceeds size bound: {path}"
128
+ )
129
+ payload = stream.read(MAX_REFERENCE_FILE_BYTES + 1)
130
+ if len(payload) > MAX_REFERENCE_FILE_BYTES:
131
+ raise ControllerError(
132
+ "ENVIRONMENT_SCAN_LIMIT", f"environment reference grew beyond size bound: {path}"
133
+ )
134
+ return payload
135
+ except OSError as exc:
136
+ raise ControllerError(
137
+ "ENVIRONMENT_SCAN_ERROR", f"cannot inspect environment reference: {path}"
138
+ ) from exc
139
+
140
+
141
+ def _sha256(path: Path) -> str:
142
+ digest = hashlib.sha256()
143
+ try:
144
+ with path.open("rb") as handle:
145
+ while chunk := handle.read(1_048_576):
146
+ digest.update(chunk)
147
+ except OSError as exc:
148
+ raise ControllerError(
149
+ "RESOURCE_IDENTITY_MISMATCH", f"cannot read identity file: {path}"
150
+ ) from exc
151
+ return digest.hexdigest()
152
+
153
+
154
+ def _pyvenv_identity(path: Path) -> tuple[str | None, str | None]:
155
+ try:
156
+ lines = path.read_text(encoding="utf-8").splitlines()
157
+ except (OSError, UnicodeError) as exc:
158
+ raise ControllerError("RESOURCE_IDENTITY_MISMATCH", "pyvenv.cfg is unreadable") from exc
159
+ values: dict[str, str] = {}
160
+ for line in lines:
161
+ key, separator, value = line.partition("=")
162
+ if separator:
163
+ values[key.strip().lower()] = value.strip()
164
+ version = values.get("version") or values.get("version_info")
165
+ implementation = values.get("implementation")
166
+ return version, implementation
167
+
168
+
169
+ def _venv_inventory(root: Path) -> tuple[int, int, str]:
170
+ total_bytes = 0
171
+ entries = 0
172
+ identity = hashlib.sha256()
173
+ pending = [root]
174
+ while pending:
175
+ directory = pending.pop()
176
+ try:
177
+ with os.scandir(directory) as scan:
178
+ children = sorted(scan, key=lambda entry: entry.name)
179
+ except OSError as exc:
180
+ raise ControllerError(
181
+ "RESOURCE_IDENTITY_MISMATCH", "cannot inventory reproducible .venv"
182
+ ) from exc
183
+ for child in children:
184
+ entries += 1
185
+ if entries > MAX_VENV_ENTRIES:
186
+ raise ControllerError(
187
+ "RESOURCE_SCAN_LIMIT", "reproducible .venv inventory exceeded its bound"
188
+ )
189
+ path = Path(child.path)
190
+ try:
191
+ metadata = child.stat(follow_symlinks=False)
192
+ except OSError as exc:
193
+ raise ControllerError(
194
+ "RESOURCE_IDENTITY_MISMATCH", "cannot stat reproducible .venv content"
195
+ ) from exc
196
+ total_bytes += metadata.st_size
197
+ relative = path.relative_to(root).as_posix()
198
+ identity.update(os.fsencode(relative))
199
+ identity.update(b"\0")
200
+ identity.update(
201
+ f"{metadata.st_mode}:{metadata.st_size}:{metadata.st_mtime_ns}".encode("ascii")
202
+ )
203
+ identity.update(b"\0")
204
+ if child.is_symlink():
205
+ try:
206
+ identity.update(os.fsencode(os.readlink(path)))
207
+ except OSError as exc:
208
+ raise ControllerError(
209
+ "RESOURCE_IDENTITY_MISMATCH",
210
+ "cannot inspect reproducible .venv symlink",
211
+ ) from exc
212
+ continue
213
+ if child.is_dir(follow_symlinks=False):
214
+ if path.is_mount():
215
+ raise ControllerError(
216
+ "RESOURCE_IDENTITY_MISMATCH", "reproducible .venv contains a mount"
217
+ )
218
+ pending.append(path)
219
+ elif not stat.S_ISREG(metadata.st_mode):
220
+ raise ControllerError(
221
+ "RESOURCE_IDENTITY_MISMATCH",
222
+ "reproducible .venv contains a special filesystem object",
223
+ )
224
+ return total_bytes, entries, identity.hexdigest()
225
+
226
+
227
+ def discover_python_project(
228
+ worktree: Path,
229
+ *,
230
+ venv_relative_path: str = ".venv",
231
+ ) -> tuple[str, str]:
232
+ """Discover and validate the python project (pyproject.toml, uv.lock) for a worktree environment.
233
+
234
+ Returns (project_path, lock_path) as POSIX repository-relative strings.
235
+ Fails closed if the project or lockfile is missing, ambiguous, untracked, or escapes the root.
236
+ """
237
+ root = worktree.resolve(strict=True)
238
+ venv_rel = PurePosixPath(venv_relative_path)
239
+ if venv_rel.is_absolute() or any(part in {"..", ""} for part in venv_rel.parts):
240
+ raise ControllerError(
241
+ "RESOURCE_PATH_ESCAPE", f"invalid venv relative path: {venv_relative_path}"
242
+ )
243
+
244
+ venv_parent = venv_rel.parent.as_posix()
245
+
246
+ cand_dir: str | None = None
247
+ if venv_parent not in {".", ""}:
248
+ cand_project = f"{venv_parent}/pyproject.toml"
249
+ cand_project_file = root / cand_project
250
+ if not cand_project_file.exists():
251
+ raise ControllerError(
252
+ "PROJECT_IDENTITY_MISMATCH",
253
+ f"reproducible teardown requires tracked {cand_project}",
254
+ )
255
+ cand_dir = venv_parent
256
+ else:
257
+ root_project_file = root / "pyproject.toml"
258
+ if root_project_file.exists():
259
+ cand_dir = ""
260
+ else:
261
+ tracked = tracked_paths(root, "*pyproject.toml")
262
+ candidates = sorted(
263
+ p
264
+ for p in tracked
265
+ if PurePosixPath(p).name == "pyproject.toml" and not PurePosixPath(p).is_absolute()
266
+ )
267
+ if len(candidates) == 0:
268
+ raise ControllerError(
269
+ "PROJECT_IDENTITY_MISMATCH",
270
+ "reproducible teardown requires tracked pyproject.toml",
271
+ )
272
+ if len(candidates) > 1:
273
+ raise ControllerError(
274
+ "PROJECT_IDENTITY_MISMATCH",
275
+ f"ambiguous python project candidates: {', '.join(candidates)}",
276
+ )
277
+ cand_dir = PurePosixPath(candidates[0]).parent.as_posix()
278
+ if cand_dir == ".":
279
+ cand_dir = ""
280
+
281
+ project_rel = f"{cand_dir}/pyproject.toml" if cand_dir else "pyproject.toml"
282
+ lock_rel = f"{cand_dir}/uv.lock" if cand_dir else "uv.lock"
283
+
284
+ project_path = root / project_rel
285
+ lock_path = root / lock_rel
286
+
287
+ for rel_path, abs_path in ((project_rel, project_path), (lock_rel, lock_path)):
288
+ if abs_path.is_symlink():
289
+ raise ControllerError("RESOURCE_PATH_ESCAPE", f"{rel_path} is a symlink")
290
+ try:
291
+ if abs_path.resolve(strict=True) != abs_path:
292
+ raise ControllerError("RESOURCE_PATH_ESCAPE", f"{rel_path} escapes its root")
293
+ except OSError as exc:
294
+ raise ControllerError(
295
+ "PROJECT_IDENTITY_MISMATCH",
296
+ f"reproducible teardown requires tracked {rel_path}",
297
+ ) from exc
298
+ if not abs_path.is_file():
299
+ raise ControllerError(
300
+ "PROJECT_IDENTITY_MISMATCH",
301
+ f"reproducible teardown requires regular {rel_path}",
302
+ )
303
+ if tracked_paths(root, rel_path) != (rel_path,) or head_paths(root, rel_path) != (
304
+ rel_path,
305
+ ):
306
+ raise ControllerError(
307
+ "PROJECT_IDENTITY_MISMATCH",
308
+ f"reproducible teardown requires tracked {rel_path}",
309
+ )
310
+
311
+ return project_rel, lock_rel
312
+
313
+
314
+ def reproducible_venv_evidence(
315
+ worktree: Path,
316
+ *,
317
+ venv_relative: str = ".venv",
318
+ ) -> dict[str, Any]:
319
+ """Return bounded deletion evidence for the reproducible worktree .venv."""
320
+
321
+ root = worktree.resolve(strict=True)
322
+ venv = root / venv_relative
323
+ if venv.is_symlink() or not venv.is_dir() or venv.is_mount():
324
+ raise ControllerError(
325
+ "RESOURCE_IDENTITY_MISMATCH",
326
+ f"worktree {venv_relative} is missing, a symlink, or a mount",
327
+ )
328
+ if venv.resolve(strict=True) != venv:
329
+ raise ControllerError(
330
+ "RESOURCE_IDENTITY_MISMATCH", f"worktree {venv_relative} escaped its root"
331
+ )
332
+ marker = venv / "pyvenv.cfg"
333
+ cache_tag = venv / "CACHEDIR.TAG"
334
+ project_rel, lock_rel = discover_python_project(root, venv_relative_path=venv_relative)
335
+ lock = root / lock_rel
336
+ project = root / project_rel
337
+ required = (marker, cache_tag, lock, project)
338
+ if any(path.is_symlink() or not path.is_file() for path in required):
339
+ raise ControllerError(
340
+ "RESOURCE_IDENTITY_MISMATCH",
341
+ f"reproducible {venv_relative} requires regular pyvenv.cfg, CACHEDIR.TAG, {lock_rel}, and {project_rel}",
342
+ )
343
+ try:
344
+ cache_payload = cache_tag.read_bytes()
345
+ except OSError as exc:
346
+ raise ControllerError("RESOURCE_IDENTITY_MISMATCH", "CACHEDIR.TAG is unreadable") from exc
347
+ if CACHEDIR_SIGNATURE not in cache_payload:
348
+ raise ControllerError(
349
+ "RESOURCE_IDENTITY_MISMATCH", "CACHEDIR.TAG lacks the standard cache signature"
350
+ )
351
+ if (venv / ".git").exists() or (venv / ".git").is_symlink():
352
+ raise ControllerError(
353
+ "RESOURCE_IDENTITY_MISMATCH", f"worktree {venv_relative} contains Git metadata"
354
+ )
355
+ python = venv / "bin" / "python"
356
+ try:
357
+ python_target = python.resolve(strict=True)
358
+ python_stat = python_target.stat()
359
+ except OSError as exc:
360
+ raise ControllerError(
361
+ "RESOURCE_IDENTITY_MISMATCH",
362
+ f"reproducible {venv_relative} Python executable is unavailable",
363
+ ) from exc
364
+ if not python_target.is_file() or not os.access(python_target, os.X_OK):
365
+ raise ControllerError(
366
+ "RESOURCE_IDENTITY_MISMATCH",
367
+ f"reproducible {venv_relative} Python target is not executable",
368
+ )
369
+ version, implementation = _pyvenv_identity(marker)
370
+ if version is None:
371
+ raise ControllerError("RESOURCE_IDENTITY_MISMATCH", "pyvenv.cfg has no Python version")
372
+ try:
373
+ venv_stat = venv.stat()
374
+ except OSError as exc:
375
+ raise ControllerError(
376
+ "RESOURCE_IDENTITY_MISMATCH", f"cannot stat reproducible {venv_relative} root"
377
+ ) from exc
378
+ bytes_before, entries_before, tree_identity = _venv_inventory(venv)
379
+ python_identity = (
380
+ f"{python_target}:{python_stat.st_dev}:{python_stat.st_ino}:"
381
+ f"{python_stat.st_size}:{python_stat.st_mtime_ns}"
382
+ )
383
+ environment_identity = hashlib.sha256(
384
+ (
385
+ _sha256(marker)
386
+ + _sha256(cache_tag)
387
+ + hashlib.sha256(python_identity.encode("utf-8")).hexdigest()
388
+ + tree_identity
389
+ ).encode("ascii")
390
+ ).hexdigest()
391
+ return {
392
+ "environment_path": venv_relative,
393
+ "pyvenv_cfg_sha256": _sha256(marker),
394
+ "cachedir_tag_sha256": _sha256(cache_tag),
395
+ "lock_path": lock_rel,
396
+ "lock_sha256": _sha256(lock),
397
+ "project_path": project_rel,
398
+ "project_sha256": _sha256(project),
399
+ "python_version": version,
400
+ "python_implementation": implementation,
401
+ "python_identity_sha256": hashlib.sha256(python_identity.encode("utf-8")).hexdigest(),
402
+ "environment_identity_sha256": environment_identity,
403
+ "environment_device": venv_stat.st_dev,
404
+ "environment_inode": venv_stat.st_ino,
405
+ "bytes_before": bytes_before,
406
+ "entries_before": entries_before,
407
+ }
408
+
409
+
410
+ def _path_is_within(raw: str, roots: tuple[Path, ...]) -> bool:
411
+ candidate = raw.removesuffix(" (deleted)")
412
+ if not candidate.startswith("/"):
413
+ return False
414
+ # Preserve commonpath's lexical rules (including literal '..'), without
415
+ # splitting every ordinary procfs path once per root and mapping entry.
416
+ if "//" in candidate or "/./" in candidate or candidate.endswith("/."):
417
+ candidate = "/" + "/".join(part for part in candidate.split("/") if part and part != ".")
418
+ try:
419
+ for root in roots:
420
+ prefix = str(root)
421
+ if not prefix.startswith("/"):
422
+ return False
423
+ if candidate.startswith(prefix) and (
424
+ prefix == "/" or len(candidate) == len(prefix) or candidate[len(prefix)] == "/"
425
+ ):
426
+ return True
427
+ except (OSError, ValueError):
428
+ return False
429
+ return False
430
+
431
+
432
+ def _runtime_link(path: Path) -> str | None:
433
+ try:
434
+ return os.readlink(path)
435
+ except (FileNotFoundError, PermissionError):
436
+ return None
437
+ except OSError as exc:
438
+ raise ControllerError("RUNTIME_SCAN_ERROR", f"cannot inspect runtime link: {path}") from exc
439
+
440
+
441
+ def runtime_references(worktree: Path, *, strict: bool = False) -> tuple[dict[str, str], ...]:
442
+ """Inspect same-UID Linux processes for references into the checkout or its .venv.
443
+
444
+ The release scan shares the typed cleanup policy for permission gaps: a
445
+ same-UID process whose procfs entries deny access (for example a non-dumpable
446
+ service manager) is a host visibility gap, not a reference; every other read
447
+ failure fails closed. ``strict`` additionally refuses any such gap. In both
448
+ modes the checkout must keep one identity for the whole scan.
449
+ """
450
+
451
+ if strict:
452
+ return _strict_runtime_references(worktree)
453
+ try:
454
+ root = worktree.resolve(strict=True)
455
+ before = root.stat()
456
+ except (OSError, RuntimeError) as exc:
457
+ raise ControllerError("RUNTIME_SCAN_ERROR", "cannot identify checkout for runtime scan") from exc
458
+ references = _lenient_runtime_references(root)
459
+ try:
460
+ after = root.stat()
461
+ changed = worktree.resolve(strict=True) != root or (before.st_dev, before.st_ino) != (
462
+ after.st_dev,
463
+ after.st_ino,
464
+ )
465
+ except (OSError, RuntimeError) as exc:
466
+ raise ControllerError(
467
+ "RUNTIME_SCAN_ERROR", "checkout disappeared during runtime scan"
468
+ ) from exc
469
+ if changed:
470
+ raise ControllerError("RUNTIME_SCAN_ERROR", "checkout path/identity changed during runtime scan")
471
+ return references
472
+
473
+
474
+ def _lenient_runtime_references(root: Path) -> tuple[dict[str, str], ...]:
475
+ roots = (root, root / ".venv")
476
+ try:
477
+ processes = sorted(
478
+ (path for path in Path("/proc").iterdir() if path.name.isdecimal()),
479
+ key=lambda path: int(path.name),
480
+ )
481
+ except OSError as exc:
482
+ raise ControllerError("RUNTIME_SCAN_ERROR", "cannot enumerate Linux processes") from exc
483
+ if len(processes) > MAX_RUNTIME_PROCESSES:
484
+ raise ControllerError("RUNTIME_SCAN_LIMIT", "runtime process scan exceeded its bound")
485
+ references: list[dict[str, str]] = []
486
+ inspected_fds = 0
487
+ inspected_map_bytes = 0
488
+ for process in processes:
489
+ try:
490
+ if process.stat().st_uid != os.getuid():
491
+ continue
492
+ except FileNotFoundError:
493
+ continue
494
+ except OSError as exc:
495
+ raise ControllerError("RUNTIME_SCAN_ERROR", "cannot inspect process ownership") from exc
496
+ for kind in ("cwd", "exe"):
497
+ value = _runtime_link(process / kind)
498
+ if value is not None and _path_is_within(value, roots):
499
+ references.append({"pid": process.name, "kind": kind, "path": value})
500
+ try:
501
+ descriptors = list((process / "fd").iterdir())
502
+ except (FileNotFoundError, PermissionError):
503
+ descriptors = []
504
+ except OSError as exc:
505
+ raise ControllerError(
506
+ "RUNTIME_SCAN_ERROR", "cannot inspect process descriptors"
507
+ ) from exc
508
+ inspected_fds += len(descriptors)
509
+ if inspected_fds > MAX_RUNTIME_FDS:
510
+ raise ControllerError(
511
+ "RUNTIME_SCAN_LIMIT", "runtime descriptor scan exceeded its bound"
512
+ )
513
+ for descriptor in descriptors:
514
+ value = _runtime_link(descriptor)
515
+ if value is not None and _path_is_within(value, roots):
516
+ references.append({"pid": process.name, "kind": "fd", "path": value})
517
+ try:
518
+ with (process / "maps").open("rb") as mappings:
519
+ for line in mappings:
520
+ inspected_map_bytes += len(line)
521
+ if inspected_map_bytes > MAX_RUNTIME_MAP_BYTES:
522
+ raise ControllerError(
523
+ "RUNTIME_SCAN_LIMIT", "runtime mapping scan exceeded its bound"
524
+ )
525
+ fields = line.split(maxsplit=5)
526
+ if len(fields) == 6:
527
+ value = os.fsdecode(fields[5].strip())
528
+ if _path_is_within(value, roots):
529
+ references.append({"pid": process.name, "kind": "mmap", "path": value})
530
+ except (FileNotFoundError, ProcessLookupError, PermissionError):
531
+ continue
532
+ except ControllerError:
533
+ raise
534
+ except OSError as exc:
535
+ raise ControllerError("RUNTIME_SCAN_ERROR", "cannot inspect process mappings") from exc
536
+ return tuple(references)
537
+
538
+
539
+ _PROC_DIRECTORY_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
540
+ _VANISHED_ERRNOS = frozenset({errno.ENOENT, errno.ESRCH})
541
+ _DENIED_ERRNOS = frozenset({errno.EACCES, errno.EPERM})
542
+ _MAX_PROCESS_ATTEMPTS = 3
543
+ _VANISHED_RESCAN_SECONDS = 0.05
544
+
545
+
546
+ @dataclass(frozen=True)
547
+ class _ProcessIdentity:
548
+ inode: tuple[int, int]
549
+ start_ticks: int
550
+ state: str
551
+ threads: int
552
+ uids: tuple[int, ...]
553
+ ppid: int
554
+ session_id: int = 0
555
+
556
+ @property
557
+ def terminal(self) -> bool:
558
+ # Linux do_exit releases mm/files/fs before EXIT_ZOMBIE. A zombie leader
559
+ # with other threads is NOT proof that the process has no references.
560
+ return self.state in {"Z", "X"} and self.threads == 1
561
+
562
+
563
+ @dataclass(frozen=True)
564
+ class _RuntimeIssue:
565
+ field: str
566
+ code: str
567
+ errno: int | None = None
568
+
569
+ def __str__(self) -> str:
570
+ suffix = f"({self.errno})" if self.errno is not None else ""
571
+ return f"{self.field}:{self.code}{suffix}"
572
+
573
+
574
+ @dataclass(frozen=True)
575
+ class _ProcessScan:
576
+ path: Path
577
+ identity: _ProcessIdentity | None
578
+ references: tuple[dict[str, str], ...] = ()
579
+ issues: tuple[_RuntimeIssue, ...] = ()
580
+
581
+ def diagnostic(self) -> str:
582
+ ticks = f" start_ticks={self.identity.start_ticks}" if self.identity else ""
583
+ return f"pid={self.path.name}{ticks} {','.join(map(str, self.issues))}"
584
+
585
+
586
+ def _same_process(left: _ProcessIdentity, right: _ProcessIdentity | None) -> bool:
587
+ # A resizing thread pool is the same process; becoming single-threaded (an exec
588
+ # from a non-leader thread keeps the PID and start time) or multi-threaded is not.
589
+ return right is not None and (
590
+ left.inode,
591
+ left.start_ticks,
592
+ left.uids,
593
+ left.threads > 1,
594
+ left.ppid,
595
+ left.session_id,
596
+ ) == (right.inode, right.start_ticks, right.uids, right.threads > 1, right.ppid, right.session_id)
597
+
598
+
599
+ def _proc_bytes(directory: int, name: str) -> bytes:
600
+ fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=directory)
601
+ with os.fdopen(fd, "rb") as handle:
602
+ payload = handle.read(65_537)
603
+ if len(payload) > 65_536:
604
+ raise ValueError(f"oversized {name}")
605
+ return payload
606
+
607
+
608
+ def _process_identity(directory: int, pid: str) -> _ProcessIdentity:
609
+ raw = _proc_bytes(directory, "stat")
610
+ prefix, separator, tail = raw.rpartition(b") ")
611
+ fields = tail.split()
612
+ if not separator or not prefix.startswith(os.fsencode(pid) + b" (") or len(fields) < 20:
613
+ raise ValueError("malformed stat")
614
+ state = fields[0].decode("ascii")
615
+ threads, start_ticks = int(fields[17]), int(fields[19])
616
+ if state not in {"R", "S", "D", "Z", "T", "t", "X", "I", "P"} or threads < 1 or start_ticks < 0:
617
+ raise ValueError("malformed stat identity")
618
+ rows = [
619
+ line.split()[1:]
620
+ for line in _proc_bytes(directory, "status").splitlines()
621
+ if line.startswith(b"Uid:")
622
+ ]
623
+ if len(rows) != 1 or len(rows[0]) != 4:
624
+ raise ValueError("malformed status Uid")
625
+ uids = tuple(int(value) for value in rows[0])
626
+ if any(value < 0 for value in uids):
627
+ raise ValueError("malformed status Uid")
628
+ metadata = os.fstat(directory)
629
+ ppid = int(fields[1])
630
+ if ppid < 0:
631
+ raise ValueError("malformed parent pid")
632
+ return _ProcessIdentity(
633
+ (metadata.st_dev, metadata.st_ino), start_ticks, state, threads, uids, ppid, int(fields[3])
634
+ )
635
+
636
+
637
+ def _current_process_identity(process: Path) -> _ProcessIdentity | None:
638
+ for attempt in range(_MAX_PROCESS_ATTEMPTS):
639
+ try:
640
+ directory = os.open(process, _PROC_DIRECTORY_FLAGS)
641
+ except OSError as exc:
642
+ if exc.errno in _VANISHED_ERRNOS:
643
+ return None
644
+ raise
645
+ try:
646
+ return _process_identity(directory, process.name)
647
+ except OSError as exc:
648
+ if exc.errno not in _VANISHED_ERRNOS or attempt == _MAX_PROCESS_ATTEMPTS - 1:
649
+ raise
650
+ # Exit between opening the process directory and reading stat/status:
651
+ # reopen the numeric PID before concluding that it vanished.
652
+ finally:
653
+ os.close(directory)
654
+ raise AssertionError("unreachable process identity retry")
655
+
656
+
657
+ def verify_holder_lineage(holder: WriterHolder, process_id: int) -> None:
658
+ """Prove the calling process is the recorded holder or its current descendant.
659
+
660
+ Inspect only this ancestry, using the same fd-bound proc identity reader as
661
+ cleanup. Recheck the whole chain to reject exit, reparenting and PID reuse.
662
+ A declaration copied to an unrelated process grants no host-managed access.
663
+ """
664
+ chain: list[tuple[Path, _ProcessIdentity]] = []
665
+ seen: set[int] = set()
666
+ pid = process_id
667
+ try:
668
+ while pid > 0 and pid not in seen and len(chain) < MAX_RUNTIME_PROCESSES:
669
+ seen.add(pid)
670
+ path = Path(f"/proc/{pid}")
671
+ identity = _current_process_identity(path)
672
+ if identity is None or identity.state in {"Z", "X"}:
673
+ break
674
+ chain.append((path, identity))
675
+ if pid == holder.pid:
676
+ if holder.boot_id != boot_id() or identity.start_ticks != holder.start_ticks:
677
+ break
678
+ if all(
679
+ _same_process(before, _current_process_identity(item)) for item, before in chain
680
+ ):
681
+ return
682
+ break
683
+ pid = identity.ppid
684
+ except (OSError, ValueError):
685
+ pass
686
+ raise ControllerError(
687
+ "WRITER_LEASE_MISMATCH", "caller is not a verified descendant of the host holder"
688
+ )
689
+
690
+
691
+ def verify_runtime_transfer(source: WriterHolder, target: WriterHolder) -> None:
692
+ """A host-created Linux session remains identifiable across parent exit.
693
+
694
+ The original session leader must still have its exact identity (it may be
695
+ unreaped). A child cannot join a foreign Linux session. This permits transfer
696
+ after kernel reparenting without trusting copied environment or command text.
697
+ """
698
+ paths = [Path(f"/proc/{holder.pid}") for holder in (source, target)]
699
+ try:
700
+ before = [_current_process_identity(path) for path in paths]
701
+ if source.boot_id != boot_id() or target.boot_id != source.boot_id:
702
+ raise ValueError("boot changed")
703
+ if any(value is None for value in before):
704
+ raise ValueError("process disappeared")
705
+ leader, child = before
706
+ if (
707
+ leader.start_ticks != source.start_ticks
708
+ or child.start_ticks != target.start_ticks
709
+ or leader.session_id != source.pid
710
+ or child.session_id != source.pid
711
+ or leader.uids != child.uids
712
+ or child.terminal
713
+ or not all(
714
+ _same_process(value, _current_process_identity(path))
715
+ for value, path in zip(before, paths, strict=True)
716
+ )
717
+ ):
718
+ raise ValueError("runtime session changed")
719
+ except (OSError, ValueError):
720
+ raise ControllerError(
721
+ "RUNTIME_TRANSFER_UNPROVEN", "background process is not in the exact host session"
722
+ ) from None
723
+
724
+
725
+ def _runtime_issue(field: str, exc: OSError | ValueError) -> _RuntimeIssue:
726
+ if isinstance(exc, OSError):
727
+ return _RuntimeIssue(field, errno.errorcode.get(exc.errno, "OS_ERROR"), exc.errno)
728
+ return _RuntimeIssue(field, "MALFORMED_PROC_ENTRY")
729
+
730
+
731
+ def _strict_process_references(
732
+ directory: int, pid: str, roots: tuple[Path, ...], budget: list[int]
733
+ ) -> tuple[list[dict[str, str]], list[_RuntimeIssue]]:
734
+ references: list[dict[str, str]] = []
735
+ issues: list[_RuntimeIssue] = []
736
+
737
+ def reference(kind: str, value: str) -> None:
738
+ if _path_is_within(value, roots):
739
+ references.append({"pid": pid, "kind": kind, "path": value})
740
+
741
+ for kind in ("cwd", "exe"):
742
+ try:
743
+ reference(kind, os.readlink(kind, dir_fd=directory))
744
+ except OSError as exc:
745
+ issues.append(_runtime_issue(kind, exc))
746
+ try:
747
+ descriptors = os.open("fd", _PROC_DIRECTORY_FLAGS, dir_fd=directory)
748
+ try:
749
+ names = os.listdir(descriptors)
750
+ budget[0] += len(names)
751
+ if budget[0] > MAX_RUNTIME_FDS:
752
+ raise ControllerError(
753
+ "RUNTIME_SCAN_LIMIT", "runtime descriptor scan exceeded its bound"
754
+ )
755
+ for name in names:
756
+ if not name.isdecimal():
757
+ issues.append(_RuntimeIssue("fd", "MALFORMED_PROC_ENTRY"))
758
+ continue
759
+ try:
760
+ reference("fd", os.readlink(name, dir_fd=descriptors))
761
+ except OSError as exc:
762
+ if exc.errno in _VANISHED_ERRNOS:
763
+ try:
764
+ os.stat(name, dir_fd=descriptors, follow_symlinks=False)
765
+ except OSError as confirm:
766
+ if confirm.errno in _VANISHED_ERRNOS:
767
+ continue # This descriptor closed, not an unreadable live link.
768
+ exc = confirm
769
+ issue = _runtime_issue("fd", exc)
770
+ if issue not in issues:
771
+ issues.append(issue)
772
+ finally:
773
+ os.close(descriptors)
774
+ except OSError as exc:
775
+ issues.append(_runtime_issue("fd", exc))
776
+ try:
777
+ fd = os.open("maps", os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=directory)
778
+ with os.fdopen(fd, "rb") as mappings:
779
+ for line in mappings:
780
+ budget[1] += len(line)
781
+ if budget[1] > MAX_RUNTIME_MAP_BYTES:
782
+ raise ControllerError(
783
+ "RUNTIME_SCAN_LIMIT", "runtime mapping scan exceeded its bound"
784
+ )
785
+ fields = line.split(maxsplit=5)
786
+ if len(fields) < 5 or not re.fullmatch(
787
+ rb"[0-9a-f]+-[0-9a-f]+ [r-][w-][x-][ps] [0-9a-f]+ [0-9a-f]+:[0-9a-f]+ [0-9]+",
788
+ b" ".join(fields[:5]),
789
+ ):
790
+ raise ValueError("malformed maps")
791
+ if len(fields) == 6:
792
+ reference("mmap", os.fsdecode(fields[5].rstrip(b"\n")))
793
+ except (OSError, ValueError) as exc:
794
+ issues.append(_runtime_issue("maps", exc))
795
+ return references, issues
796
+
797
+
798
+ def _inspect_runtime_process(
799
+ process: Path, roots: tuple[Path, ...], budget: list[int], *, inspect_foreign: bool = False
800
+ ) -> _ProcessScan:
801
+ last_issue = _RuntimeIssue("identity", "PID_IDENTITY_CHANGED")
802
+ after = None
803
+ for attempt in range(_MAX_PROCESS_ATTEMPTS):
804
+ before: _ProcessIdentity | None = None
805
+ references: list[dict[str, str]] = []
806
+ issues: list[_RuntimeIssue] = []
807
+ try:
808
+ directory = os.open(process, _PROC_DIRECTORY_FLAGS)
809
+ try:
810
+ before = _process_identity(directory, process.name)
811
+ if (os.getuid() in before.uids or inspect_foreign) and not before.terminal:
812
+ references, issues = _strict_process_references(
813
+ directory, process.name, roots, budget
814
+ )
815
+ finally:
816
+ os.close(directory)
817
+ except (OSError, ValueError) as exc:
818
+ last_issue = _runtime_issue("identity", exc)
819
+ issues.append(last_issue)
820
+ try:
821
+ after = _current_process_identity(process)
822
+ except (OSError, ValueError) as exc:
823
+ return _ProcessScan(
824
+ process,
825
+ before,
826
+ tuple(references),
827
+ (*issues, _runtime_issue("identity-recheck", exc)),
828
+ )
829
+ if after is None:
830
+ return _ProcessScan(process, None) # Numeric PID absent, not just a missing field.
831
+ if before is None or not _same_process(before, after):
832
+ if before is not None:
833
+ last_issue = _RuntimeIssue(
834
+ "identity",
835
+ "PID_REUSED"
836
+ if (before.inode, before.start_ticks) != (after.inode, after.start_ticks)
837
+ else "PID_IDENTITY_CHANGED",
838
+ )
839
+ continue # Scan the replacement identity immediately, with a bounded retry.
840
+ if after.terminal:
841
+ return _ProcessScan(process, after)
842
+ if before.terminal:
843
+ return _ProcessScan(
844
+ process, after, issues=(_RuntimeIssue("identity", "TERMINAL_STATE_CHANGED"),)
845
+ )
846
+ if (
847
+ issues
848
+ and attempt + 1 < _MAX_PROCESS_ATTEMPTS
849
+ and any(issue.errno in _VANISHED_ERRNOS for issue in issues)
850
+ and all(issue.errno in _VANISHED_ERRNOS | _DENIED_ERRNOS for issue in issues)
851
+ ):
852
+ # An exiting task loses its /proc fd/cwd entries before it turns into a
853
+ # zombie, while its identity is still unchanged; without its mm the fd
854
+ # directory also denies access. Rescan after a short pause: a finished
855
+ # exit resolves to terminal or absent, a live process fails again.
856
+ time.sleep(_VANISHED_RESCAN_SECONDS)
857
+ continue
858
+ return _ProcessScan(process, after, tuple(references), tuple(issues))
859
+ return _ProcessScan(process, after, issues=(last_issue,))
860
+
861
+
862
+ def _runtime_process_scans(worktree: Path, budget: list[int]) -> tuple[_ProcessScan, ...]:
863
+ try:
864
+ root = worktree.resolve(strict=True)
865
+ metadata = root.stat()
866
+ identity = (metadata.st_dev, metadata.st_ino)
867
+ processes = sorted(
868
+ (path for path in Path("/proc").iterdir() if path.name.isdecimal()),
869
+ key=lambda path: int(path.name),
870
+ )
871
+ except (OSError, RuntimeError) as exc:
872
+ raise ControllerError(
873
+ "RUNTIME_SCAN_UNCERTAIN", "cannot identify checkout or enumerate procfs"
874
+ ) from exc
875
+ if len(processes) > MAX_RUNTIME_PROCESSES:
876
+ raise ControllerError("RUNTIME_SCAN_LIMIT", "runtime process scan exceeded its bound")
877
+ scans = tuple(_inspect_runtime_process(process, (root,), budget) for process in processes)
878
+ try:
879
+ metadata = root.stat()
880
+ if worktree.resolve(strict=True) != root or (metadata.st_dev, metadata.st_ino) != identity:
881
+ raise ValueError("checkout identity changed")
882
+ except (OSError, RuntimeError, ValueError) as exc:
883
+ raise ControllerError(
884
+ "RUNTIME_SCAN_UNCERTAIN", "checkout path/identity changed during runtime scan"
885
+ ) from exc
886
+ return scans
887
+
888
+
889
+ def _strict_runtime_references(worktree: Path) -> tuple[dict[str, str], ...]:
890
+ scans = _runtime_process_scans(worktree, [0, 0])
891
+ references = tuple(row for scan in scans for row in scan.references)
892
+ if references:
893
+ return references
894
+ issues = [scan.diagnostic() for scan in scans if scan.issues]
895
+ if issues:
896
+ raise ControllerError(
897
+ "RUNTIME_SCAN_UNCERTAIN", "cannot exclude checkout references: " + "; ".join(issues)
898
+ )
899
+ return ()
900
+
901
+
902
+ RUNTIME_AUTHORITY_CONTRACT = "allocation-runtime-authority/v1"
903
+
904
+
905
+ def _managed_lineage(
906
+ scan: _ProcessScan,
907
+ processes: dict[int, _ProcessScan],
908
+ holders: dict[int, WriterHolder],
909
+ current_boot: str,
910
+ ) -> tuple[WriterHolder | None, tuple[_ProcessScan, ...], str | None]:
911
+ chain: list[_ProcessScan] = []
912
+ current = scan
913
+ seen: set[int] = set()
914
+ while int(current.path.name) not in seen:
915
+ pid = int(current.path.name)
916
+ seen.add(pid)
917
+ chain.append(current)
918
+ holder = holders.get(pid)
919
+ if holder is not None:
920
+ if (
921
+ holder.boot_id != current_boot
922
+ or not holder.boot_id
923
+ or holder.boot_id == "unknown-boot"
924
+ ):
925
+ return holder, tuple(chain), "HOLDER_BOOT_IDENTITY_CHANGED"
926
+ if current.identity is None:
927
+ return holder, tuple(chain), "HOLDER_IDENTITY_UNREADABLE"
928
+ if current.identity.start_ticks != holder.start_ticks:
929
+ return holder, tuple(chain), "HOLDER_PID_REUSED"
930
+ # Revalidate the positive ancestry proof, including parent identity and
931
+ # PPID edges. A changed edge must never turn a managed PID into a host gap.
932
+ for member in chain:
933
+ try:
934
+ observed = _current_process_identity(member.path)
935
+ except (OSError, ValueError):
936
+ return holder, tuple(chain), "LINEAGE_IDENTITY_UNREADABLE"
937
+ if member.identity is None or not _same_process(member.identity, observed):
938
+ return holder, tuple(chain), "LINEAGE_IDENTITY_CHANGED"
939
+ return holder, tuple(chain), None
940
+ if current.identity is None or current.identity.ppid == 0:
941
+ break
942
+ parent_pid = current.identity.ppid
943
+ parent = processes.get(parent_pid)
944
+ if parent is None:
945
+ if parent_pid in holders:
946
+ return holders[parent_pid], tuple(chain), "HOLDER_IDENTITY_UNAVAILABLE"
947
+ break
948
+ if parent.identity and parent.identity.start_ticks > current.identity.start_ticks:
949
+ # An impossible parent age indicates a mixed-generation observation.
950
+ # Preserve a declared parent association as uncertainty, never permission.
951
+ return holders.get(parent_pid), tuple(chain), "LINEAGE_PID_REUSED"
952
+ current = parent
953
+ if int(current.path.name) in seen and current.identity and current.identity.ppid in seen:
954
+ return None, tuple(chain), "LINEAGE_CYCLE"
955
+ return None, (), None
956
+
957
+
958
+ def runtime_authority_evidence(
959
+ worktree: Path, *, holders: tuple[WriterHolder, ...] = ()
960
+ ) -> dict[str, Any]:
961
+ """Typed cleanup authority: positive observations block; unassociated permission gaps warn.
962
+
963
+ Holder roots must come from the allocation's formal Controller metadata, never
964
+ process names, cgroups, command lines, environment variables or caller guesses.
965
+ """
966
+ budget = [0, 0]
967
+ scans = _runtime_process_scans(worktree, budget)
968
+ processes = {int(scan.path.name): scan for scan in scans}
969
+ declared = {holder.pid: holder for holder in holders}
970
+ current_boot = boot_id() if holders else ""
971
+ result: dict[str, Any] = {
972
+ "contract": RUNTIME_AUTHORITY_CONTRACT,
973
+ "observation_scope": "same-uid-and-managed-processes",
974
+ "managed_references": [],
975
+ "observed_references": [],
976
+ "managed_visibility_uncertainties": [],
977
+ "host_visibility_gaps": [],
978
+ "scan_uncertainties": [],
979
+ }
980
+ for scan in scans:
981
+ if scan.identity is None and not scan.issues:
982
+ continue # Confirmed gone.
983
+ if scan.identity and scan.identity.terminal and not scan.issues:
984
+ continue
985
+ holder, chain, lineage_issue = (
986
+ _managed_lineage(scan, processes, declared, current_boot)
987
+ if holders
988
+ else (None, (), None)
989
+ )
990
+ if holder and scan.identity and os.getuid() not in scan.identity.uids:
991
+ inspected = _inspect_runtime_process(
992
+ scan.path, (worktree.resolve(strict=True),), budget, inspect_foreign=True
993
+ )
994
+ if not _same_process(scan.identity, inspected.identity):
995
+ lineage_issue = "LINEAGE_IDENTITY_CHANGED"
996
+ scan = inspected
997
+ row = {
998
+ "pid": int(scan.path.name),
999
+ "start_ticks": scan.identity.start_ticks if scan.identity else None,
1000
+ "issues": [asdict(issue) for issue in scan.issues],
1001
+ }
1002
+ if holder:
1003
+ row["holder"] = asdict(holder)
1004
+ row["lineage"] = [
1005
+ {
1006
+ "pid": int(member.path.name),
1007
+ "start_ticks": member.identity.start_ticks if member.identity else None,
1008
+ }
1009
+ for member in chain
1010
+ ]
1011
+ if scan.issues or lineage_issue:
1012
+ result["managed_visibility_uncertainties"].append(
1013
+ {
1014
+ **row,
1015
+ "classification": "MANAGED_VISIBILITY_UNCERTAIN",
1016
+ "lineage_issue": lineage_issue,
1017
+ }
1018
+ )
1019
+ else:
1020
+ result["managed_references"].append({**row, "classification": "MANAGED_REFERENCE"})
1021
+ elif lineage_issue or any(
1022
+ issue.errno not in {errno.EACCES, errno.EPERM} for issue in scan.issues
1023
+ ):
1024
+ result["scan_uncertainties"].append(
1025
+ {**row, "classification": "SCAN_UNCERTAIN", "lineage_issue": lineage_issue}
1026
+ )
1027
+ elif scan.issues:
1028
+ result["host_visibility_gaps"].append(
1029
+ {
1030
+ **row,
1031
+ "classification": "HOST_VISIBILITY_GAP",
1032
+ "reason": "no formal allocation holder or verified holder lineage association; absence of references is not proven",
1033
+ }
1034
+ )
1035
+ result["observed_references"].extend(
1036
+ {**reference, "classification": "OBSERVED_REFERENCE"} for reference in scan.references
1037
+ )
1038
+ return result