@dzhechkov/p-replicator 1.12.0 → 1.13.1

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 (106) hide show
  1. package/.dz-manifest.json +225 -61
  2. package/CHANGELOG.md +148 -1
  3. package/LICENSE +21 -0
  4. package/MULTIPLATFORM_ROADMAP.md +1 -1
  5. package/README/eng/01_quickstart.md +2 -2
  6. package/README/eng/02_user_guide.md +1 -1
  7. package/README/eng/03_admin_guide.md +2 -2
  8. package/README/eng/05_architecture.md +1 -1
  9. package/README/eng/README.md +2 -1
  10. package/README/ru/01_quickstart.md +2 -2
  11. package/README/ru/02_user_guide.md +1 -1
  12. package/README/ru/03_admin_guide.md +2 -2
  13. package/README/ru/05_architecture.md +1 -1
  14. package/README/ru/README.md +2 -1
  15. package/README/ru/html/index.html +8 -8
  16. package/README.md +132 -9
  17. package/bin/cli.js +0 -0
  18. package/package.json +10 -11
  19. package/sbom.json +470 -60
  20. package/scripts/check-pipeline-gaps.sh +0 -0
  21. package/src/commands/init.js +1 -1
  22. package/src/rule-components.json +5 -1
  23. package/src/utils.js +32 -3
  24. package/templates/.claude/agents/product-discoverer.md +38 -0
  25. package/templates/.claude/agents/replicate-coordinator.md +11 -1
  26. package/templates/.claude/commands/feature.md +29 -5
  27. package/templates/.claude/commands/go.md +6 -8
  28. package/templates/.claude/commands/harvest.md +5 -7
  29. package/templates/.claude/commands/replicate.md +169 -44
  30. package/templates/.claude/commands/start.md +28 -7
  31. package/templates/.claude/hooks/capture-source-path.cjs +795 -0
  32. package/templates/.claude/hooks/check-canon.cjs +493 -0
  33. package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
  34. package/templates/.claude/hooks/check-external-deps.cjs +288 -0
  35. package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
  36. package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
  37. package/templates/.claude/hooks/check-job-contract.cjs +501 -0
  38. package/templates/.claude/hooks/check-look-origin.cjs +240 -0
  39. package/templates/.claude/hooks/check-look-trace.cjs +385 -0
  40. package/templates/.claude/hooks/check-metric-source.cjs +296 -0
  41. package/templates/.claude/hooks/check-model-cost.cjs +470 -0
  42. package/templates/.claude/hooks/check-ports.cjs +27 -6
  43. package/templates/.claude/hooks/check-source-version.cjs +312 -0
  44. package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
  45. package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
  46. package/templates/.claude/hooks/statusline.cjs +2 -2
  47. package/templates/.claude/rules/embeddable-widget.md +73 -0
  48. package/templates/.claude/rules/feature-lifecycle.md +5 -6
  49. package/templates/.claude/rules/incoming-webhooks.md +99 -0
  50. package/templates/.claude/rules/long-running-job.md +73 -0
  51. package/templates/.claude/rules/model-call-cost.md +85 -0
  52. package/templates/.claude/rules/replicate-pipeline.md +121 -52
  53. package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
  54. package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
  55. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
  56. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
  57. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
  58. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
  59. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
  60. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
  61. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
  62. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
  63. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
  64. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
  65. package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
  66. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
  67. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
  68. package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
  69. package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
  70. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
  71. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
  72. package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
  73. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
  74. package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
  75. package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
  76. package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
  77. package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
  78. package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
  79. package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
  80. package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
  81. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +173 -725
  82. package/tests/snapshot/baseline.json +60 -38
  83. package/tests/unit/capture-source-path.test.js +492 -0
  84. package/tests/unit/check-canon.test.js +403 -0
  85. package/tests/unit/check-embed-contract.test.js +422 -0
  86. package/tests/unit/check-external-deps.test.js +363 -0
  87. package/tests/unit/check-file-ownership.test.js +388 -0
  88. package/tests/unit/check-handoff-manifest.test.js +410 -0
  89. package/tests/unit/check-job-contract.test.js +514 -0
  90. package/tests/unit/check-look-origin.test.js +180 -0
  91. package/tests/unit/check-look-trace.test.js +420 -0
  92. package/tests/unit/check-metric-source.test.js +325 -0
  93. package/tests/unit/check-model-cost.test.js +425 -0
  94. package/tests/unit/check-ports.test.js +46 -2
  95. package/tests/unit/check-source-version.test.js +344 -0
  96. package/tests/unit/check-swarm-receipts.test.js +231 -0
  97. package/tests/unit/check-webhook-contract.test.js +536 -0
  98. package/tests/unit/db-port-rule.test.js +8 -2
  99. package/tests/unit/detection-ladder-registry.test.js +2 -2
  100. package/tests/unit/generator-swarm-contract.test.js +287 -0
  101. package/tests/unit/guard-honest-input-meta.test.js +64 -0
  102. package/tests/unit/honest-failure-rules.test.js +91 -9
  103. package/tests/unit/look-phase-contract.test.js +231 -0
  104. package/tests/unit/negative-conclusion-gate.test.js +300 -0
  105. package/tests/unit/quote-provenance.test.js +122 -0
  106. package/tests/unit/utils.test.js +40 -3
