@dzhechkov/p-replicator 1.5.17 → 1.6.0

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 (85) hide show
  1. package/.dz-manifest.json +173 -65
  2. package/CHANGELOG.md +135 -0
  3. package/MULTIPLATFORM_ROADMAP.md +1 -1
  4. package/README/eng/01_quickstart.md +2 -2
  5. package/README/eng/02_user_guide.md +1 -1
  6. package/README/eng/03_admin_guide.md +2 -2
  7. package/README/eng/05_architecture.md +6 -2
  8. package/README/eng/README.md +1 -1
  9. package/README/ru/01_quickstart.md +2 -2
  10. package/README/ru/02_user_guide.md +1 -1
  11. package/README/ru/03_admin_guide.md +2 -2
  12. package/README/ru/05_architecture.md +1 -1
  13. package/README/ru/README.md +1 -1
  14. package/README/ru/html/index.html +7 -7
  15. package/README.md +41 -18
  16. package/bin/cli.js +0 -0
  17. package/package.json +11 -10
  18. package/sbom.json +347 -77
  19. package/src/utils.js +2 -0
  20. package/templates/.claude/agents/doc-validator.md +2 -1
  21. package/templates/.claude/agents/product-discoverer.md +1 -1
  22. package/templates/.claude/commands/next.md +16 -0
  23. package/templates/.claude/commands/replicate.md +126 -8
  24. package/templates/.claude/commands/start.md +19 -1
  25. package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
  26. package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
  27. package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
  28. package/templates/.claude/hooks/check-ports.cjs +232 -0
  29. package/templates/.claude/hooks/session-insights.cjs +13 -1
  30. package/templates/.claude/hooks/state-update.cjs +13 -1
  31. package/templates/.claude/hooks/statusline.cjs +145 -18
  32. package/templates/.claude/rules/docker-ports.md +123 -0
  33. package/templates/.claude/rules/replicate-pipeline.md +5 -2
  34. package/templates/.claude/settings.json +5 -5
  35. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
  36. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
  37. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
  38. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
  39. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
  40. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
  41. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
  42. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
  43. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
  44. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
  45. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
  46. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
  47. package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
  48. package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
  49. package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
  50. package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +890 -303
  51. package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
  52. package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
  53. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
  54. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +969 -344
  55. package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
  56. package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
  57. package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
  58. package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
  59. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
  60. package/templates/.claude/skills/requirements-validator/SKILL.md +7 -0
  61. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
  62. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
  63. package/tests/e2e/lifecycle.test.js +21 -10
  64. package/tests/snapshot/baseline.json +51 -34
  65. package/tests/snapshot/update-baseline.js +2 -1
  66. package/tests/unit/adr-decision-coverage.test.js +137 -0
  67. package/tests/unit/adr-scanner-contract.test.js +108 -0
  68. package/tests/unit/autocommit-deletion.test.js +242 -0
  69. package/tests/unit/check-ports.test.js +184 -0
  70. package/tests/unit/db-port-rule.test.js +216 -0
  71. package/tests/unit/detect-parse-anchor.test.js +109 -0
  72. package/tests/unit/external-dependency-check.test.js +209 -0
  73. package/tests/unit/growth-module-b2b-gate.test.js +104 -0
  74. package/tests/unit/hooks-project-anchored.test.js +223 -0
  75. package/tests/unit/hooks-report-failures.test.js +207 -0
  76. package/tests/unit/pipeline-file-ownership.test.js +95 -0
  77. package/tests/unit/roadmap-one-schema.test.js +179 -0
  78. package/tests/unit/sparc-reconciliation.test.js +117 -0
  79. package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
  80. package/tests/unit/statusline-honest-labels.test.js +178 -0
  81. package/tests/unit/statusline-two-roots.test.js +237 -0
  82. package/tests/unit/sync-templates-guard.test.js +209 -0
  83. package/tests/unit/utils.test.js +2 -2
  84. package/tests/unit/validation-gate-teeth.test.js +158 -0
  85. package/LICENSE +0 -21
@@ -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 and the growth point for a future
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
- return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
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. Presence of the evidence axis
242
- IS the discriminator — a legacy fact has none and stays on v1 forever."""
243
- return 2 if fact.evidence_class is not None else 1
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
- return canonical_fact_message_v2(fact) if fact_schema_version(fact) == 2 else canonical_fact_message_v1(fact)
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 TO v2 FACTS ONLY. A new ceiling must not retroactively re-score records
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
- if fact_schema_version(fact) != 2 or not fact.source_url:
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
+ }