sourcebound 1.2.1__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 (71) hide show
  1. clean_docs/__init__.py +26 -0
  2. clean_docs/__main__.py +3 -0
  3. clean_docs/accessibility.py +182 -0
  4. clean_docs/adapters/__init__.py +1 -0
  5. clean_docs/adapters/event_capture.py +56 -0
  6. clean_docs/adapters/mdx_dependencies.json +714 -0
  7. clean_docs/adapters/mdx_parser.mjs +29992 -0
  8. clean_docs/applicability.py +330 -0
  9. clean_docs/audit.py +1120 -0
  10. clean_docs/bootstrap.py +507 -0
  11. clean_docs/capabilities.py +200 -0
  12. clean_docs/changed.py +452 -0
  13. clean_docs/claims.py +840 -0
  14. clean_docs/cli.py +1612 -0
  15. clean_docs/context.py +307 -0
  16. clean_docs/corpus.py +377 -0
  17. clean_docs/demo.py +369 -0
  18. clean_docs/doctor.py +184 -0
  19. clean_docs/emit/__init__.py +4 -0
  20. clean_docs/emit/llms_txt.py +102 -0
  21. clean_docs/emit/stepwise.py +168 -0
  22. clean_docs/engine.py +324 -0
  23. clean_docs/errors.py +20 -0
  24. clean_docs/evaluation.py +867 -0
  25. clean_docs/execution.py +138 -0
  26. clean_docs/explain.py +123 -0
  27. clean_docs/extractors/__init__.py +19 -0
  28. clean_docs/extractors/command.py +51 -0
  29. clean_docs/extractors/inventory.py +176 -0
  30. clean_docs/extractors/json_pointer.py +84 -0
  31. clean_docs/extractors/python_literal.py +104 -0
  32. clean_docs/extractors/static.py +111 -0
  33. clean_docs/feedback.py +1390 -0
  34. clean_docs/impact.py +1624 -0
  35. clean_docs/improvements.py +1178 -0
  36. clean_docs/inventory.py +474 -0
  37. clean_docs/isolation.py +157 -0
  38. clean_docs/manifest.py +898 -0
  39. clean_docs/mdx.py +272 -0
  40. clean_docs/migration.py +121 -0
  41. clean_docs/models.py +194 -0
  42. clean_docs/outcomes.py +296 -0
  43. clean_docs/performance.py +123 -0
  44. clean_docs/phrasing.py +448 -0
  45. clean_docs/plugins.py +249 -0
  46. clean_docs/policy.py +536 -0
  47. clean_docs/projections.py +255 -0
  48. clean_docs/regions.py +75 -0
  49. clean_docs/release.py +232 -0
  50. clean_docs/renderers.py +57 -0
  51. clean_docs/residue.py +311 -0
  52. clean_docs/review_contracts.py +862 -0
  53. clean_docs/review_ledger.py +297 -0
  54. clean_docs/review_limits.py +9 -0
  55. clean_docs/sensitivity.py +602 -0
  56. clean_docs/snapshot.py +212 -0
  57. clean_docs/standard.py +281 -0
  58. clean_docs/standards/default.json +309 -0
  59. clean_docs/standards/exemplars.md +87 -0
  60. clean_docs/standards/v0-migrations.json +26 -0
  61. clean_docs/symbols.py +47 -0
  62. clean_docs/templates.py +96 -0
  63. clean_docs/verdict.py +1397 -0
  64. clean_docs/visuals.py +346 -0
  65. clean_docs/write_gate.py +170 -0
  66. sourcebound-1.2.1.dist-info/LICENSE +21 -0
  67. sourcebound-1.2.1.dist-info/METADATA +109 -0
  68. sourcebound-1.2.1.dist-info/RECORD +71 -0
  69. sourcebound-1.2.1.dist-info/WHEEL +5 -0
  70. sourcebound-1.2.1.dist-info/entry_points.txt +2 -0
  71. sourcebound-1.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1178 @@