@@ -21,7 +21,7 @@ import json
21
21
  import os
22
22
  from dataclasses import asdict, dataclass, field
23
23
  from datetime import datetime
24
- from typing import Any, Dict, List, Optional, Tuple
24
+ from typing import Any, Dict, List, Mapping, Optional, Tuple
25
25
 
26
26
  try:
27
27
  from cryptography.exceptions import InvalidSignature
@@ -87,6 +87,11 @@ FACT_SCHEMA_V2 = "fact-v2"
87
87
  # rebuilds a different text than the one that was signed.
88
88
  FACT_SCHEMA_V3 = "fact-v3"
89
89
 
90
+ # v4 covers quote provenance. The verdict is deliberately absent: "verbatim" is
91
+ # derived only by quote_provenance.verify_verbatim() against captured source bytes.
92
+ FACT_SCHEMA_V4 = "fact-v4"
93
+ QUOTE_ATTESTED_MIN_SCHEMA = 4
94
+
90
95
  # The source-tier ceiling applies FROM this schema ONWARD (ADR-005 / D-20).
91
96
  # Written as a LOWER BOUND, never as an equality: `!= 2` meant "applies to exactly
92
97
  # the schema that introduced it", which silently switched the third ceiling off the
@@ -96,7 +101,7 @@ TIER_CEILING_MIN_SCHEMA = 2
96
101
 
97
102
  # The schemas this verifier can reconstruct a signed message for. A version outside
98
103
  # this tuple is REFUSED, never approximated to the nearest known one (QE G6).
99
- KNOWN_SCHEMA_VERSIONS = (1, 2, 3)
104
+ KNOWN_SCHEMA_VERSIONS = (1, 2, 3, 4)
100
105
 
101
106
  # `VerificationResult.schema_version` when the fact's own version could not be
102
107
  # identified at all. Not 1: reporting an unidentifiable record as "legacy v1" is the
@@ -166,6 +171,13 @@ class SignedFact:
166
171
  # record cannot report on it, and a record we cannot parse is one we cannot warn
167
172
  # about. The mandatory-ness lives on the five CREATION paths, not here.
168
173
  study_population: Optional[Dict[str, Any]] = None
174
+ # Quote-provenance inputs, never an author-writable verdict. They are all inside
175
+ # the v4 canonical message; None preserves the byte-identical v1-v3 paths.
176
+ quote: Optional[str] = None
177
+ acquisition: Optional[str] = None
178
+ sha256_body: Optional[str] = None
179
+ locator: Optional[str] = None
180
+ excerpt_id: Optional[str] = None
169
181
  # Self-describing schema version. Signed indirectly, via the "schema" marker in
