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.
Files changed (43) hide show
  1. package/docs/architecture.md +7 -1
  2. package/docs/cli.md +10 -2
  3. package/docs/project-structure-overview.md +16 -9
  4. package/docs/task-process/implementation-planning.md +11 -5
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +3 -1
  8. package/runtime/agents/workers/claude-worker.md +1 -1
  9. package/runtime/agents/workers/codex-worker.md +3 -1
  10. package/runtime/agents/workers/report-writer-worker.md +8 -4
  11. package/runtime/prompts/lead/adapters/claude-code.md +2 -2
  12. package/runtime/prompts/lead/convergence.md +44 -21
  13. package/runtime/prompts/lead/okstra-lead-contract.md +9 -2
  14. package/runtime/prompts/lead/plan-body-verification.md +55 -12
  15. package/runtime/prompts/lead/report-writer.md +16 -15
  16. package/runtime/prompts/lead/team-contract.md +8 -6
  17. package/runtime/prompts/profiles/implementation-planning.md +10 -2
  18. package/runtime/python/okstra_ctl/codex_dispatch.py +5 -1
  19. package/runtime/python/okstra_ctl/convergence.py +121 -1
  20. package/runtime/python/okstra_ctl/convergence_engine.py +903 -13
  21. package/runtime/python/okstra_ctl/convergence_migration.py +9 -1
  22. package/runtime/python/okstra_ctl/dispatch_core.py +13 -0
  23. package/runtime/python/okstra_ctl/plan_items.py +203 -0
  24. package/runtime/python/okstra_ctl/plan_items_cli.py +81 -0
  25. package/runtime/python/okstra_ctl/report_finalize.py +6 -4
  26. package/runtime/python/okstra_ctl/worker_artifact_paths.py +23 -0
  27. package/runtime/python/okstra_ctl/worker_liveness.py +4 -4
  28. package/runtime/python/okstra_ctl/worker_prompt_body.py +1 -1
  29. package/runtime/python/okstra_ctl/worker_prompt_contract.py +2 -1
  30. package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
  31. package/runtime/schemas/convergence-critic-results-v1.0.schema.json +57 -0
  32. package/runtime/schemas/convergence-groups-v1.0.schema.json +109 -0
  33. package/runtime/schemas/convergence-round-results-v1.0.schema.json +55 -0
  34. package/runtime/schemas/final-report-v1.0.schema.json +21 -2
  35. package/runtime/templates/report-writer-prompt-preamble.md +5 -3
  36. package/runtime/templates/reports/final-report.template.md +1 -1
  37. package/runtime/templates/worker-prompt-preamble.md +8 -7
  38. package/runtime/validators/validate-run.py +154 -118
  39. package/runtime/validators/validate_session_conformance.py +66 -23
  40. package/src/cli-registry.mjs +7 -0
  41. package/src/commands/execute/convergence.mjs +6 -1
  42. package/src/commands/execute/plan-items.mjs +9 -0
  43. 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.2"
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.2 convergence artifact."""
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/v1.1 and current v1.2 final artifacts."""
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
- if state.get("schemaVersion") not in {"1.0", "1.1", "1.2"}:
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
- if finding_id in seen_ids:
442
- errors.append(f"duplicate findingId: {finding_id}")
443
- seen_ids.add(str(finding_id))
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], adversarial=adversarial
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(value: Any, *, adversarial: bool) -> dict[str, Any]:
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 verdict not in _VERDICTS:
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
- return {
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
- }, source_workers
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")