@mc-and-his-agents/loom-installer 0.1.12 → 0.1.13

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 (22) hide show
  1. package/package.json +1 -1
  2. package/payload/manifest.json +42 -42
  3. package/payload/plugin/loom/skills/shared/scripts/loom_check.py +93 -4
  4. package/payload/plugin/loom/skills/shared/scripts/loom_flow.py +34 -2
  5. package/payload/skills/loom-adopt/.loom-runtime/shared/scripts/loom_check.py +93 -4
  6. package/payload/skills/loom-adopt/.loom-runtime/shared/scripts/loom_flow.py +34 -2
  7. package/payload/skills/loom-handoff/.loom-runtime/shared/scripts/loom_check.py +93 -4
  8. package/payload/skills/loom-handoff/.loom-runtime/shared/scripts/loom_flow.py +34 -2
  9. package/payload/skills/loom-init/.loom-runtime/shared/scripts/loom_check.py +93 -4
  10. package/payload/skills/loom-init/.loom-runtime/shared/scripts/loom_flow.py +34 -2
  11. package/payload/skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_check.py +93 -4
  12. package/payload/skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_flow.py +34 -2
  13. package/payload/skills/loom-pre-review/.loom-runtime/shared/scripts/loom_check.py +93 -4
  14. package/payload/skills/loom-pre-review/.loom-runtime/shared/scripts/loom_flow.py +34 -2
  15. package/payload/skills/loom-resume/.loom-runtime/shared/scripts/loom_check.py +93 -4
  16. package/payload/skills/loom-resume/.loom-runtime/shared/scripts/loom_flow.py +34 -2
  17. package/payload/skills/loom-retire/.loom-runtime/shared/scripts/loom_check.py +93 -4
  18. package/payload/skills/loom-retire/.loom-runtime/shared/scripts/loom_flow.py +34 -2
  19. package/payload/skills/loom-review/.loom-runtime/shared/scripts/loom_check.py +93 -4
  20. package/payload/skills/loom-review/.loom-runtime/shared/scripts/loom_flow.py +34 -2
  21. package/payload/skills/loom-spec-review/.loom-runtime/shared/scripts/loom_check.py +93 -4
  22. package/payload/skills/loom-spec-review/.loom-runtime/shared/scripts/loom_flow.py +34 -2