170
182
  # the v3 message: stripping it makes the verifier rebuild a different text.
171
183
  schema_version: Optional[int] = None
@@ -339,6 +351,38 @@ def canonical_fact_message_v3(fact: SignedFact) -> str:
339
351
  )
340
352
 
341
353
 
354
+ def canonical_fact_message_v4(fact: SignedFact) -> str:
355
+ """v3 plus every input that binds a quote to its captured source excerpt.
356
+
357
+ No verdict is signed or stored. A signature can prove these provenance inputs
358
+ were not edited; only a later byte comparison can decide whether they earn the
359
+ closed ``verbatim-confirmed`` verdict.
360
+ """
361
+ return canonical_json(
362
+ {
363
+ "schema": FACT_SCHEMA_V4,
364
+ "acquisition": fact.acquisition,
365
+ "claim": fact.claim,
366
+ "confidence": format(round(float(fact.confidence or 0.0), 4), ".4f"),
367
+ "evidence_class": fact.evidence_class,
368
+ "excerpt_id": fact.excerpt_id,
369
+ "fetch_date": fact.fetch_date,
370
+ "issuer": fact.issuer,
371
+ "locator": fact.locator,
372
+ "metadata": _canonical_metadata(fact.metadata),
373
+ "quote": fact.quote,
374
+ "research_context": fact.research_context,
375
+ "sha256_body": fact.sha256_body,
376
+ "source_date": fact.source_date,
377
+ "source_hash": fact.source_hash,
378
+ "source_url": fact.source_url,
379
+ "study_population": fact.study_population,
380
+ "timestamp": fact.timestamp,
381
+ "trust_class": fact.trust_class,
382
+ }
383
+ )
384
+
385
+
342
386
  class SchemaVersionError(ValueError):
343
387
  """A fact whose schema cannot be identified. A subclass of ValueError so callers
344
388
  that already handle malformed facts keep working."""
@@ -387,6 +431,8 @@ def schema_version_of_mapping(data: Any) -> int:
387
431
  f"cannot be identified cannot be verified against any message"
388
432
  )
389
433
  return version
434
+ if data.get("quote") is not None:
435
+ return 4
390
436
  if data.get("study_population") is not None:
391
437
  return 3
392
438
  if data.get("evidence_class") is not None:
@@ -410,6 +456,7 @@ def fact_schema_version(fact: SignedFact) -> int:
410
456
  """
411
457
  return schema_version_of_mapping({
412
458
  "schema_version": fact.schema_version,
459
+ "quote": fact.quote,
413
460
  "study_population": fact.study_population,
414
461
  "evidence_class": fact.evidence_class,
415
462
  })
@@ -441,6 +488,8 @@ def canonical_fact_message(fact: SignedFact) -> str:
441
488
  Adding v4 is one new branch, and the refusal is what forces that branch to exist.
442
489
  """
443
490
  version = fact_schema_version(fact)
491
+ if version == 4:
492
+ return canonical_fact_message_v4(fact)
444
493
  if version == 3:
445
494
  return canonical_fact_message_v3(fact)
446
495
  if version == 2:
@@ -552,6 +601,51 @@ def _require_study_population(value: Any) -> Dict[str, Any]:
552
601
  return value
553
602
 
554
603
 
