okstra 0.130.1 → 0.130.3
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/docs/architecture.md +7 -1
- package/docs/cli.md +10 -2
- package/docs/project-structure-overview.md +16 -9
- package/docs/task-process/implementation-planning.md +11 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +3 -1
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +3 -1
- package/runtime/agents/workers/report-writer-worker.md +8 -4
- package/runtime/prompts/lead/adapters/claude-code.md +2 -2
- package/runtime/prompts/lead/convergence.md +44 -21
- package/runtime/prompts/lead/okstra-lead-contract.md +9 -2
- package/runtime/prompts/lead/plan-body-verification.md +55 -12
- package/runtime/prompts/lead/report-writer.md +16 -15
- package/runtime/prompts/lead/team-contract.md +8 -6
- package/runtime/prompts/profiles/implementation-planning.md +10 -2
- package/runtime/python/okstra_ctl/codex_dispatch.py +5 -1
- package/runtime/python/okstra_ctl/convergence.py +121 -1
- package/runtime/python/okstra_ctl/convergence_engine.py +903 -13
- package/runtime/python/okstra_ctl/convergence_migration.py +9 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +13 -0
- package/runtime/python/okstra_ctl/plan_items.py +203 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +81 -0
- package/runtime/python/okstra_ctl/report_finalize.py +6 -4
- package/runtime/python/okstra_ctl/worker_artifact_paths.py +23 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +4 -4
- package/runtime/python/okstra_ctl/worker_prompt_body.py +1 -1
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +2 -1
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
- package/runtime/schemas/convergence-critic-results-v1.0.schema.json +57 -0
- package/runtime/schemas/convergence-groups-v1.0.schema.json +109 -0
- package/runtime/schemas/convergence-round-results-v1.0.schema.json +55 -0
- package/runtime/schemas/final-report-v1.0.schema.json +21 -2
- package/runtime/templates/report-writer-prompt-preamble.md +5 -3
- package/runtime/templates/reports/final-report.template.md +1 -1
- package/runtime/templates/worker-prompt-preamble.md +8 -7
- package/runtime/validators/validate-run.py +154 -118
- package/runtime/validators/validate_session_conformance.py +66 -23
- package/src/cli-registry.mjs +7 -0
- package/src/commands/execute/convergence.mjs +6 -1
- package/src/commands/execute/plan-items.mjs +9 -0
- package/src/commands/inspect/worker-liveness.mjs +2 -2
|
@@ -8,7 +8,7 @@ from typing import Any, Mapping
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
WORKING_SCHEMA_VERSION = "1.0"
|
|
11
|
-
FINAL_SCHEMA_VERSION = "1.
|
|
11
|
+
FINAL_SCHEMA_VERSION = "1.3"
|
|
12
12
|
_AUDIENCES = {"analysis", "report-writer"}
|
|
13
13
|
_VERIFICATION_MODES = {"lightweight", "full-reanalysis"}
|
|
14
14
|
_CLASSIFICATION_COUNT_KEYS = {
|
|
@@ -18,8 +18,23 @@ _CLASSIFICATION_COUNT_KEYS = {
|
|
|
18
18
|
"worker-unique": "workerUnique",
|
|
19
19
|
}
|
|
20
20
|
_VERDICTS = {"agree", "disagree", "supplement", "verification-error"}
|
|
21
|
+
_INPUT_VERDICTS = _VERDICTS | {"unverifiable"}
|
|
21
22
|
_DISAGREE_BASES = {"counter-evidence", "burden-not-met"}
|
|
22
23
|
_DISPATCH_STATUSES = {"completed", "timeout", "error", "not-run"}
|
|
24
|
+
_CRITIC_CLASSIFICATIONS = set(_CLASSIFICATION_COUNT_KEYS) | {"unverified"}
|
|
25
|
+
_FINAL_V13_KEYS = {
|
|
26
|
+
"schemaVersion",
|
|
27
|
+
"taskKey",
|
|
28
|
+
"config",
|
|
29
|
+
"findings",
|
|
30
|
+
"roundHistory",
|
|
31
|
+
"round2SkippedReason",
|
|
32
|
+
"finalState",
|
|
33
|
+
"totalRounds",
|
|
34
|
+
"finalClassificationCounts",
|
|
35
|
+
"criticVerification",
|
|
36
|
+
"unverifiedGaps",
|
|
37
|
+
}
|
|
23
38
|
|
|
24
39
|
|
|
25
40
|
class ConvergenceContractError(ValueError):
|
|
@@ -282,8 +297,68 @@ def apply_round_results(
|
|
|
282
297
|
return updated
|
|
283
298
|
|
|
284
299
|
|
|
300
|
+
def apply_critic_gap_results(
|
|
301
|
+
state: Mapping[str, Any],
|
|
302
|
+
results: Mapping[str, Any],
|
|
303
|
+
) -> dict[str, Any]:
|
|
304
|
+
"""Reduce one coverage-critic verification batch into working state."""
|
|
305
|
+
current = _object(state, "working state")
|
|
306
|
+
errors = validate_working_state(current)
|
|
307
|
+
if errors:
|
|
308
|
+
raise ConvergenceContractError("invalid working state: " + "; ".join(errors))
|
|
309
|
+
current_critic = current.get("config", {}).get("critic")
|
|
310
|
+
if (
|
|
311
|
+
isinstance(current_critic, Mapping)
|
|
312
|
+
and current_critic.get("mode") == "acceptance-devils-advocate"
|
|
313
|
+
):
|
|
314
|
+
raise ConvergenceContractError(
|
|
315
|
+
"acceptance critic candidates must use confirm-or-downgrade"
|
|
316
|
+
)
|
|
317
|
+
if current.get("criticVerification") is not None:
|
|
318
|
+
raise ConvergenceContractError("critic gap batch was already applied")
|
|
319
|
+
if plan_next_round(current).get("action") != "finalize":
|
|
320
|
+
raise ConvergenceContractError("main finding queue must be terminal")
|
|
321
|
+
|
|
322
|
+
payload = _object(results, "critic gap results")
|
|
323
|
+
provider, model, dispatches, gaps = _parse_critic_gap_results(current, payload)
|
|
324
|
+
updated = deepcopy(dict(current))
|
|
325
|
+
ledger_gaps, unverified = _reduce_critic_gaps(updated, provider, gaps)
|
|
326
|
+
updated["criticVerification"] = {
|
|
327
|
+
"schemaVersion": WORKING_SCHEMA_VERSION,
|
|
328
|
+
"provider": provider,
|
|
329
|
+
"modelExecutionValue": model,
|
|
330
|
+
"analyserRoster": [
|
|
331
|
+
worker["workerId"]
|
|
332
|
+
for worker in updated["workers"]
|
|
333
|
+
if worker["audience"] == "analysis"
|
|
334
|
+
],
|
|
335
|
+
"dispatches": dispatches,
|
|
336
|
+
"gaps": ledger_gaps,
|
|
337
|
+
}
|
|
338
|
+
merged = sum(gap["mergedFindingId"] is not None for gap in ledger_gaps)
|
|
339
|
+
rejected = sum(
|
|
340
|
+
gap["classification"] in {"contested", "worker-unique"}
|
|
341
|
+
for gap in ledger_gaps
|
|
342
|
+
)
|
|
343
|
+
updated["config"]["critic"] = {
|
|
344
|
+
"provider": provider,
|
|
345
|
+
"modelExecutionValue": model,
|
|
346
|
+
"gapsProposed": len(ledger_gaps),
|
|
347
|
+
"gapsMerged": merged,
|
|
348
|
+
"gapsRejected": rejected,
|
|
349
|
+
"gapsUnverified": len(unverified),
|
|
350
|
+
}
|
|
351
|
+
updated["unverifiedGaps"] = unverified
|
|
352
|
+
updated_errors = validate_working_state(updated)
|
|
353
|
+
if updated_errors:
|
|
354
|
+
raise ConvergenceContractError(
|
|
355
|
+
"invalid updated working state: " + "; ".join(updated_errors)
|
|
356
|
+
)
|
|
357
|
+
return updated
|
|
358
|
+
|
|
359
|
+
|
|
285
360
|
def finalize_working_state(state: Mapping[str, Any]) -> dict[str, Any]:
|
|
286
|
-
"""Return the public schema-v1.
|
|
361
|
+
"""Return the public schema-v1.3 convergence artifact."""
|
|
287
362
|
errors = validate_working_state(state)
|
|
288
363
|
if errors:
|
|
289
364
|
raise ConvergenceContractError("invalid working state: " + "; ".join(errors))
|
|
@@ -337,6 +412,9 @@ def finalize_working_state(state: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
337
412
|
"totalRounds": len(history),
|
|
338
413
|
"finalClassificationCounts": classification_counts_from_findings(findings),
|
|
339
414
|
}
|
|
415
|
+
if "criticVerification" in current:
|
|
416
|
+
final["criticVerification"] = deepcopy(current["criticVerification"])
|
|
417
|
+
final["unverifiedGaps"] = deepcopy(current.get("unverifiedGaps", []))
|
|
340
418
|
final_errors = validate_final_state(final)
|
|
341
419
|
if final_errors:
|
|
342
420
|
raise ConvergenceContractError(
|
|
@@ -364,6 +442,22 @@ def validate_working_state(state: Mapping[str, Any]) -> list[str]:
|
|
|
364
442
|
if not isinstance(workers, list):
|
|
365
443
|
errors.append("workers must be an array")
|
|
366
444
|
workers = []
|
|
445
|
+
else:
|
|
446
|
+
seen_workers: set[str] = set()
|
|
447
|
+
for index, worker in enumerate(workers):
|
|
448
|
+
if not isinstance(worker, Mapping):
|
|
449
|
+
errors.append(f"workers[{index}] must be an object")
|
|
450
|
+
continue
|
|
451
|
+
worker_id = worker.get("workerId")
|
|
452
|
+
audience = worker.get("audience")
|
|
453
|
+
if not _nonempty_string(worker_id):
|
|
454
|
+
errors.append(f"workers[{index}].workerId must be a non-empty string")
|
|
455
|
+
elif worker_id in seen_workers:
|
|
456
|
+
errors.append(f"duplicate workerId: {worker_id}")
|
|
457
|
+
else:
|
|
458
|
+
seen_workers.add(worker_id)
|
|
459
|
+
if not _nonempty_string(audience) or audience not in _AUDIENCES:
|
|
460
|
+
errors.append(f"workers[{index}].audience is unsupported")
|
|
367
461
|
findings = state.get("findings")
|
|
368
462
|
if not isinstance(findings, list):
|
|
369
463
|
errors.append("findings must be an array")
|
|
@@ -404,15 +498,17 @@ def validate_working_state(state: Mapping[str, Any]) -> list[str]:
|
|
|
404
498
|
errors.append("last round carriedForwardCount disagrees with queue length")
|
|
405
499
|
if state.get("stopReason") not in {None, "auto-disabled", "all-reverify-non-result"}:
|
|
406
500
|
errors.append("stopReason is unsupported")
|
|
501
|
+
errors.extend(_validate_critic_contract(state, findings))
|
|
407
502
|
return errors
|
|
408
503
|
|
|
409
504
|
|
|
410
505
|
def validate_final_state(state: Mapping[str, Any]) -> list[str]:
|
|
411
|
-
"""Validate historical v1.0
|
|
506
|
+
"""Validate historical v1.0-v1.2 and strict current v1.3 artifacts."""
|
|
412
507
|
errors: list[str] = []
|
|
413
508
|
if not isinstance(state, Mapping):
|
|
414
509
|
return ["final state must be an object"]
|
|
415
|
-
|
|
510
|
+
version = state.get("schemaVersion")
|
|
511
|
+
if version not in {"1.0", "1.1", "1.2", FINAL_SCHEMA_VERSION}:
|
|
416
512
|
errors.append("unsupported final schemaVersion")
|
|
417
513
|
config = state.get("config")
|
|
418
514
|
if not isinstance(config, Mapping):
|
|
@@ -437,10 +533,12 @@ def validate_final_state(state: Mapping[str, Any]) -> list[str]:
|
|
|
437
533
|
errors.append("finding must be an object")
|
|
438
534
|
continue
|
|
439
535
|
finding_id = finding.get("findingId")
|
|
440
|
-
if _nonempty_string(finding_id):
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
536
|
+
if not _nonempty_string(finding_id):
|
|
537
|
+
errors.append("findingId must be a non-empty string")
|
|
538
|
+
elif finding_id in seen_ids:
|
|
539
|
+
errors.append(f"duplicate findingId: {finding_id}")
|
|
540
|
+
else:
|
|
541
|
+
seen_ids.add(finding_id)
|
|
444
542
|
classification = finding.get("classification")
|
|
445
543
|
if classification not in _CLASSIFICATION_COUNT_KEYS:
|
|
446
544
|
errors.append(f"finding {finding_id} has invalid classification")
|
|
@@ -480,9 +578,727 @@ def validate_final_state(state: Mapping[str, Any]) -> list[str]:
|
|
|
480
578
|
config,
|
|
481
579
|
)
|
|
482
580
|
)
|
|
581
|
+
if version == FINAL_SCHEMA_VERSION:
|
|
582
|
+
unexpected = set(state) - _FINAL_V13_KEYS
|
|
583
|
+
if unexpected:
|
|
584
|
+
errors.append(
|
|
585
|
+
"final v1.3 has unsupported top-level fields: "
|
|
586
|
+
+ ", ".join(sorted(unexpected))
|
|
587
|
+
)
|
|
588
|
+
errors.extend(_validate_critic_contract(state, findings))
|
|
483
589
|
return errors
|
|
484
590
|
|
|
485
591
|
|
|
592
|
+
def _parse_critic_gap_results(
|
|
593
|
+
state: Mapping[str, Any],
|
|
594
|
+
payload: Mapping[str, Any],
|
|
595
|
+
) -> tuple[str, str, list[dict[str, Any]], list[dict[str, Any]]]:
|
|
596
|
+
if payload.get("schemaVersion") != WORKING_SCHEMA_VERSION:
|
|
597
|
+
raise ConvergenceContractError("critic gap results schemaVersion must be 1.0")
|
|
598
|
+
if payload.get("taskKey") != state.get("taskKey"):
|
|
599
|
+
raise ConvergenceContractError("critic gap results taskKey does not match")
|
|
600
|
+
if payload.get("mode") != "coverage":
|
|
601
|
+
raise ConvergenceContractError(
|
|
602
|
+
"apply-critic-gaps accepts coverage mode only; acceptance candidates "
|
|
603
|
+
"use confirm-or-downgrade"
|
|
604
|
+
)
|
|
605
|
+
provider = _required_string(payload, "provider", "critic gap results")
|
|
606
|
+
model = _required_string(payload, "modelExecutionValue", "critic gap results")
|
|
607
|
+
roster = {
|
|
608
|
+
worker["workerId"]
|
|
609
|
+
for worker in state.get("workers", [])
|
|
610
|
+
if isinstance(worker, Mapping) and worker.get("audience") == "analysis"
|
|
611
|
+
}
|
|
612
|
+
dispatches, completed = _parse_critic_dispatches(
|
|
613
|
+
payload.get("dispatches"), roster, provider
|
|
614
|
+
)
|
|
615
|
+
gaps = _parse_critic_gaps(payload.get("gaps"), roster, provider, completed)
|
|
616
|
+
return provider, model, dispatches, gaps
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _parse_critic_dispatches(
|
|
620
|
+
value: Any,
|
|
621
|
+
roster: set[str],
|
|
622
|
+
provider: str,
|
|
623
|
+
) -> tuple[list[dict[str, Any]], set[str]]:
|
|
624
|
+
if not isinstance(value, list):
|
|
625
|
+
raise ConvergenceContractError("critic gap results dispatches must be an array")
|
|
626
|
+
dispatches: list[dict[str, Any]] = []
|
|
627
|
+
seen: set[str] = set()
|
|
628
|
+
for index, raw in enumerate(value):
|
|
629
|
+
row = _object(raw, f"critic dispatches[{index}]")
|
|
630
|
+
worker = _required_string(row, "worker", f"critic dispatches[{index}]")
|
|
631
|
+
if worker in seen:
|
|
632
|
+
raise ConvergenceContractError(f"duplicate critic voter dispatch: {worker}")
|
|
633
|
+
if worker not in roster or _is_critic_worker(worker, provider):
|
|
634
|
+
raise ConvergenceContractError(
|
|
635
|
+
f"critic voter must be a non-critic analyser: {worker}"
|
|
636
|
+
)
|
|
637
|
+
status = _required_string(row, "status", f"critic dispatch {worker}")
|
|
638
|
+
duration = row.get("durationMs")
|
|
639
|
+
if status not in _DISPATCH_STATUSES:
|
|
640
|
+
raise ConvergenceContractError(f"critic dispatch {worker} has invalid status")
|
|
641
|
+
if not isinstance(duration, int) or isinstance(duration, bool) or duration < 0:
|
|
642
|
+
raise ConvergenceContractError(
|
|
643
|
+
f"critic dispatch {worker}.durationMs must be a non-negative integer"
|
|
644
|
+
)
|
|
645
|
+
seen.add(worker)
|
|
646
|
+
dispatches.append({"worker": worker, "status": status, "durationMs": duration})
|
|
647
|
+
expected = {
|
|
648
|
+
worker for worker in roster if not _is_critic_worker(worker, provider)
|
|
649
|
+
}
|
|
650
|
+
if seen != expected:
|
|
651
|
+
missing = sorted(expected - seen)
|
|
652
|
+
raise ConvergenceContractError(
|
|
653
|
+
"critic dispatches must account for every non-critic analyser exactly "
|
|
654
|
+
f"once; missing: {', '.join(missing) or 'none'}"
|
|
655
|
+
)
|
|
656
|
+
completed = {row["worker"] for row in dispatches if row["status"] == "completed"}
|
|
657
|
+
return dispatches, completed
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _parse_critic_gaps(
|
|
661
|
+
value: Any,
|
|
662
|
+
roster: set[str],
|
|
663
|
+
provider: str,
|
|
664
|
+
completed: set[str],
|
|
665
|
+
) -> list[dict[str, Any]]:
|
|
666
|
+
if not isinstance(value, list):
|
|
667
|
+
raise ConvergenceContractError("critic gap results gaps must be an array")
|
|
668
|
+
gaps: list[dict[str, Any]] = []
|
|
669
|
+
seen: set[str] = set()
|
|
670
|
+
for index, raw in enumerate(value):
|
|
671
|
+
gap = _object(raw, f"critic gaps[{index}]")
|
|
672
|
+
gap_id = _required_string(gap, "gapId", f"critic gaps[{index}]")
|
|
673
|
+
if gap_id in seen:
|
|
674
|
+
raise ConvergenceContractError(f"duplicate critic gapId: {gap_id}")
|
|
675
|
+
seen.add(gap_id)
|
|
676
|
+
parsed = _parse_critic_gap(gap, index, roster, provider, completed)
|
|
677
|
+
parsed["gapId"] = gap_id
|
|
678
|
+
gaps.append(parsed)
|
|
679
|
+
return gaps
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _parse_critic_gap(
|
|
683
|
+
gap: Mapping[str, Any],
|
|
684
|
+
index: int,
|
|
685
|
+
roster: set[str],
|
|
686
|
+
provider: str,
|
|
687
|
+
completed: set[str],
|
|
688
|
+
) -> dict[str, Any]:
|
|
689
|
+
label = f"critic gaps[{index}]"
|
|
690
|
+
votes_value = _object(gap.get("votes"), f"{label}.votes")
|
|
691
|
+
votes: dict[str, dict[str, Any]] = {}
|
|
692
|
+
for worker, raw_vote in votes_value.items():
|
|
693
|
+
if worker not in roster or _is_critic_worker(worker, provider):
|
|
694
|
+
raise ConvergenceContractError(
|
|
695
|
+
f"critic voter must be a non-critic analyser: {worker}"
|
|
696
|
+
)
|
|
697
|
+
if worker not in completed:
|
|
698
|
+
raise ConvergenceContractError(
|
|
699
|
+
f"critic voter has no completed dispatch: {worker}"
|
|
700
|
+
)
|
|
701
|
+
votes[worker] = _parse_vote(
|
|
702
|
+
raw_vote,
|
|
703
|
+
adversarial=True,
|
|
704
|
+
allow_unverifiable=True,
|
|
705
|
+
)
|
|
706
|
+
parsed = {
|
|
707
|
+
"summary": _required_string(gap, "summary", label),
|
|
708
|
+
"category": _required_string(gap, "category", label),
|
|
709
|
+
"ticketIds": _string_array_allow_empty(
|
|
710
|
+
gap.get("ticketIds"), f"{label}.ticketIds"
|
|
711
|
+
),
|
|
712
|
+
"originEvidence": _required_string(gap, "originEvidence", label),
|
|
713
|
+
"votes": votes,
|
|
714
|
+
}
|
|
715
|
+
if "evidenceArtifacts" in gap:
|
|
716
|
+
parsed["evidenceArtifacts"] = _parse_evidence_artifacts(
|
|
717
|
+
gap.get("evidenceArtifacts"), label
|
|
718
|
+
)
|
|
719
|
+
return parsed
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _reduce_critic_gaps(
|
|
723
|
+
state: dict[str, Any],
|
|
724
|
+
provider: str,
|
|
725
|
+
gaps: list[dict[str, Any]],
|
|
726
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
|
727
|
+
ledger: list[dict[str, Any]] = []
|
|
728
|
+
unverified: list[dict[str, str]] = []
|
|
729
|
+
next_number = _next_finding_number(state["findings"])
|
|
730
|
+
for gap in gaps:
|
|
731
|
+
classification = _critic_gap_classification(gap["votes"])
|
|
732
|
+
merged_id = None
|
|
733
|
+
if classification in {"full-consensus", "partial-consensus"}:
|
|
734
|
+
merged_id = f"F-{next_number:03d}"
|
|
735
|
+
next_number += 1
|
|
736
|
+
state["findings"].append(
|
|
737
|
+
_critic_finding(gap, provider, merged_id, classification, state["workers"])
|
|
738
|
+
)
|
|
739
|
+
elif classification == "unverified":
|
|
740
|
+
unverified.append(
|
|
741
|
+
{
|
|
742
|
+
"gapId": gap["gapId"],
|
|
743
|
+
"summary": gap["summary"],
|
|
744
|
+
"reason": "no usable analyser vote",
|
|
745
|
+
}
|
|
746
|
+
)
|
|
747
|
+
ledger.append(
|
|
748
|
+
{
|
|
749
|
+
"gapId": gap["gapId"],
|
|
750
|
+
"summary": gap["summary"],
|
|
751
|
+
"category": gap["category"],
|
|
752
|
+
"ticketIds": deepcopy(gap["ticketIds"]),
|
|
753
|
+
"originEvidence": gap["originEvidence"],
|
|
754
|
+
"classification": classification,
|
|
755
|
+
"mergedFindingId": merged_id,
|
|
756
|
+
"votes": deepcopy(gap["votes"]),
|
|
757
|
+
**(
|
|
758
|
+
{"evidenceArtifacts": deepcopy(gap["evidenceArtifacts"])}
|
|
759
|
+
if "evidenceArtifacts" in gap
|
|
760
|
+
else {}
|
|
761
|
+
),
|
|
762
|
+
}
|
|
763
|
+
)
|
|
764
|
+
return ledger, unverified
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _critic_gap_classification(votes: Mapping[str, Mapping[str, Any]]) -> str:
|
|
768
|
+
usable = [vote for vote in votes.values() if vote["verdict"] != "verification-error"]
|
|
769
|
+
if not usable:
|
|
770
|
+
return "unverified"
|
|
771
|
+
return classify_adversarial_round(votes) or "contested"
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _next_finding_number(findings: list[Mapping[str, Any]]) -> int:
|
|
775
|
+
numbers = []
|
|
776
|
+
for finding in findings:
|
|
777
|
+
finding_id = finding.get("findingId")
|
|
778
|
+
if isinstance(finding_id, str) and finding_id.startswith("F-"):
|
|
779
|
+
suffix = finding_id[2:]
|
|
780
|
+
if suffix.isdigit():
|
|
781
|
+
numbers.append(int(suffix))
|
|
782
|
+
return max(numbers, default=0) + 1
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _critic_finding(
|
|
786
|
+
gap: Mapping[str, Any],
|
|
787
|
+
provider: str,
|
|
788
|
+
finding_id: str,
|
|
789
|
+
classification: str,
|
|
790
|
+
workers: list[Mapping[str, Any]],
|
|
791
|
+
) -> dict[str, Any]:
|
|
792
|
+
agreeing = {
|
|
793
|
+
worker
|
|
794
|
+
for worker, vote in gap["votes"].items()
|
|
795
|
+
if vote["verdict"] in {"agree", "supplement"}
|
|
796
|
+
}
|
|
797
|
+
dissenting = {
|
|
798
|
+
worker for worker, vote in gap["votes"].items() if vote["verdict"] == "disagree"
|
|
799
|
+
}
|
|
800
|
+
roster = [row["workerId"] for row in workers if row.get("audience") == "analysis"]
|
|
801
|
+
finding = {
|
|
802
|
+
"findingId": finding_id,
|
|
803
|
+
"summary": gap["summary"],
|
|
804
|
+
"category": gap["category"],
|
|
805
|
+
"ticketIds": deepcopy(gap["ticketIds"]),
|
|
806
|
+
"originWorker": f"{provider}-critic",
|
|
807
|
+
"originEvidence": gap["originEvidence"],
|
|
808
|
+
"discoveredBy": {},
|
|
809
|
+
"sourceItems": [],
|
|
810
|
+
"source": "critic",
|
|
811
|
+
"classification": classification,
|
|
812
|
+
"rounds": [],
|
|
813
|
+
"consensusWorkers": [worker for worker in roster if worker in agreeing],
|
|
814
|
+
"dissentingWorkers": [worker for worker in roster if worker in dissenting],
|
|
815
|
+
}
|
|
816
|
+
if "evidenceArtifacts" in gap:
|
|
817
|
+
finding["evidenceArtifacts"] = deepcopy(gap["evidenceArtifacts"])
|
|
818
|
+
return finding
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _validate_critic_contract(
|
|
822
|
+
state: Mapping[str, Any],
|
|
823
|
+
findings: list[Any],
|
|
824
|
+
) -> list[str]:
|
|
825
|
+
config = state.get("config")
|
|
826
|
+
critic_config = config.get("critic") if isinstance(config, Mapping) else None
|
|
827
|
+
ledger = state.get("criticVerification")
|
|
828
|
+
unverified = state.get("unverifiedGaps")
|
|
829
|
+
critic_findings: dict[str, Mapping[str, Any]] = {}
|
|
830
|
+
errors: list[str] = []
|
|
831
|
+
for finding in findings:
|
|
832
|
+
if not isinstance(finding, Mapping) or finding.get("source") != "critic":
|
|
833
|
+
continue
|
|
834
|
+
finding_id = finding.get("findingId")
|
|
835
|
+
if not _nonempty_string(finding_id):
|
|
836
|
+
errors.append("findingId must be a non-empty string")
|
|
837
|
+
continue
|
|
838
|
+
critic_findings[finding_id] = finding
|
|
839
|
+
if (
|
|
840
|
+
isinstance(critic_config, Mapping)
|
|
841
|
+
and critic_config.get("mode") == "acceptance-devils-advocate"
|
|
842
|
+
):
|
|
843
|
+
errors.extend(
|
|
844
|
+
_validate_acceptance_critic_summary(
|
|
845
|
+
critic_config, ledger, unverified, critic_findings
|
|
846
|
+
)
|
|
847
|
+
)
|
|
848
|
+
return errors
|
|
849
|
+
if ledger is None and critic_config is None and unverified is None:
|
|
850
|
+
if critic_findings:
|
|
851
|
+
errors.append("critic-origin finding requires criticVerification ledger")
|
|
852
|
+
return errors
|
|
853
|
+
if not isinstance(ledger, Mapping):
|
|
854
|
+
errors.append("criticVerification must be an object")
|
|
855
|
+
return errors
|
|
856
|
+
if not isinstance(critic_config, Mapping):
|
|
857
|
+
errors.append("config.critic must be an object")
|
|
858
|
+
critic_config = {}
|
|
859
|
+
if not isinstance(unverified, list):
|
|
860
|
+
errors.append("unverifiedGaps must be an array")
|
|
861
|
+
unverified = []
|
|
862
|
+
errors.extend(_validate_critic_ledger_shape(ledger))
|
|
863
|
+
errors.extend(_validate_critic_roster(state, ledger))
|
|
864
|
+
gaps = ledger.get("gaps") if isinstance(ledger.get("gaps"), list) else []
|
|
865
|
+
errors.extend(
|
|
866
|
+
_validate_critic_gap_links(
|
|
867
|
+
gaps,
|
|
868
|
+
critic_findings,
|
|
869
|
+
unverified,
|
|
870
|
+
critic_config,
|
|
871
|
+
ledger,
|
|
872
|
+
)
|
|
873
|
+
)
|
|
874
|
+
if critic_config.get("provider") != ledger.get("provider"):
|
|
875
|
+
errors.append("config.critic.provider does not match criticVerification")
|
|
876
|
+
if critic_config.get("modelExecutionValue") != ledger.get("modelExecutionValue"):
|
|
877
|
+
errors.append(
|
|
878
|
+
"config.critic.modelExecutionValue does not match criticVerification"
|
|
879
|
+
)
|
|
880
|
+
return errors
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _validate_acceptance_critic_summary(
|
|
884
|
+
config: Mapping[str, Any],
|
|
885
|
+
ledger: Any,
|
|
886
|
+
unverified: Any,
|
|
887
|
+
critic_findings: Mapping[Any, Mapping[str, Any]],
|
|
888
|
+
) -> list[str]:
|
|
889
|
+
allowed = {
|
|
890
|
+
"mode", "provider", "modelExecutionValue", "candidatesProposed",
|
|
891
|
+
"confirmedBlockers", "downgradedToResidual",
|
|
892
|
+
}
|
|
893
|
+
errors = [] if set(config) == allowed else [
|
|
894
|
+
"acceptance critic config has unsupported fields"
|
|
895
|
+
]
|
|
896
|
+
counts = (
|
|
897
|
+
config.get("candidatesProposed"),
|
|
898
|
+
config.get("confirmedBlockers"),
|
|
899
|
+
config.get("downgradedToResidual"),
|
|
900
|
+
)
|
|
901
|
+
valid_counts = all(
|
|
902
|
+
isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
903
|
+
for value in counts
|
|
904
|
+
)
|
|
905
|
+
if not valid_counts:
|
|
906
|
+
errors.append("acceptance critic counts must be non-negative integers")
|
|
907
|
+
elif counts[0] != counts[1] + counts[2]:
|
|
908
|
+
errors.append(
|
|
909
|
+
"acceptance critic candidatesProposed must equal confirmedBlockers + "
|
|
910
|
+
"downgradedToResidual"
|
|
911
|
+
)
|
|
912
|
+
if ledger is not None or unverified is not None or critic_findings:
|
|
913
|
+
errors.append("acceptance critic must not use coverage critic merge semantics")
|
|
914
|
+
return errors
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _validate_critic_ledger_shape(ledger: Mapping[str, Any]) -> list[str]:
|
|
918
|
+
errors: list[str] = []
|
|
919
|
+
allowed = {
|
|
920
|
+
"schemaVersion",
|
|
921
|
+
"provider",
|
|
922
|
+
"modelExecutionValue",
|
|
923
|
+
"analyserRoster",
|
|
924
|
+
"dispatches",
|
|
925
|
+
"gaps",
|
|
926
|
+
}
|
|
927
|
+
if set(ledger) != allowed:
|
|
928
|
+
errors.append("criticVerification has unsupported fields")
|
|
929
|
+
if ledger.get("schemaVersion") != WORKING_SCHEMA_VERSION:
|
|
930
|
+
errors.append("criticVerification.schemaVersion must be 1.0")
|
|
931
|
+
for key in ("provider", "modelExecutionValue"):
|
|
932
|
+
if not _nonempty_string(ledger.get(key)):
|
|
933
|
+
errors.append(f"criticVerification.{key} must be a non-empty string")
|
|
934
|
+
roster = ledger.get("analyserRoster")
|
|
935
|
+
if not isinstance(roster, list):
|
|
936
|
+
errors.append("criticVerification.analyserRoster must be a unique string array")
|
|
937
|
+
elif not all(_nonempty_string(worker) for worker in roster):
|
|
938
|
+
errors.append("criticVerification.analyserRoster must be a unique string array")
|
|
939
|
+
elif len(roster) != len(set(roster)):
|
|
940
|
+
errors.append("criticVerification.analyserRoster must be a unique string array")
|
|
941
|
+
dispatches = ledger.get("dispatches")
|
|
942
|
+
if not isinstance(dispatches, list):
|
|
943
|
+
errors.append("criticVerification.dispatches must be an array")
|
|
944
|
+
else:
|
|
945
|
+
errors.extend(_validate_critic_ledger_dispatches(dispatches))
|
|
946
|
+
gaps = ledger.get("gaps")
|
|
947
|
+
if not isinstance(gaps, list):
|
|
948
|
+
errors.append("criticVerification.gaps must be an array")
|
|
949
|
+
return errors
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _validate_critic_ledger_dispatches(dispatches: list[Any]) -> list[str]:
|
|
953
|
+
errors: list[str] = []
|
|
954
|
+
seen: set[Any] = set()
|
|
955
|
+
for index, row in enumerate(dispatches):
|
|
956
|
+
if not isinstance(row, Mapping) or set(row) != {"worker", "status", "durationMs"}:
|
|
957
|
+
errors.append(f"criticVerification.dispatches[{index}] is invalid")
|
|
958
|
+
continue
|
|
959
|
+
worker = row.get("worker")
|
|
960
|
+
if not _nonempty_string(worker):
|
|
961
|
+
errors.append("criticVerification has invalid or duplicate dispatch worker")
|
|
962
|
+
elif worker in seen:
|
|
963
|
+
errors.append("criticVerification has invalid or duplicate dispatch worker")
|
|
964
|
+
else:
|
|
965
|
+
seen.add(worker)
|
|
966
|
+
status = row.get("status")
|
|
967
|
+
if not _nonempty_string(status) or status not in _DISPATCH_STATUSES:
|
|
968
|
+
errors.append("criticVerification has invalid dispatch status")
|
|
969
|
+
duration = row.get("durationMs")
|
|
970
|
+
if not isinstance(duration, int) or isinstance(duration, bool) or duration < 0:
|
|
971
|
+
errors.append("criticVerification has invalid dispatch durationMs")
|
|
972
|
+
return errors
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _validate_critic_gap_links(
|
|
976
|
+
gaps: list[Any],
|
|
977
|
+
critic_findings: Mapping[Any, Mapping[str, Any]],
|
|
978
|
+
unverified: list[Any],
|
|
979
|
+
config: Mapping[str, Any],
|
|
980
|
+
ledger: Mapping[str, Any],
|
|
981
|
+
) -> list[str]:
|
|
982
|
+
errors: list[str] = []
|
|
983
|
+
seen_gaps: set[str] = set()
|
|
984
|
+
merged_ids: set[str] = set()
|
|
985
|
+
unverified_ids: set[str] = set()
|
|
986
|
+
classifications: list[Any] = []
|
|
987
|
+
for finding_id, finding in critic_findings.items():
|
|
988
|
+
if finding.get("rounds") != []:
|
|
989
|
+
errors.append(
|
|
990
|
+
f"critic-origin finding rounds must be empty: {finding_id}"
|
|
991
|
+
)
|
|
992
|
+
for index, gap in enumerate(gaps):
|
|
993
|
+
gap_errors, gap_id, merged_id, classification = _validate_critic_gap_row(
|
|
994
|
+
gap, index
|
|
995
|
+
)
|
|
996
|
+
errors.extend(gap_errors)
|
|
997
|
+
if _nonempty_string(gap_id):
|
|
998
|
+
if gap_id in seen_gaps:
|
|
999
|
+
errors.append(f"duplicate critic gapId: {gap_id}")
|
|
1000
|
+
seen_gaps.add(gap_id)
|
|
1001
|
+
classifications.append(classification)
|
|
1002
|
+
if isinstance(merged_id, str):
|
|
1003
|
+
if merged_id in merged_ids:
|
|
1004
|
+
errors.append(f"duplicate mergedFindingId: {merged_id}")
|
|
1005
|
+
merged_ids.add(merged_id)
|
|
1006
|
+
finding = critic_findings.get(merged_id)
|
|
1007
|
+
if finding is None:
|
|
1008
|
+
errors.append(f"critic mergedFindingId {merged_id} is not a critic-origin finding")
|
|
1009
|
+
elif finding.get("classification") != classification:
|
|
1010
|
+
errors.append(f"critic mergedFindingId {merged_id} classification mismatch")
|
|
1011
|
+
elif not gap_errors and finding != _replay_critic_finding(
|
|
1012
|
+
gap,
|
|
1013
|
+
merged_id,
|
|
1014
|
+
ledger,
|
|
1015
|
+
):
|
|
1016
|
+
errors.append(
|
|
1017
|
+
f"critic mergedFindingId {merged_id} content does not match "
|
|
1018
|
+
"criticVerification ledger"
|
|
1019
|
+
)
|
|
1020
|
+
if classification == "unverified" and isinstance(gap_id, str):
|
|
1021
|
+
unverified_ids.add(gap_id)
|
|
1022
|
+
if set(critic_findings) != merged_ids:
|
|
1023
|
+
errors.append(
|
|
1024
|
+
"critic-origin findings do not match criticVerification "
|
|
1025
|
+
"mergedFindingId links"
|
|
1026
|
+
)
|
|
1027
|
+
errors.extend(_validate_unverified_gap_rows(unverified, unverified_ids))
|
|
1028
|
+
errors.extend(_validate_critic_counts(config, classifications))
|
|
1029
|
+
return errors
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _validate_critic_gap_row(
|
|
1033
|
+
gap: Any,
|
|
1034
|
+
index: int,
|
|
1035
|
+
) -> tuple[list[str], Any, Any, Any]:
|
|
1036
|
+
if not isinstance(gap, Mapping):
|
|
1037
|
+
return ([f"criticVerification.gaps[{index}] must be an object"], None, None, None)
|
|
1038
|
+
errors: list[str] = []
|
|
1039
|
+
required = {
|
|
1040
|
+
"gapId",
|
|
1041
|
+
"summary",
|
|
1042
|
+
"category",
|
|
1043
|
+
"ticketIds",
|
|
1044
|
+
"originEvidence",
|
|
1045
|
+
"classification",
|
|
1046
|
+
"mergedFindingId",
|
|
1047
|
+
"votes",
|
|
1048
|
+
}
|
|
1049
|
+
allowed = required | {"evidenceArtifacts"}
|
|
1050
|
+
if set(gap) - allowed:
|
|
1051
|
+
errors.append(f"criticVerification.gaps[{index}] has unsupported fields")
|
|
1052
|
+
if required - set(gap):
|
|
1053
|
+
errors.append(f"criticVerification.gaps[{index}] has missing required fields")
|
|
1054
|
+
gap_id = gap.get("gapId")
|
|
1055
|
+
classification = gap.get("classification")
|
|
1056
|
+
merged_id = gap.get("mergedFindingId")
|
|
1057
|
+
if not _nonempty_string(gap_id):
|
|
1058
|
+
errors.append(f"criticVerification.gaps[{index}].gapId is invalid")
|
|
1059
|
+
classification_valid = (
|
|
1060
|
+
_nonempty_string(classification)
|
|
1061
|
+
and classification in _CRITIC_CLASSIFICATIONS
|
|
1062
|
+
)
|
|
1063
|
+
if not classification_valid:
|
|
1064
|
+
errors.append(f"criticVerification gap {gap_id} has invalid classification")
|
|
1065
|
+
if classification_valid and classification in {"full-consensus", "partial-consensus"}:
|
|
1066
|
+
if not _nonempty_string(merged_id):
|
|
1067
|
+
errors.append(f"criticVerification gap {gap_id} requires mergedFindingId")
|
|
1068
|
+
elif classification_valid and merged_id is not None:
|
|
1069
|
+
errors.append(f"criticVerification gap {gap_id} must not be merged")
|
|
1070
|
+
errors.extend(_validate_critic_gap_content(gap, gap_id))
|
|
1071
|
+
errors.extend(_validate_critic_gap_votes(gap_id, gap.get("votes"), classification))
|
|
1072
|
+
return errors, gap_id, merged_id, classification
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _validate_critic_gap_content(
|
|
1076
|
+
gap: Mapping[str, Any],
|
|
1077
|
+
gap_id: Any,
|
|
1078
|
+
) -> list[str]:
|
|
1079
|
+
errors: list[str] = []
|
|
1080
|
+
for key in ("summary", "category", "originEvidence"):
|
|
1081
|
+
if not _nonempty_string(gap.get(key)):
|
|
1082
|
+
errors.append(f"criticVerification gap {gap_id} {key} is invalid")
|
|
1083
|
+
ticket_ids = gap.get("ticketIds")
|
|
1084
|
+
if not isinstance(ticket_ids, list) or not all(
|
|
1085
|
+
_nonempty_string(ticket_id) for ticket_id in ticket_ids
|
|
1086
|
+
):
|
|
1087
|
+
errors.append(f"criticVerification gap {gap_id} ticketIds is invalid")
|
|
1088
|
+
artifacts = gap.get("evidenceArtifacts")
|
|
1089
|
+
if "evidenceArtifacts" not in gap:
|
|
1090
|
+
return errors
|
|
1091
|
+
if isinstance(artifacts, list):
|
|
1092
|
+
expected = {"path", "sha256", "command", "environment"}
|
|
1093
|
+
for index, artifact in enumerate(artifacts):
|
|
1094
|
+
if isinstance(artifact, Mapping) and set(artifact) != expected:
|
|
1095
|
+
errors.append(
|
|
1096
|
+
f"criticVerification gap {gap_id}.evidenceArtifacts[{index}] "
|
|
1097
|
+
"has unsupported fields"
|
|
1098
|
+
)
|
|
1099
|
+
try:
|
|
1100
|
+
_parse_evidence_artifacts(
|
|
1101
|
+
artifacts,
|
|
1102
|
+
f"criticVerification gap {gap_id}",
|
|
1103
|
+
)
|
|
1104
|
+
except ConvergenceContractError as exc:
|
|
1105
|
+
errors.append(str(exc))
|
|
1106
|
+
return errors
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _replay_critic_finding(
|
|
1110
|
+
gap: Mapping[str, Any],
|
|
1111
|
+
finding_id: str,
|
|
1112
|
+
ledger: Mapping[str, Any],
|
|
1113
|
+
) -> dict[str, Any]:
|
|
1114
|
+
roster = ledger.get("analyserRoster")
|
|
1115
|
+
workers = [
|
|
1116
|
+
{"workerId": worker, "audience": "analysis"}
|
|
1117
|
+
for worker in roster
|
|
1118
|
+
if _nonempty_string(worker)
|
|
1119
|
+
] if isinstance(roster, list) else []
|
|
1120
|
+
return _critic_finding(
|
|
1121
|
+
gap,
|
|
1122
|
+
str(ledger.get("provider")),
|
|
1123
|
+
finding_id,
|
|
1124
|
+
gap["classification"],
|
|
1125
|
+
workers,
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _validate_critic_gap_votes(
|
|
1130
|
+
gap_id: Any,
|
|
1131
|
+
votes: Any,
|
|
1132
|
+
classification: Any,
|
|
1133
|
+
) -> list[str]:
|
|
1134
|
+
if not isinstance(votes, Mapping):
|
|
1135
|
+
return [f"criticVerification gap {gap_id} votes must be an object"]
|
|
1136
|
+
errors: list[str] = []
|
|
1137
|
+
parsed: dict[str, dict[str, Any]] = {}
|
|
1138
|
+
for worker, vote in votes.items():
|
|
1139
|
+
basis = vote.get("disagreeBasis") if isinstance(vote, Mapping) else None
|
|
1140
|
+
if basis is not None and not _nonempty_string(basis):
|
|
1141
|
+
errors.append(
|
|
1142
|
+
f"criticVerification gap {gap_id} vote {worker}: "
|
|
1143
|
+
"disagreeBasis must be a non-empty string"
|
|
1144
|
+
)
|
|
1145
|
+
continue
|
|
1146
|
+
try:
|
|
1147
|
+
parsed[worker] = _parse_vote(vote, adversarial=True)
|
|
1148
|
+
except ConvergenceContractError as exc:
|
|
1149
|
+
errors.append(f"criticVerification gap {gap_id} vote {worker}: {exc}")
|
|
1150
|
+
if (
|
|
1151
|
+
not errors
|
|
1152
|
+
and _nonempty_string(classification)
|
|
1153
|
+
and classification in _CRITIC_CLASSIFICATIONS
|
|
1154
|
+
):
|
|
1155
|
+
expected = _critic_gap_classification(parsed)
|
|
1156
|
+
if classification != expected:
|
|
1157
|
+
errors.append(
|
|
1158
|
+
f"criticVerification gap {gap_id} classification {classification} "
|
|
1159
|
+
f"does not match replayed classification {expected}"
|
|
1160
|
+
)
|
|
1161
|
+
return errors
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _validate_unverified_gap_rows(
|
|
1165
|
+
rows: list[Any],
|
|
1166
|
+
expected_ids: set[str],
|
|
1167
|
+
) -> list[str]:
|
|
1168
|
+
errors: list[str] = []
|
|
1169
|
+
actual_ids: list[str] = []
|
|
1170
|
+
for index, row in enumerate(rows):
|
|
1171
|
+
if not isinstance(row, Mapping) or set(row) != {"gapId", "summary", "reason"}:
|
|
1172
|
+
errors.append(f"unverifiedGaps[{index}] is invalid")
|
|
1173
|
+
continue
|
|
1174
|
+
for key in ("gapId", "summary", "reason"):
|
|
1175
|
+
if not _nonempty_string(row.get(key)):
|
|
1176
|
+
errors.append(f"unverifiedGaps[{index}].{key} is invalid")
|
|
1177
|
+
if isinstance(row.get("gapId"), str):
|
|
1178
|
+
actual_ids.append(row["gapId"])
|
|
1179
|
+
if len(actual_ids) != len(set(actual_ids)) or set(actual_ids) != expected_ids:
|
|
1180
|
+
errors.append("unverifiedGaps do not match unverified criticVerification gaps")
|
|
1181
|
+
return errors
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _validate_critic_counts(
|
|
1185
|
+
config: Mapping[str, Any],
|
|
1186
|
+
classifications: list[Any],
|
|
1187
|
+
) -> list[str]:
|
|
1188
|
+
allowed = {
|
|
1189
|
+
"provider",
|
|
1190
|
+
"modelExecutionValue",
|
|
1191
|
+
"gapsProposed",
|
|
1192
|
+
"gapsMerged",
|
|
1193
|
+
"gapsRejected",
|
|
1194
|
+
"gapsUnverified",
|
|
1195
|
+
}
|
|
1196
|
+
errors: list[str] = []
|
|
1197
|
+
if set(config) != allowed:
|
|
1198
|
+
errors.append("config.critic has unsupported fields")
|
|
1199
|
+
expected = {
|
|
1200
|
+
"gapsProposed": len(classifications),
|
|
1201
|
+
"gapsMerged": sum(
|
|
1202
|
+
_nonempty_string(value)
|
|
1203
|
+
and value in {"full-consensus", "partial-consensus"}
|
|
1204
|
+
for value in classifications
|
|
1205
|
+
),
|
|
1206
|
+
"gapsRejected": sum(
|
|
1207
|
+
_nonempty_string(value)
|
|
1208
|
+
and value in {"contested", "worker-unique"}
|
|
1209
|
+
for value in classifications
|
|
1210
|
+
),
|
|
1211
|
+
"gapsUnverified": sum(value == "unverified" for value in classifications),
|
|
1212
|
+
}
|
|
1213
|
+
for key, value in expected.items():
|
|
1214
|
+
declared = config.get(key)
|
|
1215
|
+
if not isinstance(declared, int) or isinstance(declared, bool) or declared < 0:
|
|
1216
|
+
errors.append(f"config.critic.{key} must be a non-negative integer")
|
|
1217
|
+
elif declared != value:
|
|
1218
|
+
errors.append(f"config.critic.{key} {declared} does not match ledger {value}")
|
|
1219
|
+
proposed = config.get("gapsProposed")
|
|
1220
|
+
merged = config.get("gapsMerged")
|
|
1221
|
+
rejected = config.get("gapsRejected")
|
|
1222
|
+
unverified = config.get("gapsUnverified")
|
|
1223
|
+
counts = (proposed, merged, rejected, unverified)
|
|
1224
|
+
if all(
|
|
1225
|
+
isinstance(value, int) and not isinstance(value, bool)
|
|
1226
|
+
for value in counts
|
|
1227
|
+
):
|
|
1228
|
+
if proposed != merged + rejected + unverified:
|
|
1229
|
+
errors.append(
|
|
1230
|
+
"config.critic.gapsProposed must equal gapsMerged + gapsRejected + gapsUnverified"
|
|
1231
|
+
)
|
|
1232
|
+
return errors
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _validate_critic_roster(
|
|
1236
|
+
state: Mapping[str, Any],
|
|
1237
|
+
ledger: Mapping[str, Any],
|
|
1238
|
+
) -> list[str]:
|
|
1239
|
+
declared = ledger.get("analyserRoster")
|
|
1240
|
+
roster = {
|
|
1241
|
+
worker
|
|
1242
|
+
for worker in declared
|
|
1243
|
+
if _nonempty_string(worker)
|
|
1244
|
+
} if isinstance(declared, list) else set()
|
|
1245
|
+
workers = state.get("workers")
|
|
1246
|
+
provider = ledger.get("provider")
|
|
1247
|
+
dispatches = ledger.get("dispatches")
|
|
1248
|
+
errors: list[str] = []
|
|
1249
|
+
if isinstance(workers, list):
|
|
1250
|
+
actual = [
|
|
1251
|
+
worker.get("workerId")
|
|
1252
|
+
for worker in workers
|
|
1253
|
+
if isinstance(worker, Mapping) and worker.get("audience") == "analysis"
|
|
1254
|
+
]
|
|
1255
|
+
if declared != actual:
|
|
1256
|
+
errors.append("criticVerification.analyserRoster does not match workers")
|
|
1257
|
+
for row in dispatches if isinstance(dispatches, list) else []:
|
|
1258
|
+
worker = row.get("worker") if isinstance(row, Mapping) else None
|
|
1259
|
+
if (
|
|
1260
|
+
not _nonempty_string(worker)
|
|
1261
|
+
or worker not in roster
|
|
1262
|
+
or _is_critic_worker(worker, provider)
|
|
1263
|
+
):
|
|
1264
|
+
errors.append(f"critic voter must be a non-critic analyser: {worker}")
|
|
1265
|
+
expected_dispatches = {
|
|
1266
|
+
worker for worker in roster if not _is_critic_worker(worker, provider)
|
|
1267
|
+
}
|
|
1268
|
+
actual_dispatches = {
|
|
1269
|
+
row.get("worker")
|
|
1270
|
+
for row in dispatches
|
|
1271
|
+
if isinstance(row, Mapping) and _nonempty_string(row.get("worker"))
|
|
1272
|
+
} if isinstance(dispatches, list) else set()
|
|
1273
|
+
if actual_dispatches != expected_dispatches:
|
|
1274
|
+
errors.append(
|
|
1275
|
+
"criticVerification.dispatches must account for every non-critic "
|
|
1276
|
+
"analyser exactly once"
|
|
1277
|
+
)
|
|
1278
|
+
completed = {
|
|
1279
|
+
row.get("worker")
|
|
1280
|
+
for row in dispatches
|
|
1281
|
+
if (
|
|
1282
|
+
isinstance(row, Mapping)
|
|
1283
|
+
and _nonempty_string(row.get("worker"))
|
|
1284
|
+
and row.get("status") == "completed"
|
|
1285
|
+
)
|
|
1286
|
+
} if isinstance(dispatches, list) else set()
|
|
1287
|
+
gaps = ledger.get("gaps")
|
|
1288
|
+
for gap in gaps if isinstance(gaps, list) else []:
|
|
1289
|
+
votes = gap.get("votes") if isinstance(gap, Mapping) else None
|
|
1290
|
+
for voter in votes if isinstance(votes, Mapping) else {}:
|
|
1291
|
+
if voter not in roster or _is_critic_worker(voter, provider):
|
|
1292
|
+
errors.append(f"critic voter must be a non-critic analyser: {voter}")
|
|
1293
|
+
elif voter not in completed:
|
|
1294
|
+
errors.append(f"critic voter has no completed dispatch: {voter}")
|
|
1295
|
+
return errors
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _is_critic_worker(worker: Any, provider: Any) -> bool:
|
|
1299
|
+
return isinstance(provider, str) and worker in {provider, f"{provider}-worker"}
|
|
1300
|
+
|
|
1301
|
+
|
|
486
1302
|
def _final_collaborative_classification(finding: Mapping[str, Any]) -> str:
|
|
487
1303
|
usable: list[Mapping[str, Any]] = []
|
|
488
1304
|
for round_row in finding.get("rounds", []):
|
|
@@ -617,6 +1433,13 @@ def _validate_finding_ledger(
|
|
|
617
1433
|
errors: list[str] = []
|
|
618
1434
|
finding_id = finding.get("findingId")
|
|
619
1435
|
origin = finding.get("originWorker")
|
|
1436
|
+
if "evidenceArtifacts" in finding:
|
|
1437
|
+
try:
|
|
1438
|
+
_parse_evidence_artifacts(
|
|
1439
|
+
finding.get("evidenceArtifacts"), f"finding {finding_id}"
|
|
1440
|
+
)
|
|
1441
|
+
except ConvergenceContractError as exc:
|
|
1442
|
+
errors.append(str(exc))
|
|
620
1443
|
rounds = finding.get("rounds")
|
|
621
1444
|
if not isinstance(rounds, list):
|
|
622
1445
|
return [f"finding {finding_id} rounds must be an array"]
|
|
@@ -826,7 +1649,9 @@ def _votes_for_finding(
|
|
|
826
1649
|
status = statuses[worker]["status"]
|
|
827
1650
|
if status == "completed":
|
|
828
1651
|
votes[worker] = _parse_vote(
|
|
829
|
-
votes_by_finding[finding_id][worker],
|
|
1652
|
+
votes_by_finding[finding_id][worker],
|
|
1653
|
+
adversarial=adversarial,
|
|
1654
|
+
allow_unverifiable=True,
|
|
830
1655
|
)
|
|
831
1656
|
else:
|
|
832
1657
|
votes[worker] = {
|
|
@@ -874,11 +1699,19 @@ def _parsed_votes(
|
|
|
874
1699
|
}
|
|
875
1700
|
|
|
876
1701
|
|
|
877
|
-
def _parse_vote(
|
|
1702
|
+
def _parse_vote(
|
|
1703
|
+
value: Any,
|
|
1704
|
+
*,
|
|
1705
|
+
adversarial: bool,
|
|
1706
|
+
allow_unverifiable: bool = False,
|
|
1707
|
+
) -> dict[str, Any]:
|
|
878
1708
|
vote = _object(value, "vote")
|
|
879
1709
|
verdict = _required_string(vote, "verdict", "vote")
|
|
880
|
-
if
|
|
1710
|
+
allowed = _INPUT_VERDICTS if allow_unverifiable else _VERDICTS
|
|
1711
|
+
if verdict not in allowed:
|
|
881
1712
|
raise ConvergenceContractError(f"unsupported vote verdict: {verdict}")
|
|
1713
|
+
if verdict == "unverifiable":
|
|
1714
|
+
verdict = "verification-error"
|
|
882
1715
|
explanation = _required_string(vote, "explanation", "vote")
|
|
883
1716
|
basis = vote.get("disagreeBasis")
|
|
884
1717
|
if verdict == "disagree" and adversarial:
|
|
@@ -1049,7 +1882,7 @@ def _parse_group(
|
|
|
1049
1882
|
if worker_id in discovered_by
|
|
1050
1883
|
or any(item["worker"] == worker_id for item in source_items)
|
|
1051
1884
|
]
|
|
1052
|
-
|
|
1885
|
+
finding = {
|
|
1053
1886
|
"findingId": _required_string(group, "findingId", label),
|
|
1054
1887
|
"summary": _required_string(group, "summary", label),
|
|
1055
1888
|
"category": _required_string(group, "category", label),
|
|
@@ -1062,7 +1895,53 @@ def _parse_group(
|
|
|
1062
1895
|
"rounds": [],
|
|
1063
1896
|
"consensusWorkers": source_workers,
|
|
1064
1897
|
"dissentingWorkers": [],
|
|
1065
|
-
}
|
|
1898
|
+
}
|
|
1899
|
+
if "evidenceArtifacts" in group:
|
|
1900
|
+
finding["evidenceArtifacts"] = _parse_evidence_artifacts(
|
|
1901
|
+
group.get("evidenceArtifacts"), label
|
|
1902
|
+
)
|
|
1903
|
+
return finding, source_workers
|
|
1904
|
+
|
|
1905
|
+
|
|
1906
|
+
def _parse_evidence_artifacts(value: Any, label: str) -> list[dict[str, str]]:
|
|
1907
|
+
if not isinstance(value, list) or not value:
|
|
1908
|
+
raise ConvergenceContractError(
|
|
1909
|
+
f"{label}.evidenceArtifacts must be a non-empty array"
|
|
1910
|
+
)
|
|
1911
|
+
artifacts: list[dict[str, str]] = []
|
|
1912
|
+
for index, raw in enumerate(value):
|
|
1913
|
+
item_label = f"{label}.evidenceArtifacts[{index}]"
|
|
1914
|
+
artifact = _object(raw, item_label)
|
|
1915
|
+
path = _required_string(artifact, "path", item_label)
|
|
1916
|
+
if not _is_normalized_okstra_path(path):
|
|
1917
|
+
raise ConvergenceContractError(
|
|
1918
|
+
f"{item_label}.path must be a normalized project-relative .okstra path"
|
|
1919
|
+
)
|
|
1920
|
+
sha256 = _required_string(artifact, "sha256", item_label)
|
|
1921
|
+
if len(sha256) != 64 or any(
|
|
1922
|
+
char not in "0123456789abcdef" for char in sha256
|
|
1923
|
+
):
|
|
1924
|
+
raise ConvergenceContractError(
|
|
1925
|
+
f"{item_label}.sha256 must be a lowercase SHA-256 hex digest"
|
|
1926
|
+
)
|
|
1927
|
+
artifacts.append(
|
|
1928
|
+
{
|
|
1929
|
+
"path": path,
|
|
1930
|
+
"sha256": sha256,
|
|
1931
|
+
"command": _required_string(artifact, "command", item_label),
|
|
1932
|
+
"environment": _required_string(
|
|
1933
|
+
artifact, "environment", item_label
|
|
1934
|
+
),
|
|
1935
|
+
}
|
|
1936
|
+
)
|
|
1937
|
+
return artifacts
|
|
1938
|
+
|
|
1939
|
+
|
|
1940
|
+
def _is_normalized_okstra_path(path: str) -> bool:
|
|
1941
|
+
if not path.startswith(".okstra/") or "\\" in path:
|
|
1942
|
+
return False
|
|
1943
|
+
segments = path.split("/")
|
|
1944
|
+
return all(segment not in {"", ".", ".."} for segment in segments)
|
|
1066
1945
|
|
|
1067
1946
|
|
|
1068
1947
|
def _parse_discovered_by(
|
|
@@ -1135,6 +2014,17 @@ def _string_array(value: Any, label: str) -> list[str]:
|
|
|
1135
2014
|
return result
|
|
1136
2015
|
|
|
1137
2016
|
|
|
2017
|
+
def _string_array_allow_empty(value: Any, label: str) -> list[str]:
|
|
2018
|
+
if not isinstance(value, list):
|
|
2019
|
+
raise ConvergenceContractError(f"{label} must be a string array")
|
|
2020
|
+
result = []
|
|
2021
|
+
for item in value:
|
|
2022
|
+
if not isinstance(item, str) or not item.strip():
|
|
2023
|
+
raise ConvergenceContractError(f"{label} must contain non-empty strings")
|
|
2024
|
+
result.append(item.strip())
|
|
2025
|
+
return result
|
|
2026
|
+
|
|
2027
|
+
|
|
1138
2028
|
def _object(value: Any, label: str) -> Mapping[str, Any]:
|
|
1139
2029
|
if not isinstance(value, Mapping):
|
|
1140
2030
|
raise ConvergenceContractError(f"{label} must be an object")
|