@@ -94,6 +94,7 @@ CORE_DOCS = (
94
94
  "docs/evidence/landing-map.md",
95
95
  "docs/evidence/validations/validation-closeout-reconciliation-blocking-gate.md",
96
96
  "docs/evidence/validations/validation-loom-core-runtime-parity.md",
97
+ "docs/evidence/validations/validation-shadow-parity-blocking-gate.md",
97
98
  "docs/evidence/validations/validation-syvert-strong-governance-parity.md",
98
99
  "docs/adoption/rationale.md",
99
100
  "docs/adoption/routing-and-checkpoints.md",
@@ -969,10 +970,17 @@ def require_shadow_parity_payload(
969
970
  return
970
971
  if payload.get("command") != "shadow-parity":
971
972
  failures.append(Failure(category, f"{context} must report `command: shadow-parity`"))
972
- if payload.get("result") not in {"pass", "warn"}:
973
- failures.append(Failure(category, f"{context} result must be `pass` or `warn`"))
974
- if payload.get("fallback_to") is not None:
975
- failures.append(Failure(category, f"{context} fallback_to must remain `null`"))
973
+ mode = payload.get("mode", "validation-only")
974
+ if mode not in {"validation-only", "blocking"}:
975
+ failures.append(Failure(category, f"{context} mode must be `validation-only` or `blocking`"))
976
+ if payload.get("blocking") != (mode == "blocking"):
977
+ failures.append(Failure(category, f"{context} blocking flag must match mode"))
978
+ allowed_results = {"pass", "block"} if mode == "blocking" else {"pass", "warn"}
979
+ if payload.get("result") not in allowed_results:
980
+ failures.append(Failure(category, f"{context} result must stay within the stable mode-specific contract"))
981
+ expected_fallbacks = {"manual-reconciliation"} if payload.get("result") == "block" else {None}
982
+ if payload.get("fallback_to") not in expected_fallbacks:
983
+ failures.append(Failure(category, f"{context} fallback_to must match the shadow parity enforcement mode"))
976
984
  if not isinstance(payload.get("summary"), str) or not payload.get("summary"):
977
985
  failures.append(Failure(category, f"{context} must include non-empty `summary`"))
978
986
  if not isinstance(payload.get("missing_inputs"), list):
@@ -1008,6 +1016,14 @@ def require_shadow_parity_payload(
1008
1016
  failures.append(Failure(category, f"{context} reports[{index}] must declare a known surface"))
1009
1017
  if report.get("result") not in {"match", "mismatch", "unreadable"}:
1010
1018
  failures.append(Failure(category, f"{context} reports[{index}] result must stay within the stable contract"))
1019
+ if report.get("classification") not in {None, "drift", "gate_failure"}:
1020
+ failures.append(Failure(category, f"{context} reports[{index}] classification must stay within the stable contract"))
1021
+ if not isinstance(report.get("blocking"), bool):
1022
+ failures.append(Failure(category, f"{context} reports[{index}] must include boolean `blocking`"))
1023
+ if not isinstance(report.get("recommended_action"), str) or not report.get("recommended_action"):
1024
+ failures.append(Failure(category, f"{context} reports[{index}] must include non-empty `recommended_action`"))
1025
+ if mode == "blocking" and report.get("result") != "match" and report.get("blocking") is not True:
1026
+ failures.append(Failure(category, f"{context} reports[{index}] must block non-matching reports in blocking mode"))
1011
1027
  if not isinstance(report.get("summary"), str) or not report.get("summary"):
1012
1028
  failures.append(Failure(category, f"{context} reports[{index}] must include non-empty `summary`"))
1013
1029
  if not isinstance(report.get("missing_inputs"), list):
@@ -5301,6 +5317,23 @@ def check_repo_interop_contracts(root: Path) -> list[Failure]:
5301
5317
  if parity_payload.get("result") != "pass":
5302
5318
  failures.append(Failure("repo-interop", "`shadow-parity` must pass when all declared surfaces match"))
5303
5319
 
5320
+ blocking_match_payload, error = load_command_json(
5321
+ root,
5322
+ ["python3", "tools/loom_flow.py", "shadow-parity", "--target", str(present_target), "--blocking"],
5323
+ )
5324
+ if error:
5325
+ failures.append(Failure("repo-interop", f"`shadow-parity --blocking` match sample failed: {error}"))
5326
+ else:
5327
+ require_shadow_parity_payload(
5328
+ failures,
5329
+ category="repo-interop",
5330
+ context="`shadow-parity --blocking` match sample",
5331
+ payload=blocking_match_payload,
5332
+ expected_reports=4,
5333
+ )
5334
+ if blocking_match_payload.get("result") != "pass":
5335
+ failures.append(Failure("repo-interop", "`shadow-parity --blocking` must pass when all declared surfaces match"))
5336
+
5304
5337
  mismatch_target = base / "mismatch"
5305
5338
  shutil.copytree(present_target, mismatch_target)
5306
5339
  write_json(mismatch_target / ".loom/shadow/review-repo.json", {"decision": "block"})
@@ -5322,6 +5355,62 @@ def check_repo_interop_contracts(root: Path) -> list[Failure]:
5322
5355
  if not isinstance(reports, list) or not reports or reports[0].get("result") != "mismatch":
5323
5356
  failures.append(Failure("repo-interop", "`shadow-parity` mismatch sample must report `mismatch`"))
5324
5357
 
5358
+ blocking_mismatch_payload, error = load_command_json(
5359
+ root,
5360
+ [
5361
+ "python3",
5362
+ "tools/loom_flow.py",
5363
+ "shadow-parity",
5364
+ "--target",
5365
+ str(mismatch_target),
5366
+ "--surface",
5367
+ "review",
5368
+ "--mode",
5369
+ "blocking",
5370
+ ],
5371
+ )
5372
+ if error:
5373
+ failures.append(Failure("repo-interop", f"`shadow-parity --mode blocking` mismatch sample failed: {error}"))
5374
+ else:
5375
+ require_shadow_parity_payload(
5376
+ failures,
5377
+ category="repo-interop",
5378
+ context="`shadow-parity --mode blocking` mismatch sample",
5379
+ payload=blocking_mismatch_payload,
5380
+ expected_reports=1,
5381
+ )
5382
+ if blocking_mismatch_payload.get("result") != "block":
5383
+ failures.append(Failure("repo-interop", "`shadow-parity --mode blocking` must block mismatches"))
5384
+
5385
+ unreadable_target = base / "unreadable"
5386
+ shutil.copytree(present_target, unreadable_target)
5387
+ (unreadable_target / ".loom/shadow/closeout-repo.json").unlink()
5388
+ blocking_unreadable_payload, error = load_command_json(
5389
+ root,
5390
+ [
5391
+ "python3",
5392
+ "tools/loom_flow.py",
5393
+ "shadow-parity",
5394
+ "--target",
5395
+ str(unreadable_target),
5396
+ "--surface",
5397
+ "closeout",
5398
+ "--blocking",
5399
+ ],
5400
+ )
5401
+ if error:
5402
+ failures.append(Failure("repo-interop", f"`shadow-parity --blocking` unreadable sample failed: {error}"))
5403
+ else:
5404
+ require_shadow_parity_payload(
5405
+ failures,
5406
+ category="repo-interop",
5407
+ context="`shadow-parity --blocking` unreadable sample",
5408
+ payload=blocking_unreadable_payload,
5409
+ expected_reports=1,
5410
+ )
5411
+ if blocking_unreadable_payload.get("result") != "block":
5412
+ failures.append(Failure("repo-interop", "`shadow-parity --blocking` must block unreadable surfaces"))
5413
+
5325
5414
  return failures
5326
5415
 
5327
5416
 
@@ -276,6 +276,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
276
276
  default=".loom/bootstrap/init-result.json",
277
277
  help="Init-result path relative to the target root",
278
278
  )
279
+ shadow.add_argument(
280
+ "--mode",
281
+ choices=("validation-only", "blocking"),
282
+ default="validation-only",
283
+ help="Shadow parity enforcement mode; defaults to validation-only.",
284
+ )
285
+ shadow.add_argument(
286
+ "--blocking",
287
+ action="store_true",
288
+ help="Shortcut for --mode blocking. This is explicit opt-in and never the default.",
289
+ )
279
290
 
280
291
  runtime_parity = subparsers.add_parser(
281
292
  "runtime-parity",
@@ -543,8 +554,11 @@ def shadow_parity_report(
543
554
  empty_report = {
544
555
  "surface": surface,
545
556
  "result": "unreadable",
557
+ "classification": "gate_failure",
558
+ "blocking": False,
546
559
  "summary": "shadow parity could not be evaluated for this surface.",
547
560
  "missing_inputs": [],
561
+ "recommended_action": "restore the declared Loom and repo-native shadow parity locators before treating this surface as authoritative.",
548
562
  "host_adapters": [],
549
563
  "repo_native_carriers": [],
550
564
  "loom_surface": {
@@ -633,8 +647,11 @@ def shadow_parity_report(
633
647
  return {
634
648
  "surface": surface,
635
649
  "result": "match",
650
+ "classification": None,
651
+ "blocking": False,
636
652
  "summary": "Loom and repo-native surfaces report the same normalized result.",
637
653
  "missing_inputs": [],
654
+ "recommended_action": "no shadow parity action required.",
638
655
  "host_adapters": relevant_host_adapters,
639
656
  "repo_native_carriers": relevant_repo_native_carriers,
640
657
  "loom_surface": loom_surface,
@@ -643,8 +660,11 @@ def shadow_parity_report(
643
660
  return {
644
661
  "surface": surface,
645
662
  "result": "mismatch",
663
+ "classification": "drift",
664
+ "blocking": False,
646
665
  "summary": "Loom and repo-native surfaces disagree on the normalized result.",
647
666
  "missing_inputs": [],
667
+ "recommended_action": "resolve the parity mismatch or explicitly choose the authoritative surface outside repo interop before enabling blocking consumption.",
648
668
  "host_adapters": relevant_host_adapters,
649
669
  "repo_native_carriers": relevant_repo_native_carriers,
650
670
  "loom_surface": loom_surface,
@@ -6122,6 +6142,7 @@ def handle_shadow_parity(args: argparse.Namespace) -> int:
6122
6142
  governance_surface = build_governance_surface(target_root)
6123
6143
  repo_interop = governance_surface.get("repo_interop")
6124
6144
  requested_surfaces = SHADOW_PARITY_SURFACES if args.surface == "all" else (args.surface,)
6145
+ mode = "blocking" if args.blocking else args.mode
6125
6146
  reports = [
6126
6147
  shadow_parity_report(
6127
6148
  repo_interop,
@@ -6131,9 +6152,18 @@ def handle_shadow_parity(args: argparse.Namespace) -> int:
6131
6152
  for surface in requested_surfaces
6132
6153
  ]
6133
6154
 
6134
- result = "pass" if reports and all(report["result"] == "match" for report in reports) else "warn"
6155
+ all_match = bool(reports) and all(report["result"] == "match" for report in reports)
6156
+ blocking_reports = [report for report in reports if report.get("result") != "match"]
6157
+ if mode == "blocking":
6158
+ result = "pass" if all_match else "block"
6159
+ for report in blocking_reports:
6160
+ report["blocking"] = True
6161
+ else:
6162
+ result = "pass" if all_match else "warn"
6135
6163
  if result == "pass":
6136
6164
  summary = "shadow parity matches across all requested surfaces."
6165
+ elif mode == "blocking":
6166
+ summary = "shadow parity blocking mode found mismatch or unreadable surfaces."
6137
6167
  else:
6138
6168
  summaries = {report["result"] for report in reports}
6139
6169
  if "mismatch" in summaries:
@@ -6150,10 +6180,12 @@ def handle_shadow_parity(args: argparse.Namespace) -> int:
6150
6180
  return emit(
6151
6181
  {
6152
6182
  "command": "shadow-parity",
6183
+ "mode": mode,
6184
+ "blocking": mode == "blocking",
6153
6185
  "result": result,
6154
6186
  "summary": summary,
6155
6187
  "missing_inputs": missing_inputs,
6156
- "fallback_to": None,
6188
+ "fallback_to": "manual-reconciliation" if result == "block" else None,
6157
6189
  "runtime_state": runtime_state,
6158
6190
  "governance_surface": governance_surface,
6159
6191
  "reports": reports,
@@ -94,6 +94,7 @@ CORE_DOCS = (
94
94
  "docs/evidence/landing-map.md",
95
95
  "docs/evidence/validations/validation-closeout-reconciliation-blocking-gate.md",
96
96
  "docs/evidence/validations/validation-loom-core-runtime-parity.md",
97
+ "docs/evidence/validations/validation-shadow-parity-blocking-gate.md",
97
98
  "docs/evidence/validations/validation-syvert-strong-governance-parity.md",
98
99
  "docs/adoption/rationale.md",
99
100
  "docs/adoption/routing-and-checkpoints.md",
@@ -969,10 +970,17 @@ def require_shadow_parity_payload(
969
970
  return
970
971
  if payload.get("command") != "shadow-parity":
971
972
  failures.append(Failure(category, f"{context} must report `command: shadow-parity`"))
972
- if payload.get("result") not in {"pass", "warn"}:
973
- failures.append(Failure(category, f"{context} result must be `pass` or `warn`"))
974
- if payload.get("fallback_to") is not None:
975
- failures.append(Failure(category, f"{context} fallback_to must remain `null`"))
973
+ mode = payload.get("mode", "validation-only")
974
+ if mode not in {"validation-only", "blocking"}:
975
+ failures.append(Failure(category, f"{context} mode must be `validation-only` or `blocking`"))
976
+ if payload.get("blocking") != (mode == "blocking"):
977
+ failures.append(Failure(category, f"{context} blocking flag must match mode"))
978
+ allowed_results = {"pass", "block"} if mode == "blocking" else {"pass", "warn"}
979
+ if payload.get("result") not in allowed_results:
980
+ failures.append(Failure(category, f"{context} result must stay within the stable mode-specific contract"))
981
+ expected_fallbacks = {"manual-reconciliation"} if payload.get("result") == "block" else {None}
982
+ if payload.get("fallback_to") not in expected_fallbacks:
983
+ failures.append(Failure(category, f"{context} fallback_to must match the shadow parity enforcement mode"))
976
984
  if not isinstance(payload.get("summary"), str) or not payload.get("summary"):
977
985
  failures.append(Failure(category, f"{context} must include non-empty `summary`"))
978
986
  if not isinstance(payload.get("missing_inputs"), list):
@@ -1008,6 +1016,14 @@ def require_shadow_parity_payload(
1008
1016
  failures.append(Failure(category, f"{context} reports[{index}] must declare a known surface"))
1009
1017
  if report.get("result") not in {"match", "mismatch", "unreadable"}:
1010
1018
  failures.append(Failure(category, f"{context} reports[{index}] result must stay within the stable contract"))
1019
+ if report.get("classification") not in {None, "drift", "gate_failure"}:
1020
+ failures.append(Failure(category, f"{context} reports[{index}] classification must stay within the stable contract"))
1021
+ if not isinstance(report.get("blocking"), bool):
1022
+ failures.append(Failure(category, f"{context} reports[{index}] must include boolean `blocking`"))
1023
+ if not isinstance(report.get("recommended_action"), str) or not report.get("recommended_action"):
1024
+ failures.append(Failure(category, f"{context} reports[{index}] must include non-empty `recommended_action`"))
1025
+ if mode == "blocking" and report.get("result") != "match" and report.get("blocking") is not True:
1026
+ failures.append(Failure(category, f"{context} reports[{index}] must block non-matching reports in blocking mode"))
1011
1027
  if not isinstance(report.get("summary"), str) or not report.get("summary"):
1012
1028
  failures.append(Failure(category, f"{context} reports[{index}] must include non-empty `summary`"))
1013
1029
  if not isinstance(report.get("missing_inputs"), list):
@@ -5301,6 +5317,23 @@ def check_repo_interop_contracts(root: Path) -> list[Failure]:
5301
5317
  if parity_payload.get("result") != "pass":
5302
5318
  failures.append(Failure("repo-interop", "`shadow-parity` must pass when all declared surfaces match"))
5303
5319
 
5320
+ blocking_match_payload, error = load_command_json(
5321
+ root,
5322
+ ["python3", "tools/loom_flow.py", "shadow-parity", "--target", str(present_target), "--blocking"],
5323
+ )
5324
+ if error:
5325
+ failures.append(Failure("repo-interop", f"`shadow-parity --blocking` match sample failed: {error}"))
5326
+ else:
5327
+ require_shadow_parity_payload(
5328
+ failures,
5329
+ category="repo-interop",
5330
+ context="`shadow-parity --blocking` match sample",
5331
+ payload=blocking_match_payload,
5332
+ expected_reports=4,
5333
+ )
5334
+ if blocking_match_payload.get("result") != "pass":
5335
+ failures.append(Failure("repo-interop", "`shadow-parity --blocking` must pass when all declared surfaces match"))
5336
+
5304
5337
  mismatch_target = base / "mismatch"
5305
5338
  shutil.copytree(present_target, mismatch_target)
5306
5339
  write_json(mismatch_target / ".loom/shadow/review-repo.json", {"decision": "block"})
@@ -5322,6 +5355,62 @@ def check_repo_interop_contracts(root: Path) -> list[Failure]:
5322
5355
  if not isinstance(reports, list) or not reports or reports[0].get("result") != "mismatch":
5323
5356
  failures.append(Failure("repo-interop", "`shadow-parity` mismatch sample must report `mismatch`"))
5324
5357
 
5358
+ blocking_mismatch_payload, error = load_command_json(
5359
+ root,
5360
+ [
5361
+ "python3",
5362
+ "tools/loom_flow.py",
5363
+ "shadow-parity",
5364
+ "--target",
5365
+ str(mismatch_target),
5366
+ "--surface",
5367
+ "review",
5368
+ "--mode",
5369
+ "blocking",
5370
+ ],
5371
+ )
5372
+ if error:
5373
+ failures.append(Failure("repo-interop", f"`shadow-parity --mode blocking` mismatch sample failed: {error}"))
5374
+ else:
5375
+ require_shadow_parity_payload(
5376
+ failures,
5377
+ category="repo-interop",
5378
+ context="`shadow-parity --mode blocking` mismatch sample",
5379
+ payload=blocking_mismatch_payload,
5380
+ expected_reports=1,
5381
+ )
5382
+ if blocking_mismatch_payload.get("result") != "block":
5383
+ failures.append(Failure("repo-interop", "`shadow-parity --mode blocking` must block mismatches"))
5384
+
5385
+ unreadable_target = base / "unreadable"
5386
+ shutil.copytree(present_target, unreadable_target)
5387
+ (unreadable_target / ".loom/shadow/closeout-repo.json").unlink()
5388
+ blocking_unreadable_payload, error = load_command_json(
5389
+ root,
5390
+ [
5391
+ "python3",
5392
+ "tools/loom_flow.py",
5393
+ "shadow-parity",
5394
+ "--target",
5395
+ str(unreadable_target),
5396
+ "--surface",
5397
+ "closeout",
5398
+ "--blocking",
5399
+ ],
5400
+ )
5401
+ if error:
5402
+ failures.append(Failure("repo-interop", f"`shadow-parity --blocking` unreadable sample failed: {error}"))
5403
+ else:
5404
+ require_shadow_parity_payload(
5405
+ failures,
5406
+ category="repo-interop",
5407
+ context="`shadow-parity --blocking` unreadable sample",
5408
+ payload=blocking_unreadable_payload,
5409
+ expected_reports=1,
5410
+ )
5411
+ if blocking_unreadable_payload.get("result") != "block":
5412
+ failures.append(Failure("repo-interop", "`shadow-parity --blocking` must block unreadable surfaces"))
5413
+
5325
5414
  return failures
5326
5415
 
5327
5416
 
@@ -276,6 +276,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
276
276
  default=".loom/bootstrap/init-result.json",
277
277
  help="Init-result path relative to the target root",
278
278
  )
279
+ shadow.add_argument(
280
+ "--mode",
281
+ choices=("validation-only", "blocking"),
282
+ default="validation-only",
283
+ help="Shadow parity enforcement mode; defaults to validation-only.",
284
+ )
285
+ shadow.add_argument(
286
+ "--blocking",
287
+ action="store_true",
288
+ help="Shortcut for --mode blocking. This is explicit opt-in and never the default.",
289
+ )
279
290
 
280
291
  runtime_parity = subparsers.add_parser(
281
292
  "runtime-parity",
@@ -543,8 +554,11 @@ def shadow_parity_report(
543
554
  empty_report = {
544
555
  "surface": surface,
545
556
  "result": "unreadable",
557
+ "classification": "gate_failure",
558
+ "blocking": False,
546
559
  "summary": "shadow parity could not be evaluated for this surface.",
547
560
  "missing_inputs": [],
561
+ "recommended_action": "restore the declared Loom and repo-native shadow parity locators before treating this surface as authoritative.",
548
562
  "host_adapters": [],
549
563
  "repo_native_carriers": [],
550
564
  "loom_surface": {
@@ -633,8 +647,11 @@ def shadow_parity_report(
633
647
  return {
634
648
  "surface": surface,
635
649
  "result": "match",
650
+ "classification": None,
651
+ "blocking": False,
636
652
  "summary": "Loom and repo-native surfaces report the same normalized result.",
637
653
  "missing_inputs": [],
654
+ "recommended_action": "no shadow parity action required.",
638
655
  "host_adapters": relevant_host_adapters,
639
656
  "repo_native_carriers": relevant_repo_native_carriers,
640
657
  "loom_surface": loom_surface,
@@ -643,8 +660,11 @@ def shadow_parity_report(
643
660
  return {
644
661
  "surface": surface,
645
662
  "result": "mismatch",
663
+ "classification": "drift",
664
+ "blocking": False,
646
665
  "summary": "Loom and repo-native surfaces disagree on the normalized result.",
647
666
  "missing_inputs": [],
667
+ "recommended_action": "resolve the parity mismatch or explicitly choose the authoritative surface outside repo interop before enabling blocking consumption.",
648
668
  "host_adapters": relevant_host_adapters,
649
669
  "repo_native_carriers": relevant_repo_native_carriers,
650
670
  "loom_surface": loom_surface,
@@ -6122,6 +6142,7 @@ def handle_shadow_parity(args: argparse.Namespace) -> int:
6122
6142
  governance_surface = build_governance_surface(target_root)
6123
6143
  repo_interop = governance_surface.get("repo_interop")
6124
6144
  requested_surfaces = SHADOW_PARITY_SURFACES if args.surface == "all" else (args.surface,)
6145
+ mode = "blocking" if args.blocking else args.mode
6125
6146
  reports = [
6126
6147
  shadow_parity_report(
6127
6148
  repo_interop,
@@ -6131,9 +6152,18 @@ def handle_shadow_parity(args: argparse.Namespace) -> int:
6131
6152
  for surface in requested_surfaces
6132
6153
  ]
6133
6154
 
6134
- result = "pass" if reports and all(report["result"] == "match" for report in reports) else "warn"
6155
+ all_match = bool(reports) and all(report["result"] == "match" for report in reports)
6156
+ blocking_reports = [report for report in reports if report.get("result") != "match"]
6157
+ if mode == "blocking":
6158
+ result = "pass" if all_match else "block"
6159
+ for report in blocking_reports:
6160
+ report["blocking"] = True
6161
+ else:
6162
+ result = "pass" if all_match else "warn"
6135
6163
  if result == "pass":
6136
6164
  summary = "shadow parity matches across all requested surfaces."
6165
+ elif mode == "blocking":
6166
+ summary = "shadow parity blocking mode found mismatch or unreadable surfaces."
6137
6167
  else:
6138
6168
  summaries = {report["result"] for report in reports}
6139
6169
  if "mismatch" in summaries:
@@ -6150,10 +6180,12 @@ def handle_shadow_parity(args: argparse.Namespace) -> int:
6150
6180
  return emit(
6151
6181
  {
6152
6182
  "command": "shadow-parity",
6183
+ "mode": mode,
6184
+ "blocking": mode == "blocking",
6153
6185
  "result": result,
6154
6186
  "summary": summary,
6155
6187
  "missing_inputs": missing_inputs,
6156
- "fallback_to": None,
6188
+ "fallback_to": "manual-reconciliation" if result == "block" else None,
6157
6189
  "runtime_state": runtime_state,
6158
6190
  "governance_surface": governance_surface,
6159
6191
  "reports": reports,
@@ -94,6 +94,7 @@ CORE_DOCS = (
94
94
  "docs/evidence/landing-map.md",
95
95
  "docs/evidence/validations/validation-closeout-reconciliation-blocking-gate.md",
96
96
  "docs/evidence/validations/validation-loom-core-runtime-parity.md",
97
+ "docs/evidence/validations/validation-shadow-parity-blocking-gate.md",
97
98
  "docs/evidence/validations/validation-syvert-strong-governance-parity.md",
98
99
  "docs/adoption/rationale.md",
99
100
  "docs/adoption/routing-and-checkpoints.md",
@@ -969,10 +970,17 @@ def require_shadow_parity_payload(
969
970
  return
970
971
  if payload.get("command") != "shadow-parity":
971
972
  failures.append(Failure(category, f"{context} must report `command: shadow-parity`"))
972
- if payload.get("result") not in {"pass", "warn"}:
973
- failures.append(Failure(category, f"{context} result must be `pass` or `warn`"))
974
- if payload.get("fallback_to") is not None:
975
- failures.append(Failure(category, f"{context} fallback_to must remain `null`"))
973
+ mode = payload.get("mode", "validation-only")
974
+ if mode not in {"validation-only", "blocking"}:
975
+ failures.append(Failure(category, f"{context} mode must be `validation-only` or `blocking`"))
976
+ if payload.get("blocking") != (mode == "blocking"):
977
+ failures.append(Failure(category, f"{context} blocking flag must match mode"))
978
+ allowed_results = {"pass", "block"} if mode == "blocking" else {"pass", "warn"}
979
+ if payload.get("result") not in allowed_results:
980
+ failures.append(Failure(category, f"{context} result must stay within the stable mode-specific contract"))
981
+ expected_fallbacks = {"manual-reconciliation"} if payload.get("result") == "block" else {None}
982
+ if payload.get("fallback_to") not in expected_fallbacks:
983
+ failures.append(Failure(category, f"{context} fallback_to must match the shadow parity enforcement mode"))
976
984
  if not isinstance(payload.get("summary"), str) or not payload.get("summary"):
977
985
  failures.append(Failure(category, f"{context} must include non-empty `summary`"))
978
986
  if not isinstance(payload.get("missing_inputs"), list):
@@ -1008,6 +1016,14 @@ def require_shadow_parity_payload(
1008
1016
  failures.append(Failure(category, f"{context} reports[{index}] must declare a known surface"))
1009
1017
  if report.get("result") not in {"match", "mismatch", "unreadable"}:
1010
1018
  failures.append(Failure(category, f"{context} reports[{index}] result must stay within the stable contract"))
1019
+ if report.get("classification") not in {None, "drift", "gate_failure"}:
1020
+ failures.append(Failure(category, f"{context} reports[{index}] classification must stay within the stable contract"))
1021
+ if not isinstance(report.get("blocking"), bool):
1022
+ failures.append(Failure(category, f"{context} reports[{index}] must include boolean `blocking`"))
1023
+ if not isinstance(report.get("recommended_action"), str) or not report.get("recommended_action"):
1024
+ failures.append(Failure(category, f"{context} reports[{index}] must include non-empty `recommended_action`"))
1025
+ if mode == "blocking" and report.get("result") != "match" and report.get("blocking") is not True:
1026
+ failures.append(Failure(category, f"{context} reports[{index}] must block non-matching reports in blocking mode"))
1011
1027
  if not isinstance(report.get("summary"), str) or not report.get("summary"):
1012
1028
  failures.append(Failure(category, f"{context} reports[{index}] must include non-empty `summary`"))
1013
1029
  if not isinstance(report.get("missing_inputs"), list):
@@ -5301,6 +5317,23 @@ def check_repo_interop_contracts(root: Path) -> list[Failure]:
5301
5317
  if parity_payload.get("result") != "pass":
5302
5318
  failures.append(Failure("repo-interop", "`shadow-parity` must pass when all declared surfaces match"))
5303
5319
 
5320
+ blocking_match_payload, error = load_command_json(
5321
+ root,
5322
+ ["python3", "tools/loom_flow.py", "shadow-parity", "--target", str(present_target), "--blocking"],
5323
+ )
5324
+ if error:
5325
+ failures.append(Failure("repo-interop", f"`shadow-parity --blocking` match sample failed: {error}"))
5326
+ else:
5327
+ require_shadow_parity_payload(
5328
+ failures,
5329
+ category="repo-interop",
5330
+ context="`shadow-parity --blocking` match sample",
5331
+ payload=blocking_match_payload,
5332
+ expected_reports=4,
5333
+ )
5334
+ if blocking_match_payload.get("result") != "pass":
5335
+ failures.append(Failure("repo-interop", "`shadow-parity --blocking` must pass when all declared surfaces match"))
5336
+
5304
5337
  mismatch_target = base / "mismatch"
5305
5338
  shutil.copytree(present_target, mismatch_target)
5306
5339
  write_json(mismatch_target / ".loom/shadow/review-repo.json", {"decision": "block"})
@@ -5322,6 +5355,62 @@ def check_repo_interop_contracts(root: Path) -> list[Failure]:
5322
5355
  if not isinstance(reports, list) or not reports or reports[0].get("result") != "mismatch":
5323
5356
  failures.append(Failure("repo-interop", "`shadow-parity` mismatch sample must report `mismatch`"))
5324
5357
 
5358
+ blocking_mismatch_payload, error = load_command_json(
5359
+ root,
5360
+ [
5361
+ "python3",
5362
+ "tools/loom_flow.py",
5363
+ "shadow-parity",
5364
+ "--target",
5365
+ str(mismatch_target),
5366
+ "--surface",
5367
+ "review",
5368
+ "--mode",
5369
+ "blocking",
5370
+ ],
5371
+ )
5372
+ if error:
5373
+ failures.append(Failure("repo-interop", f"`shadow-parity --mode blocking` mismatch sample failed: {error}"))
5374
+ else:
5375
+ require_shadow_parity_payload(
5376
+ failures,
5377
+ category="repo-interop",
5378
+ context="`shadow-parity --mode blocking` mismatch sample",
5379
+ payload=blocking_mismatch_payload,
5380
+ expected_reports=1,
5381
+ )
5382
+ if blocking_mismatch_payload.get("result") != "block":
5383
+ failures.append(Failure("repo-interop", "`shadow-parity --mode blocking` must block mismatches"))
5384
+
5385
+ unreadable_target = base / "unreadable"
5386
+ shutil.copytree(present_target, unreadable_target)
5387
+ (unreadable_target / ".loom/shadow/closeout-repo.json").unlink()
5388
+ blocking_unreadable_payload, error = load_command_json(
5389
+ root,
5390
+ [
5391
+ "python3",
5392
+ "tools/loom_flow.py",
5393
+ "shadow-parity",
5394
+ "--target",
5395
+ str(unreadable_target),
5396
+ "--surface",
5397
+ "closeout",
5398
+ "--blocking",
5399
+ ],
5400
+ )
5401
+ if error:
5402
+ failures.append(Failure("repo-interop", f"`shadow-parity --blocking` unreadable sample failed: {error}"))
5403
+ else:
5404
+ require_shadow_parity_payload(
5405
+ failures,
5406
+ category="repo-interop",
5407
+ context="`shadow-parity --blocking` unreadable sample",
5408
+ payload=blocking_unreadable_payload,
5409
+ expected_reports=1,
5410
+ )
5411
+ if blocking_unreadable_payload.get("result") != "block":
5412
+ failures.append(Failure("repo-interop", "`shadow-parity --blocking` must block unreadable surfaces"))
5413
+
5325
5414
  return failures
5326
5415
 
5327
5416