604
+ def _quote_fields(
605
+ value: Any,
606
+ *,
607
+ allow_raw_fetch: bool,
608
+ expected_source_url: Optional[str] = None,
609
+ expected_sha256_body: Optional[str] = None,
610
+ ) -> Dict[str, Any]:
611
+ """Validate a QuoteRecord-shaped value without accepting a caller verdict."""
612
+ if value is None:
613
+ return {}
614
+ if hasattr(value, "to_dict") and not isinstance(value, Mapping):
615
+ value = value.to_dict()
616
+ if not isinstance(value, Mapping):
617
+ raise ValueError(f"quote must be a QuoteRecord or JSON object, got {type(value).__name__}")
618
+ try:
619
+ from quote_provenance import QuoteRecord
620
+ record = QuoteRecord(
621
+ quote=value.get("quote"),
622
+ acquisition=value.get("acquisition"),
623
+ source_url=value.get("source_url"),
624
+ sha256_body=value.get("sha256_body"),
625
+ locator=value.get("locator"),
626
+ excerpt_id=value.get("excerpt_id"),
627
+ )
628
+ except (TypeError, ValueError) as exc:
629
+ raise ValueError(f"invalid quote provenance: {exc}") from None
630
+ if record.acquisition == "raw-fetch" and not allow_raw_fetch:
631
+ raise ValueError(
632
+ "acquisition 'raw-fetch' requires create_fetched_fact and an authentic FetchRecord"
633
+ )
634
+ if expected_source_url is not None and record.source_url != expected_source_url:
635
+ raise ValueError(
636
+ f"quote source_url {record.source_url!r} does not match FetchRecord.final_url {expected_source_url!r}"
637
+ )
638
+ if expected_sha256_body is not None and record.sha256_body != expected_sha256_body:
639
+ raise ValueError("quote sha256_body does not match FetchRecord.sha256_body")
640
+ return {
641
+ "quote": record.quote,
642
+ "acquisition": record.acquisition,
643
+ "sha256_body": record.sha256_body,
644
+ "locator": record.locator,
645
+ "excerpt_id": record.excerpt_id,
646
+ }
647
+
648
+
555
649
  def fact_content_hash(fact: SignedFact) -> str:
556
650
  """Stable hash for chain linkage. Excludes parent links and chain position."""
557
651
  return hashlib.sha256(canonical_fact_message(fact).encode("utf-8")).hexdigest()
@@ -750,6 +844,7 @@ class Ed25519Verifier:
750
844
  research_context: Optional[str] = None,
751
845
  *,
752
846
  study_population: Any,
847
+ quote: Any = None,
753
848
  ) -> SignedFact:
754
849
  """Create a researcher self-attested fact. This never grants issuer trust.
755
850
 
@@ -766,6 +861,7 @@ class Ed25519Verifier:
766
861
  source_hash = hashlib.sha256(source_content.encode("utf-8")).hexdigest()
767
862
  timestamp = datetime.utcnow().isoformat() + "Z"
768
863
  public_key_b64 = self.get_public_key_b64()
864
+ quote_fields = _quote_fields(quote, allow_raw_fetch=False)
769
865
  fact = SignedFact(
770
866
  claim=claim,
771
867
  source_url=source_url,
@@ -779,7 +875,8 @@ class Ed25519Verifier:
779
875
  research_context=research_context,
780
876
  metadata=metadata or {},
781
877
  study_population=population,
782
- schema_version=3,
878
+ schema_version=4 if quote_fields else 3,
879
+ **quote_fields,
783
880
  )
784
881
  fact.signature, _ = self.sign_content(canonical_fact_message(fact))
785
882
  return fact
@@ -805,12 +902,14 @@ class Ed25519Verifier:
805
902
  base_confidence: float,
806
903
  *,
807
904
  study_population: Any,
905
+ quote_fields: Optional[Dict[str, Any]] = None,
808
906
  ) -> SignedFact:
809
907
  if self._private_key is None or self._public_key is None:
810
908
  raise ValueError("No keypair loaded.")
811
909
  if evidence_class not in EVIDENCE_CLASSES:
812
910
  raise ValueError(f"unknown evidence_class {evidence_class!r}; expected one of {EVIDENCE_CLASSES}")
813
911
  population = _require_study_population(study_population)
912
+ quote_fields = dict(quote_fields or {})
814
913
  fact = SignedFact(
815
914
  claim=claim,
816
915
  source_url=source_url,
@@ -827,7 +926,8 @@ class Ed25519Verifier:
827
926
  fetch_date=fetch_date,
828
927
  source_date=source_date,
829
928
  study_population=population,
830
- schema_version=3,
929
+ schema_version=4 if quote_fields else 3,
930
+ **quote_fields,
831
931
  )
832
932
  # ORDERING IS LOAD-BEARING: `confidence` is clamped by the evidence ceiling
833
933
  # ABOVE, before signing. Signing first and clamping after would put a number
@@ -845,6 +945,7 @@ class Ed25519Verifier:
845
945
  research_context: Optional[str] = None,
846
946
  *,
847
947
  study_population: Any,
948
+ quote: Any = None,
848
949
  ) -> SignedFact:
849
950
  """FETCH_VERIFIED — requires proof the request actually happened.
