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
clean_docs/feedback.py ADDED
@@ -0,0 +1,1390 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import time
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, Mapping
14
+
15
+ from clean_docs import __version__
16
+ from clean_docs.adapters.event_capture import deliver_event
17
+ from clean_docs.errors import ConfigurationError
18
+ from clean_docs.regions import atomic_write
19
+ from clean_docs.verdict import validate_verdict_payload
20
+
21
+ CONFIG_PATH = Path(".sourcebound/feedback.json")
22
+ STATE_DIR = Path(".sourcebound/feedback")
23
+ OUTBOX_DIR = STATE_DIR / "outbox"
24
+ DEAD_LETTER_DIR = STATE_DIR / "dead-letter"
25
+ ATTEMPTS_DIR = STATE_DIR / "attempts"
26
+ SIGNALS_DIR = STATE_DIR / "signals"
27
+ CASES_DIR = STATE_DIR / "cases"
28
+ RECEIPTS_DIR = STATE_DIR / "receipts"
29
+
30
+ CONFIG_SCHEMA = "sourcebound.feedback-config.v1"
31
+ ENVELOPE_SCHEMA = "sourcebound.feedback.v1"
32
+ SIGNAL_SCHEMA = "sourcebound.behavior-signal.v1"
33
+ CASE_SCHEMA = "sourcebound.improvement-case.v1"
34
+
35
+ DEFAULT_RETENTION_DAYS = 30
36
+ DEFAULT_MAX_RECORDS = 1_000
37
+ DEFAULT_MAX_BYTES = 5 * 1024 * 1024
38
+ MAX_DELIVERY_ATTEMPTS = 3
39
+
40
+ _CONFIG_KEYS = {
41
+ "schema",
42
+ "enabled",
43
+ "installation_id",
44
+ "sink",
45
+ "retention_days",
46
+ "max_records",
47
+ "max_bytes",
48
+ }
49
+ _ENVELOPE_KEYS = {
50
+ "schema",
51
+ "event_id",
52
+ "run_id",
53
+ "outcome_id",
54
+ "occurred_at",
55
+ "product_version",
56
+ "installation_id",
57
+ "command",
58
+ "exit_code",
59
+ "result_class",
60
+ "execution_policy",
61
+ "adapter",
62
+ "repository_size_class",
63
+ "outcome",
64
+ }
65
+ _SIGNAL_KEYS = {
66
+ "schema",
67
+ "signal_id",
68
+ "metric",
69
+ "window",
70
+ "counts",
71
+ "segment",
72
+ "data_quality",
73
+ "privacy_class",
74
+ "source_receipt",
75
+ "contradictory_evidence",
76
+ "evidence_class",
77
+ }
78
+ _CASE_STATES = (
79
+ "observed",
80
+ "reproduced",
81
+ "root-cause-classified",
82
+ "evaluation-proposed",
83
+ "regression-added",
84
+ "shadow-measured",
85
+ "candidate-change",
86
+ "ordinary-verified-pr",
87
+ )
88
+ _METRIC_KEYS = {"name", "version", "direction"}
89
+ _WINDOW_KEYS = {"start", "end"}
90
+ _COUNT_KEYS = {"numerator", "denominator"}
91
+ _SEGMENT_KEYS = {
92
+ "scope",
93
+ "installations",
94
+ "contributing_installations",
95
+ "product_versions",
96
+ "adapters",
97
+ "execution_policies",
98
+ "repository_size_classes",
99
+ }
100
+ _SOURCE_RECEIPT_KEYS = {"schema", "sha256"}
101
+ _TRANSITION_RECEIPT_SCHEMAS = {
102
+ "reproduced": "sourcebound.reproduction.v1",
103
+ "root-cause-classified": "sourcebound.root-cause.v1",
104
+ "evaluation-proposed": "sourcebound.evaluation-proposal.v1",
105
+ "regression-added": "sourcebound.regression-receipt.v1",
106
+ "shadow-measured": "sourcebound.shadow-evaluation.v1",
107
+ "candidate-change": "sourcebound.candidate-change.v1",
108
+ "ordinary-verified-pr": "sourcebound.pr-verdict.v1",
109
+ }
110
+ _SHADOW_KEYS = {
111
+ "schema",
112
+ "prior_receipt_sha256",
113
+ "cohort_version",
114
+ "direction",
115
+ "baseline",
116
+ "candidate",
117
+ "protected_segments",
118
+ "baseline_metric_digest",
119
+ "candidate_metric_digest",
120
+ "baseline_scorer_digest",
121
+ "candidate_scorer_digest",
122
+ "baseline_threshold_digest",
123
+ "candidate_threshold_digest",
124
+ }
125
+ _PROTECTED_SEGMENT_KEYS = {"name", "baseline", "candidate"}
126
+ _REPRODUCTION_KEYS = {
127
+ "schema",
128
+ "signal_id",
129
+ "fixture_sha256",
130
+ "baseline_outcome_id",
131
+ "reproduced",
132
+ "failure_class",
133
+ }
134
+ _ROOT_CAUSE_KEYS = {
135
+ "schema",
136
+ "prior_receipt_sha256",
137
+ "classification",
138
+ "evidence_sha256",
139
+ }
140
+ _EVALUATION_PROPOSAL_KEYS = {
141
+ "schema",
142
+ "prior_receipt_sha256",
143
+ "metric_sha256",
144
+ "scorer_sha256",
145
+ "threshold_sha256",
146
+ "protected_segments",
147
+ }
148
+ _REGRESSION_KEYS = {
149
+ "schema",
150
+ "prior_receipt_sha256",
151
+ "fixture_sha256",
152
+ "demonstrated_red",
153
+ }
154
+ _CANDIDATE_CHANGE_KEYS = {
155
+ "schema",
156
+ "prior_receipt_sha256",
157
+ "change_sha256",
158
+ "regression_suite_sha256",
159
+ }
160
+
161
+
162
+ def _canonical_json(payload: Mapping[str, Any]) -> str:
163
+ return json.dumps(
164
+ payload,
165
+ ensure_ascii=False,
166
+ separators=(",", ":"),
167
+ sort_keys=True,
168
+ ) + "\n"
169
+
170
+
171
+ def _sha256(value: bytes) -> str:
172
+ return hashlib.sha256(value).hexdigest()
173
+
174
+
175
+ def _utc_now() -> str:
176
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
177
+
178
+
179
+ def _parse_utc(value: object, where: str) -> datetime:
180
+ text = _require_string(value, where)
181
+ if not text.endswith("Z"):
182
+ raise ConfigurationError(f"{where} must be an ISO 8601 UTC timestamp")
183
+ try:
184
+ parsed = datetime.fromisoformat(text.removesuffix("Z") + "+00:00")
185
+ except ValueError as exc:
186
+ raise ConfigurationError(f"{where} must be an ISO 8601 UTC timestamp") from exc
187
+ return parsed
188
+
189
+
190
+ def _require_mapping(value: object, where: str) -> Mapping[str, Any]:
191
+ if not isinstance(value, dict):
192
+ raise ConfigurationError(f"{where} must be an object")
193
+ return value
194
+
195
+
196
+ def _require_exact_keys(
197
+ value: Mapping[str, Any],
198
+ allowed: set[str],
199
+ where: str,
200
+ ) -> None:
201
+ unknown = sorted(set(value) - allowed)
202
+ if unknown:
203
+ raise ConfigurationError(f"{where} has unknown field(s): {', '.join(unknown)}")
204
+
205
+
206
+ def _require_string(value: object, where: str) -> str:
207
+ if not isinstance(value, str) or not value:
208
+ raise ConfigurationError(f"{where} must be a non-empty string")
209
+ return value
210
+
211
+
212
+ def _require_digest(value: object, where: str) -> str:
213
+ digest = _require_string(value, where)
214
+ if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
215
+ raise ConfigurationError(f"{where} must be a lowercase SHA-256 digest")
216
+ return digest
217
+
218
+
219
+ def _require_token(
220
+ value: object,
221
+ where: str,
222
+ *,
223
+ pattern: str = r"[a-z][a-z0-9-]{0,63}",
224
+ ) -> str:
225
+ token = _require_string(value, where)
226
+ if re.fullmatch(pattern, token) is None:
227
+ raise ConfigurationError(f"{where} has an invalid bounded value")
228
+ return token
229
+
230
+
231
+ def _relative_path(value: str, where: str) -> str:
232
+ path = Path(value)
233
+ if path.is_absolute() or ".." in path.parts:
234
+ raise ConfigurationError(f"{where} must stay inside the repository")
235
+ return path.as_posix()
236
+
237
+
238
+ def _confined_path(root: Path, relative: Path, where: str) -> Path:
239
+ candidate = root / relative
240
+ root_resolved = root.resolve()
241
+ try:
242
+ resolved_parent = candidate.parent.resolve()
243
+ except OSError as exc:
244
+ raise ConfigurationError(f"cannot resolve {where}") from exc
245
+ if not resolved_parent.is_relative_to(root_resolved) or candidate.is_symlink():
246
+ raise ConfigurationError(f"{where} must stay inside the repository")
247
+ return candidate
248
+
249
+
250
+ def _require_string_list(value: object, where: str) -> list[str]:
251
+ if not isinstance(value, list) or not value:
252
+ raise ConfigurationError(f"{where} must be a non-empty array")
253
+ return [
254
+ _require_string(item, f"{where}[{index}]")
255
+ for index, item in enumerate(value)
256
+ ]
257
+
258
+
259
+ def _require_count_pair(value: object, where: str) -> Mapping[str, int]:
260
+ pair = _require_mapping(value, where)
261
+ _require_exact_keys(pair, _COUNT_KEYS, where)
262
+ numerator = pair.get("numerator")
263
+ denominator = pair.get("denominator")
264
+ if not isinstance(numerator, int) or numerator < 0:
265
+ raise ConfigurationError(f"{where}.numerator must be a non-negative integer")
266
+ if not isinstance(denominator, int) or denominator <= 0:
267
+ raise ConfigurationError(f"{where}.denominator must be a positive integer")
268
+ if numerator > denominator:
269
+ raise ConfigurationError(f"{where}.numerator cannot exceed denominator")
270
+ return {"numerator": numerator, "denominator": denominator}
271
+
272
+
273
+ def _rate(pair: Mapping[str, int]) -> float:
274
+ return pair["numerator"] / pair["denominator"]
275
+
276
+
277
+ @dataclass(frozen=True)
278
+ class FeedbackConfig:
279
+ enabled: bool
280
+ installation_id: str
281
+ sink: Mapping[str, str]
282
+ retention_days: int
283
+ max_records: int
284
+ max_bytes: int
285
+
286
+ def as_dict(self) -> dict[str, object]:
287
+ return {
288
+ "schema": CONFIG_SCHEMA,
289
+ "enabled": self.enabled,
290
+ "installation_id": self.installation_id,
291
+ "sink": dict(self.sink),
292
+ "retention_days": self.retention_days,
293
+ "max_records": self.max_records,
294
+ "max_bytes": self.max_bytes,
295
+ }
296
+
297
+
298
+ def load_feedback_config(root: Path) -> FeedbackConfig | None:
299
+ path = _confined_path(root, CONFIG_PATH, "feedback configuration")
300
+ if not path.is_file():
301
+ return None
302
+ try:
303
+ raw = json.loads(path.read_text(encoding="utf-8"))
304
+ except (OSError, json.JSONDecodeError) as exc:
305
+ raise ConfigurationError(f"cannot read feedback configuration {path}") from exc
306
+ data = _require_mapping(raw, "feedback configuration")
307
+ _require_exact_keys(data, _CONFIG_KEYS, "feedback configuration")
308
+ if data.get("schema") != CONFIG_SCHEMA:
309
+ raise ConfigurationError(f"feedback configuration schema must be {CONFIG_SCHEMA}")
310
+ if not isinstance(data.get("enabled"), bool):
311
+ raise ConfigurationError("feedback configuration enabled must be a boolean")
312
+ installation_id = _require_string(
313
+ data.get("installation_id"),
314
+ "feedback configuration installation_id",
315
+ )
316
+ try:
317
+ uuid.UUID(installation_id)
318
+ except ValueError as exc:
319
+ raise ConfigurationError(
320
+ "feedback configuration installation_id must be a UUID"
321
+ ) from exc
322
+ sink_raw = _require_mapping(data.get("sink"), "feedback configuration sink")
323
+ kind = _require_string(sink_raw.get("kind"), "feedback configuration sink.kind")
324
+ if kind not in {"local", "connected"}:
325
+ raise ConfigurationError("feedback sink kind must be local or connected")
326
+ sink: dict[str, str] = {"kind": kind}
327
+ if kind == "local":
328
+ _require_exact_keys(
329
+ sink_raw,
330
+ {"kind", "target"},
331
+ "feedback configuration sink",
332
+ )
333
+ sink["target"] = _relative_path(
334
+ _require_string(
335
+ sink_raw.get("target"),
336
+ "feedback configuration sink.target",
337
+ ),
338
+ "feedback configuration sink.target",
339
+ )
340
+ else:
341
+ _require_exact_keys(
342
+ sink_raw,
343
+ {"kind", "endpoint", "token_env"},
344
+ "feedback configuration sink",
345
+ )
346
+ endpoint = _require_string(
347
+ sink_raw.get("endpoint"),
348
+ "feedback configuration sink.endpoint",
349
+ )
350
+ if not endpoint.startswith("https://"):
351
+ raise ConfigurationError("connected feedback endpoint must use HTTPS")
352
+ sink["endpoint"] = endpoint
353
+ sink["token_env"] = _require_string(
354
+ sink_raw.get("token_env"),
355
+ "feedback configuration sink.token_env",
356
+ )
357
+ if re.fullmatch(r"[A-Z_][A-Z0-9_]{0,127}", sink["token_env"]) is None:
358
+ raise ConfigurationError(
359
+ "feedback configuration sink.token_env is invalid"
360
+ )
361
+ retention_days = data.get("retention_days")
362
+ max_records = data.get("max_records")
363
+ max_bytes = data.get("max_bytes")
364
+ if not isinstance(retention_days, int) or not 1 <= retention_days <= 365:
365
+ raise ConfigurationError("feedback retention_days must be between 1 and 365")
366
+ if not isinstance(max_records, int) or not 1 <= max_records <= 100_000:
367
+ raise ConfigurationError("feedback max_records must be between 1 and 100000")
368
+ if not isinstance(max_bytes, int) or not 1_024 <= max_bytes <= 100 * 1024 * 1024:
369
+ raise ConfigurationError(
370
+ "feedback max_bytes must be between 1024 and 104857600"
371
+ )
372
+ return FeedbackConfig(
373
+ enabled=bool(data["enabled"]),
374
+ installation_id=installation_id,
375
+ sink=sink,
376
+ retention_days=retention_days,
377
+ max_records=max_records,
378
+ max_bytes=max_bytes,
379
+ )
380
+
381
+
382
+ def enable_feedback(
383
+ root: Path,
384
+ *,
385
+ sink: str,
386
+ target: str | None = None,
387
+ endpoint: str | None = None,
388
+ token_env: str | None = None,
389
+ ) -> FeedbackConfig:
390
+ previous = load_feedback_config(root)
391
+ installation_id = (
392
+ previous.installation_id if previous is not None else str(uuid.uuid4())
393
+ )
394
+ if sink == "local":
395
+ resolved_target = _relative_path(
396
+ target or ".sourcebound/feedback/delivered",
397
+ "feedback sink target",
398
+ )
399
+ sink_config = {
400
+ "kind": "local",
401
+ "target": resolved_target,
402
+ }
403
+ elif sink == "connected":
404
+ if endpoint is None or token_env is None:
405
+ raise ConfigurationError(
406
+ "connected feedback requires --endpoint and --token-env"
407
+ )
408
+ if not endpoint.startswith("https://"):
409
+ raise ConfigurationError("connected feedback endpoint must use HTTPS")
410
+ sink_config = {
411
+ "kind": "connected",
412
+ "endpoint": endpoint,
413
+ "token_env": token_env,
414
+ }
415
+ else:
416
+ raise ConfigurationError("feedback sink must be local or connected")
417
+ config = FeedbackConfig(
418
+ enabled=True,
419
+ installation_id=installation_id,
420
+ sink=sink_config,
421
+ retention_days=DEFAULT_RETENTION_DAYS,
422
+ max_records=DEFAULT_MAX_RECORDS,
423
+ max_bytes=DEFAULT_MAX_BYTES,
424
+ )
425
+ # Round-trip validation keeps the writer and reader on the same closed schema.
426
+ atomic_write(
427
+ _confined_path(root, CONFIG_PATH, "feedback configuration"),
428
+ json.dumps(config.as_dict(), indent=2) + "\n",
429
+ )
430
+ return load_feedback_config(root) or config
431
+
432
+
433
+ def disable_feedback(root: Path) -> FeedbackConfig:
434
+ config = load_feedback_config(root)
435
+ if config is None:
436
+ raise ConfigurationError("feedback is not configured")
437
+ disabled = FeedbackConfig(
438
+ enabled=False,
439
+ installation_id=config.installation_id,
440
+ sink=config.sink,
441
+ retention_days=config.retention_days,
442
+ max_records=config.max_records,
443
+ max_bytes=config.max_bytes,
444
+ )
445
+ atomic_write(
446
+ _confined_path(root, CONFIG_PATH, "feedback configuration"),
447
+ json.dumps(disabled.as_dict(), indent=2) + "\n",
448
+ )
449
+ return disabled
450
+
451
+
452
+ def rotate_feedback_identity(root: Path) -> FeedbackConfig:
453
+ config = load_feedback_config(root)
454
+ if config is None:
455
+ raise ConfigurationError("feedback is not configured")
456
+ rotated = FeedbackConfig(
457
+ enabled=config.enabled,
458
+ installation_id=str(uuid.uuid4()),
459
+ sink=config.sink,
460
+ retention_days=config.retention_days,
461
+ max_records=config.max_records,
462
+ max_bytes=config.max_bytes,
463
+ )
464
+ atomic_write(
465
+ _confined_path(root, CONFIG_PATH, "feedback configuration"),
466
+ json.dumps(rotated.as_dict(), indent=2) + "\n",
467
+ )
468
+ return rotated
469
+
470
+
471
+ def _result_class(exit_code: int) -> str:
472
+ return {
473
+ 0: "success",
474
+ 1: "drift",
475
+ 2: "invalid",
476
+ 3: "extraction-failed",
477
+ }.get(exit_code, "failure")
478
+
479
+
480
+ def _repository_ref(root: Path) -> str:
481
+ head = root / ".git/HEAD"
482
+ try:
483
+ value = head.read_text(encoding="utf-8").strip()
484
+ if value.startswith("ref: "):
485
+ ref_path = root / ".git" / value.removeprefix("ref: ")
486
+ value = ref_path.read_text(encoding="utf-8").strip()
487
+ except OSError:
488
+ return "WORKTREE"
489
+ if len(value) == 40 and all(char in "0123456789abcdef" for char in value):
490
+ return value
491
+ return "WORKTREE"
492
+
493
+
494
+ def _repository_classes(root: Path) -> tuple[str, str]:
495
+ file_count = 0
496
+ markdown = False
497
+ mdx = False
498
+ excluded = {".git", ".sourcebound", ".venv", "node_modules"}
499
+ try:
500
+ for directory, directories, files in os.walk(root):
501
+ directories[:] = [name for name in directories if name not in excluded]
502
+ file_count += len(files)
503
+ markdown = markdown or any(name.endswith(".md") for name in files)
504
+ mdx = mdx or any(name.endswith(".mdx") for name in files)
505
+ if file_count > 10_000:
506
+ break
507
+ except OSError:
508
+ return "unknown", "unknown"
509
+ size_class = (
510
+ "small"
511
+ if file_count < 100
512
+ else "medium"
513
+ if file_count < 1_000
514
+ else "large"
515
+ if file_count <= 10_000
516
+ else "very-large"
517
+ )
518
+ adapter = (
519
+ "mixed"
520
+ if markdown and mdx
521
+ else "mdx"
522
+ if mdx
523
+ else "markdown"
524
+ if markdown
525
+ else "none"
526
+ )
527
+ return adapter, size_class
528
+
529
+
530
+ def build_feedback_envelope(
531
+ root: Path,
532
+ config: FeedbackConfig,
533
+ *,
534
+ command: str,
535
+ exit_code: int,
536
+ execution_policy: str,
537
+ outcome: str | None = None,
538
+ ) -> dict[str, object]:
539
+ adapter, repository_size_class = _repository_classes(root)
540
+ run_id = _sha256(uuid.uuid4().bytes)
541
+ outcome_material = {
542
+ "run_id": run_id,
543
+ "product_version": __version__,
544
+ "repository_ref": _repository_ref(root),
545
+ "command": command,
546
+ "exit_code": exit_code,
547
+ "execution_policy": execution_policy,
548
+ }
549
+ outcome_id = _sha256(_canonical_json(outcome_material).encode())
550
+ event_id = _sha256(f"{config.installation_id}:{run_id}".encode())
551
+ envelope: dict[str, object] = {
552
+ "schema": ENVELOPE_SCHEMA,
553
+ "event_id": event_id,
554
+ "run_id": run_id,
555
+ "outcome_id": outcome_id,
556
+ "occurred_at": _utc_now(),
557
+ "product_version": __version__,
558
+ "installation_id": config.installation_id,
559
+ "command": command,
560
+ "exit_code": exit_code,
561
+ "result_class": _result_class(exit_code),
562
+ "execution_policy": execution_policy,
563
+ "adapter": adapter,
564
+ "repository_size_class": repository_size_class,
565
+ }
566
+ if outcome is not None:
567
+ envelope["outcome"] = outcome
568
+ return envelope
569
+
570
+
571
+ def _outbox_records(root: Path) -> list[Path]:
572
+ directory = _confined_path(root, OUTBOX_DIR, "feedback outbox")
573
+ if not directory.is_dir():
574
+ return []
575
+ return sorted(path for path in directory.iterdir() if path.suffix == ".json")
576
+
577
+
578
+ def _prune_feedback_state(root: Path, config: FeedbackConfig) -> None:
579
+ cutoff = time.time() - config.retention_days * 86_400
580
+ directories = [
581
+ _confined_path(root, OUTBOX_DIR, "feedback outbox"),
582
+ _confined_path(root, DEAD_LETTER_DIR, "feedback dead-letter directory"),
583
+ _confined_path(root, ATTEMPTS_DIR, "feedback attempts directory"),
584
+ ]
585
+ if config.sink["kind"] == "local":
586
+ directories.append(
587
+ _confined_path(
588
+ root,
589
+ Path(config.sink["target"]),
590
+ "local feedback sink",
591
+ )
592
+ )
593
+ for directory in directories:
594
+ if not directory.is_dir():
595
+ continue
596
+ for path in directory.iterdir():
597
+ try:
598
+ if path.is_file() and path.stat().st_mtime < cutoff:
599
+ path.unlink()
600
+ except OSError:
601
+ continue
602
+
603
+
604
+ def enqueue_feedback(
605
+ root: Path,
606
+ *,
607
+ command: str,
608
+ exit_code: int,
609
+ execution_policy: str,
610
+ outcome: str | None = None,
611
+ ) -> Path | None:
612
+ config = load_feedback_config(root)
613
+ if config is None or not config.enabled:
614
+ return None
615
+ _prune_feedback_state(root, config)
616
+ envelope = build_feedback_envelope(
617
+ root,
618
+ config,
619
+ command=command,
620
+ exit_code=exit_code,
621
+ execution_policy=execution_policy,
622
+ outcome=outcome,
623
+ )
624
+ event_id = str(envelope["event_id"])
625
+ path = _confined_path(
626
+ root,
627
+ OUTBOX_DIR / f"{event_id}.json",
628
+ "feedback outbox record",
629
+ )
630
+ if path.is_file():
631
+ return path
632
+ payload = _canonical_json(envelope)
633
+ records = [
634
+ *_outbox_records(root),
635
+ *sorted(
636
+ _confined_path(
637
+ root,
638
+ DEAD_LETTER_DIR,
639
+ "feedback dead-letter directory",
640
+ ).glob("*.json")
641
+ ),
642
+ ]
643
+ total_bytes = sum(record.stat().st_size for record in records)
644
+ if len(records) >= config.max_records:
645
+ raise ConfigurationError("feedback outbox record quota reached")
646
+ if total_bytes + len(payload.encode()) > config.max_bytes:
647
+ raise ConfigurationError("feedback outbox byte quota reached")
648
+ atomic_write(path, payload)
649
+ return path
650
+
651
+
652
+ def preview_feedback(root: Path) -> bytes:
653
+ records = _outbox_records(root)
654
+ return b"".join(record.read_bytes() for record in records)
655
+
656
+
657
+ def _validate_envelope_bytes(payload: bytes) -> Mapping[str, Any]:
658
+ try:
659
+ raw = json.loads(payload)
660
+ except json.JSONDecodeError as exc:
661
+ raise ConfigurationError("feedback envelope is invalid JSON") from exc
662
+ envelope = _require_mapping(raw, "feedback envelope")
663
+ allowed_keys = _ENVELOPE_KEYS - {"outcome"}
664
+ if "outcome" in envelope:
665
+ allowed_keys = _ENVELOPE_KEYS
666
+ _require_exact_keys(envelope, allowed_keys, "feedback envelope")
667
+ if envelope.get("schema") != ENVELOPE_SCHEMA:
668
+ raise ConfigurationError(f"feedback envelope schema must be {ENVELOPE_SCHEMA}")
669
+ event_id = _require_digest(envelope.get("event_id"), "feedback envelope event_id")
670
+ run_id = _require_digest(envelope.get("run_id"), "feedback envelope run_id")
671
+ _require_digest(envelope.get("outcome_id"), "feedback envelope outcome_id")
672
+ installation_id = _require_string(
673
+ envelope.get("installation_id"),
674
+ "feedback envelope installation_id",
675
+ )
676
+ try:
677
+ uuid.UUID(installation_id)
678
+ except ValueError as exc:
679
+ raise ConfigurationError(
680
+ "feedback envelope installation_id must be a UUID"
681
+ ) from exc
682
+ if event_id != _sha256(f"{installation_id}:{run_id}".encode()):
683
+ raise ConfigurationError("feedback envelope event_id does not match its run")
684
+ _parse_utc(envelope.get("occurred_at"), "feedback envelope occurred_at")
685
+ _require_token(
686
+ envelope.get("product_version"),
687
+ "feedback envelope product_version",
688
+ pattern=r"[0-9A-Za-z.+-]{1,32}",
689
+ )
690
+ _require_token(envelope.get("command"), "feedback envelope command")
691
+ exit_code = envelope.get("exit_code")
692
+ if isinstance(exit_code, bool) or not isinstance(exit_code, int):
693
+ raise ConfigurationError("feedback envelope exit_code must be an integer")
694
+ if envelope.get("result_class") != _result_class(exit_code):
695
+ raise ConfigurationError("feedback envelope result_class does not match exit_code")
696
+ if "outcome" in envelope and envelope["outcome"] not in {
697
+ "accept", "parser-reject", "provider-failed",
698
+ }:
699
+ raise ConfigurationError("feedback envelope outcome is invalid")
700
+ if envelope.get("execution_policy") not in {"trusted", "static-only"}:
701
+ raise ConfigurationError("feedback envelope execution_policy is invalid")
702
+ if envelope.get("adapter") not in {"none", "markdown", "mdx", "mixed", "unknown"}:
703
+ raise ConfigurationError("feedback envelope adapter is invalid")
704
+ if envelope.get("repository_size_class") not in {
705
+ "small",
706
+ "medium",
707
+ "large",
708
+ "very-large",
709
+ "unknown",
710
+ }:
711
+ raise ConfigurationError("feedback envelope repository_size_class is invalid")
712
+ return envelope
713
+
714
+
715
+ def _deliver_local(root: Path, config: FeedbackConfig, payload: bytes) -> None:
716
+ envelope = _validate_envelope_bytes(payload)
717
+ path = _confined_path(
718
+ root,
719
+ Path(config.sink["target"]) / f"{envelope['event_id']}.json",
720
+ "local feedback event",
721
+ )
722
+ if path.is_file():
723
+ return
724
+ atomic_write(path, payload.decode())
725
+
726
+
727
+ def _deliver_connected(config: FeedbackConfig, payload: bytes) -> None:
728
+ envelope = _validate_envelope_bytes(payload)
729
+ deliver_event(
730
+ endpoint=config.sink["endpoint"],
731
+ token_env=config.sink["token_env"],
732
+ envelope=envelope,
733
+ )
734
+
735
+
736
+ def flush_feedback(root: Path) -> dict[str, int]:
737
+ config = load_feedback_config(root)
738
+ if config is None or not config.enabled:
739
+ raise ConfigurationError("feedback delivery is disabled")
740
+ _prune_feedback_state(root, config)
741
+ delivered = 0
742
+ duplicate = 0
743
+ failed = 0
744
+ for record in _outbox_records(root):
745
+ payload = record.read_bytes()
746
+ delivery_failed = False
747
+ try:
748
+ if config.sink["kind"] == "local":
749
+ target = _confined_path(
750
+ root,
751
+ Path(config.sink["target"]) / record.name,
752
+ "local feedback event",
753
+ )
754
+ existed = target.is_file()
755
+ _deliver_local(root, config, payload)
756
+ duplicate += int(existed)
757
+ else:
758
+ _deliver_connected(config, payload)
759
+ record.unlink()
760
+ _confined_path(
761
+ root,
762
+ ATTEMPTS_DIR / record.name,
763
+ "feedback attempt record",
764
+ ).unlink(missing_ok=True)
765
+ delivered += 1
766
+ except ConfigurationError:
767
+ failed += 1
768
+ delivery_failed = True
769
+ if delivery_failed:
770
+ attempts_path = _confined_path(
771
+ root,
772
+ ATTEMPTS_DIR / record.name,
773
+ "feedback attempt record",
774
+ )
775
+ attempts = 0
776
+ if attempts_path.is_file():
777
+ try:
778
+ attempts = int(attempts_path.read_text(encoding="utf-8"))
779
+ except (OSError, ValueError):
780
+ attempts = MAX_DELIVERY_ATTEMPTS
781
+ attempts += 1
782
+ if attempts >= MAX_DELIVERY_ATTEMPTS:
783
+ dead_letter = _confined_path(
784
+ root,
785
+ DEAD_LETTER_DIR / record.name,
786
+ "feedback dead-letter record",
787
+ )
788
+ dead_letter.parent.mkdir(parents=True, exist_ok=True)
789
+ record.replace(dead_letter)
790
+ attempts_path.unlink(missing_ok=True)
791
+ else:
792
+ atomic_write(attempts_path, f"{attempts}\n")
793
+ return {"delivered": delivered, "duplicates": duplicate, "failed": failed}
794
+
795
+
796
+ def validate_behavior_signal(payload: Mapping[str, Any]) -> dict[str, object]:
797
+ _require_exact_keys(payload, _SIGNAL_KEYS, "behavior signal")
798
+ if payload.get("schema") != SIGNAL_SCHEMA:
799
+ raise ConfigurationError(f"behavior signal schema must be {SIGNAL_SCHEMA}")
800
+ signal_id = _require_digest(payload.get("signal_id"), "behavior signal signal_id")
801
+ metric = _require_mapping(payload.get("metric"), "behavior signal metric")
802
+ _require_exact_keys(metric, _METRIC_KEYS, "behavior signal metric")
803
+ direction = _require_string(
804
+ metric.get("direction"),
805
+ "behavior signal metric.direction",
806
+ )
807
+ if direction not in {"higher-is-better", "lower-is-better"}:
808
+ raise ConfigurationError(
809
+ "behavior signal metric.direction must be higher-is-better or lower-is-better"
810
+ )
811
+ normalized_metric = {
812
+ "name": _require_string(metric.get("name"), "behavior signal metric.name"),
813
+ "version": _require_string(
814
+ metric.get("version"),
815
+ "behavior signal metric.version",
816
+ ),
817
+ "direction": direction,
818
+ }
819
+ window = _require_mapping(payload.get("window"), "behavior signal window")
820
+ _require_exact_keys(window, _WINDOW_KEYS, "behavior signal window")
821
+ start = _parse_utc(window.get("start"), "behavior signal window.start")
822
+ end = _parse_utc(window.get("end"), "behavior signal window.end")
823
+ if start >= end:
824
+ raise ConfigurationError("behavior signal window.start must precede window.end")
825
+ normalized_window = {
826
+ "start": str(window["start"]),
827
+ "end": str(window["end"]),
828
+ }
829
+ counts = dict(_require_count_pair(payload.get("counts"), "behavior signal counts"))
830
+ segment = _require_mapping(payload.get("segment"), "behavior signal segment")
831
+ _require_exact_keys(segment, _SEGMENT_KEYS, "behavior signal segment")
832
+ scope = _require_string(segment.get("scope"), "behavior signal segment.scope")
833
+ if scope not in {"installation", "cross-installation"}:
834
+ raise ConfigurationError(
835
+ "behavior signal segment.scope must be installation or cross-installation"
836
+ )
837
+ installations = segment.get("installations")
838
+ contributing = segment.get("contributing_installations")
839
+ if not isinstance(installations, int) or installations <= 0:
840
+ raise ConfigurationError(
841
+ "behavior signal segment.installations must be a positive integer"
842
+ )
843
+ if (
844
+ not isinstance(contributing, int)
845
+ or contributing <= 0
846
+ or contributing > installations
847
+ ):
848
+ raise ConfigurationError(
849
+ "behavior signal segment.contributing_installations must be positive "
850
+ "and cannot exceed installations"
851
+ )
852
+ if scope == "cross-installation" and contributing < 2:
853
+ raise ConfigurationError(
854
+ "cross-installation signals require at least two contributing installations"
855
+ )
856
+ normalized_segment = {
857
+ "scope": scope,
858
+ "installations": installations,
859
+ "contributing_installations": contributing,
860
+ "product_versions": _require_string_list(
861
+ segment.get("product_versions"),
862
+ "behavior signal segment.product_versions",
863
+ ),
864
+ "adapters": _require_string_list(
865
+ segment.get("adapters"),
866
+ "behavior signal segment.adapters",
867
+ ),
868
+ "execution_policies": _require_string_list(
869
+ segment.get("execution_policies"),
870
+ "behavior signal segment.execution_policies",
871
+ ),
872
+ "repository_size_classes": _require_string_list(
873
+ segment.get("repository_size_classes"),
874
+ "behavior signal segment.repository_size_classes",
875
+ ),
876
+ }
877
+ data_quality = _require_string(
878
+ payload.get("data_quality"),
879
+ "behavior signal data_quality",
880
+ )
881
+ if data_quality not in {"complete", "partial"}:
882
+ raise ConfigurationError(
883
+ "behavior signal data_quality must be complete or partial"
884
+ )
885
+ privacy_class = _require_string(
886
+ payload.get("privacy_class"),
887
+ "behavior signal privacy_class",
888
+ )
889
+ if privacy_class != "aggregate":
890
+ raise ConfigurationError("behavior signal privacy_class must be aggregate")
891
+ evidence_class = _require_string(
892
+ payload.get("evidence_class"),
893
+ "behavior signal evidence_class",
894
+ )
895
+ if evidence_class not in {"independent-observation", "internal-regression"}:
896
+ raise ConfigurationError(
897
+ "behavior signal evidence_class must be independent-observation "
898
+ "or internal-regression"
899
+ )
900
+ source_receipt = _require_mapping(
901
+ payload.get("source_receipt"),
902
+ "behavior signal source_receipt",
903
+ )
904
+ _require_exact_keys(
905
+ source_receipt,
906
+ _SOURCE_RECEIPT_KEYS,
907
+ "behavior signal source_receipt",
908
+ )
909
+ normalized_source = {
910
+ "schema": _require_string(
911
+ source_receipt.get("schema"),
912
+ "behavior signal source_receipt.schema",
913
+ ),
914
+ "sha256": _require_digest(
915
+ source_receipt.get("sha256"),
916
+ "behavior signal source_receipt.sha256",
917
+ ),
918
+ }
919
+ contradictions_raw = payload.get("contradictory_evidence")
920
+ if not isinstance(contradictions_raw, list):
921
+ raise ConfigurationError(
922
+ "behavior signal contradictory_evidence must be an array"
923
+ )
924
+ contradictions = [
925
+ _require_digest(
926
+ item,
927
+ f"behavior signal contradictory_evidence[{index}]",
928
+ )
929
+ for index, item in enumerate(contradictions_raw)
930
+ ]
931
+ normalized_without_id: dict[str, object] = {
932
+ "schema": SIGNAL_SCHEMA,
933
+ "metric": normalized_metric,
934
+ "window": normalized_window,
935
+ "counts": counts,
936
+ "segment": normalized_segment,
937
+ "data_quality": data_quality,
938
+ "privacy_class": privacy_class,
939
+ "source_receipt": normalized_source,
940
+ "contradictory_evidence": contradictions,
941
+ "evidence_class": evidence_class,
942
+ }
943
+ expected_id = _sha256(_canonical_json(normalized_without_id).encode())
944
+ if signal_id != expected_id:
945
+ raise ConfigurationError(
946
+ "behavior signal signal_id does not match its canonical content"
947
+ )
948
+ return {"signal_id": signal_id, **normalized_without_id}
949
+
950
+
951
+ def prepare_behavior_signal(payload: Mapping[str, Any]) -> dict[str, object]:
952
+ body_keys = _SIGNAL_KEYS - {"signal_id"}
953
+ _require_exact_keys(payload, body_keys, "behavior signal body")
954
+ missing = sorted(body_keys - set(payload))
955
+ if missing:
956
+ raise ConfigurationError(
957
+ f"behavior signal body is missing field(s): {', '.join(missing)}"
958
+ )
959
+ signal_id = _sha256(_canonical_json(payload).encode())
960
+ return validate_behavior_signal({"signal_id": signal_id, **payload})
961
+
962
+
963
+ def load_behavior_signal(path: Path) -> tuple[dict[str, object], bytes]:
964
+ try:
965
+ payload_bytes = path.read_bytes()
966
+ raw = json.loads(payload_bytes)
967
+ except (OSError, json.JSONDecodeError) as exc:
968
+ raise ConfigurationError(f"cannot read behavior signal {path}") from exc
969
+ payload = _require_mapping(raw, "behavior signal")
970
+ return validate_behavior_signal(payload), payload_bytes
971
+
972
+
973
+ def ingest_behavior_signal(root: Path, path: Path) -> dict[str, object]:
974
+ signal, _source_bytes = load_behavior_signal(path)
975
+ signal_id = str(signal["signal_id"])
976
+ stored_signal = _confined_path(
977
+ root,
978
+ SIGNALS_DIR / f"{signal_id}.json",
979
+ "behavior signal record",
980
+ )
981
+ canonical_signal = _canonical_json(signal)
982
+ if stored_signal.is_file() and stored_signal.read_text(encoding="utf-8") != canonical_signal:
983
+ raise ConfigurationError("behavior signal id conflicts with stored bytes")
984
+ atomic_write(stored_signal, canonical_signal)
985
+ case_path = _confined_path(
986
+ root,
987
+ CASES_DIR / f"{signal_id}.json",
988
+ "improvement case",
989
+ )
990
+ case: dict[str, object] = {
991
+ "schema": CASE_SCHEMA,
992
+ "case_id": signal_id,
993
+ "signal_id": signal_id,
994
+ "state": "observed",
995
+ "evidence_class": signal["evidence_class"],
996
+ "history": [{
997
+ "state": "observed",
998
+ "receipt_schema": SIGNAL_SCHEMA,
999
+ "receipt_sha256": _sha256(canonical_signal.encode()),
1000
+ }],
1001
+ }
1002
+ if case_path.is_file():
1003
+ existing = json.loads(case_path.read_text(encoding="utf-8"))
1004
+ if not isinstance(existing, dict) or existing.get("signal_id") != signal_id:
1005
+ raise ConfigurationError("improvement case conflicts with stored signal")
1006
+ return existing
1007
+ atomic_write(case_path, json.dumps(case, indent=2) + "\n")
1008
+ return case
1009
+
1010
+
1011
+ def _stored_receipt_path(root: Path, case_id: str, state: str) -> Path:
1012
+ if state == "observed":
1013
+ relative = SIGNALS_DIR / f"{case_id}.json"
1014
+ else:
1015
+ relative = RECEIPTS_DIR / case_id / f"{state}.json"
1016
+ return _confined_path(root, relative, "improvement receipt")
1017
+
1018
+
1019
+ def _validated_case_history(
1020
+ root: Path,
1021
+ case_id: str,
1022
+ current_state: str,
1023
+ history_raw: object,
1024
+ ) -> tuple[list[object], str]:
1025
+ if not isinstance(history_raw, list) or not history_raw:
1026
+ raise ConfigurationError("improvement case history must be a non-empty array")
1027
+ current_index = _CASE_STATES.index(current_state)
1028
+ expected_states = list(_CASE_STATES[: current_index + 1])
1029
+ observed_states: list[str] = []
1030
+ for index, entry_raw in enumerate(history_raw):
1031
+ entry = _require_mapping(
1032
+ entry_raw,
1033
+ f"improvement case history[{index}]",
1034
+ )
1035
+ _require_exact_keys(
1036
+ entry,
1037
+ {"state", "receipt_schema", "receipt_sha256"},
1038
+ f"improvement case history[{index}]",
1039
+ )
1040
+ observed_states.append(
1041
+ _require_string(
1042
+ entry.get("state"),
1043
+ f"improvement case history[{index}].state",
1044
+ )
1045
+ )
1046
+ receipt_schema = _require_string(
1047
+ entry.get("receipt_schema"),
1048
+ f"improvement case history[{index}].receipt_schema",
1049
+ )
1050
+ expected_schema = (
1051
+ SIGNAL_SCHEMA
1052
+ if observed_states[-1] == "observed"
1053
+ else _TRANSITION_RECEIPT_SCHEMAS.get(observed_states[-1])
1054
+ )
1055
+ if receipt_schema != expected_schema:
1056
+ raise ConfigurationError(
1057
+ f"improvement case history[{index}] has the wrong receipt schema"
1058
+ )
1059
+ receipt_digest = _require_digest(
1060
+ entry.get("receipt_sha256"),
1061
+ f"improvement case history[{index}].receipt_sha256",
1062
+ )
1063
+ receipt_path = _stored_receipt_path(root, case_id, observed_states[-1])
1064
+ try:
1065
+ stored_digest = _sha256(receipt_path.read_bytes())
1066
+ except OSError as exc:
1067
+ raise ConfigurationError(
1068
+ f"improvement case is missing receipt for {observed_states[-1]}"
1069
+ ) from exc
1070
+ if receipt_digest != stored_digest:
1071
+ raise ConfigurationError(
1072
+ f"improvement case receipt changed for {observed_states[-1]}"
1073
+ )
1074
+ if observed_states != expected_states:
1075
+ raise ConfigurationError(
1076
+ "improvement case history does not match its adjacent state sequence"
1077
+ )
1078
+ last = _require_mapping(history_raw[-1], "improvement case prior history")
1079
+ return history_raw, _require_digest(
1080
+ last.get("receipt_sha256"),
1081
+ "improvement case prior receipt_sha256",
1082
+ )
1083
+
1084
+
1085
+ def _validate_prior_digest(
1086
+ receipt: Mapping[str, Any],
1087
+ expected: str,
1088
+ where: str,
1089
+ ) -> None:
1090
+ observed = _require_digest(
1091
+ receipt.get("prior_receipt_sha256"),
1092
+ f"{where} prior_receipt_sha256",
1093
+ )
1094
+ if observed != expected:
1095
+ raise ConfigurationError(f"{where} does not chain to the prior receipt")
1096
+
1097
+
1098
+ def _validate_transition_receipt(
1099
+ receipt: Mapping[str, Any],
1100
+ *,
1101
+ target_state: str,
1102
+ signal_id: str,
1103
+ prior_receipt_sha256: str,
1104
+ ) -> None:
1105
+ if target_state == "reproduced":
1106
+ _require_exact_keys(receipt, _REPRODUCTION_KEYS, "reproduction receipt")
1107
+ if receipt.get("signal_id") != signal_id:
1108
+ raise ConfigurationError("reproduction receipt signal_id does not match the case")
1109
+ _require_digest(
1110
+ receipt.get("fixture_sha256"),
1111
+ "reproduction receipt fixture_sha256",
1112
+ )
1113
+ _require_digest(
1114
+ receipt.get("baseline_outcome_id"),
1115
+ "reproduction receipt baseline_outcome_id",
1116
+ )
1117
+ if receipt.get("reproduced") is not True:
1118
+ raise ConfigurationError("reproduction receipt must record reproduced: true")
1119
+ failure_class = _require_string(
1120
+ receipt.get("failure_class"),
1121
+ "reproduction receipt failure_class",
1122
+ )
1123
+ if failure_class not in {
1124
+ "tool-defect",
1125
+ "unsupported-surface",
1126
+ "configuration-defect",
1127
+ "proposal-defect",
1128
+ "user-abandonment",
1129
+ }:
1130
+ raise ConfigurationError("reproduction receipt failure_class is invalid")
1131
+ return
1132
+ if target_state == "root-cause-classified":
1133
+ _require_exact_keys(receipt, _ROOT_CAUSE_KEYS, "root-cause receipt")
1134
+ _validate_prior_digest(receipt, prior_receipt_sha256, "root-cause receipt")
1135
+ classification = _require_string(
1136
+ receipt.get("classification"),
1137
+ "root-cause receipt classification",
1138
+ )
1139
+ if classification not in {
1140
+ "tool-defect",
1141
+ "unsupported-surface",
1142
+ "configuration-defect",
1143
+ "proposal-defect",
1144
+ "user-abandonment",
1145
+ }:
1146
+ raise ConfigurationError("root-cause receipt classification is invalid")
1147
+ _require_digest(
1148
+ receipt.get("evidence_sha256"),
1149
+ "root-cause receipt evidence_sha256",
1150
+ )
1151
+ return
1152
+ if target_state == "evaluation-proposed":
1153
+ _require_exact_keys(
1154
+ receipt,
1155
+ _EVALUATION_PROPOSAL_KEYS,
1156
+ "evaluation proposal receipt",
1157
+ )
1158
+ _validate_prior_digest(
1159
+ receipt,
1160
+ prior_receipt_sha256,
1161
+ "evaluation proposal receipt",
1162
+ )
1163
+ for field in ("metric_sha256", "scorer_sha256", "threshold_sha256"):
1164
+ _require_digest(
1165
+ receipt.get(field),
1166
+ f"evaluation proposal receipt {field}",
1167
+ )
1168
+ _require_string_list(
1169
+ receipt.get("protected_segments"),
1170
+ "evaluation proposal receipt protected_segments",
1171
+ )
1172
+ return
1173
+ if target_state == "regression-added":
1174
+ _require_exact_keys(receipt, _REGRESSION_KEYS, "regression receipt")
1175
+ _validate_prior_digest(receipt, prior_receipt_sha256, "regression receipt")
1176
+ _require_digest(
1177
+ receipt.get("fixture_sha256"),
1178
+ "regression receipt fixture_sha256",
1179
+ )
1180
+ if receipt.get("demonstrated_red") is not True:
1181
+ raise ConfigurationError(
1182
+ "regression receipt must record demonstrated_red: true"
1183
+ )
1184
+ return
1185
+ if target_state == "shadow-measured":
1186
+ _validate_prior_digest(receipt, prior_receipt_sha256, "shadow evaluation")
1187
+ _validate_shadow_receipt(receipt)
1188
+ return
1189
+ if target_state == "candidate-change":
1190
+ _require_exact_keys(
1191
+ receipt,
1192
+ _CANDIDATE_CHANGE_KEYS,
1193
+ "candidate change receipt",
1194
+ )
1195
+ _validate_prior_digest(
1196
+ receipt,
1197
+ prior_receipt_sha256,
1198
+ "candidate change receipt",
1199
+ )
1200
+ _require_digest(
1201
+ receipt.get("change_sha256"),
1202
+ "candidate change receipt change_sha256",
1203
+ )
1204
+ _require_digest(
1205
+ receipt.get("regression_suite_sha256"),
1206
+ "candidate change receipt regression_suite_sha256",
1207
+ )
1208
+ return
1209
+ validate_verdict_payload(receipt)
1210
+ if receipt.get("state") != "ready" or receipt.get("ready") is not True:
1211
+ raise ConfigurationError("ordinary verified PR requires a ready verdict")
1212
+
1213
+
1214
+ def _validate_shadow_receipt(receipt: Mapping[str, Any]) -> None:
1215
+ _require_exact_keys(receipt, _SHADOW_KEYS, "shadow evaluation receipt")
1216
+ if receipt.get("schema") != "sourcebound.shadow-evaluation.v1":
1217
+ raise ConfigurationError(
1218
+ "shadow evaluation receipt schema must be "
1219
+ "sourcebound.shadow-evaluation.v1"
1220
+ )
1221
+ _require_string(receipt.get("cohort_version"), "shadow evaluation cohort_version")
1222
+ direction = _require_string(
1223
+ receipt.get("direction"),
1224
+ "shadow evaluation direction",
1225
+ )
1226
+ if direction not in {"higher-is-better", "lower-is-better"}:
1227
+ raise ConfigurationError(
1228
+ "shadow evaluation direction must be higher-is-better or lower-is-better"
1229
+ )
1230
+ digest_pairs = (
1231
+ ("baseline_metric_digest", "candidate_metric_digest"),
1232
+ ("baseline_scorer_digest", "candidate_scorer_digest"),
1233
+ ("baseline_threshold_digest", "candidate_threshold_digest"),
1234
+ )
1235
+ for baseline_key, candidate_key in digest_pairs:
1236
+ baseline_digest = _require_digest(
1237
+ receipt.get(baseline_key),
1238
+ f"shadow evaluation {baseline_key}",
1239
+ )
1240
+ candidate_digest = _require_digest(
1241
+ receipt.get(candidate_key),
1242
+ f"shadow evaluation {candidate_key}",
1243
+ )
1244
+ if baseline_digest != candidate_digest:
1245
+ raise ConfigurationError(
1246
+ "shadow comparison changed its metric, scorer, or threshold"
1247
+ )
1248
+ baseline = _require_count_pair(
1249
+ receipt.get("baseline"),
1250
+ "shadow evaluation baseline",
1251
+ )
1252
+ candidate = _require_count_pair(
1253
+ receipt.get("candidate"),
1254
+ "shadow evaluation candidate",
1255
+ )
1256
+ improved = (
1257
+ _rate(candidate) > _rate(baseline)
1258
+ if direction == "higher-is-better"
1259
+ else _rate(candidate) < _rate(baseline)
1260
+ )
1261
+ if not improved:
1262
+ raise ConfigurationError("shadow candidate did not improve the aggregate metric")
1263
+ protected = receipt.get("protected_segments")
1264
+ if not isinstance(protected, list) or not protected:
1265
+ raise ConfigurationError(
1266
+ "shadow evaluation protected_segments must be a non-empty array"
1267
+ )
1268
+ for index, raw_segment in enumerate(protected):
1269
+ segment = _require_mapping(
1270
+ raw_segment,
1271
+ f"shadow evaluation protected_segments[{index}]",
1272
+ )
1273
+ _require_exact_keys(
1274
+ segment,
1275
+ _PROTECTED_SEGMENT_KEYS,
1276
+ f"shadow evaluation protected_segments[{index}]",
1277
+ )
1278
+ _require_string(
1279
+ segment.get("name"),
1280
+ f"shadow evaluation protected_segments[{index}].name",
1281
+ )
1282
+ segment_baseline = _require_count_pair(
1283
+ segment.get("baseline"),
1284
+ f"shadow evaluation protected_segments[{index}].baseline",
1285
+ )
1286
+ segment_candidate = _require_count_pair(
1287
+ segment.get("candidate"),
1288
+ f"shadow evaluation protected_segments[{index}].candidate",
1289
+ )
1290
+ regressed = (
1291
+ _rate(segment_candidate) < _rate(segment_baseline)
1292
+ if direction == "higher-is-better"
1293
+ else _rate(segment_candidate) > _rate(segment_baseline)
1294
+ )
1295
+ if regressed:
1296
+ raise ConfigurationError(
1297
+ "shadow candidate regressed a protected segment"
1298
+ )
1299
+
1300
+
1301
+ def transition_improvement_case(
1302
+ root: Path,
1303
+ *,
1304
+ case_id: str,
1305
+ target_state: str,
1306
+ receipt_path: Path,
1307
+ ) -> dict[str, object]:
1308
+ _require_digest(case_id, "improvement case id")
1309
+ if target_state not in _CASE_STATES:
1310
+ raise ConfigurationError(f"unknown improvement state: {target_state}")
1311
+ path = _confined_path(
1312
+ root,
1313
+ CASES_DIR / f"{case_id}.json",
1314
+ "improvement case",
1315
+ )
1316
+ try:
1317
+ case_raw = json.loads(path.read_text(encoding="utf-8"))
1318
+ receipt_bytes = receipt_path.read_bytes()
1319
+ receipt_raw = json.loads(receipt_bytes)
1320
+ except (OSError, json.JSONDecodeError) as exc:
1321
+ raise ConfigurationError("cannot read improvement case or transition receipt") from exc
1322
+ case = _require_mapping(case_raw, "improvement case")
1323
+ receipt = _require_mapping(receipt_raw, "transition receipt")
1324
+ current_state = _require_string(case.get("state"), "improvement case state")
1325
+ history_raw, prior_receipt_sha256 = _validated_case_history(
1326
+ root,
1327
+ case_id,
1328
+ current_state,
1329
+ case.get("history"),
1330
+ )
1331
+ receipt_sha256 = _sha256(receipt_bytes)
1332
+ if current_state == target_state:
1333
+ last_history = _require_mapping(
1334
+ history_raw[-1],
1335
+ "improvement case current history",
1336
+ )
1337
+ if last_history.get("receipt_sha256") != receipt_sha256:
1338
+ raise ConfigurationError(
1339
+ "improvement case state already has a different receipt"
1340
+ )
1341
+ return dict(case)
1342
+ expected_index = _CASE_STATES.index(current_state) + 1
1343
+ if expected_index >= len(_CASE_STATES) or _CASE_STATES[expected_index] != target_state:
1344
+ raise ConfigurationError(
1345
+ f"improvement case must transition from {current_state} "
1346
+ f"to {_CASE_STATES[expected_index] if expected_index < len(_CASE_STATES) else 'none'}"
1347
+ )
1348
+ expected_schema = _TRANSITION_RECEIPT_SCHEMAS[target_state]
1349
+ if receipt.get("schema") != expected_schema:
1350
+ raise ConfigurationError(
1351
+ f"{target_state} requires receipt schema {expected_schema}"
1352
+ )
1353
+ signal_id = _require_digest(
1354
+ case.get("signal_id"),
1355
+ "improvement case signal_id",
1356
+ )
1357
+ _validate_transition_receipt(
1358
+ receipt,
1359
+ target_state=target_state,
1360
+ signal_id=signal_id,
1361
+ prior_receipt_sha256=prior_receipt_sha256,
1362
+ )
1363
+ stored_receipt = _stored_receipt_path(root, case_id, target_state)
1364
+ if stored_receipt.is_file() and stored_receipt.read_bytes() != receipt_bytes:
1365
+ raise ConfigurationError(
1366
+ f"improvement case already has conflicting {target_state} receipt bytes"
1367
+ )
1368
+ atomic_write(stored_receipt, receipt_bytes.decode())
1369
+ updated = dict(case)
1370
+ updated["state"] = target_state
1371
+ updated["history"] = [
1372
+ *history_raw,
1373
+ {
1374
+ "state": target_state,
1375
+ "receipt_schema": expected_schema,
1376
+ "receipt_sha256": receipt_sha256,
1377
+ },
1378
+ ]
1379
+ atomic_write(path, json.dumps(updated, indent=2) + "\n")
1380
+ return updated
1381
+
1382
+
1383
+ def purge_feedback(root: Path) -> None:
1384
+ state_path = root / STATE_DIR
1385
+ if state_path.is_symlink():
1386
+ state_path.unlink(missing_ok=True)
1387
+ else:
1388
+ shutil.rmtree(state_path, ignore_errors=True)
1389
+ config_path = root / CONFIG_PATH
1390
+ config_path.unlink(missing_ok=True)