1
+ """Compile review observations into authority-bounded improvement candidates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import subprocess
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from clean_docs.errors import ConfigurationError, ExtractionError, PolicyError
14
+ from clean_docs.regions import atomic_write
15
+ from clean_docs.snapshot import RepositorySnapshot
16
+
17
+
18
+ OBSERVATIONS_SCHEMA = "sourcebound.review-observations.v1"
19
+ CANDIDATES_SCHEMA = "sourcebound.improvement-candidates.v1"
20
+ LIFECYCLE_SCHEMA_V1 = "sourcebound.improvement-candidate-lifecycle.v1"
21
+ LIFECYCLE_SCHEMA = "sourcebound.improvement-candidate-lifecycle.v2"
22
+ LIFECYCLE_PROVIDER_SCHEMA = "sourcebound.lifecycle-evidence-providers.v1"
23
+ LIFECYCLE_PROVIDER_PATH = Path(".sourcebound/lifecycle-evidence-providers.json")
24
+ LIFECYCLE_TEST_RECEIPT_SCHEMA = "sourcebound.lifecycle-test-receipt.v1"
25
+ TEST_KINDS = {
26
+ "command",
27
+ "fixture",
28
+ "integration",
29
+ "reader-task",
30
+ "release",
31
+ "static-analysis",
32
+ }
33
+ EVIDENCE_KINDS = {"external", "repository", "receipt"}
34
+ LIFECYCLE_EVIDENCE_KINDS = {"commit", "decision", "issue", "test-receipt"}
35
+ LIFECYCLE_STATES = {"proposed", "reproduced", "implemented", "verified", "declined"}
36
+ LIFECYCLE_TRANSITIONS = {
37
+ "proposed": {"reproduced", "declined"},
38
+ "reproduced": {"implemented", "declined"},
39
+ "implemented": {"verified", "declined"},
40
+ "verified": set(),
41
+ "declined": set(),
42
+ }
43
+ LIFECYCLE_EVIDENCE_BY_STATE = {
44
+ "reproduced": {"test-receipt"},
45
+ "implemented": {"commit", "issue"},
46
+ "verified": {"test-receipt"},
47
+ "declined": {"decision", "issue"},
48
+ }
49
+ SHA1 = re.compile(r"^[0-9a-f]{40}$")
50
+ SHA256 = re.compile(r"^[0-9a-f]{64}$")
51
+ IDENTIFIER = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class CandidateTest:
56
+ kind: str
57
+ setup: str
58
+ action: str
59
+ passes_when: str
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class CandidateTrack:
64
+ target: str
65
+ proposed_change: str
66
+ test: CandidateTest
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class ImprovementCandidate:
71
+ id: str
72
+ observation_id: str
73
+ summary: str
74
+ evidence: tuple[dict[str, Any], ...]
75
+ tracks: tuple[CandidateTrack, ...]
76
+ state: str = "proposed"
77
+ authority: str = "assessment"
78
+ gate_authority: bool = False
79
+ change_authority: bool = False
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class ImprovementCandidateSet:
84
+ review_id: str
85
+ repository_commit: str
86
+ source_urls: tuple[str, ...]
87
+ source_sha256: str
88
+ candidates: tuple[ImprovementCandidate, ...]
89
+ digest: str
90
+
91
+ def as_dict(self) -> dict[str, object]:
92
+ return {
93
+ "schema": CANDIDATES_SCHEMA,
94
+ "review": {
95
+ "id": self.review_id,
96
+ "repository_commit": self.repository_commit,
97
+ "source_urls": list(self.source_urls),
98
+ "source_sha256": self.source_sha256,
99
+ },
100
+ "authority": {
101
+ "state": "assessment",
102
+ "gate_authority": False,
103
+ "change_authority": False,
104
+ "next_step": (
105
+ "Reproduce the observation and implement its proposed test before "
106
+ "requesting an ordinary verified change."
107
+ ),
108
+ },
109
+ "candidates": [
110
+ {
111
+ **asdict(candidate),
112
+ "evidence": list(candidate.evidence),
113
+ "tracks": [
114
+ {
115
+ "target": track.target,
116
+ "proposed_change": track.proposed_change,
117
+ "test": asdict(track.test),
118
+ }
119
+ for track in candidate.tracks
120
+ ],
121
+ }
122
+ for candidate in self.candidates
123
+ ],
124
+ "digest": self.digest,
125
+ }
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class LifecycleEvidence:
130
+ kind: str
131
+ reference: str
132
+ detail: str
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class LifecycleResolution:
137
+ state: str
138
+ reason: str
139
+ evidence: dict[str, object]
140
+
141
+ def as_dict(self) -> dict[str, object]:
142
+ return {
143
+ "state": self.state,
144
+ "reason": self.reason,
145
+ "evidence": self.evidence,
146
+ }
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class LifecycleEvent:
151
+ from_state: str
152
+ to_state: str
153
+ evidence: LifecycleEvidence
154
+ resolution: LifecycleResolution | None
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class CandidateLifecycle:
159
+ observation_id: str
160
+ candidate_id: str
161
+ state: str
162
+ history: tuple[LifecycleEvent, ...]
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class CandidateLifecycleSet:
167
+ schema: str
168
+ review_id: str
169
+ repository_commit: str
170
+ candidate_digest: str
171
+ candidates: tuple[CandidateLifecycle, ...]
172
+ digest: str
173
+
174
+ def as_dict(self) -> dict[str, object]:
175
+ payload = _lifecycle_payload(
176
+ self.schema,
177
+ self.review_id,
178
+ self.repository_commit,
179
+ self.candidate_digest,
180
+ self.candidates,
181
+ )
182
+ return {"schema": self.schema, **payload, "digest": self.digest}
183
+
184
+
185
+ def _mapping(value: Any, where: str) -> dict[str, Any]:
186
+ if not isinstance(value, dict):
187
+ raise ConfigurationError(f"{where} must be an object")
188
+ return value
189
+
190
+
191
+ def _exact_keys(value: dict[str, Any], expected: set[str], where: str) -> None:
192
+ if set(value) != expected:
193
+ raise ConfigurationError(
194
+ f"{where} must contain exactly: {', '.join(sorted(expected))}"
195
+ )
196
+
197
+
198
+ def _string(value: Any, where: str) -> str:
199
+ if not isinstance(value, str) or not value.strip():
200
+ raise ConfigurationError(f"{where} must be a non-empty string")
201
+ return value.strip()
202
+
203
+
204
+ def _identifier(value: Any, where: str) -> str:
205
+ identifier = _string(value, where)
206
+ if not IDENTIFIER.fullmatch(identifier):
207
+ raise ConfigurationError(f"{where} must be a lowercase kebab-case identifier")
208
+ return identifier
209
+
210
+
211
+ def _canonical_bytes(value: object) -> bytes:
212
+ return json.dumps(
213
+ value,
214
+ sort_keys=True,
215
+ separators=(",", ":"),
216
+ ensure_ascii=False,
217
+ ).encode("utf-8")
218
+
219
+
220
+ def _digest(value: object) -> str:
221
+ return hashlib.sha256(_canonical_bytes(value)).hexdigest()
222
+
223
+
224
+ def _test(value: Any, where: str) -> CandidateTest:
225
+ raw = _mapping(value, where)
226
+ _exact_keys(raw, {"kind", "setup", "action", "passes_when"}, where)
227
+ kind = _string(raw["kind"], f"{where}.kind")
228
+ if kind not in TEST_KINDS:
229
+ raise ConfigurationError(
230
+ f"{where}.kind must be one of: {', '.join(sorted(TEST_KINDS))}"
231
+ )
232
+ return CandidateTest(
233
+ kind=kind,
234
+ setup=_string(raw["setup"], f"{where}.setup"),
235
+ action=_string(raw["action"], f"{where}.action"),
236
+ passes_when=_string(raw["passes_when"], f"{where}.passes_when"),
237
+ )
238
+
239
+
240
+ def _track(value: Any, target: str, where: str) -> CandidateTrack:
241
+ raw = _mapping(value, where)
242
+ _exact_keys(raw, {"proposed_change", "test"}, where)
243
+ return CandidateTrack(
244
+ target=target,
245
+ proposed_change=_string(raw["proposed_change"], f"{where}.proposed_change"),
246
+ test=_test(raw["test"], f"{where}.test"),
247
+ )
248
+
249
+
250
+ def _receipt_evidence(
251
+ value: Any,
252
+ *,
253
+ where: str,
254
+ source: str,
255
+ repository_commit: str,
256
+ root: Path | None,
257
+ ) -> dict[str, object]:
258
+ raw = _mapping(value, where)
259
+ _exact_keys(
260
+ raw,
261
+ {"sha256", "producer_version", "repository_commit", "command"},
262
+ where,
263
+ )
264
+ digest = _string(raw["sha256"], f"{where}.sha256")
265
+ if not SHA256.fullmatch(digest):
266
+ raise ConfigurationError(f"{where}.sha256 must be a lowercase SHA-256")
267
+ receipt_commit = _string(raw["repository_commit"], f"{where}.repository_commit")
268
+ if receipt_commit != repository_commit:
269
+ raise ConfigurationError(f"{where}.repository_commit must match the review commit")
270
+ command = raw["command"]
271
+ if not isinstance(command, list) or not command or not all(
272
+ isinstance(item, str) and item for item in command
273
+ ):
274
+ raise ConfigurationError(f"{where}.command must be a non-empty string list")
275
+ receipt: dict[str, object] = {
276
+ "sha256": digest,
277
+ "producer_version": _string(raw["producer_version"], f"{where}.producer_version"),
278
+ "repository_commit": receipt_commit,
279
+ "command": list(command),
280
+ "state": "unknown",
281
+ }
282
+ if root is None:
283
+ return receipt
284
+ receipt_path = root / source
285
+ try:
286
+ receipt_path.resolve().relative_to(root.resolve())
287
+ observed = receipt_path.read_bytes()
288
+ except (OSError, ValueError):
289
+ return receipt
290
+ if hashlib.sha256(observed).hexdigest() != digest:
291
+ raise ConfigurationError(f"{where}.sha256 does not match receipt bytes")
292
+ receipt["state"] = "grounded"
293
+ return receipt
294
+
295
+
296
+ def _evidence(
297
+ value: Any,
298
+ where: str,
299
+ *,
300
+ repository_commit: str,
301
+ root: Path | None,
302
+ ) -> tuple[dict[str, object], ...]:
303
+ if not isinstance(value, list) or not value:
304
+ raise ConfigurationError(f"{where} must be a non-empty list")
305
+ normalized = []
306
+ for index, item in enumerate(value):
307
+ item_where = f"{where}[{index}]"
308
+ raw = _mapping(item, item_where)
309
+ _exact_keys(
310
+ raw,
311
+ ({"kind", "source", "locator", "detail", "receipt"}
312
+ if raw.get("kind") == "receipt"
313
+ else {"kind", "source", "locator", "detail"}),
314
+ item_where,
315
+ )
316
+ kind = _string(raw["kind"], f"{item_where}.kind")
317
+ if kind not in EVIDENCE_KINDS:
318
+ raise ConfigurationError(
319
+ f"{item_where}.kind must be one of: "
320
+ f"{', '.join(sorted(EVIDENCE_KINDS))}"
321
+ )
322
+ evidence: dict[str, object] = {
323
+ "kind": kind,
324
+ "source": _string(raw["source"], f"{item_where}.source"),
325
+ "locator": _string(raw["locator"], f"{item_where}.locator"),
326
+ "detail": _string(raw["detail"], f"{item_where}.detail"),
327
+ }
328
+ if kind == "receipt":
329
+ evidence["receipt"] = _receipt_evidence(
330
+ raw["receipt"],
331
+ where=f"{item_where}.receipt",
332
+ source=str(evidence["source"]),
333
+ repository_commit=repository_commit,
334
+ root=root,
335
+ )
336
+ normalized.append(evidence)
337
+ return tuple(normalized)
338
+
339
+
340
+ def _candidate_identity_evidence(
341
+ evidence: tuple[dict[str, object], ...],
342
+ ) -> list[dict[str, object]]:
343
+ """Keep availability observations out of a candidate's stable identity."""
344
+ identity = []
345
+ for item in evidence:
346
+ normalized = dict(item)
347
+ normalized.pop("grounding", None)
348
+ receipt = normalized.get("receipt")
349
+ if isinstance(receipt, dict):
350
+ normalized["receipt"] = {
351
+ key: value for key, value in receipt.items() if key != "state"
352
+ }
353
+ identity.append(normalized)
354
+ return identity
355
+
356
+
357
+ def compile_improvement_candidates(
358
+ payload: dict[str, Any],
359
+ *,
360
+ source_sha256: str | None = None,
361
+ root: Path | None = None,
362
+ ) -> ImprovementCandidateSet:
363
+ """Validate one review and compile its observations into stable candidates."""
364
+ _exact_keys(
365
+ payload,
366
+ {"schema", "review_id", "repository_commit", "source_urls", "observations"},
367
+ "review observations",
368
+ )
369
+ if payload["schema"] != OBSERVATIONS_SCHEMA:
370
+ raise ConfigurationError(
371
+ f"review observations schema must be {OBSERVATIONS_SCHEMA}"
372
+ )
373
+ review_id = _identifier(payload["review_id"], "review observations.review_id")
374
+ repository_commit = _string(
375
+ payload["repository_commit"],
376
+ "review observations.repository_commit",
377
+ )
378
+ if not SHA1.fullmatch(repository_commit):
379
+ raise ConfigurationError(
380
+ "review observations.repository_commit must be a full lowercase SHA-1"
381
+ )
382
+ source_urls_raw = payload["source_urls"]
383
+ if not isinstance(source_urls_raw, list) or not source_urls_raw:
384
+ raise ConfigurationError(
385
+ "review observations.source_urls must be a non-empty list"
386
+ )
387
+ source_urls = tuple(
388
+ _string(value, f"review observations.source_urls[{index}]")
389
+ for index, value in enumerate(source_urls_raw)
390
+ )
391
+ if len(set(source_urls)) != len(source_urls):
392
+ raise ConfigurationError("review observations.source_urls must be unique")
393
+ observations = payload["observations"]
394
+ if not isinstance(observations, list) or not observations:
395
+ raise ConfigurationError(
396
+ "review observations.observations must be a non-empty list"
397
+ )
398
+
399
+ compiled = []
400
+ seen: set[str] = set()
401
+ for index, value in enumerate(observations):
402
+ where = f"review observations.observations[{index}]"
403
+ raw = _mapping(value, where)
404
+ _exact_keys(
405
+ raw,
406
+ {"id", "summary", "evidence", "documentation", "product"},
407
+ where,
408
+ )
409
+ observation_id = _identifier(raw["id"], f"{where}.id")
410
+ if observation_id in seen:
411
+ raise ConfigurationError(
412
+ f"duplicate review observation id: {observation_id}"
413
+ )
414
+ seen.add(observation_id)
415
+ summary = _string(raw["summary"], f"{where}.summary")
416
+ evidence = _evidence(
417
+ raw["evidence"],
418
+ f"{where}.evidence",
419
+ repository_commit=repository_commit,
420
+ root=root,
421
+ )
422
+ tracks = (
423
+ _track(raw["documentation"], "documentation", f"{where}.documentation"),
424
+ _track(raw["product"], "product", f"{where}.product"),
425
+ )
426
+ candidate_identity = {
427
+ "review_id": review_id,
428
+ "observation_id": observation_id,
429
+ "summary": summary,
430
+ "evidence": _candidate_identity_evidence(evidence),
431
+ "tracks": [
432
+ {
433
+ "target": track.target,
434
+ "proposed_change": track.proposed_change,
435
+ "test": asdict(track.test),
436
+ }
437
+ for track in tracks
438
+ ],
439
+ }
440
+ compiled.append(
441
+ ImprovementCandidate(
442
+ id=_digest(candidate_identity),
443
+ observation_id=observation_id,
444
+ summary=summary,
445
+ evidence=evidence,
446
+ tracks=tracks,
447
+ )
448
+ )
449
+
450
+ compiled.sort(key=lambda item: item.observation_id)
451
+ source_digest = source_sha256 or _digest(payload)
452
+ unsigned = {
453
+ "review": {
454
+ "id": review_id,
455
+ "repository_commit": repository_commit,
456
+ "source_urls": list(source_urls),
457
+ "source_sha256": source_digest,
458
+ },
459
+ "candidates": [
460
+ {
461
+ **asdict(candidate),
462
+ "evidence": list(candidate.evidence),
463
+ "tracks": [
464
+ {
465
+ "target": track.target,
466
+ "proposed_change": track.proposed_change,
467
+ "test": asdict(track.test),
468
+ }
469
+ for track in candidate.tracks
470
+ ],
471
+ }
472
+ for candidate in compiled
473
+ ],
474
+ }
475
+ return ImprovementCandidateSet(
476
+ review_id=review_id,
477
+ repository_commit=repository_commit,
478
+ source_urls=source_urls,
479
+ source_sha256=source_digest,
480
+ candidates=tuple(compiled),
481
+ digest=_digest(unsigned),
482
+ )
483
+
484
+
485
+ def ground_review_candidates(
486
+ root: Path,
487
+ candidates: ImprovementCandidateSet,
488
+ ) -> ImprovementCandidateSet:
489
+ """Resolve repository evidence at the review's pinned commit.
490
+
491
+ Unknown evidence remains assessment-only. It never becomes a false grounded claim.
492
+ """
493
+ snapshot = RepositorySnapshot(root, candidates.repository_commit)
494
+ try:
495
+ resolved_commit = snapshot.label
496
+ except ExtractionError as exc:
497
+ resolved_commit = None
498
+ unavailable_detail = str(exc)
499
+ grounded_candidates = []
500
+ for candidate in candidates.candidates:
501
+ evidence = []
502
+ for item in candidate.evidence:
503
+ grounded_item = dict(item)
504
+ if item["kind"] == "repository":
505
+ if resolved_commit is None:
506
+ grounding = {
507
+ "state": "unknown",
508
+ "detail": f"pinned commit is unavailable: {unavailable_detail}",
509
+ }
510
+ else:
511
+ try:
512
+ source = Path(str(item["source"]))
513
+ text = snapshot.read_text(source)
514
+ except ExtractionError as exc:
515
+ grounding = {
516
+ "state": "unknown",
517
+ "commit": resolved_commit,
518
+ "detail": str(exc),
519
+ }
520
+ else:
521
+ locator = str(item["locator"])
522
+ if locator not in text:
523
+ grounding = {
524
+ "state": "unknown",
525
+ "commit": resolved_commit,
526
+ "content_sha256": hashlib.sha256(
527
+ text.encode("utf-8")
528
+ ).hexdigest(),
529
+ "detail": "locator is absent from the pinned source bytes",
530
+ }
531
+ else:
532
+ grounding = {
533
+ "state": "grounded",
534
+ "commit": resolved_commit,
535
+ "content_sha256": hashlib.sha256(
536
+ text.encode("utf-8")
537
+ ).hexdigest(),
538
+ }
539
+ else:
540
+ grounding = {
541
+ "state": "unverified",
542
+ "detail": (
543
+ "external evidence requires an immutable retrieval receipt"
544
+ if item["kind"] == "external"
545
+ else "receipt evidence requires immutable bytes and execution context"
546
+ ),
547
+ }
548
+ grounded_item["grounding"] = grounding
549
+ evidence.append(grounded_item)
550
+ grounded_candidates.append(
551
+ ImprovementCandidate(
552
+ id=candidate.id,
553
+ observation_id=candidate.observation_id,
554
+ summary=candidate.summary,
555
+ evidence=tuple(evidence),
556
+ tracks=candidate.tracks,
557
+ )
558
+ )
559
+ grounded_candidates_tuple = tuple(grounded_candidates)
560
+ unsigned = {
561
+ "review": {
562
+ "id": candidates.review_id,
563
+ "repository_commit": candidates.repository_commit,
564
+ "source_urls": list(candidates.source_urls),
565
+ "source_sha256": candidates.source_sha256,
566
+ },
567
+ "candidates": [
568
+ {
569
+ **asdict(candidate),
570
+ "evidence": list(candidate.evidence),
571
+ "tracks": [
572
+ {
573
+ "target": track.target,
574
+ "proposed_change": track.proposed_change,
575
+ "test": asdict(track.test),
576
+ }
577
+ for track in candidate.tracks
578
+ ],
579
+ }
580
+ for candidate in grounded_candidates_tuple
581
+ ],
582
+ }
583
+ return ImprovementCandidateSet(
584
+ review_id=candidates.review_id,
585
+ repository_commit=candidates.repository_commit,
586
+ source_urls=candidates.source_urls,
587
+ source_sha256=candidates.source_sha256,
588
+ candidates=grounded_candidates_tuple,
589
+ digest=_digest(unsigned),
590
+ )
591
+
592
+ def load_review_candidates(
593
+ path: Path,
594
+ *,
595
+ root: Path | None = None,
596
+ ) -> ImprovementCandidateSet:
597
+ """Load a review-observation file and compile its candidates."""
598
+ try:
599
+ source = path.read_bytes()
600
+ payload = json.loads(source)
601
+ except (OSError, json.JSONDecodeError) as exc:
602
+ raise ConfigurationError(f"cannot read review observations {path}: {exc}") from exc
603
+ if not isinstance(payload, dict):
604
+ raise ConfigurationError("review observations must be an object")
605
+ candidates = compile_improvement_candidates(
606
+ payload,
607
+ source_sha256=hashlib.sha256(source).hexdigest(),
608
+ root=root,
609
+ )
610
+ return ground_review_candidates(root, candidates) if root is not None else candidates
611
+
612
+
613
+ def write_improvement_candidates(
614
+ candidates: ImprovementCandidateSet,
615
+ output: Path,
616
+ ) -> Path:
617
+ """Write one deterministic candidate set."""
618
+ atomic_write(
619
+ output,
620
+ json.dumps(candidates.as_dict(), indent=2, ensure_ascii=False) + "\n",
621
+ )
622
+ return output
623
+
624
+
625
+ def _lifecycle_authority() -> dict[str, object]:
626
+ return {
627
+ "state": "assessment",
628
+ "gate_authority": False,
629
+ "change_authority": False,
630
+ "next_step": (
631
+ "Use linked evidence to record the candidate lifecycle; ordinary repository "
632
+ "gates still decide whether a change is accepted."
633
+ ),
634
+ }
635
+
636
+
637
+ def _lifecycle_payload(
638
+ schema: str,
639
+ review_id: str,
640
+ repository_commit: str,
641
+ candidate_digest: str,
642
+ candidates: tuple[CandidateLifecycle, ...],
643
+ ) -> dict[str, object]:
644
+ if schema not in {LIFECYCLE_SCHEMA_V1, LIFECYCLE_SCHEMA}:
645
+ raise ConfigurationError(f"unsupported lifecycle schema: {schema}")
646
+ candidate_set: dict[str, object] = {
647
+ "review_id": review_id,
648
+ "digest": candidate_digest,
649
+ }
650
+ if schema == LIFECYCLE_SCHEMA:
651
+ candidate_set["repository_commit"] = repository_commit
652
+ return {
653
+ "candidate_set": candidate_set,
654
+ "authority": _lifecycle_authority(),
655
+ "candidates": [
656
+ {
657
+ "observation_id": candidate.observation_id,
658
+ "candidate_id": candidate.candidate_id,
659
+ "state": candidate.state,
660
+ "history": [
661
+ {
662
+ "from": event.from_state,
663
+ "to": event.to_state,
664
+ "evidence": asdict(event.evidence),
665
+ **(
666
+ {"resolution": event.resolution.as_dict()}
667
+ if schema == LIFECYCLE_SCHEMA and event.resolution is not None
668
+ else {}
669
+ ),
670
+ }
671
+ for event in candidate.history
672
+ ],
673
+ }
674
+ for candidate in candidates
675
+ ],
676
+ }
677
+
678
+
679
+ def _lifecycle_digest(
680
+ schema: str,
681
+ review_id: str,
682
+ repository_commit: str,
683
+ candidate_digest: str,
684
+ candidates: tuple[CandidateLifecycle, ...],
685
+ ) -> str:
686
+ return _digest(
687
+ _lifecycle_payload(
688
+ schema,
689
+ review_id,
690
+ repository_commit,
691
+ candidate_digest,
692
+ candidates,
693
+ )
694
+ )
695
+
696
+
697
+ def _lifecycle_evidence(value: Any, where: str) -> LifecycleEvidence:
698
+ raw = _mapping(value, where)
699
+ _exact_keys(raw, {"kind", "reference", "detail"}, where)
700
+ kind = _string(raw["kind"], f"{where}.kind")
701
+ if kind not in LIFECYCLE_EVIDENCE_KINDS:
702
+ raise ConfigurationError(
703
+ f"{where}.kind must be one of: "
704
+ f"{', '.join(sorted(LIFECYCLE_EVIDENCE_KINDS))}"
705
+ )
706
+ return LifecycleEvidence(
707
+ kind=kind,
708
+ reference=_string(raw["reference"], f"{where}.reference"),
709
+ detail=_string(raw["detail"], f"{where}.detail"),
710
+ )
711
+
712
+
713
+ def _lifecycle_resolution(value: Any, where: str) -> LifecycleResolution:
714
+ raw = _mapping(value, where)
715
+ _exact_keys(raw, {"state", "reason", "evidence"}, where)
716
+ state = _string(raw["state"], f"{where}.state")
717
+ if state not in {"grounded", "unknown"}:
718
+ raise ConfigurationError(f"{where}.state must be grounded or unknown")
719
+ evidence = _mapping(raw["evidence"], f"{where}.evidence")
720
+ return LifecycleResolution(
721
+ state=state,
722
+ reason=_string(raw["reason"], f"{where}.reason"),
723
+ evidence=dict(evidence),
724
+ )
725
+
726
+
727
+ def _lifecycle_event(value: Any, where: str, *, schema: str) -> LifecycleEvent:
728
+ raw = _mapping(value, where)
729
+ expected = {"from", "to", "evidence"}
730
+ if schema == LIFECYCLE_SCHEMA:
731
+ expected.add("resolution")
732
+ _exact_keys(raw, expected, where)
733
+ from_state = _string(raw["from"], f"{where}.from")
734
+ to_state = _string(raw["to"], f"{where}.to")
735
+ if from_state not in LIFECYCLE_STATES or to_state not in LIFECYCLE_STATES:
736
+ raise ConfigurationError(f"{where} has an invalid lifecycle state")
737
+ if to_state not in LIFECYCLE_TRANSITIONS[from_state]:
738
+ raise ConfigurationError(
739
+ f"{where} cannot transition from {from_state} to {to_state}"
740
+ )
741
+ evidence = _lifecycle_evidence(raw["evidence"], f"{where}.evidence")
742
+ if evidence.kind not in LIFECYCLE_EVIDENCE_BY_STATE[to_state]:
743
+ raise ConfigurationError(
744
+ f"{where}.evidence.kind must support transition to {to_state}"
745
+ )
746
+ resolution = (
747
+ _lifecycle_resolution(raw["resolution"], f"{where}.resolution")
748
+ if schema == LIFECYCLE_SCHEMA
749
+ else None
750
+ )
751
+ return LifecycleEvent(from_state, to_state, evidence, resolution)
752
+
753
+
754
+ def _unknown_resolution(reason: str, **evidence: object) -> LifecycleResolution:
755
+ return LifecycleResolution("unknown", reason, dict(evidence))
756
+
757
+
758
+ def _contained_path(root: Path, reference: str) -> Path | None:
759
+ # Evidence paths are repository-relative POSIX references, independent of the host OS.
760
+ if "\\" in reference:
761
+ return None
762
+ candidate = Path(reference)
763
+ if candidate.is_absolute() or ".." in candidate.parts:
764
+ return None
765
+ try:
766
+ resolved = (root / candidate).resolve()
767
+ resolved.relative_to(root.resolve())
768
+ except ValueError:
769
+ return None
770
+ return resolved
771
+
772
+
773
+ def _git_succeeds(root: Path, *args: str) -> bool:
774
+ try:
775
+ proc = subprocess.run(
776
+ ["git", "-C", str(root), *args],
777
+ text=True,
778
+ capture_output=True,
779
+ timeout=10,
780
+ check=False,
781
+ )
782
+ except (OSError, subprocess.TimeoutExpired):
783
+ return False
784
+ return proc.returncode == 0
785
+
786
+
787
+ def _provider_config(root: Path) -> tuple[dict[str, object], str] | None:
788
+ path = root / LIFECYCLE_PROVIDER_PATH
789
+ try:
790
+ raw_bytes = path.read_bytes()
791
+ except FileNotFoundError:
792
+ return None
793
+ except OSError:
794
+ return None
795
+ try:
796
+ raw = json.loads(raw_bytes)
797
+ except json.JSONDecodeError as exc:
798
+ raise ConfigurationError(f"cannot parse lifecycle provider config {path}: {exc}") from exc
799
+ config = _mapping(raw, "lifecycle evidence providers")
800
+ _exact_keys(config, {"schema", "providers"}, "lifecycle evidence providers")
801
+ if config["schema"] != LIFECYCLE_PROVIDER_SCHEMA:
802
+ raise ConfigurationError(
803
+ f"lifecycle evidence providers schema must be {LIFECYCLE_PROVIDER_SCHEMA}"
804
+ )
805
+ providers = _mapping(config["providers"], "lifecycle evidence providers.providers")
806
+ return providers, hashlib.sha256(raw_bytes).hexdigest()
807
+
808
+
809
+ def _resolve_provider_evidence(
810
+ evidence: LifecycleEvidence,
811
+ *,
812
+ root: Path,
813
+ ) -> LifecycleResolution:
814
+ configured = _provider_config(root)
815
+ if configured is None:
816
+ return _unknown_resolution("provider-unconfigured", kind=evidence.kind)
817
+ providers, config_digest = configured
818
+ provider = providers.get(evidence.kind)
819
+ if provider is None:
820
+ return _unknown_resolution("provider-unconfigured", kind=evidence.kind)
821
+ raw_provider = _mapping(provider, f"lifecycle evidence provider {evidence.kind}")
822
+ _exact_keys(raw_provider, {"kind", "root"}, f"lifecycle evidence provider {evidence.kind}")
823
+ if raw_provider["kind"] != "local-file":
824
+ return _unknown_resolution("provider-unsupported", kind=evidence.kind)
825
+ provider_root = _contained_path(root, _string(raw_provider["root"], "lifecycle provider root"))
826
+ reference = _contained_path(provider_root, evidence.reference) if provider_root else None
827
+ if reference is None:
828
+ return _unknown_resolution("provider-reference-invalid", kind=evidence.kind)
829
+ try:
830
+ content = reference.read_bytes()
831
+ except OSError:
832
+ return _unknown_resolution("provider-reference-unavailable", kind=evidence.kind)
833
+ return LifecycleResolution(
834
+ "grounded",
835
+ "resolved",
836
+ {
837
+ "provider_config_sha256": config_digest,
838
+ "sha256": hashlib.sha256(content).hexdigest(),
839
+ },
840
+ )
841
+
842
+
843
+ def _resolve_lifecycle_evidence(
844
+ evidence: LifecycleEvidence,
845
+ *,
846
+ root: Path,
847
+ repository_commit: str,
848
+ ) -> LifecycleResolution:
849
+ if not _git_succeeds(root, "cat-file", "-e", f"{repository_commit}^{{commit}}"):
850
+ return _unknown_resolution("review-commit-unavailable", commit=repository_commit)
851
+ if evidence.kind == "commit":
852
+ if not SHA1.fullmatch(evidence.reference):
853
+ return _unknown_resolution("commit-reference-invalid")
854
+ if not _git_succeeds(root, "cat-file", "-e", f"{evidence.reference}^{{commit}}"):
855
+ return _unknown_resolution("commit-unavailable", commit=evidence.reference)
856
+ if not _git_succeeds(
857
+ root,
858
+ "merge-base",
859
+ "--is-ancestor",
860
+ repository_commit,
861
+ evidence.reference,
862
+ ):
863
+ return _unknown_resolution("commit-wrong-repository", commit=evidence.reference)
864
+ return LifecycleResolution(
865
+ "grounded",
866
+ "resolved",
867
+ {"commit": evidence.reference, "review_commit": repository_commit},
868
+ )
869
+ if evidence.kind == "test-receipt":
870
+ receipt_path = _contained_path(root, evidence.reference)
871
+ if receipt_path is None:
872
+ return _unknown_resolution("receipt-reference-invalid")
873
+ try:
874
+ receipt_bytes = receipt_path.read_bytes()
875
+ except OSError:
876
+ return _unknown_resolution("receipt-unavailable", reference=evidence.reference)
877
+ try:
878
+ receipt = _mapping(json.loads(receipt_bytes), "lifecycle test receipt")
879
+ except (ConfigurationError, json.JSONDecodeError):
880
+ return _unknown_resolution("receipt-schema-invalid")
881
+ required = {"schema", "repository_commit", "producer_version", "command", "ok"}
882
+ if not required <= set(receipt):
883
+ return _unknown_resolution("receipt-schema-invalid")
884
+ if receipt["schema"] != LIFECYCLE_TEST_RECEIPT_SCHEMA:
885
+ return _unknown_resolution("receipt-schema-invalid")
886
+ if receipt["repository_commit"] != repository_commit:
887
+ return _unknown_resolution("receipt-wrong-repository")
888
+ if not isinstance(receipt["producer_version"], str) or not receipt["producer_version"].strip():
889
+ return _unknown_resolution("receipt-schema-invalid")
890
+ command = receipt["command"]
891
+ if not isinstance(command, list) or not command or not all(
892
+ isinstance(item, str) and item for item in command
893
+ ):
894
+ return _unknown_resolution("receipt-schema-invalid")
895
+ if receipt["ok"] is not True:
896
+ return _unknown_resolution("receipt-not-passing")
897
+ return LifecycleResolution(
898
+ "grounded",
899
+ "resolved",
900
+ {
901
+ "sha256": hashlib.sha256(receipt_bytes).hexdigest(),
902
+ "schema": LIFECYCLE_TEST_RECEIPT_SCHEMA,
903
+ "repository_commit": repository_commit,
904
+ "producer_version": receipt["producer_version"].strip(),
905
+ "command": list(command),
906
+ },
907
+ )
908
+ return _resolve_provider_evidence(evidence, root=root)
909
+
910
+
911
+ def initialize_candidate_lifecycle(
912
+ candidates: ImprovementCandidateSet,
913
+ ) -> CandidateLifecycleSet:
914
+ """Start an assessment-only lifecycle for every candidate in one review."""
915
+ records = tuple(
916
+ CandidateLifecycle(
917
+ observation_id=candidate.observation_id,
918
+ candidate_id=candidate.id,
919
+ state="proposed",
920
+ history=(),
921
+ )
922
+ for candidate in candidates.candidates
923
+ )
924
+ return CandidateLifecycleSet(
925
+ schema=LIFECYCLE_SCHEMA,
926
+ review_id=candidates.review_id,
927
+ repository_commit=candidates.repository_commit,
928
+ candidate_digest=candidates.digest,
929
+ candidates=records,
930
+ digest=_lifecycle_digest(
931
+ LIFECYCLE_SCHEMA,
932
+ candidates.review_id,
933
+ candidates.repository_commit,
934
+ candidates.digest,
935
+ records,
936
+ ),
937
+ )
938
+
939
+
940
+ def load_candidate_lifecycle(
941
+ path: Path,
942
+ candidates: ImprovementCandidateSet,
943
+ ) -> CandidateLifecycleSet:
944
+ """Load a lifecycle record and prove that it still matches one candidate set."""
945
+ try:
946
+ raw = json.loads(path.read_text(encoding="utf-8"))
947
+ except (OSError, json.JSONDecodeError) as exc:
948
+ raise ConfigurationError(f"cannot read candidate lifecycle {path}: {exc}") from exc
949
+ root = _mapping(raw, "candidate lifecycle")
950
+ _exact_keys(
951
+ root,
952
+ {"schema", "candidate_set", "authority", "candidates", "digest"},
953
+ "candidate lifecycle",
954
+ )
955
+ schema = _string(root["schema"], "candidate lifecycle.schema")
956
+ if schema not in {LIFECYCLE_SCHEMA_V1, LIFECYCLE_SCHEMA}:
957
+ raise ConfigurationError(
958
+ f"candidate lifecycle schema must be {LIFECYCLE_SCHEMA_V1} or {LIFECYCLE_SCHEMA}"
959
+ )
960
+ candidate_set = _mapping(root["candidate_set"], "candidate lifecycle.candidate_set")
961
+ _exact_keys(
962
+ candidate_set,
963
+ ({"review_id", "digest", "repository_commit"}
964
+ if schema == LIFECYCLE_SCHEMA else {"review_id", "digest"}),
965
+ "candidate lifecycle.candidate_set",
966
+ )
967
+ review_id = _identifier(candidate_set["review_id"], "candidate lifecycle.candidate_set.review_id")
968
+ candidate_digest = _string(
969
+ candidate_set["digest"], "candidate lifecycle.candidate_set.digest"
970
+ )
971
+ if not re.fullmatch(r"[0-9a-f]{64}", candidate_digest):
972
+ raise ConfigurationError("candidate lifecycle.candidate_set.digest must be a SHA-256")
973
+ repository_commit = (
974
+ _string(candidate_set["repository_commit"], "candidate lifecycle.candidate_set.repository_commit")
975
+ if schema == LIFECYCLE_SCHEMA
976
+ else candidates.repository_commit
977
+ )
978
+ if repository_commit != candidates.repository_commit:
979
+ raise PolicyError("candidate lifecycle belongs to another repository commit")
980
+ authority = _mapping(root["authority"], "candidate lifecycle.authority")
981
+ if authority != _lifecycle_authority():
982
+ raise ConfigurationError("candidate lifecycle.authority must preserve assessment-only authority")
983
+ raw_records = root["candidates"]
984
+ if not isinstance(raw_records, list):
985
+ raise ConfigurationError("candidate lifecycle.candidates must be a list")
986
+ records = []
987
+ seen: set[str] = set()
988
+ for index, item in enumerate(raw_records):
989
+ where = f"candidate lifecycle.candidates[{index}]"
990
+ record = _mapping(item, where)
991
+ _exact_keys(record, {"observation_id", "candidate_id", "state", "history"}, where)
992
+ observation_id = _identifier(record["observation_id"], f"{where}.observation_id")
993
+ if observation_id in seen:
994
+ raise ConfigurationError(f"duplicate lifecycle observation id: {observation_id}")
995
+ seen.add(observation_id)
996
+ candidate_id = _string(record["candidate_id"], f"{where}.candidate_id")
997
+ if not re.fullmatch(r"[0-9a-f]{64}", candidate_id):
998
+ raise ConfigurationError(f"{where}.candidate_id must be a SHA-256")
999
+ state = _string(record["state"], f"{where}.state")
1000
+ if state not in LIFECYCLE_STATES:
1001
+ raise ConfigurationError(f"{where}.state is invalid")
1002
+ raw_history = record["history"]
1003
+ if not isinstance(raw_history, list):
1004
+ raise ConfigurationError(f"{where}.history must be a list")
1005
+ history = tuple(
1006
+ _lifecycle_event(
1007
+ event,
1008
+ f"{where}.history[{event_index}]",
1009
+ schema=schema,
1010
+ )
1011
+ for event_index, event in enumerate(raw_history)
1012
+ )
1013
+ derived = "proposed"
1014
+ for event in history:
1015
+ if event.from_state != derived:
1016
+ raise ConfigurationError(
1017
+ f"{where}.history does not continue from {derived}"
1018
+ )
1019
+ derived = event.to_state
1020
+ if state != derived:
1021
+ raise ConfigurationError(f"{where}.state contradicts its history")
1022
+ records.append(CandidateLifecycle(observation_id, candidate_id, state, history))
1023
+ records.sort(key=lambda record: record.observation_id)
1024
+ records_tuple = tuple(records)
1025
+ expected_records = tuple(
1026
+ (candidate.observation_id, candidate.id)
1027
+ for candidate in candidates.candidates
1028
+ )
1029
+ observed_records = tuple(
1030
+ (record.observation_id, record.candidate_id) for record in records_tuple
1031
+ )
1032
+ if review_id != candidates.review_id or candidate_digest != candidates.digest:
1033
+ raise PolicyError("candidate lifecycle is stale for the current review candidate set")
1034
+ if observed_records != expected_records:
1035
+ raise PolicyError("candidate lifecycle records do not match the current candidate set")
1036
+ digest = _string(root["digest"], "candidate lifecycle.digest")
1037
+ expected_digest = _lifecycle_digest(
1038
+ schema,
1039
+ review_id,
1040
+ repository_commit,
1041
+ candidate_digest,
1042
+ records_tuple,
1043
+ )
1044
+ if digest != expected_digest:
1045
+ raise ConfigurationError("candidate lifecycle.digest does not match its content")
1046
+ return CandidateLifecycleSet(
1047
+ schema,
1048
+ review_id,
1049
+ repository_commit,
1050
+ candidate_digest,
1051
+ records_tuple,
1052
+ digest,
1053
+ )
1054
+
1055
+
1056
+ def transition_candidate_lifecycle(
1057
+ lifecycle: CandidateLifecycleSet,
1058
+ *,
1059
+ root: Path | None = None,
1060
+ observation_id: str,
1061
+ to_state: str,
1062
+ evidence: LifecycleEvidence,
1063
+ ) -> CandidateLifecycleSet:
1064
+ """Apply one evidence-backed adjacent transition without granting authority."""
1065
+ observation_id = _identifier(observation_id, "lifecycle observation id")
1066
+ if to_state not in LIFECYCLE_STATES:
1067
+ raise ConfigurationError("lifecycle transition target state is invalid")
1068
+ if lifecycle.schema != LIFECYCLE_SCHEMA:
1069
+ raise ConfigurationError(
1070
+ "legacy lifecycle records cannot transition; reinitialize the state to migrate"
1071
+ )
1072
+ evidence = _lifecycle_evidence(asdict(evidence), "lifecycle transition evidence")
1073
+ records = []
1074
+ found = False
1075
+ for record in lifecycle.candidates:
1076
+ if record.observation_id != observation_id:
1077
+ records.append(record)
1078
+ continue
1079
+ found = True
1080
+ if to_state not in LIFECYCLE_TRANSITIONS[record.state]:
1081
+ raise ConfigurationError(
1082
+ f"candidate {observation_id} cannot transition from {record.state} to {to_state}"
1083
+ )
1084
+ if evidence.kind not in LIFECYCLE_EVIDENCE_BY_STATE[to_state]:
1085
+ raise ConfigurationError(
1086
+ f"evidence kind {evidence.kind} cannot support transition to {to_state}"
1087
+ )
1088
+ resolution = (
1089
+ _resolve_lifecycle_evidence(
1090
+ evidence,
1091
+ root=root,
1092
+ repository_commit=lifecycle.repository_commit,
1093
+ )
1094
+ if root is not None
1095
+ else _unknown_resolution("repository-root-unavailable")
1096
+ )
1097
+ records.append(
1098
+ CandidateLifecycle(
1099
+ observation_id=record.observation_id,
1100
+ candidate_id=record.candidate_id,
1101
+ state=to_state,
1102
+ history=(
1103
+ *record.history,
1104
+ LifecycleEvent(record.state, to_state, evidence, resolution),
1105
+ ),
1106
+ )
1107
+ )
1108
+ if not found:
1109
+ raise ConfigurationError(f"candidate observation id was not found: {observation_id}")
1110
+ records_tuple = tuple(records)
1111
+ return CandidateLifecycleSet(
1112
+ schema=lifecycle.schema,
1113
+ review_id=lifecycle.review_id,
1114
+ repository_commit=lifecycle.repository_commit,
1115
+ candidate_digest=lifecycle.candidate_digest,
1116
+ candidates=records_tuple,
1117
+ digest=_lifecycle_digest(
1118
+ lifecycle.schema,
1119
+ lifecycle.review_id,
1120
+ lifecycle.repository_commit,
1121
+ lifecycle.candidate_digest,
1122
+ records_tuple,
1123
+ ),
1124
+ )
1125
+
1126
+
1127
+ def check_candidate_lifecycle(
1128
+ lifecycle: CandidateLifecycleSet,
1129
+ *,
1130
+ root: Path,
1131
+ ) -> tuple[dict[str, object], ...]:
1132
+ """Return unresolved lifecycle evidence without granting the record gate authority."""
1133
+ unknown: list[dict[str, object]] = []
1134
+ for record in lifecycle.candidates:
1135
+ for index, event in enumerate(record.history):
1136
+ if event.resolution is None:
1137
+ unknown.append(
1138
+ {
1139
+ "observation_id": record.observation_id,
1140
+ "history_index": index,
1141
+ "state": "unknown",
1142
+ "reason": "legacy-unresolved",
1143
+ }
1144
+ )
1145
+ continue
1146
+ observed = _resolve_lifecycle_evidence(
1147
+ event.evidence,
1148
+ root=root,
1149
+ repository_commit=lifecycle.repository_commit,
1150
+ )
1151
+ if event.resolution != observed:
1152
+ unknown.append(
1153
+ {
1154
+ "observation_id": record.observation_id,
1155
+ "history_index": index,
1156
+ "state": "unknown",
1157
+ "reason": "resolution-changed",
1158
+ }
1159
+ )
1160
+ elif observed.state != "grounded":
1161
+ unknown.append(
1162
+ {
1163
+ "observation_id": record.observation_id,
1164
+ "history_index": index,
1165
+ "state": "unknown",
1166
+ "reason": observed.reason,
1167
+ }
1168
+ )
1169
+ return tuple(unknown)
1170
+
1171
+
1172
+ def write_candidate_lifecycle(
1173
+ lifecycle: CandidateLifecycleSet,
1174
+ output: Path,
1175
+ ) -> Path:
1176
+ """Write one explicit lifecycle record."""
1177
+ atomic_write(output, json.dumps(lifecycle.as_dict(), indent=2, ensure_ascii=False) + "\n")
1178
+ return output