850
951
 
@@ -874,6 +975,12 @@ class Ed25519Verifier:
874
975
  f"create_fetched_fact refuses a non-2xx fetch (status {getattr(fetch_record, 'status')}) — "
875
976
  "an error page is not the source it stands for"
876
977
  )
978
+ quote_fields = _quote_fields(
979
+ quote,
980
+ allow_raw_fetch=True,
981
+ expected_source_url=getattr(fetch_record, "final_url"),
982
+ expected_sha256_body=getattr(fetch_record, "sha256_body"),
983
+ )
877
984
  meta = dict(metadata or {})
878
985
  meta.setdefault("fetch_status", int(getattr(fetch_record, "status")))
879
986
  meta.setdefault("fetch_bytes", getattr(fetch_record, "bytes_len", None))
@@ -891,6 +998,7 @@ class Ed25519Verifier:
891
998
  research_context=research_context,
892
999
  base_confidence=0.60,
893
1000
  study_population=study_population,
1001
+ quote_fields=quote_fields,
894
1002
  )
895
1003
 
896
1004
  def create_listing_fact(
@@ -905,6 +1013,7 @@ class Ed25519Verifier:
905
1013
  research_context: Optional[str] = None,
906
1014
  *,
907
1015
  study_population: Any,
1016
+ quote: Any = None,
908
1017
  ) -> SignedFact:
909
1018
  """LISTING_ONLY — the URL is known but this script did not fetch it, or a
910
1019
  body was supplied by hand. `reason` is MANDATORY and stored verbatim: a
@@ -912,6 +1021,7 @@ class Ed25519Verifier:
912
1021
  """
913
1022
  if not reason or not reason.strip():
914
1023
  raise ValueError("create_listing_fact requires a non-empty reason (why was this not fetched?)")
1024
+ quote_fields = _quote_fields(quote, allow_raw_fetch=False)
915
1025
  meta = dict(metadata or {})
916
1026
  meta["evidence_note"] = reason.strip()
917
1027
  digest = hashlib.sha256((source_content or "").encode("utf-8")).hexdigest()
@@ -929,6 +1039,7 @@ class Ed25519Verifier:
929
1039
  research_context=research_context,
930
1040
  base_confidence=0.50,
931
1041
  study_population=study_population,
1042
+ quote_fields=quote_fields,
932
1043
  )
933
1044
 
934
1045
  def create_asserted_fact(
@@ -940,12 +1051,14 @@ class Ed25519Verifier:
940
1051
  research_context: Optional[str] = None,
941
1052
  *,
942
1053
  study_population: Any,
1054
+ quote: Any = None,
943
1055
  ) -> SignedFact:
944
1056
  """ASSERTED — stated from model memory, source never opened. Confidence is
945
1057
  0.0 by construction: this is not weak evidence, it is no evidence. Such a
946
1058
  fact exists so it can be RECORDED and then refused by the report gate,
947
1059
  rather than quietly becoming a sentence in a medical document.
948
1060
  """
1061
+ quote_fields = _quote_fields(quote, allow_raw_fetch=False)
949
1062
  return self._sign_evidence_fact(
950
1063
  claim=claim,
951
1064
  source_url=source_url,
@@ -958,6 +1071,7 @@ class Ed25519Verifier:
958
1071
  research_context=research_context,
959
1072
  base_confidence=0.0,
960
1073
  study_population=study_population,
1074
+ quote_fields=quote_fields,
961
1075
  )
