@dzhechkov/p-replicator 1.10.4 → 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.
- package/.dz-manifest.json +312 -76
- package/CHANGELOG.md +232 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +3 -3
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/04_api_reference.md +11 -5
- package/README/eng/05_architecture.md +1 -1
- package/README/eng/README.md +2 -1
- package/README/ru/01_quickstart.md +3 -3
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/04_api_reference.md +11 -5
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +2 -1
- package/README/ru/html/index.html +9 -9
- package/README.md +278 -39
- package/package.json +5 -4
- package/sbom.json +665 -75
- package/scripts/check-pipeline-gaps.sh +413 -0
- package/src/commands/doctor.js +94 -4
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +15 -0
- package/src/utils.js +35 -11
- package/templates/.claude/agents/harvest-coordinator.md +10 -1
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +81 -9
- package/templates/.claude/commands/go.md +9 -0
- package/templates/.claude/commands/harvest.md +39 -3
- package/templates/.claude/commands/myinsights.md +21 -26
- package/templates/.claude/commands/replicate.md +171 -37
- package/templates/.claude/commands/start.md +29 -0
- package/templates/.claude/hooks/capture-source-path.cjs +795 -0
- package/templates/.claude/hooks/check-canon.cjs +493 -0
- package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
- package/templates/.claude/hooks/check-external-deps.cjs +288 -0
- package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
- package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
- package/templates/.claude/hooks/check-job-contract.cjs +501 -0
- package/templates/.claude/hooks/check-look-origin.cjs +240 -0
- package/templates/.claude/hooks/check-look-trace.cjs +385 -0
- package/templates/.claude/hooks/check-metric-source.cjs +296 -0
- package/templates/.claude/hooks/check-model-cost.cjs +470 -0
- package/templates/.claude/hooks/check-ports.cjs +434 -24
- package/templates/.claude/hooks/check-source-version.cjs +312 -0
- package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
- package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
- package/templates/.claude/hooks/session-insights.cjs +158 -25
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/hooks/write-insight.cjs +253 -0
- package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
- package/templates/.claude/rules/docker-ports.md +41 -19
- package/templates/.claude/rules/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +13 -3
- package/templates/.claude/rules/honest-configuration.md +54 -0
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- package/templates/.claude/rules/insights-capture.md +10 -5
- package/templates/.claude/rules/long-running-job.md +73 -0
- package/templates/.claude/rules/model-call-cost.md +85 -0
- package/templates/.claude/rules/replicate-pipeline.md +123 -52
- package/templates/.claude/rules/skill-interface-protocol.md +1 -0
- package/templates/.claude/rules/swarm-file-evidence.md +46 -0
- package/templates/.claude/settings.json +13 -1
- package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
- package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
- package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
- package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
- package/templates/.claude/skills/knowledge-extractor/modules/01-agent-review.md +16 -5
- package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
- package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
- package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
- package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
- package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +234 -716
- package/tests/e2e/lifecycle.test.js +55 -9
- package/tests/e2e/packed-insights-writer.test.js +308 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
- package/tests/snapshot/baseline.json +72 -46
- package/tests/snapshot/templates.test.js +47 -0
- package/tests/unit/absence-is-not-emptiness.test.js +15 -1
- package/tests/unit/capture-source-path.test.js +492 -0
- package/tests/unit/check-canon.test.js +403 -0
- package/tests/unit/check-embed-contract.test.js +422 -0
- package/tests/unit/check-external-deps.test.js +363 -0
- package/tests/unit/check-file-ownership.test.js +388 -0
- package/tests/unit/check-handoff-manifest.test.js +410 -0
- package/tests/unit/check-job-contract.test.js +514 -0
- package/tests/unit/check-look-origin.test.js +180 -0
- package/tests/unit/check-look-trace.test.js +420 -0
- package/tests/unit/check-metric-source.test.js +325 -0
- package/tests/unit/check-model-cost.test.js +425 -0
- package/tests/unit/check-pipeline-gaps.test.js +94 -0
- package/tests/unit/check-ports.test.js +773 -2
- package/tests/unit/check-source-version.test.js +344 -0
- package/tests/unit/check-swarm-receipts.test.js +231 -0
- package/tests/unit/check-webhook-contract.test.js +536 -0
- package/tests/unit/db-port-rule.test.js +43 -6
- package/tests/unit/detection-ladder-contract.test.js +302 -0
- package/tests/unit/detection-ladder-registry.test.js +52 -0
- package/tests/unit/doctor-insight-flow.test.js +315 -0
- package/tests/unit/external-dependency-check.test.js +19 -19
- package/tests/unit/generator-swarm-contract.test.js +287 -0
- package/tests/unit/guard-honest-input-meta.test.js +64 -0
- package/tests/unit/honest-failure-rules.test.js +574 -0
- package/tests/unit/hooks-project-anchored.test.js +67 -3
- package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
- package/tests/unit/insights-dz-delegation.test.js +197 -0
- package/tests/unit/insights-writer.test.js +285 -0
- package/tests/unit/look-phase-contract.test.js +231 -0
- package/tests/unit/negative-conclusion-gate.test.js +300 -0
- package/tests/unit/quote-provenance.test.js +122 -0
- package/tests/unit/shipped-suite-context.test.js +3 -1
- package/tests/unit/traceability-machine-ids.test.js +413 -0
- package/tests/unit/traceability-negative-fixture.test.js +322 -0
- package/tests/unit/utils.test.js +40 -2
|
@@ -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
|
|
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
|
|
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
|