@dzhechkov/p-replicator 1.5.16 → 1.5.18
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 +51 -23
- package/README.md +5 -3
- package/package.json +1 -1
- package/sbom.json +106 -36
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
- package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +891 -280
- package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +970 -306
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
- package/tests/snapshot/baseline.json +24 -9
|
@@ -70,8 +70,7 @@ EVIDENCE_CEILINGS = {
|
|
|
70
70
|
|
|
71
71
|
# Signed-message schema marker (ADR-002 + its AM-1 amendment).
|
|
72
72
|
#
|
|
73
|
-
# HONEST ROLE: this marker is self-description
|
|
74
|
-
# v3 (slice C will add study_population). It is NOT what stops tampering.
|
|
73
|
+
# HONEST ROLE: this marker is self-description. It is NOT what stops tampering.
|
|
75
74
|
# What stops tampering is that the v2 message CONTAINS the three new keys at all:
|
|
76
75
|
# strip evidence_class → verifier builds the 6-key v1 text ≠ signed v2 text → fail
|
|
77
76
|
# add evidence_class → verifier builds the v2 text ≠ signed v1 text → fail
|
|
@@ -81,6 +80,29 @@ EVIDENCE_CEILINGS = {
|
|
|
81
80
|
# class of error this feature exists to prevent.
|
|
82
81
|
FACT_SCHEMA_V2 = "fact-v2"
|
|
83
82
|
|
|
83
|
+
# v3 has LANDED (slice C, ADR-003): study_population, trust_class, confidence and
|
|
84
|
+
# metadata are inside the signed message. The same honest role applies to this
|
|
85
|
+
# marker as to v2's — self-description, not the protection. What protects a v3
|
|
86
|
+
# fact is that its message CONTAINS those four keys, so any reclaim/rewrite
|
|
87
|
+
# rebuilds a different text than the one that was signed.
|
|
88
|
+
FACT_SCHEMA_V3 = "fact-v3"
|
|
89
|
+
|
|
90
|
+
# The source-tier ceiling applies FROM this schema ONWARD (ADR-005 / D-20).
|
|
91
|
+
# Written as a LOWER BOUND, never as an equality: `!= 2` meant "applies to exactly
|
|
92
|
+
# the schema that introduced it", which silently switched the third ceiling off the
|
|
93
|
+
# moment v3 was minted (MEASURED: an unknown-domain fact went 0.40 → 1.0). A future
|
|
94
|
+
# v4 must not be able to repeat this a third time.
|
|
95
|
+
TIER_CEILING_MIN_SCHEMA = 2
|
|
96
|
+
|
|
97
|
+
# The schemas this verifier can reconstruct a signed message for. A version outside
|
|
98
|
+
# this tuple is REFUSED, never approximated to the nearest known one (QE G6).
|
|
99
|
+
KNOWN_SCHEMA_VERSIONS = (1, 2, 3)
|
|
100
|
+
|
|
101
|
+
# `VerificationResult.schema_version` when the fact's own version could not be
|
|
102
|
+
# identified at all. Not 1: reporting an unidentifiable record as "legacy v1" is the
|
|
103
|
+
# same laundering this slice exists to stop.
|
|
104
|
+
SCHEMA_VERSION_UNIDENTIFIED = 0
|
|
105
|
+
|
|
84
106
|
|
|
85
107
|
@dataclass
|
|
86
108
|
class PinnedKey:
|
|
@@ -105,6 +127,12 @@ class VerificationResult:
|
|
|
105
127
|
confidence: float
|
|
106
128
|
trust_class: str = TRUST_CLASS_UNVERIFIED
|
|
107
129
|
error: Optional[str] = None
|
|
130
|
+
# --- what the signature actually covered (ADR-003, D-6) --------------------
|
|
131
|
+
# Turns "the pre-v3 hole is documented" into "the pre-v3 hole is a VALUE a
|
|
132
|
+
# caller can branch on". A consumer asking *may I rely on trust_class?* gets an
|
|
133
|
+
# answer from the object instead of from a paragraph a tired reader skips at 2am.
|
|
134
|
+
schema_version: int = 1
|
|
135
|
+
signed_fields: Tuple[str, ...] = ()
|
|
108
136
|
|
|
109
137
|
def to_dict(self) -> Dict[str, Any]:
|
|
110
138
|
return asdict(self)
|
|
@@ -132,6 +160,15 @@ class SignedFact:
|
|
|
132
160
|
evidence_class: Optional[str] = None
|
|
133
161
|
fetch_date: Optional[str] = None
|
|
134
162
|
source_date: Optional[str] = None
|
|
163
|
+
# --- applicability axis (slice C / ADR-001). None = legacy fact: the study
|
|
164
|
+
# population is UNKNOWN, which is neither "stated" nor "unstated-with-reason".
|
|
165
|
+
# The DTO stays PERMISSIVE on purpose — a loader that refuses to parse a legacy
|
|
166
|
+
# record cannot report on it, and a record we cannot parse is one we cannot warn
|
|
167
|
+
# about. The mandatory-ness lives on the five CREATION paths, not here.
|
|
168
|
+
study_population: Optional[Dict[str, Any]] = None
|
|
169
|
+
# Self-describing schema version. Signed indirectly, via the "schema" marker in
|
|
170
|
+
# the v3 message: stripping it makes the verifier rebuild a different text.
|
|
171
|
+
schema_version: Optional[int] = None
|
|
135
172
|
|
|
136
173
|
def to_dict(self) -> Dict[str, Any]:
|
|
137
174
|
return asdict(self)
|
|
@@ -193,8 +230,16 @@ class CitationChain:
|
|
|
193
230
|
|
|
194
231
|
|
|
195
232
|
def canonical_json(data: Dict[str, Any]) -> str:
|
|
196
|
-
"""Return deterministic JSON for signatures and hashes.
|
|
197
|
-
|
|
233
|
+
"""Return deterministic JSON for signatures and hashes.
|
|
234
|
+
|
|
235
|
+
`allow_nan=False` (D-18): NaN/Infinity are not JSON, and Python's default emits
|
|
236
|
+
them as bare `NaN`/`Infinity` tokens that another runtime may reject or reparse
|
|
237
|
+
differently — a serialization difference on the far side of a signed message is
|
|
238
|
+
a security bug, not a formatting nit. Signing REFUSES rather than emitting a
|
|
239
|
+
text no other reader can reproduce. Byte-identical for every value that does
|
|
240
|
+
not contain NaN/Infinity, so v1/v2 messages are unchanged.
|
|
241
|
+
"""
|
|
242
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False)
|
|
198
243
|
|
|
199
244
|
|
|
200
245
|
def canonical_fact_message_v1(fact: SignedFact) -> str:
|
|
@@ -237,15 +282,175 @@ def canonical_fact_message_v2(fact: SignedFact) -> str:
|
|
|
237
282
|
)
|
|
238
283
|
|
|
239
284
|
|
|
285
|
+
def _canonical_metadata(metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
286
|
+
"""`metadata` enters the v3 message as a NESTED OBJECT, never as a digest.
|
|
287
|
+
|
|
288
|
+
A digest would sign the bytes without letting a reader see what was signed; a
|
|
289
|
+
nested object keeps the audit trail readable and still tamper-evident, because
|
|
290
|
+
`canonical_json` sorts keys deterministically. Non-serializable content raises
|
|
291
|
+
(D-18) instead of being dropped — a silently dropped key is an UNSIGNED key.
|
|
292
|
+
"""
|
|
293
|
+
payload = dict(metadata or {})
|
|
294
|
+
try:
|
|
295
|
+
canonical_json(payload)
|
|
296
|
+
except (TypeError, ValueError) as exc:
|
|
297
|
+
raise ValueError(
|
|
298
|
+
f"metadata is not canonically serializable ({exc}) — refusing to sign a message whose "
|
|
299
|
+
f"contents cannot be reproduced byte-for-byte by the verifier"
|
|
300
|
+
) from None
|
|
301
|
+
return payload
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def canonical_fact_message_v3(fact: SignedFact) -> str:
|
|
305
|
+
"""Signed message that ADDITIONALLY covers the fields that decide how far a fact
|
|
306
|
+
is trusted (ADR-003): `study_population`, `trust_class`, `confidence`, `metadata`.
|
|
307
|
+
|
|
308
|
+
Why this is the whole of Part 3: `verify_fact()` selects its verification branch
|
|
309
|
+
from `fact.trust_class` BEFORE any signature check. While `trust_class` sat
|
|
310
|
+
outside the signed bytes, a fact signed `ISSUER_SIGNED` under a since-revoked pin
|
|
311
|
+
could be relabelled `SELF_ATTESTED` with a text editor — the signature still
|
|
312
|
+
verified (against the embedded key, on a branch that never consults the pin) and
|
|
313
|
+
the fact came back verified at 0.60. A field that SELECTS the verification branch
|
|
314
|
+
must be inside the envelope that branch is verifying.
|
|
315
|
+
|
|
316
|
+
`confidence` is signed as a FIXED-WIDTH string: float repr differs across
|
|
317
|
+
runtimes, and a signed message that two runtimes serialize differently is a
|
|
318
|
+
signature that fails for the wrong reason.
|
|
319
|
+
|
|
320
|
+
v1 and v2 remain byte-frozen: this function is additive, never a rewrite of them.
|
|
321
|
+
"""
|
|
322
|
+
return canonical_json(
|
|
323
|
+
{
|
|
324
|
+
"schema": FACT_SCHEMA_V3,
|
|
325
|
+
"claim": fact.claim,
|
|
326
|
+
"confidence": format(round(float(fact.confidence or 0.0), 4), ".4f"),
|
|
327
|
+
"evidence_class": fact.evidence_class,
|
|
328
|
+
"fetch_date": fact.fetch_date,
|
|
329
|
+
"issuer": fact.issuer,
|
|
330
|
+
"metadata": _canonical_metadata(fact.metadata),
|
|
331
|
+
"research_context": fact.research_context,
|
|
332
|
+
"source_date": fact.source_date,
|
|
333
|
+
"source_hash": fact.source_hash,
|
|
334
|
+
"source_url": fact.source_url,
|
|
335
|
+
"study_population": fact.study_population,
|
|
336
|
+
"timestamp": fact.timestamp,
|
|
337
|
+
"trust_class": fact.trust_class,
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class SchemaVersionError(ValueError):
|
|
343
|
+
"""A fact whose schema cannot be identified. A subclass of ValueError so callers
|
|
344
|
+
that already handle malformed facts keep working."""
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def coerce_schema_version(value: Any) -> Optional[int]:
|
|
348
|
+
"""`value` → an integer schema version, or None when it is not one (QE G6).
|
|
349
|
+
|
|
350
|
+
`int("not-a-number")` raised an UNCAUGHT ValueError out of the middle of a gate
|
|
351
|
+
run — a traceback instead of a refusal. Parsing is now total: every input either
|
|
352
|
+
names a version or names nothing, and the caller decides what to do with nothing.
|
|
353
|
+
`bool` is rejected explicitly (`True` is not schema 1), and a non-integral float
|
|
354
|
+
is rejected rather than truncated: `2.9` is not "schema 2".
|
|
355
|
+
"""
|
|
356
|
+
if isinstance(value, bool):
|
|
357
|
+
return None
|
|
358
|
+
if isinstance(value, int):
|
|
359
|
+
return value
|
|
360
|
+
if isinstance(value, float):
|
|
361
|
+
return int(value) if float(value).is_integer() else None
|
|
362
|
+
if isinstance(value, str):
|
|
363
|
+
try:
|
|
364
|
+
return int(value.strip())
|
|
365
|
+
except (TypeError, ValueError):
|
|
366
|
+
return None
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def schema_version_of_mapping(data: Any) -> int:
|
|
371
|
+
"""The schema dispatch, over a RAW ledger dict.
|
|
372
|
+
|
|
373
|
+
ONE definition, two entry points: `fact_schema_version` (a `SignedFact`) delegates
|
|
374
|
+
here, and the report gate calls it directly on the JSON it loaded. The gate cannot
|
|
375
|
+
build a `SignedFact` from a partial ledger record — the DTO's fields are mandatory
|
|
376
|
+
— and a second copy of "which schema is this?" would be free to disagree with this
|
|
377
|
+
one exactly where it matters.
|
|
378
|
+
"""
|
|
379
|
+
if not isinstance(data, dict):
|
|
380
|
+
raise SchemaVersionError(f"schema dispatch needs a mapping, got {type(data).__name__}")
|
|
381
|
+
declared = data.get("schema_version")
|
|
382
|
+
if declared is not None:
|
|
383
|
+
version = coerce_schema_version(declared)
|
|
384
|
+
if version is None:
|
|
385
|
+
raise SchemaVersionError(
|
|
386
|
+
f"schema_version {declared!r} is not an integer — a fact whose schema "
|
|
387
|
+
f"cannot be identified cannot be verified against any message"
|
|
388
|
+
)
|
|
389
|
+
return version
|
|
390
|
+
if data.get("study_population") is not None:
|
|
391
|
+
return 3
|
|
392
|
+
if data.get("evidence_class") is not None:
|
|
393
|
+
return 2
|
|
394
|
+
return 1
|
|
395
|
+
|
|
396
|
+
|
|
240
397
|
def fact_schema_version(fact: SignedFact) -> int:
|
|
241
|
-
"""Which signed-message schema this fact uses.
|
|
242
|
-
|
|
243
|
-
|
|
398
|
+
"""Which signed-message schema this fact uses.
|
|
399
|
+
|
|
400
|
+
Self-describing first (a v3 fact stores `schema_version=3`), then dispatch by
|
|
401
|
+
field PRESENCE for records written before that field existed. Both routes are
|
|
402
|
+
tamper-evident, and for the same reason: whichever version the verifier picks, it
|
|
403
|
+
rebuilds THAT version's text, and a text that differs from the signed one fails.
|
|
404
|
+
Stripping `schema_version` from a v3 fact makes it look like v2 → the v2 text is
|
|
405
|
+
rebuilt → mismatch → refused.
|
|
406
|
+
|
|
407
|
+
A `schema_version` that is not an integer raises `SchemaVersionError` — a NAMED
|
|
408
|
+
refusal that `verify_fact()` converts into `verified=False`, instead of the bare
|
|
409
|
+
`int()` crash it used to be.
|
|
410
|
+
"""
|
|
411
|
+
return schema_version_of_mapping({
|
|
412
|
+
"schema_version": fact.schema_version,
|
|
413
|
+
"study_population": fact.study_population,
|
|
414
|
+
"evidence_class": fact.evidence_class,
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def signed_fields_for(fact: SignedFact) -> Tuple[str, ...]:
|
|
419
|
+
"""The keys the fact's own signed message actually covers, sorted (D-6).
|
|
420
|
+
|
|
421
|
+
Derived from the message that was ACTUALLY built, not from a hand-maintained
|
|
422
|
+
table — a table would be a second definition of the same truth, and the two
|
|
423
|
+
would drift. `"schema"` is dropped: it is the marker, not a fact field.
|
|
424
|
+
"""
|
|
425
|
+
try:
|
|
426
|
+
payload = json.loads(canonical_fact_message(fact))
|
|
427
|
+
except Exception:
|
|
428
|
+
return ()
|
|
429
|
+
return tuple(sorted(key for key in payload if key != "schema"))
|
|
244
430
|
|
|
245
431
|
|
|
246
432
|
def canonical_fact_message(fact: SignedFact) -> str:
|
|
247
|
-
"""Canonical signed message for a fact — dispatched by schema version.
|
|
248
|
-
|
|
433
|
+
"""Canonical signed message for a fact — dispatched by schema version.
|
|
434
|
+
|
|
435
|
+
DISPATCH IS EXACT, NOT A BAND (QE G6). The band `version >= 3` accepted any
|
|
436
|
+
number at or above 3 as "v3", so `schema_version` could be moved 3 → 99 and the
|
|
437
|
+
fact still verified: within the band the value is not covered by the signed text,
|
|
438
|
+
which made a self-describing field silently editable. An unknown version now
|
|
439
|
+
RAISES — the verifier refuses to guess which text a schema it has never seen was
|
|
440
|
+
signed against, and `verify_fact()` turns that refusal into `verified=False`.
|
|
441
|
+
Adding v4 is one new branch, and the refusal is what forces that branch to exist.
|
|
442
|
+
"""
|
|
443
|
+
version = fact_schema_version(fact)
|
|
444
|
+
if version == 3:
|
|
445
|
+
return canonical_fact_message_v3(fact)
|
|
446
|
+
if version == 2:
|
|
447
|
+
return canonical_fact_message_v2(fact)
|
|
448
|
+
if version == 1:
|
|
449
|
+
return canonical_fact_message_v1(fact)
|
|
450
|
+
raise SchemaVersionError(
|
|
451
|
+
f"schema_version {version!r} is not a schema this verifier knows "
|
|
452
|
+
f"{KNOWN_SCHEMA_VERSIONS} — refusing to reconstruct a message for it"
|
|
453
|
+
)
|
|
249
454
|
|
|
250
455
|
|
|
251
456
|
def evidence_ceiling(evidence_class: Optional[str]) -> float:
|
|
@@ -264,15 +469,30 @@ def source_tier_ceiling(fact: SignedFact) -> float:
|
|
|
264
469
|
min(trust, evidence) while the docs promised min(trust, evidence, tier), so an
|
|
265
470
|
unknown-domain fact kept 0.60 instead of the promised 0.40 (Codex QE #6).
|
|
266
471
|
|
|
267
|
-
APPLIES
|
|
268
|
-
signed before it existed: wiring it globally silently downgraded a legacy
|
|
472
|
+
APPLIES FROM SCHEMA v2 ONWARD. A new ceiling must not retroactively re-score
|
|
473
|
+
records signed before it existed: wiring it globally silently downgraded a legacy
|
|
269
474
|
ISSUER_SIGNED fact from 0.95 to 0.40 and broke the backward-compatibility test
|
|
270
475
|
that is this feature's NFR-2 evidence. Old facts keep the semantics they were
|
|
271
476
|
created under; the tier applies from the schema that introduced it.
|
|
272
477
|
|
|
478
|
+
THE SCOPE IS A LOWER BOUND, NOT AN EQUALITY (ADR-005 / D-20). The condition read
|
|
479
|
+
`!= 2` — which says "applies to exactly v2" — and the two readings coincided only
|
|
480
|
+
while 2 was the newest schema. Minting v3 therefore switched this ceiling OFF in
|
|
481
|
+
silence (MEASURED: the same unknown-domain fact scored 0.40 at schema 2 and 1.0 at
|
|
482
|
+
schema 3). That is the SAME defect this docstring already records as shipped once,
|
|
483
|
+
with one difference that makes it worse: the first time the ceiling was never
|
|
484
|
+
wired; the second time it was wired, tested, and then disarmed by a migration two
|
|
485
|
+
functions away.
|
|
486
|
+
|
|
273
487
|
Import is local and fail-open: source_tiers is a DATA module, and missing data
|
|
274
488
|
must never break signature verification."""
|
|
275
|
-
|
|
489
|
+
try:
|
|
490
|
+
version = fact_schema_version(fact)
|
|
491
|
+
except SchemaVersionError:
|
|
492
|
+
# An unidentifiable schema is the MOST cautious case, not an exempt one: the
|
|
493
|
+
# same fail-closed reasoning as a missing source_tiers module below.
|
|
494
|
+
return 0.40
|
|
495
|
+
if version < TIER_CEILING_MIN_SCHEMA or not fact.source_url:
|
|
276
496
|
return 1.0
|
|
277
497
|
# FAIL CLOSED (Codex QE r2): the first version returned 1.0 when source_tiers
|
|
278
498
|
# was missing or raised, so losing the security-data module SILENTLY RAISED
|
|
@@ -289,6 +509,49 @@ def source_tier_ceiling(fact: SignedFact) -> float:
|
|
|
289
509
|
return TIER_CEILINGS.get(TIER_D, 0.40)
|
|
290
510
|
|
|
291
511
|
|
|
512
|
+
def _require_study_population(value: Any) -> Dict[str, Any]:
|
|
513
|
+
"""SHAPE-ONLY validation of the `study_population` an author supplies (D-2).
|
|
514
|
+
|
|
515
|
+
ARCHITECTURAL CONSTRAINT (05_architecture.md §1.1c): this module must NEVER
|
|
516
|
+
import `population_match`. A crypto module whose correctness depends on importing
|
|
517
|
+
a semantics module is exactly the fail-open shape `source_tier_ceiling` was
|
|
518
|
+
already bitten by — losing a data module SILENTLY RAISED every confidence. So
|
|
519
|
+
`study_population` crosses the boundary as an OPAQUE JSON dict: the verifier
|
|
520
|
+
validates its shape, never its meaning, and never interprets a criterion.
|
|
521
|
+
|
|
522
|
+
Accepts anything exposing `to_dict()` (e.g. population_match.StudyPopulation)
|
|
523
|
+
without importing that type — duck-typing here is a boundary, not a shortcut.
|
|
524
|
+
"""
|
|
525
|
+
if value is None:
|
|
526
|
+
raise ValueError(
|
|
527
|
+
"study_population is required — a fact that does not say WHO the finding was measured "
|
|
528
|
+
"in cannot be checked against any patient. Use StudyPopulation.unstated(reason) if the "
|
|
529
|
+
"source genuinely does not state it; an unrecorded reason is indistinguishable from a bug."
|
|
530
|
+
)
|
|
531
|
+
if hasattr(value, "to_dict") and not isinstance(value, dict):
|
|
532
|
+
value = value.to_dict()
|
|
533
|
+
if not isinstance(value, dict):
|
|
534
|
+
raise ValueError(f"study_population must be a JSON object, got {type(value).__name__}")
|
|
535
|
+
description = value.get("description")
|
|
536
|
+
if not isinstance(description, str) or not description.strip():
|
|
537
|
+
raise ValueError("study_population.description must be a non-empty string")
|
|
538
|
+
criteria = value.get("criteria") or {}
|
|
539
|
+
unstated_reason = value.get("unstated_reason") or ""
|
|
540
|
+
if not criteria and not str(unstated_reason).strip():
|
|
541
|
+
raise ValueError(
|
|
542
|
+
"study_population needs at least one criterion, or an explicit unstated_reason — "
|
|
543
|
+
"`{description: 'adults', criteria: {}}` is present and meaningless"
|
|
544
|
+
)
|
|
545
|
+
try:
|
|
546
|
+
canonical_json(value)
|
|
547
|
+
except (TypeError, ValueError) as exc:
|
|
548
|
+
raise ValueError(
|
|
549
|
+
f"study_population is not canonically serializable ({exc}) — a key the verifier cannot "
|
|
550
|
+
f"reproduce byte-for-byte is a key that is not really signed"
|
|
551
|
+
) from None
|
|
552
|
+
return value
|
|
553
|
+
|
|
554
|
+
|
|
292
555
|
def fact_content_hash(fact: SignedFact) -> str:
|
|
293
556
|
"""Stable hash for chain linkage. Excludes parent links and chain position."""
|
|
294
557
|
return hashlib.sha256(canonical_fact_message(fact).encode("utf-8")).hexdigest()
|
|
@@ -485,11 +748,21 @@ class Ed25519Verifier:
|
|
|
485
748
|
issuer: str,
|
|
486
749
|
metadata: Optional[Dict[str, Any]] = None,
|
|
487
750
|
research_context: Optional[str] = None,
|
|
751
|
+
*,
|
|
752
|
+
study_population: Any,
|
|
488
753
|
) -> SignedFact:
|
|
489
|
-
"""Create a researcher self-attested fact. This never grants issuer trust.
|
|
754
|
+
"""Create a researcher self-attested fact. This never grants issuer trust.
|
|
755
|
+
|
|
756
|
+
`study_population` is KEYWORD-ONLY WITH NO DEFAULT (FR-1, D-1): omitting it is
|
|
757
|
+
a `TypeError` raised by Python itself, not a validation branch a later author
|
|
758
|
+
can soften. Keyword-only rather than positional so an un-migrated caller fails
|
|
759
|
+
AT the call, naming the parameter, instead of silently absorbing its next
|
|
760
|
+
argument.
|
|
761
|
+
"""
|
|
490
762
|
if self._private_key is None or self._public_key is None:
|
|
491
763
|
raise ValueError("No keypair loaded.")
|
|
492
764
|
|
|
765
|
+
population = _require_study_population(study_population)
|
|
493
766
|
source_hash = hashlib.sha256(source_content.encode("utf-8")).hexdigest()
|
|
494
767
|
timestamp = datetime.utcnow().isoformat() + "Z"
|
|
495
768
|
public_key_b64 = self.get_public_key_b64()
|
|
@@ -505,6 +778,8 @@ class Ed25519Verifier:
|
|
|
505
778
|
trust_class=TRUST_CLASS_SELF_ATTESTED,
|
|
506
779
|
research_context=research_context,
|
|
507
780
|
metadata=metadata or {},
|
|
781
|
+
study_population=population,
|
|
782
|
+
schema_version=3,
|
|
508
783
|
)
|
|
509
784
|
fact.signature, _ = self.sign_content(canonical_fact_message(fact))
|
|
510
785
|
return fact
|
|
@@ -528,11 +803,14 @@ class Ed25519Verifier:
|
|
|
528
803
|
metadata: Optional[Dict[str, Any]],
|
|
529
804
|
research_context: Optional[str],
|
|
530
805
|
base_confidence: float,
|
|
806
|
+
*,
|
|
807
|
+
study_population: Any,
|
|
531
808
|
) -> SignedFact:
|
|
532
809
|
if self._private_key is None or self._public_key is None:
|
|
533
810
|
raise ValueError("No keypair loaded.")
|
|
534
811
|
if evidence_class not in EVIDENCE_CLASSES:
|
|
535
812
|
raise ValueError(f"unknown evidence_class {evidence_class!r}; expected one of {EVIDENCE_CLASSES}")
|
|
813
|
+
population = _require_study_population(study_population)
|
|
536
814
|
fact = SignedFact(
|
|
537
815
|
claim=claim,
|
|
538
816
|
source_url=source_url,
|
|
@@ -548,7 +826,12 @@ class Ed25519Verifier:
|
|
|
548
826
|
evidence_class=evidence_class,
|
|
549
827
|
fetch_date=fetch_date,
|
|
550
828
|
source_date=source_date,
|
|
829
|
+
study_population=population,
|
|
830
|
+
schema_version=3,
|
|
551
831
|
)
|
|
832
|
+
# ORDERING IS LOAD-BEARING: `confidence` is clamped by the evidence ceiling
|
|
833
|
+
# ABOVE, before signing. Signing first and clamping after would put a number
|
|
834
|
+
# in the envelope that the verifier then contradicts.
|
|
552
835
|
fact.signature, _ = self.sign_content(canonical_fact_message(fact))
|
|
553
836
|
return fact
|
|
554
837
|
|
|
@@ -560,6 +843,8 @@ class Ed25519Verifier:
|
|
|
560
843
|
source_date: Optional[str] = None,
|
|
561
844
|
metadata: Optional[Dict[str, Any]] = None,
|
|
562
845
|
research_context: Optional[str] = None,
|
|
846
|
+
*,
|
|
847
|
+
study_population: Any,
|
|
563
848
|
) -> SignedFact:
|
|
564
849
|
"""FETCH_VERIFIED — requires proof the request actually happened.
|
|
565
850
|
|
|
@@ -605,6 +890,7 @@ class Ed25519Verifier:
|
|
|
605
890
|
metadata=meta,
|
|
606
891
|
research_context=research_context,
|
|
607
892
|
base_confidence=0.60,
|
|
893
|
+
study_population=study_population,
|
|
608
894
|
)
|
|
609
895
|
|
|
610
896
|
def create_listing_fact(
|
|
@@ -617,6 +903,8 @@ class Ed25519Verifier:
|
|
|
617
903
|
source_date: Optional[str] = None,
|
|
618
904
|
metadata: Optional[Dict[str, Any]] = None,
|
|
619
905
|
research_context: Optional[str] = None,
|
|
906
|
+
*,
|
|
907
|
+
study_population: Any,
|
|
620
908
|
) -> SignedFact:
|
|
621
909
|
"""LISTING_ONLY — the URL is known but this script did not fetch it, or a
|
|
622
910
|
body was supplied by hand. `reason` is MANDATORY and stored verbatim: a
|
|
@@ -640,6 +928,7 @@ class Ed25519Verifier:
|
|
|
640
928
|
metadata=meta,
|
|
641
929
|
research_context=research_context,
|
|
642
930
|
base_confidence=0.50,
|
|
931
|
+
study_population=study_population,
|
|
643
932
|
)
|
|
644
933
|
|
|
645
934
|
def create_asserted_fact(
|
|
@@ -649,6 +938,8 @@ class Ed25519Verifier:
|
|
|
649
938
|
source_url: str = "",
|
|
650
939
|
metadata: Optional[Dict[str, Any]] = None,
|
|
651
940
|
research_context: Optional[str] = None,
|
|
941
|
+
*,
|
|
942
|
+
study_population: Any,
|
|
652
943
|
) -> SignedFact:
|
|
653
944
|
"""ASSERTED — stated from model memory, source never opened. Confidence is
|
|
654
945
|
0.0 by construction: this is not weak evidence, it is no evidence. Such a
|
|
@@ -666,6 +957,7 @@ class Ed25519Verifier:
|
|
|
666
957
|
metadata=metadata,
|
|
667
958
|
research_context=research_context,
|
|
668
959
|
base_confidence=0.0,
|
|
960
|
+
study_population=study_population,
|
|
669
961
|
)
|
|
670
962
|
|
|
671
963
|
def create_issuer_signed_fact(
|
|
@@ -676,10 +968,13 @@ class Ed25519Verifier:
|
|
|
676
968
|
issuer: str,
|
|
677
969
|
metadata: Optional[Dict[str, Any]] = None,
|
|
678
970
|
research_context: Optional[str] = None,
|
|
971
|
+
*,
|
|
972
|
+
study_population: Any,
|
|
679
973
|
) -> SignedFact:
|
|
680
974
|
"""Create a fact intended to verify against the active pinned key for issuer."""
|
|
681
975
|
if self._public_key is None:
|
|
682
976
|
raise ValueError("No keypair loaded.")
|
|
977
|
+
population = _require_study_population(study_population)
|
|
683
978
|
source_hash = hashlib.sha256(source_content.encode("utf-8")).hexdigest()
|
|
684
979
|
timestamp = datetime.utcnow().isoformat() + "Z"
|
|
685
980
|
fact = SignedFact(
|
|
@@ -694,6 +989,8 @@ class Ed25519Verifier:
|
|
|
694
989
|
trust_class=TRUST_CLASS_ISSUER_SIGNED,
|
|
695
990
|
research_context=research_context,
|
|
696
991
|
metadata=metadata or {},
|
|
992
|
+
study_population=population,
|
|
993
|
+
schema_version=3,
|
|
697
994
|
)
|
|
698
995
|
fact.signature, _ = self.sign_content(canonical_fact_message(fact))
|
|
699
996
|
return fact
|
|
@@ -706,6 +1003,12 @@ class Ed25519Verifier:
|
|
|
706
1003
|
trust_class: str,
|
|
707
1004
|
error: Optional[str],
|
|
708
1005
|
) -> VerificationResult:
|
|
1006
|
+
try:
|
|
1007
|
+
schema_version = fact_schema_version(fact)
|
|
1008
|
+
except SchemaVersionError:
|
|
1009
|
+
# The result object must be constructible for EVERY fact, including the
|
|
1010
|
+
# one whose version is the reason it is being refused (QE G6).
|
|
1011
|
+
schema_version = SCHEMA_VERSION_UNIDENTIFIED
|
|
709
1012
|
result = VerificationResult(
|
|
710
1013
|
verified=verified,
|
|
711
1014
|
content_hash=fact.source_hash,
|
|
@@ -716,10 +1019,55 @@ class Ed25519Verifier:
|
|
|
716
1019
|
confidence=confidence,
|
|
717
1020
|
trust_class=trust_class,
|
|
718
1021
|
error=error,
|
|
1022
|
+
schema_version=schema_version,
|
|
1023
|
+
signed_fields=signed_fields_for(fact),
|
|
719
1024
|
)
|
|
720
1025
|
self.verification_ledger.append(result)
|
|
721
1026
|
return result
|
|
722
1027
|
|
|
1028
|
+
def _schema_refusal(self, fact: SignedFact) -> Optional[str]:
|
|
1029
|
+
"""The refusal text for a fact whose schema cannot be identified, or None.
|
|
1030
|
+
|
|
1031
|
+
Two ways a schema is unusable, and both used to end in a traceback rather than
|
|
1032
|
+
a verdict (QE G6): a `schema_version` that is not an integer at all, and one
|
|
1033
|
+
that is an integer this verifier has no message builder for.
|
|
1034
|
+
"""
|
|
1035
|
+
try:
|
|
1036
|
+
version = fact_schema_version(fact)
|
|
1037
|
+
except SchemaVersionError as exc:
|
|
1038
|
+
return str(exc)
|
|
1039
|
+
if version not in KNOWN_SCHEMA_VERSIONS:
|
|
1040
|
+
return (
|
|
1041
|
+
f"schema_version {version} is not one of {KNOWN_SCHEMA_VERSIONS} — this verifier "
|
|
1042
|
+
f"cannot reconstruct the message such a fact would have been signed against"
|
|
1043
|
+
)
|
|
1044
|
+
return None
|
|
1045
|
+
|
|
1046
|
+
def _legacy_reclaim_belt(self, fact: SignedFact) -> Optional[str]:
|
|
1047
|
+
"""BOUNDED BELT for pre-v3 facts, not a closure (M3.7).
|
|
1048
|
+
|
|
1049
|
+
A v1/v2 fact never signed its `trust_class`, so the §0.1 reclaim attack
|
|
1050
|
+
remains available against every record signed before this slice. What CAN be
|
|
1051
|
+
checked is the one field those schemas DO sign: `issuer`. So if a fact claims
|
|
1052
|
+
SELF_ATTESTED while its issuer holds a NON-ACTIVE pin, refuse — the attacker
|
|
1053
|
+
cannot redirect the lookup, because `issuer` is inside the signed bytes.
|
|
1054
|
+
|
|
1055
|
+
HONEST SCOPE: an UNPINNED issuer's fact is still launderable to
|
|
1056
|
+
SELF_ATTESTED @ 0.60. That residual is unclosable for bytes signed without
|
|
1057
|
+
`trust_class`, and it is named in SKILL.md rather than papered over.
|
|
1058
|
+
"""
|
|
1059
|
+
if fact_schema_version(fact) >= 3:
|
|
1060
|
+
return None
|
|
1061
|
+
if fact.trust_class != TRUST_CLASS_SELF_ATTESTED:
|
|
1062
|
+
return None
|
|
1063
|
+
pin = self.registry.get(fact.issuer)
|
|
1064
|
+
if pin is not None and pin.status != "active":
|
|
1065
|
+
return (
|
|
1066
|
+
f"legacy fact claims {TRUST_CLASS_SELF_ATTESTED} but its issuer's pinned key is "
|
|
1067
|
+
f"{pin.status} — pre-v3 facts do not sign trust_class, so the claim cannot be trusted"
|
|
1068
|
+
)
|
|
1069
|
+
return None
|
|
1070
|
+
|
|
723
1071
|
def verify_fact(self, fact: SignedFact) -> VerificationResult:
|
|
724
1072
|
"""
|
|
725
1073
|
Verify a signed fact.
|
|
@@ -731,8 +1079,20 @@ class Ed25519Verifier:
|
|
|
731
1079
|
if not fact.issuer_pubkey.startswith("ed25519:"):
|
|
732
1080
|
return self._result(fact, False, 0.0, TRUST_CLASS_UNVERIFIED, "Invalid public key format")
|
|
733
1081
|
|
|
1082
|
+
# A fact whose schema cannot be identified is REFUSED, never crashed on and
|
|
1083
|
+
# never guessed at (QE G6). This is deliberately the FIRST substantive check:
|
|
1084
|
+
# every branch below reconstructs a message, and there is no message to
|
|
1085
|
+
# reconstruct for a schema this verifier does not know.
|
|
1086
|
+
schema_refusal = self._schema_refusal(fact)
|
|
1087
|
+
if schema_refusal is not None:
|
|
1088
|
+
return self._result(fact, False, 0.0, TRUST_CLASS_UNVERIFIED, schema_refusal)
|
|
1089
|
+
|
|
734
1090
|
embedded_pubkey_b64 = strip_ed25519_prefix(fact.issuer_pubkey)
|
|
735
1091
|
|
|
1092
|
+
legacy_refusal = self._legacy_reclaim_belt(fact)
|
|
1093
|
+
if legacy_refusal is not None:
|
|
1094
|
+
return self._result(fact, False, 0.0, TRUST_CLASS_UNVERIFIED, legacy_refusal)
|
|
1095
|
+
|
|
736
1096
|
if fact.trust_class == TRUST_CLASS_SELF_ATTESTED:
|
|
737
1097
|
try:
|
|
738
1098
|
public_key = decode_pubkey_b64(embedded_pubkey_b64)
|
|
@@ -887,12 +1247,23 @@ if __name__ == "__main__":
|
|
|
887
1247
|
verifier = Ed25519Verifier(trusted_issuers={"nature.com": {"pubkey_b64": issuer_pubkey, "status": "active"}})
|
|
888
1248
|
issuer.load_keypair(issuer._private_key, issuer._public_key)
|
|
889
1249
|
|
|
1250
|
+
# The demo is user-facing example code, so it must MODEL the required argument
|
|
1251
|
+
# rather than route around it. This is the shape a caller should copy.
|
|
1252
|
+
DEMO_POPULATION = {
|
|
1253
|
+
"description": "adults aged 40-70 enrolled in the demo cohort",
|
|
1254
|
+
"criteria": {
|
|
1255
|
+
"age": {"op": "range", "value": [40, 70], "kind": "eligibility",
|
|
1256
|
+
"verbatim": "adults aged 40-70", "locator": "[Methods, Participants]"},
|
|
1257
|
+
},
|
|
1258
|
+
}
|
|
1259
|
+
|
|
890
1260
|
fact = issuer.create_issuer_signed_fact(
|
|
891
1261
|
claim="The study found a 25% improvement in efficiency",
|
|
892
1262
|
source_url="https://nature.com/articles/example",
|
|
893
1263
|
source_content="Full article content here...",
|
|
894
1264
|
issuer="nature.com",
|
|
895
1265
|
research_context="demo-run",
|
|
1266
|
+
study_population=DEMO_POPULATION,
|
|
896
1267
|
)
|
|
897
1268
|
result = verifier.verify_fact(fact)
|
|
898
1269
|
print("[1] Pinned issuer fact")
|
|
@@ -907,6 +1278,7 @@ if __name__ == "__main__":
|
|
|
907
1278
|
source_url="https://nature.com/articles/example",
|
|
908
1279
|
source_content="Fake content",
|
|
909
1280
|
issuer="nature.com",
|
|
1281
|
+
study_population=DEMO_POPULATION,
|
|
910
1282
|
)
|
|
911
1283
|
forged_result = verifier.verify_fact(forged)
|
|
912
1284
|
print("[2] Attacker self-signed trusted string")
|
|
@@ -924,6 +1296,7 @@ if __name__ == "__main__":
|
|
|
924
1296
|
source_content=f"Source content {i + 1}",
|
|
925
1297
|
issuer="nature.com",
|
|
926
1298
|
research_context="demo-run",
|
|
1299
|
+
study_population=DEMO_POPULATION,
|
|
927
1300
|
)
|
|
928
1301
|
)
|
|
929
1302
|
issuer.sign_chain(chain)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"fact": {
|
|
3
|
+
"claim": "Omega-3 raised LDL by 44.5% at baseline triglycerides >= 800 mg/dL",
|
|
4
|
+
"confidence": 0.5,
|
|
5
|
+
"evidence_class": "LISTING_ONLY",
|
|
6
|
+
"fetch_date": null,
|
|
7
|
+
"issuer": "researcher",
|
|
8
|
+
"issuer_pubkey": "ed25519:DDG6cj/Q0iJRBvk8lXHissMSQeV7GQQCnYSEysbhV74=",
|
|
9
|
+
"metadata": {
|
|
10
|
+
"evidence_note": "card seen in the search listing; the full text was never opened"
|
|
11
|
+
},
|
|
12
|
+
"parent_citation": null,
|
|
13
|
+
"parent_hash": null,
|
|
14
|
+
"research_context": null,
|
|
15
|
+
"signature": "i9kvNmONaCdlggJQRa5gaqMeQNDvChZ4ehEO9QFqYtRXFUk43ycHCu6aDdoxszOKuZY/uL8tYgsZ0GgbtOBaBA==",
|
|
16
|
+
"source_date": "2012-11-01",
|
|
17
|
+
"source_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
18
|
+
"source_url": "https://pubmed.ncbi.nlm.nih.gov/23083789",
|
|
19
|
+
"timestamp": "2026-08-04T21:11:31.577264Z",
|
|
20
|
+
"trust_class": "SELF_ATTESTED"
|
|
21
|
+
},
|
|
22
|
+
"pubkey_b64": "DDG6cj/Q0iJRBvk8lXHissMSQeV7GQQCnYSEysbhV74="
|
|
23
|
+
}
|