962
1076
 
963
1077
  def create_issuer_signed_fact(
@@ -970,6 +1084,7 @@ class Ed25519Verifier:
970
1084
  research_context: Optional[str] = None,
971
1085
  *,
972
1086
  study_population: Any,
1087
+ quote: Any = None,
973
1088
  ) -> SignedFact:
974
1089
  """Create a fact intended to verify against the active pinned key for issuer."""
975
1090
  if self._public_key is None:
@@ -977,6 +1092,7 @@ class Ed25519Verifier:
977
1092
  population = _require_study_population(study_population)
978
1093
  source_hash = hashlib.sha256(source_content.encode("utf-8")).hexdigest()
979
1094
  timestamp = datetime.utcnow().isoformat() + "Z"
1095
+ quote_fields = _quote_fields(quote, allow_raw_fetch=False)
980
1096
  fact = SignedFact(
981
1097
  claim=claim,
982
1098
  source_url=source_url,
@@ -990,7 +1106,8 @@ class Ed25519Verifier:
990
1106
  research_context=research_context,
991
1107
  metadata=metadata or {},
992
1108
  study_population=population,
993
- schema_version=3,
1109
+ schema_version=4 if quote_fields else 3,
1110
+ **quote_fields,
994
1111
  )
995
1112
  fact.signature, _ = self.sign_content(canonical_fact_message(fact))
996
1113
  return fact
@@ -34,7 +34,7 @@ import ipaddress
34
34
  import socket
35
35
  from dataclasses import asdict, dataclass, replace
36
36
  from datetime import datetime, timezone
37
- from typing import Any, Dict, Optional
37
+ from typing import Any, Dict, Optional, Tuple
38
38
  from urllib import error as urlerror
39
39
  from urllib import request as urlrequest
40
40
  from urllib.parse import urlparse
@@ -201,39 +201,40 @@ class _CappedRedirectHandler(urlrequest.HTTPRedirectHandler):
201
201
  return super().redirect_request(req, fp, code, msg, headers, newurl)
202
202
 
203
203
 
