@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
@@ -0,0 +1,554 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ADR-003 / ADR-005 Confirmation tests — the v3 signed message (T-1…T-8, T-17, T-19).
4
+
5
+ cd .../goap-research-ed25519/scripts && python3 -m unittest discover -s . -p 'test_*.py' -v
6
+
7
+ The load-bearing test here is `test_trust_class_reclaim_breaks_v3_signature_discrimination`:
8
+ it models the LIVE attack — sign ISSUER_SIGNED while the pin is active, revoke the
9
+ pin at verify time, reclaim to SELF_ATTESTED, verify — because a pin that was never
10
+ active would fail for the wrong reason and prove nothing.
11
+ """
12
+
13
+ import copy
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+
19
+ # Import NOTHING local before this line (stray __pycache__ reads as canonical drift).
20
+ sys.dont_write_bytecode = True
21
+
22
+ import unittest
23
+
24
+ import ed25519_verifier as ev
25
+
26
+ HERE = os.path.dirname(os.path.abspath(__file__))
27
+
28
+
29
+ def _pop(description="men with obesity (BMI >= 30) enrolled in a weight-loss trial"):
30
+ return {
31
+ "description": description,
32
+ "criteria": {
33
+ "bmi_min": {"op": ">=", "value": 30, "kind": "baseline",
34
+ "verbatim": "men with obesity (BMI >= 30 kg/m2)",
35
+ "locator": "[Methods, Participants]"},
36
+ },
37
+ }
38
+
39
+
40
+ def _verifier():
41
+ v = ev.Ed25519Verifier()
42
+ v.generate_keypair()
43
+ return v
44
+
45
+
46
+ def _fixture(name):
47
+ with open(os.path.join(HERE, name), "r", encoding="utf-8") as handle:
48
+ return json.load(handle)
49
+
50
+
51
+ @unittest.skipIf(ev.CRYPTO_BACKEND is None, "No Ed25519 backend installed")
52
+ class MandatoryStudyPopulationTests(unittest.TestCase):
53
+ """T-1 (AM-1 / FR-1) — five factories x four malformed inputs = 20 refusals.
54
+
55
+ ASSERTS THE `TypeError`, never "the suite is green" (AM-13): giving the argument
56
+ a default keeps the whole suite green and makes only this test fail — which is
57
+ exactly why it is written this way.
58
+ """
59
+
60
+ def setUp(self):
61
+ self.v = _verifier()
62
+ import evidence_fetch as ef
63
+ self.record = ef.FetchRecord(
64
+ url="https://pubmed.ncbi.nlm.nih.gov/123", final_url="https://pubmed.ncbi.nlm.nih.gov/123",
65
+ status=200, sha256_body="a" * 64, bytes_len=10, fetched_at="2026-08-03T10:00:00Z",
66
+ witness=ef._FETCH_WITNESS)
67
+
68
+ def _factories(self):
69
+ return {
70
+ "create_signed_fact": lambda **kw: self.v.create_signed_fact(
71
+ claim="c", source_url="https://x.test", source_content="b", issuer="researcher", **kw),
72
+ "create_issuer_signed_fact": lambda **kw: self.v.create_issuer_signed_fact(
73
+ claim="c", source_url="https://x.test", source_content="b", issuer="researcher", **kw),
74
+ "create_fetched_fact": lambda **kw: self.v.create_fetched_fact(
75
+ claim="c", fetch_record=self.record, issuer="researcher", **kw),
76
+ "create_listing_fact": lambda **kw: self.v.create_listing_fact(
77
+ claim="c", source_url="https://x.test", reason="offline run", **kw),
78
+ "create_asserted_fact": lambda **kw: self.v.create_asserted_fact(claim="c", **kw),
79
+ }
80
+
81
+ def test_create_fact_without_study_population_raises(self):
82
+ cases = 0
83
+ for name, factory in self._factories().items():
84
+ with self.subTest(factory=name, case="omitted"):
85
+ with self.assertRaises(TypeError):
86
+ factory()
87
+ cases += 1
88
+ for case, value in (("None", None), ("empty string", ""),
89
+ ("present but meaningless", {"description": " ", "criteria": {}})):
90
+ with self.subTest(factory=name, case=case):
91
+ with self.assertRaises(ValueError):
92
+ factory(study_population=value)
93
+ cases += 1
94
+ self.assertEqual(cases, 20, "5 factories x 4 malformed inputs")
95
+
96
+ def test_study_population_blank_or_empty_raises(self):
97
+ """T-2 — the API-shape refusal, exercised through the fact-construction path."""
98
+ for bad in ({"description": "adults", "criteria": {}},
99
+ {"description": "", "criteria": {"bmi": 1}},
100
+ {"criteria": {"bmi": 1}},
101
+ "adults"):
102
+ with self.subTest(value=bad):
103
+ with self.assertRaises(ValueError):
104
+ self.v.create_asserted_fact(claim="c", study_population=bad)
105
+
106
+ def test_unstated_requires_reason(self):
107
+ with self.assertRaises(ValueError):
108
+ self.v.create_asserted_fact(claim="c", study_population={
109
+ "description": "population not stated", "criteria": {}, "unstated_reason": " "})
110
+ fact = self.v.create_asserted_fact(claim="c", study_population={
111
+ "description": "population not stated", "criteria": {},
112
+ "unstated_reason": "the abstract never describes who was enrolled"})
113
+ self.assertTrue(self.v.verify_fact(fact).verified)
114
+
115
+ def test_every_new_fact_is_v3(self):
116
+ for name, factory in self._factories().items():
117
+ with self.subTest(factory=name):
118
+ fact = factory(study_population=_pop())
119
+ self.assertEqual(ev.fact_schema_version(fact), 3)
120
+ self.assertIsNotNone(fact.study_population)
121
+ if fact.trust_class == ev.TRUST_CLASS_ISSUER_SIGNED:
122
+ # An issuer-signed fact verifies against the PINNED key, never the
123
+ # embedded one — that is the whole point of the class, so the pin
124
+ # must exist for this check to mean anything.
125
+ self.v.registry.add("researcher", self.v.get_public_key_b64())
126
+ self.assertTrue(self.v.verify_fact(fact).verified)
127
+
128
+ def test_from_dict_stays_permissive(self):
129
+ """FR-1's other half: mandatory on CREATION, never on LOADING. A loader that
130
+ refuses a legacy record cannot report on it."""
131
+ legacy = _fixture("fixture_legacy_v1_fact.json")["fact"]
132
+ fact = ev.SignedFact.from_dict(legacy)
133
+ self.assertIsNone(fact.study_population)
134
+ self.assertEqual(ev.fact_schema_version(fact), 1)
135
+
136
+
137
+ @unittest.skipIf(ev.CRYPTO_BACKEND is None, "No Ed25519 backend installed")
138
+ class V3SignatureCoverageTests(unittest.TestCase):
139
+ """T-3 / T-5 — the four new keys are UNDER the signature, both tamper directions."""
140
+
141
+ def setUp(self):
142
+ self.v = _verifier()
143
+
144
+ def _fact(self, **kw):
145
+ return self.v.create_listing_fact(claim="c", source_url="https://x.test",
146
+ reason="offline run", study_population=_pop(), **kw)
147
+
148
+ def test_tamper_study_population_breaks_v3_signature(self):
149
+ """T-3 (AM-2 / FR-13) — strip / add / swap, the same three-test shape the
150
+ evidence axis already uses."""
151
+ stripped = self._fact()
152
+ self.assertTrue(self.v.verify_fact(stripped).verified)
153
+ stripped.study_population = None
154
+ self.assertFalse(self.v.verify_fact(stripped).verified, "stripping it must break the signature")
155
+
156
+ swapped = self._fact()
157
+ swapped.study_population = _pop("a completely different cohort")
158
+ self.assertFalse(self.v.verify_fact(swapped).verified, "swapping it must break the signature")
159
+
160
+ added = ev.SignedFact.from_dict(_fixture("fixture_legacy_v2_fact.json")["fact"])
161
+ self.assertTrue(self.v.verify_fact(added).verified)
162
+ added.study_population = _pop()
163
+ self.assertFalse(self.v.verify_fact(added).verified, "adding it to a v2 fact must break it")
164
+
165
+ def test_dispatch_tamper_table(self):
166
+ """ADR-003 §2's dispatch rows — MEASURED, and one row came back different from
167
+ what the ADR predicted.
168
+
169
+ What refuses a tampered fact is the KEY-SET/VALUE difference between the two
170
+ reconstructed texts, never the `schema` marker or the `schema_version` field
171
+ (both are self-description). This module has already been taught that lesson
172
+ once: ADR-002's first draft credited the v2 marker with closing the downgrade
173
+ attack and its own discrimination run refuted it.
174
+ """
175
+ rows = {}
176
+ edited = self._fact()
177
+ edited.trust_class = ev.TRUST_CLASS_ISSUER_SIGNED
178
+ rows["edit trust_class"] = edited
179
+
180
+ downgraded = self._fact()
181
+ downgraded.schema_version = 1
182
+ rows["schema_version = 1"] = downgraded
183
+
184
+ both_stripped = self._fact()
185
+ both_stripped.schema_version = None
186
+ both_stripped.study_population = None
187
+ rows["strip schema_version AND study_population (the real downgrade)"] = both_stripped
188
+
189
+ upgraded = ev.SignedFact.from_dict(_fixture("fixture_legacy_v2_fact.json")["fact"])
190
+ upgraded.schema_version = 3
191
+ rows["schema_version added to a v2 fact"] = upgraded
192
+
193
+ for name, fact in rows.items():
194
+ with self.subTest(row=name):
195
+ self.assertFalse(self.v.verify_fact(fact).verified, f"{name} must not verify")
196
+
197
+ def test_stripping_schema_version_alone_is_a_no_op_not_an_attack(self):
198
+ """MEASURED CORRECTION to ADR-003 §2's predicted tamper table (reported, not
199
+ smoothed over — the ADR listed this row as "must fail").
200
+
201
+ It does NOT fail, and it should not: dispatch is by PRESENCE (D-5), so with
202
+ `study_population` still there the verifier recovers version 3, rebuilds the
203
+ identical text, and the signature matches. Nothing about the fact changed —
204
+ `schema_version` is not in the signed message, it is a convenience mirror of a
205
+ property already derivable from the fields. The attack it was supposed to model
206
+ is covered by the row above: stripping the FIELD as well really does downgrade
207
+ the reconstruction, and really does fail.
208
+ """
209
+ fact = self._fact()
210
+ fact.schema_version = None
211
+ self.assertEqual(ev.fact_schema_version(fact), 3, "presence-dispatch recovers the version")
212
+ self.assertTrue(self.v.verify_fact(fact).verified,
213
+ "a no-op edit must not be reported as tampering")
214
+
215
+ def test_tamper_metadata_breaks_v3_signature(self):
216
+ """T-5 / AC-13 — `evidence_note` is the MANDATORY reason for a LISTING_ONLY
217
+ degradation; rewriting it hollows out the audit trail the axis exists for."""
218
+ fact = self._fact()
219
+ self.assertIn("evidence_note", fact.metadata)
220
+ fact.metadata["evidence_note"] = "fetched and verified"
221
+ self.assertFalse(self.v.verify_fact(fact).verified)
222
+
223
+ stripped = self._fact()
224
+ stripped.metadata.pop("evidence_note")
225
+ self.assertFalse(self.v.verify_fact(stripped).verified)
226
+
227
+ def test_tamper_confidence_breaks_v3_signature(self):
228
+ """A DIFFERENT protection than the min() clamp: it protects a consumer that
229
+ reads `fact.confidence` directly without re-running verify_fact()."""
230
+ fact = self._fact()
231
+ fact.confidence = 0.99
232
+ self.assertFalse(self.v.verify_fact(fact).verified)
233
+
234
+ def test_confidence_is_signed_as_a_fixed_width_string(self):
235
+ """Float repr differs across runtimes; a signed text two runtimes serialize
236
+ differently is a signature that fails for the wrong reason."""
237
+ fact = self._fact()
238
+ message = json.loads(ev.canonical_fact_message_v3(fact))
239
+ self.assertEqual(message["confidence"], "0.5000")
240
+ self.assertIsInstance(message["confidence"], str)
241
+
242
+ def test_unserializable_payload_refuses_to_sign(self):
243
+ """D-18 — a silently dropped key is an UNSIGNED key, so signing fails closed."""
244
+ with self.assertRaises(ValueError):
245
+ self.v.create_asserted_fact(claim="c", metadata={"bad": float("nan")},
246
+ study_population=_pop())
247
+ with self.assertRaises(ValueError):
248
+ self.v.create_asserted_fact(claim="c", study_population={
249
+ "description": "d", "criteria": {"bmi": float("inf")}})
250
+
251
+
252
+ @unittest.skipIf(ev.CRYPTO_BACKEND is None, "No Ed25519 backend installed")
253
+ class TrustClassReclaimDiscriminationTests(unittest.TestCase):
254
+ """T-4 (AM-4) — the MANDATORY discrimination proof, modelled as the live attack."""
255
+
256
+ def test_trust_class_reclaim_breaks_v3_signature_discrimination(self):
257
+ signer = ev.Ed25519Verifier(auto_generate_keypair=True)
258
+ verifier = ev.Ed25519Verifier(trusted_issuers={
259
+ "nature.com": {"pubkey_b64": signer.get_public_key_b64(), "status": "active"}})
260
+
261
+ fact = signer.create_issuer_signed_fact(
262
+ claim="testosterone therapy showed no excess cardiovascular harm",
263
+ source_url="https://nature.com/articles/example", source_content="body",
264
+ issuer="nature.com", study_population=_pop())
265
+ before = verifier.verify_fact(fact)
266
+ self.assertTrue(before.verified, "signed while the pin was ACTIVE")
267
+ self.assertEqual(before.trust_class, ev.TRUST_CLASS_ISSUER_SIGNED)
268
+
269
+ # …the key is revoked AFTER signing. This sequencing is the point: a pin that
270
+ # was never active would fail for the wrong reason and prove nothing.
271
+ verifier.registry.add("nature.com", signer.get_public_key_b64(), status="revoked")
272
+ self.assertFalse(verifier.verify_fact(fact).verified, "a revoked pin refuses the honest fact")
273
+
274
+ reclaimed = copy.deepcopy(fact)
275
+ reclaimed.trust_class = ev.TRUST_CLASS_SELF_ATTESTED # the attack, in a text editor
276
+ result = verifier.verify_fact(reclaimed)
277
+ self.assertFalse(result.verified,
278
+ f"the reclaim must not verify (got verified={result.verified}, "
279
+ f"confidence={result.confidence})")
280
+ self.assertEqual(result.confidence, 0.0,
281
+ "pre-fix this returned confidence=0.60 on a revoked-pin fact")
282
+
283
+ # …and the reverse direction, for completeness (FR-11).
284
+ promoted = signer.create_signed_fact(
285
+ claim="c", source_url="https://nature.com/articles/example", source_content="b",
286
+ issuer="nature.com", study_population=_pop())
287
+ self.assertTrue(ev.Ed25519Verifier().verify_fact(promoted).verified)
288
+ promoted.trust_class = ev.TRUST_CLASS_ISSUER_SIGNED
289
+ self.assertFalse(verifier.verify_fact(promoted).verified,
290
+ "SELF_ATTESTED -> ISSUER_SIGNED must break it too")
291
+
292
+ def test_legacy_reclaim_belt_is_bounded_and_honest(self):
293
+ """M3.7 — the v1/v2 belt catches the REVOKED-BUT-PINNED case (issuer IS signed
294
+ in v1/v2, so the lookup cannot be redirected) and openly does NOT catch the
295
+ unpinned-issuer case, which stays launderable to SELF_ATTESTED @ 0.60."""
296
+ signer = ev.Ed25519Verifier(auto_generate_keypair=True)
297
+ legacy = ev.SignedFact(
298
+ claim="c", source_url="https://x.test", source_hash="0" * 64, issuer="nature.com",
299
+ issuer_pubkey=f"ed25519:{signer.get_public_key_b64()}", signature="",
300
+ timestamp="2026-08-03T12:16:18.191716Z", confidence=0.60,
301
+ trust_class=ev.TRUST_CLASS_SELF_ATTESTED)
302
+ legacy.signature, _ = signer.sign_content(ev.canonical_fact_message_v1(legacy))
303
+ self.assertEqual(ev.fact_schema_version(legacy), 1)
304
+
305
+ unpinned = ev.Ed25519Verifier()
306
+ residual = unpinned.verify_fact(legacy)
307
+ self.assertTrue(residual.verified, "the unclosable residual, named rather than hidden")
308
+ self.assertEqual(residual.confidence, 0.60)
309
+
310
+ pinned = ev.Ed25519Verifier(trusted_issuers={
311
+ "nature.com": {"pubkey_b64": signer.get_public_key_b64(), "status": "revoked"}})
312
+ caught = pinned.verify_fact(legacy)
313
+ self.assertFalse(caught.verified)
314
+ self.assertIn("revoked", caught.error)
315
+
316
+
317
+ @unittest.skipIf(ev.CRYPTO_BACKEND is None, "No Ed25519 backend installed")
318
+ class BackwardCompatibilityTests(unittest.TestCase):
319
+ """T-6 / T-7 (AM-5) — COMMITTED fixtures, replayed. Comparing two CURRENT code
320
+ paths would mask exactly the regression these exist to catch."""
321
+
322
+ def test_v1_legacy_fact_still_verifies_after_v3(self):
323
+ fact = ev.SignedFact.from_dict(_fixture("fixture_legacy_v1_fact.json")["fact"])
324
+ self.assertEqual(ev.fact_schema_version(fact), 1)
325
+ self.assertTrue(ev.Ed25519Verifier().verify_fact(fact).verified)
326
+
327
+ def test_v2_legacy_fact_still_verifies_after_v3(self):
328
+ """The v2 fixture was generated from PRE-CHANGE code and committed (M0.2):
329
+ generating it after the first edit would have made this test self-fulfilling."""
330
+ fact = ev.SignedFact.from_dict(_fixture("fixture_legacy_v2_fact.json")["fact"])
331
+ self.assertEqual(ev.fact_schema_version(fact), 2)
332
+ self.assertIsNotNone(fact.evidence_class)
333
+ self.assertIsNone(fact.study_population)
334
+ self.assertTrue(ev.Ed25519Verifier().verify_fact(fact).verified)
335
+
336
+ def test_v1_and_v2_messages_are_byte_frozen(self):
337
+ """D-4 / D-4a — the two older texts must not gain a key when v3 lands."""
338
+ v1 = ev.SignedFact.from_dict(_fixture("fixture_legacy_v1_fact.json")["fact"])
339
+ v2 = ev.SignedFact.from_dict(_fixture("fixture_legacy_v2_fact.json")["fact"])
340
+ self.assertEqual(
341
+ sorted(json.loads(ev.canonical_fact_message_v1(v1))),
342
+ ["claim", "issuer", "research_context", "source_hash", "source_url", "timestamp"])
343
+ self.assertEqual(
344
+ sorted(json.loads(ev.canonical_fact_message_v2(v2))),
345
+ ["claim", "evidence_class", "fetch_date", "issuer", "research_context", "schema",
346
+ "source_date", "source_hash", "source_url", "timestamp"])
347
+
348
+
349
+ @unittest.skipIf(ev.CRYPTO_BACKEND is None, "No Ed25519 backend installed")
350
+ class SignedFieldsTests(unittest.TestCase):
351
+ """T-8 (D-6) — the pre-v3 hole becomes a VALUE a caller can branch on."""
352
+
353
+ def test_verification_result_names_unsigned_fields(self):
354
+ v1 = ev.SignedFact.from_dict(_fixture("fixture_legacy_v1_fact.json")["fact"])
355
+ v2 = ev.SignedFact.from_dict(_fixture("fixture_legacy_v2_fact.json")["fact"])
356
+ verifier = ev.Ed25519Verifier()
357
+
358
+ r1 = verifier.verify_fact(v1)
359
+ self.assertEqual(r1.schema_version, 1)
360
+ self.assertNotIn("trust_class", r1.signed_fields)
361
+
362
+ r2 = verifier.verify_fact(v2)
363
+ self.assertEqual(r2.schema_version, 2)
364
+ self.assertNotIn("trust_class", r2.signed_fields)
365
+ self.assertIn("evidence_class", r2.signed_fields)
366
+
367
+ signer = _verifier()
368
+ v3 = signer.create_asserted_fact(claim="c", study_population=_pop())
369
+ r3 = signer.verify_fact(v3)
370
+ self.assertEqual(r3.schema_version, 3)
371
+ for key in ("trust_class", "metadata", "confidence", "study_population"):
372
+ self.assertIn(key, r3.signed_fields)
373
+
374
+
375
+ class TierCeilingTests(unittest.TestCase):
376
+ """T-19 (AM-15 / ADR-005 / D-20) — the ceiling survives the schema bump."""
377
+
378
+ @unittest.skipIf(ev.CRYPTO_BACKEND is None, "No Ed25519 backend installed")
379
+ def test_am15_tier_ceiling_applies_to_schema_3_facts(self):
380
+ """Three assertions, one property.
381
+
382
+ Minting schema 3 must NOT switch the third ceiling off. The predicate used to
383
+ read `fact_schema_version(fact) != 2` — "applies to exactly v2" — so every fact
384
+ this slice creates would have scored 0.60 instead of the promised 0.40 from an
385
+ unknown domain, silently. The tier-A row proves the tier term is actually being
386
+ MEASURED rather than being a constant; the v1 row proves the lower edge (no
387
+ retroactive re-scoring) still holds.
388
+ """
389
+ v = _verifier()
390
+ unknown_domain = v.create_fetched_fact(
391
+ claim="c", issuer="researcher", study_population=_pop(),
392
+ fetch_record=_authentic_record("https://some-blog.example/p"))
393
+ self.assertEqual(ev.fact_schema_version(unknown_domain), 3)
394
+ self.assertEqual(v.verify_fact(unknown_domain).confidence, 0.40,
395
+ "tier D must cap a schema-3 fact, exactly as it caps a schema-2 one")
396
+
397
+ tier_a = v.create_fetched_fact(
398
+ claim="c", issuer="researcher", study_population=_pop(),
399
+ fetch_record=_authentic_record("https://www.cochrane.org/x"))
400
+ self.assertEqual(v.verify_fact(tier_a).confidence, 0.60,
401
+ "tier A leaves the trust ceiling binding — the tier term is measured, not constant")
402
+
403
+ legacy = ev.SignedFact(
404
+ claim="c", source_url="https://some-blog.example/p", source_hash="0" * 64,
405
+ issuer="researcher", issuer_pubkey=f"ed25519:{v.get_public_key_b64()}", signature="",
406
+ timestamp="2026-08-03T12:16:18.191716Z", confidence=0.60,
407
+ trust_class=ev.TRUST_CLASS_SELF_ATTESTED)
408
+ legacy.signature, _ = v.sign_content(ev.canonical_fact_message_v1(legacy))
409
+ self.assertEqual(ev.fact_schema_version(legacy), 1)
410
+ self.assertEqual(v.verify_fact(legacy).confidence, 0.60,
411
+ "v1 stays exempt — old records keep the semantics they were created under")
412
+
413
+ def test_the_scope_is_a_lower_bound_not_an_equality(self):
414
+ """The shape of the fix, asserted directly: any FUTURE schema must inherit the
415
+ ceiling without another migration. An equality gate has to be re-edited by
416
+ every migration; a lower bound is edited once."""
417
+ self.assertEqual(ev.TIER_CEILING_MIN_SCHEMA, 2)
418
+ source = open(os.path.join(HERE, "ed25519_verifier.py"), encoding="utf-8").read()
419
+ self.assertNotIn("fact_schema_version(fact) != 2", source,
420
+ "the equality predicate must not return")
421
+ self.assertIn("< TIER_CEILING_MIN_SCHEMA", source,
422
+ "the scope must stay a `<` lower bound against the named constant")
423
+
424
+
425
+ @unittest.skipIf(ev.CRYPTO_BACKEND is None, "No Ed25519 backend installed")
426
+ class SchemaVersionRefusalTests(unittest.TestCase):
427
+ """QE G6 — a schema the verifier cannot identify is REFUSED, never crashed on.
428
+
429
+ MEASURED before the fix: loading the committed v2 fixture with
430
+ `schema_version='not-a-number'` and calling `verify_fact` raised an UNCAUGHT
431
+ `ValueError` out of `int(fact.schema_version)`, taking down a gate run with a
432
+ traceback instead of returning `verified=False`. And within the old `>= 3` band the
433
+ value was not covered by the signed text, so `3` could be moved to `99` and the
434
+ fact still verified.
435
+ """
436
+
437
+ def _v3_fact(self):
438
+ v = _verifier()
439
+ return v, v.create_listing_fact(claim="c", source_url="https://x.test",
440
+ reason="offline run", study_population=_pop())
441
+
442
+ def test_malformed_schema_version_refuses_instead_of_raising(self):
443
+ fact = ev.SignedFact.from_dict(_fixture("fixture_legacy_v2_fact.json")["fact"])
444
+ verifier = ev.Ed25519Verifier()
445
+ self.assertTrue(verifier.verify_fact(fact).verified, "the honest fixture still verifies")
446
+
447
+ fact.schema_version = "not-a-number"
448
+ result = verifier.verify_fact(fact) # must NOT raise
449
+ self.assertFalse(result.verified)
450
+ self.assertIn("not an integer", result.error)
451
+ self.assertEqual(result.schema_version, ev.SCHEMA_VERSION_UNIDENTIFIED,
452
+ "an unidentifiable record is not reported as legacy v1")
453
+
454
+ def test_every_malformed_spelling_is_refused(self):
455
+ for bad in ("not-a-number", "", "3.5", " ", [], {}, True, 2.9, object()):
456
+ with self.subTest(value=bad):
457
+ self.assertIsNone(ev.coerce_schema_version(bad))
458
+ for good, expected in ((3, 3), ("3", 3), (" 2 ", 2), (3.0, 3)):
459
+ with self.subTest(value=good):
460
+ self.assertEqual(ev.coerce_schema_version(good), expected)
461
+
462
+ def test_moving_the_version_inside_the_old_band_now_breaks_verification(self):
463
+ """`3 → 99` used to verify: the band accepted anything at or above 3 as v3."""
464
+ v, fact = self._v3_fact()
465
+ self.assertTrue(v.verify_fact(fact).verified)
466
+ fact.schema_version = 99
467
+ result = v.verify_fact(fact)
468
+ self.assertFalse(result.verified, "an unknown schema must not be treated as v3")
469
+ self.assertIn("99", result.error)
470
+ self.assertEqual(ev.KNOWN_SCHEMA_VERSIONS, (1, 2, 3))
471
+
472
+ def test_an_unknown_schema_has_no_message_to_reconstruct(self):
473
+ v, fact = self._v3_fact()
474
+ fact.schema_version = 4
475
+ with self.assertRaises(ev.SchemaVersionError):
476
+ ev.canonical_fact_message(fact)
477
+ self.assertEqual(ev.signed_fields_for(fact), (),
478
+ "and no field may be claimed as signed for it")
479
+
480
+ def test_the_gate_reports_a_malformed_record_instead_of_dying(self):
481
+ """The composition that motivated the finding: this ran inside a gate."""
482
+ import check_report_evidence as gate
483
+ fact = dict(_fixture("fixture_legacy_v2_fact.json")["fact"], schema_version="not-a-number")
484
+ findings = gate.verify_ledger_signatures([fact])
485
+ self.assertTrue(findings, "a record the verifier refuses must surface as a finding")
486
+ self.assertIn(findings[0].kind, ("TAMPERED_FACT", "UNVERIFIABLE_FACT"))
487
+
488
+
489
+ def _authentic_record(url):
490
+ import evidence_fetch as ef
491
+ return ef.FetchRecord(url=url, final_url=url, status=200, sha256_body="a" * 64,
492
+ bytes_len=10, fetched_at="2026-08-03T10:00:00Z", witness=ef._FETCH_WITNESS)
493
+
494
+
495
+ class GrepGateTests(unittest.TestCase):
496
+ """T-17 — no module builds a canonical fact message outside the three functions."""
497
+
498
+ def test_no_inlined_canonical_message_dict(self):
499
+ """Step-0 recalled pattern #2: a new resolver does not deliver a migration
500
+ unless every call site is wired — so grep for old/duplicate literals."""
501
+ offenders = []
502
+ for name in ("population_match.py", "risk_statement.py", "check_report_evidence.py"):
503
+ text = open(os.path.join(HERE, name), encoding="utf-8").read()
504
+ for match in re.finditer(r'"claim"\s*:', text):
505
+ window = text[max(0, match.start() - 400):match.start() + 400]
506
+ if '"source_hash"' in window or '"issuer_pubkey"' in window:
507
+ offenders.append(f"{name}:{text[:match.start()].count(chr(10)) + 1}")
508
+ self.assertEqual(offenders, [], "a canonical message is built in ed25519_verifier.py only")
509
+
510
+ def test_verifier_never_imports_the_semantics_module(self):
511
+ """05_architecture §1.1c — a crypto module whose correctness depends on
512
+ importing a semantics module repeats the fail-open shape source_tier_ceiling
513
+ was already bitten by."""
514
+ source = open(os.path.join(HERE, "ed25519_verifier.py"), encoding="utf-8").read()
515
+ self.assertNotIn("import population_match", source)
516
+ self.assertNotIn("from population_match", source)
517
+
518
+ def test_no_new_file_assumes_an_install_layout(self):
519
+ """M7.2 — keeps the eventual cross-copy propagation cheap.
520
+
521
+ The needles are assembled at runtime so that this file, which must scan
522
+ ITSELF, does not fail on its own assertion text.
523
+ """
524
+ needles = ("base" + "/skills", "templates" + "/.claude")
525
+ for name in ("population_match.py", "risk_statement.py", "test_signature_v3.py",
526
+ "test_population_match.py", "test_risk_absolute.py",
527
+ "fixtures_field_cases.json", "fixture_legacy_v2_fact.json"):
528
+ with open(os.path.join(HERE, name), encoding="utf-8") as handle:
529
+ text = handle.read()
530
+ for needle in needles:
531
+ with self.subTest(file=name, needle=needle):
532
+ self.assertNotIn(needle, text)
533
+
534
+
535
+ class SkillDocumentationTests(unittest.TestCase):
536
+ """T-16 (FR-15 / AC-15) — a WEAK test on purpose: it proves the sentence exists,
537
+ never that it is true. Documentation is layer 4 and this file says so."""
538
+
539
+ def test_skillmd_names_the_pre_v3_signature_hole(self):
540
+ path = os.path.join(os.path.dirname(HERE), "SKILL.md")
541
+ text = open(path, encoding="utf-8").read()
542
+ self.assertIn("can be rewritten without invalidating the signature", text)
543
+ self.assertIn("fact-v3", text)
544
+ self.assertIn("out of scope for this slice", text)
545
+
546
+ def test_skillmd_states_the_relative_risk_rule(self):
547
+ path = os.path.join(os.path.dirname(HERE), "SKILL.md")
548
+ text = open(path, encoding="utf-8").read()
549
+ self.assertIn("Relative risk never travels alone", text)
550
+ self.assertIn("BASELINE RISK NOT ESTABLISHED", text)
551
+
552
+
553
+ if __name__ == "__main__":
554
+ unittest.main()
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ T-20 / D-21 (AM-14, ADR-006) — every `test_*.py` on disk is actually COLLECTED.
4
+
5
+ cd .../goap-research-ed25519/scripts && python3 -m unittest discover -s . -p 'test_*.py' -v
6
+
7
+ Why this file exists: before slice C, `test_ed25519_verifier.py` — the security suite
8
+ of the module the slice edits — was named by NO run command anywhere. A report of
9
+ "full suite green" would have been true of the command and false of the code. A test
10
+ module that nothing runs is a file, not a check; this makes forgetting one fail BY
11
+ NAME instead of by omission.
12
+ """
13
+
14
+ import glob
15
+ import os
16
+ import sys
17
+
18
+ sys.dont_write_bytecode = True
19
+
20
+ import unittest
21
+
22
+ HERE = os.path.dirname(os.path.abspath(__file__))
23
+ PATTERN = "test_*.py"
24
+
25
+
26
+ def _on_disk():
27
+ return sorted(os.path.basename(p)[:-3] for p in glob.glob(os.path.join(HERE, PATTERN)))
28
+
29
+
30
+ def _collected():
31
+ names = set()
32
+
33
+ def walk(suite):
34
+ for item in suite:
35
+ if isinstance(item, unittest.TestSuite):
36
+ walk(item)
37
+ elif isinstance(item, unittest.loader._FailedTest):
38
+ # A module discovery could not IMPORT is reported BY NAME, never
39
+ # silently skipped — a gate that cannot run has cleared nothing.
40
+ names.add(item.id().split(".")[-1])
41
+ else:
42
+ names.add(type(item).__module__)
43
+
44
+ walk(unittest.TestLoader().discover(start_dir=HERE, pattern=PATTERN, top_level_dir=HERE))
45
+ return sorted(names)
46
+
47
+
48
+ class SuiteCompletenessTests(unittest.TestCase):
49
+ def test_discovery_collects_every_test_file_on_disk(self):
50
+ on_disk = _on_disk()
51
+ collected = _collected()
52
+ missing = [name for name in on_disk if name not in collected]
53
+ self.assertEqual(missing, [],
54
+ f"these modules exist but discovery does not collect them: {missing}")
55
+ self.assertGreaterEqual(len(on_disk), 6, "the directory must still hold its test modules")
56
+
57
+ def test_the_security_suite_of_the_edited_module_is_in_the_run(self):
58
+ """The specific omission AM-14 caught, pinned by name so it cannot recur."""
59
+ collected = _collected()
60
+ for required in ("test_ed25519_verifier", "test_evidence_provenance", "test_goap_planner",
61
+ "test_signature_v3", "test_population_match", "test_risk_absolute"):
62
+ self.assertIn(required, collected)
63
+
64
+ def test_no_enumerated_run_command_is_reintroduced(self):
65
+ """ADR-006 D5's Monitoring clause: a SUITE command must be the discovery one,
66
+ so a hand-kept module list can never drift from the directory.
67
+
68
+ Scope, stated precisely: TWO OR MORE module names after `-m unittest` is an
69
+ enumeration and fails. A single module (`python3 -m unittest test_x -v`) is a
70
+ run-just-this-file hint, not a claim about the suite, and is allowed. This
71
+ file is skipped — it is the checker, and its own prose describes the pattern.
72
+ """
73
+ offenders = []
74
+ for name in sorted(os.listdir(HERE)):
75
+ if not name.endswith((".py", ".md")) or name == os.path.basename(__file__):
76
+ continue
77
+ with open(os.path.join(HERE, name), encoding="utf-8", errors="ignore") as handle:
78
+ text = handle.read()
79
+ for line in text.splitlines():
80
+ if "-m unittest" not in line or "discover" in line:
81
+ continue
82
+ modules = [token for token in line.split("-m unittest")[1].split()
83
+ if token.startswith("test_")]
84
+ if len(modules) > 1:
85
+ offenders.append(f"{name}: {line.strip()}")
86
+ self.assertEqual(offenders, [], f"enumerated run commands found: {offenders}")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ unittest.main()
@@ -64,6 +64,13 @@ Always flag these terms and suggest specific replacements:
64
64
 
65
65
  **Score <50 = BLOCKED from development.** Provide rewrite suggestions.
66
66
 
67
+ **Blocking floor — the weakest link decides, never the average.** Independently of the score, a
68
+ requirement is BLOCKED if `Testable = 0` (no acceptance criteria) or `Completeness = 0` (rubric "No
69
+ AC"). A story can total 72/100 with neither, and 72 reads as "minor fixes" in the table above. A non-zero `Testable` or `Completeness` REQUIRES quoting the acceptance criteria it scores — no quote
70
+ means 0, because the agent that scores is the agent the floor binds. The
71
+ floor is closed at those two: `Measurable` and `Traceability` are deliberately excluded — see
72
+ `references/scoring-system.md` → "Blocking floor" for the worked case and the reason.
73
+
67
74
  ## Output Format
68
75
 
69
76
  ### Requirements Analysis Report