204
- def fetch_source(
204
+ def fetch_source_returning_body(
205
205
  url: str,
206
206
  timeout: int = DEFAULT_TIMEOUT_SECONDS,
207
207
  max_bytes: int = DEFAULT_MAX_BYTES,
208
208
  _allow_private: bool = False,
209
- ) -> "FetchRecord | FetchFailure":
210
- """Perform the request and return proof of it, or a NAMED failure.
209
+ ) -> Tuple["FetchRecord | FetchFailure", Optional[bytes]]:
210
+ """Perform the request and return ``(proof-or-failure, captured-body)``.
211
211
 
212
212
  Never raises for network conditions: an unreachable source is a normal state
213
213
  of the world, and the caller's correct response is to degrade the evidence
214
214
  class, not to abort the research. `_allow_private` is for the test server
215
- only — the library never sets it.
215
+ only — the library never sets it. Failures always carry ``None`` as the body,
216
+ so no truncated or error body can cross the witness boundary.
216
217
  """
217
218
  try:
218
219
  max_bytes = int(max_bytes)
219
220
  except (TypeError, ValueError):
220
- return FetchFailure(url=str(url), reason="max_bytes is not an integer", attempted_at=_now_iso())
221
+ return FetchFailure(url=str(url), reason="max_bytes is not an integer", attempted_at=_now_iso()), None
221
222
  if max_bytes <= 0 or max_bytes > HARD_MAX_BYTES:
222
223
  return FetchFailure(
223
224
  url=str(url),
224
225
  reason=f"max_bytes must be in 1..{HARD_MAX_BYTES}; refusing an unbounded read",
225
226
  attempted_at=_now_iso(),
226
- )
227
+ ), None
227
228
  try:
228
229
  _validate_url(url, allow_private=_allow_private)
229
230
  except FetchRefused as exc:
230
- return FetchFailure(url=str(url), reason=str(exc), attempted_at=_now_iso())
231
+ return FetchFailure(url=str(url), reason=str(exc), attempted_at=_now_iso()), None
231
232
 
232
233
  opener = urlrequest.build_opener(_CappedRedirectHandler(allow_private=_allow_private))
233
234
  try:
234
235
  req = urlrequest.Request(url, headers={"User-Agent": USER_AGENT})
235
236
  except ValueError as exc:
236
- return FetchFailure(url=str(url), reason=f"malformed request: {exc}", attempted_at=_now_iso())
237
+ return FetchFailure(url=str(url), reason=f"malformed request: {exc}", attempted_at=_now_iso()), None
237
238
  try:
238
239
  with opener.open(req, timeout=timeout) as response:
239
240
  # Read ONE byte past the cap so truncation is detectable rather than
@@ -245,22 +246,22 @@ def fetch_source(
245
246
  url=url,
246
247
  reason=f"response exceeds max_bytes={max_bytes}; refusing to hash a truncated body",
247
248
  attempted_at=_now_iso(),
248
- )
249
+ ), None
249
250
  status = getattr(response, "status", None) or response.getcode()
250
251
  final_url = response.geturl()
251
252
  content_type = response.headers.get("Content-Type") if response.headers else None
252
253
  except urlerror.HTTPError as exc:
253
254
  # An HTTP error still carries a status — report it as a failure with the
254
255
  # status named, because a 403/404 page is not the source it stands for.
255
- return FetchFailure(url=url, reason=f"HTTP {exc.code} {exc.reason}", attempted_at=_now_iso())
256
+ return FetchFailure(url=url, reason=f"HTTP {exc.code} {exc.reason}", attempted_at=_now_iso()), None
256
257
  except (urlerror.URLError, socket.timeout, TimeoutError, OSError, UnicodeError) as exc:
257
- return FetchFailure(url=str(url), reason=f"network error: {exc}", attempted_at=_now_iso())
258
+ return FetchFailure(url=str(url), reason=f"network error: {exc}", attempted_at=_now_iso()), None
258
259
  except FetchRefused as exc:
259
- return FetchFailure(url=url, reason=str(exc), attempted_at=_now_iso())
260
+ return FetchFailure(url=url, reason=str(exc), attempted_at=_now_iso()), None
260
261
  except (http.client.HTTPException, ValueError) as exc:
261
262
  # InvalidURL, bad chunking, control characters in the URL — a NAMED
262
263
  # failure, never an exception in the caller's face (Codex QE #11).
263
- return FetchFailure(url=str(url), reason=f"protocol/URL error: {exc}", attempted_at=_now_iso())
264
+ return FetchFailure(url=str(url), reason=f"protocol/URL error: {exc}", attempted_at=_now_iso()), None
264
265
 
265
266
  record = FetchRecord(
266
267
  url=url,
@@ -273,5 +274,21 @@ def fetch_source(
273
274
  witness=_FETCH_WITNESS, # only real fetches carry it
274
275
  )
275
276
  if not record.ok:
276
- return FetchFailure(url=url, reason=f"non-2xx status {record.status}", attempted_at=record.fetched_at)
277
- return record
277
+ return FetchFailure(url=url, reason=f"non-2xx status {record.status}", attempted_at=record.fetched_at), None
278
+ return record, body
279
+
280
+
281
+ def fetch_source(
282
+ url: str,
283
+ timeout: int = DEFAULT_TIMEOUT_SECONDS,
284
+ max_bytes: int = DEFAULT_MAX_BYTES,
285
+ _allow_private: bool = False,
286
+ ) -> "FetchRecord | FetchFailure":
287
+ """Compatibility wrapper: return the existing record/failure shape only."""
288
+ result, _ = fetch_source_returning_body(
289
+ url,
290
+ timeout=timeout,
291
+ max_bytes=max_bytes,
292
+ _allow_private=_allow_private,
293
+ )
294
+ return result