@guanlin4924/devflow-pi 0.1.40 → 0.1.47

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 (49) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +609 -42
  3. package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
  4. package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
  5. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
  6. package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
  7. package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
  8. package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
  10. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +143 -28
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
  24. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
  25. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
  26. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
  27. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
  28. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
  29. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -1
  30. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
  31. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
  32. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +13 -4
  33. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
  34. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +28 -2
  35. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
  36. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
  37. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
  38. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
  39. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +10 -1
  40. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
  41. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
  42. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
  43. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +106 -1
  44. package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
  45. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
  46. package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
  47. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
  48. package/src/constants.ts +1 -1
  49. package/src/runtime.ts +53 -22
@@ -0,0 +1,1063 @@
1
+ #!/usr/bin/env python3
2
+ """Strict structural validator and canonicalizer for Devflow business models.
3
+
4
+ P0-01 intentionally validates only schema shape, strict keys, local union
5
+ semantics, and deterministic serialization. Source resolution and business
6
+ closure checks are added by P0-02 through P0-04.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import hashlib
12
+ import json
13
+ import re
14
+ import subprocess
15
+ import sys
16
+ import unicodedata
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ DEFAULT_SCHEMA = Path(__file__).resolve().parents[1] / "contracts" / "business-model.schema.yaml"
22
+ SOURCE_RESOLVER = Path(__file__).resolve().with_name("business_source_resolver.rb")
23
+
24
+
25
+ def load_yaml(path: Path) -> Any:
26
+ try:
27
+ import yaml # type: ignore
28
+
29
+ return yaml.safe_load(path.read_text(encoding="utf-8"))
30
+ except ImportError:
31
+ result = subprocess.run(
32
+ [
33
+ "ruby",
34
+ "-ryaml",
35
+ "-rjson",
36
+ "-e",
37
+ "puts JSON.generate(YAML.safe_load(File.read(ARGV.fetch(0))))",
38
+ str(path),
39
+ ],
40
+ text=True,
41
+ capture_output=True,
42
+ check=False,
43
+ )
44
+ if result.returncode != 0:
45
+ raise ValueError(result.stderr.strip() or "YAML parse failed")
46
+ return json.loads(result.stdout)
47
+
48
+
49
+ def add_error(errors: list[dict[str, str]], code: str, path: str, message: str) -> None:
50
+ errors.append({"code": code, "path": path, "message": message})
51
+
52
+
53
+ def object_spec(schema: dict[str, Any], name: str) -> dict[str, Any]:
54
+ value = schema.get("objects", {}).get(name, {})
55
+ return value if isinstance(value, dict) else {}
56
+
57
+
58
+ def strict_object(
59
+ value: Any,
60
+ spec: dict[str, Any],
61
+ path: str,
62
+ errors: list[dict[str, str]],
63
+ *,
64
+ missing_code: str = "BUSINESS_REQUIRED_FIELD_MISSING",
65
+ ) -> bool:
66
+ if not isinstance(value, dict):
67
+ add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", path, "object required")
68
+ return False
69
+ properties = spec.get("properties", {})
70
+ allowed = set(properties) if isinstance(properties, dict) else set()
71
+ for key in value:
72
+ if key not in allowed:
73
+ add_error(errors, "BUSINESS_UNKNOWN_FIELD", f"{path}.{key}", f"unknown field {key}")
74
+ for key in spec.get("required", []):
75
+ if key not in value:
76
+ add_error(errors, missing_code, f"{path}.{key}", f"required field {key} is missing")
77
+ return True
78
+
79
+
80
+ def validate_id(value: Any, path: str, pattern: re.Pattern[str], errors: list[dict[str, str]]) -> bool:
81
+ if not isinstance(value, str) or not pattern.fullmatch(value):
82
+ add_error(errors, "BUSINESS_ID_INVALID", path, "value must match the business ID pattern")
83
+ return False
84
+ return True
85
+
86
+
87
+ def validate_string(value: Any, path: str, errors: list[dict[str, str]], *, non_empty: bool = True) -> None:
88
+ if not isinstance(value, str) or (non_empty and not value.strip()):
89
+ add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", path, "non-empty string required")
90
+
91
+
92
+ def validate_id_list(
93
+ value: Any,
94
+ path: str,
95
+ pattern: re.Pattern[str],
96
+ errors: list[dict[str, str]],
97
+ *,
98
+ min_items: int = 0,
99
+ ) -> None:
100
+ if not isinstance(value, list):
101
+ add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", path, "array required")
102
+ return
103
+ if len(value) < min_items:
104
+ add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", path, f"at least {min_items} item(s) required")
105
+ seen: set[str] = set()
106
+ for index, item in enumerate(value):
107
+ if validate_id(item, f"{path}[{index}]", pattern, errors):
108
+ if item in seen:
109
+ add_error(errors, "BUSINESS_DUPLICATE_ID", f"{path}[{index}]", f"duplicate reference {item}")
110
+ seen.add(item)
111
+
112
+
113
+ def validate_source_refs(value: Any, path: str, pattern: re.Pattern[str], errors: list[dict[str, str]]) -> None:
114
+ validate_id_list(value, path, pattern, errors, min_items=1)
115
+
116
+
117
+ def register_id(value: Any, path: str, pattern: re.Pattern[str], errors: list[dict[str, str]], seen: set[str]) -> None:
118
+ if validate_id(value, path, pattern, errors):
119
+ if value in seen:
120
+ add_error(errors, "BUSINESS_DUPLICATE_ID", path, f"duplicate ID {value}")
121
+ else:
122
+ seen.add(value)
123
+
124
+
125
+ def validate_simple_object(
126
+ value: Any,
127
+ kind: str,
128
+ path: str,
129
+ schema: dict[str, Any],
130
+ pattern: re.Pattern[str],
131
+ errors: list[dict[str, str]],
132
+ seen: set[str],
133
+ ) -> None:
134
+ spec = object_spec(schema, kind)
135
+ if not strict_object(value, spec, path, errors):
136
+ return
137
+ register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
138
+ if "name" in value:
139
+ validate_string(value.get("name"), f"{path}.name", errors)
140
+ validate_source_refs(value.get("source_refs"), f"{path}.source_refs", pattern, errors)
141
+
142
+
143
+ def validate_entity(
144
+ value: Any,
145
+ path: str,
146
+ schema: dict[str, Any],
147
+ pattern: re.Pattern[str],
148
+ errors: list[dict[str, str]],
149
+ seen: set[str],
150
+ ) -> None:
151
+ spec = object_spec(schema, "entity")
152
+ if not strict_object(value, spec, path, errors):
153
+ return
154
+ register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
155
+ if "name" in value:
156
+ validate_string(value.get("name"), f"{path}.name", errors)
157
+ validate_source_refs(value.get("source_refs"), f"{path}.source_refs", pattern, errors)
158
+ states = value.get("states")
159
+ if not isinstance(states, list):
160
+ add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", f"{path}.states", "array required")
161
+ return
162
+ state_spec = object_spec(schema, "state")
163
+ for index, state in enumerate(states):
164
+ state_path = f"{path}.states[{index}]"
165
+ if not strict_object(state, state_spec, state_path, errors):
166
+ continue
167
+ register_id(state.get("id"), f"{state_path}.id", pattern, errors, seen)
168
+ validate_string(state.get("name"), f"{state_path}.name", errors)
169
+ if "terminal" in state and not isinstance(state.get("terminal"), bool):
170
+ add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", f"{state_path}.terminal", "boolean required")
171
+ validate_source_refs(state.get("source_refs"), f"{state_path}.source_refs", pattern, errors)
172
+
173
+
174
+ def validate_origin(
175
+ value: Any,
176
+ path: str,
177
+ schema: dict[str, Any],
178
+ pattern: re.Pattern[str],
179
+ errors: list[dict[str, str]],
180
+ ) -> None:
181
+ variants = schema.get("unions", {}).get("resource_origin", {}).get("variants", {})
182
+ if not isinstance(value, dict):
183
+ add_error(errors, "BUSINESS_INVALID_ORIGIN", path, "resource origin must be an object")
184
+ return
185
+ kind = value.get("kind")
186
+ spec = variants.get(kind) if isinstance(variants, dict) else None
187
+ if not isinstance(spec, dict):
188
+ add_error(errors, "BUSINESS_INVALID_ORIGIN", f"{path}.kind", f"unknown resource origin {kind}")
189
+ return
190
+ properties = spec.get("properties", {})
191
+ allowed = set(properties) if isinstance(properties, dict) else set()
192
+ for key in value:
193
+ if key not in allowed:
194
+ add_error(errors, "BUSINESS_UNKNOWN_FIELD", f"{path}.{key}", f"field {key} is not valid for origin {kind}")
195
+ for key in spec.get("required", []):
196
+ if key not in value:
197
+ add_error(errors, "BUSINESS_REQUIRED_FIELD_MISSING", f"{path}.{key}", f"required field {key} is missing")
198
+ for key, item in value.items():
199
+ if key != "kind":
200
+ validate_id(item, f"{path}.{key}", pattern, errors)
201
+
202
+
203
+ def validate_resource(
204
+ value: Any,
205
+ path: str,
206
+ schema: dict[str, Any],
207
+ pattern: re.Pattern[str],
208
+ errors: list[dict[str, str]],
209
+ seen: set[str],
210
+ ) -> None:
211
+ spec = object_spec(schema, "resource")
212
+ if not strict_object(value, spec, path, errors):
213
+ return
214
+ register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
215
+ if "name" in value:
216
+ validate_string(value.get("name"), f"{path}.name", errors)
217
+ validate_origin(value.get("origin"), f"{path}.origin", schema, pattern, errors)
218
+ validate_source_refs(value.get("source_refs"), f"{path}.source_refs", pattern, errors)
219
+
220
+
221
+ def validate_operand(
222
+ value: Any,
223
+ path: str,
224
+ schema: dict[str, Any],
225
+ pattern: re.Pattern[str],
226
+ errors: list[dict[str, str]],
227
+ ) -> None:
228
+ variants = schema.get("conditions", {}).get("operand_variants", {})
229
+ if not isinstance(value, dict):
230
+ add_error(errors, "BUSINESS_RULE_INVALID", path, "operand must be an object")
231
+ return
232
+ kind = value.get("kind")
233
+ allowed_fields = variants.get(kind) if isinstance(variants, dict) else None
234
+ if not isinstance(allowed_fields, list):
235
+ add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.kind", f"unknown operand kind {kind}")
236
+ return
237
+ allowed = set(allowed_fields)
238
+ for key in value:
239
+ if key not in allowed:
240
+ add_error(errors, "BUSINESS_UNKNOWN_FIELD", f"{path}.{key}", f"field {key} is not valid for operand {kind}")
241
+ for key in allowed - {"value"}:
242
+ if key not in value:
243
+ add_error(errors, "BUSINESS_REQUIRED_FIELD_MISSING", f"{path}.{key}", f"required field {key} is missing")
244
+ if kind == "literal":
245
+ if "value" not in value:
246
+ add_error(errors, "BUSINESS_REQUIRED_FIELD_MISSING", f"{path}.value", "literal value is required")
247
+ else:
248
+ for key in allowed - {"kind", "attribute"}:
249
+ if key in value:
250
+ validate_id(value[key], f"{path}.{key}", pattern, errors)
251
+ if "attribute" in allowed and "attribute" in value:
252
+ validate_string(value["attribute"], f"{path}.attribute", errors)
253
+
254
+
255
+ def validate_condition(
256
+ value: Any,
257
+ path: str,
258
+ schema: dict[str, Any],
259
+ pattern: re.Pattern[str],
260
+ errors: list[dict[str, str]],
261
+ ) -> None:
262
+ if not isinstance(value, dict):
263
+ add_error(errors, "BUSINESS_RULE_INVALID", path, "condition must be an object")
264
+ return
265
+ allowed = {"op", "args", "operand", "left", "right"}
266
+ for key in value:
267
+ if key not in allowed:
268
+ add_error(errors, "BUSINESS_UNKNOWN_FIELD", f"{path}.{key}", f"unknown condition field {key}")
269
+ op = value.get("op")
270
+ operators = set(schema.get("conditions", {}).get("operators", []))
271
+ if op not in operators:
272
+ add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.op", f"unsupported operator {op}")
273
+ return
274
+ if op in {"and", "or", "not"}:
275
+ args = value.get("args")
276
+ expected = 1 if op == "not" else 2
277
+ if not isinstance(args, list) or len(args) < expected or (op == "not" and len(args) != 1):
278
+ add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.args", f"{op} requires {'one' if op == 'not' else 'at least two'} argument(s)")
279
+ return
280
+ if set(value) - {"op", "args"}:
281
+ add_error(errors, "BUSINESS_RULE_INVALID", path, f"{op} only accepts args")
282
+ for index, item in enumerate(args):
283
+ validate_condition(item, f"{path}.args[{index}]", schema, pattern, errors)
284
+ elif op == "exists":
285
+ if set(value) - {"op", "operand"}:
286
+ add_error(errors, "BUSINESS_RULE_INVALID", path, "exists only accepts operand")
287
+ validate_operand(value.get("operand"), f"{path}.operand", schema, pattern, errors)
288
+ else:
289
+ if set(value) - {"op", "left", "right"}:
290
+ add_error(errors, "BUSINESS_RULE_INVALID", path, f"{op} only accepts left/right operands")
291
+ validate_operand(value.get("left"), f"{path}.left", schema, pattern, errors)
292
+ validate_operand(value.get("right"), f"{path}.right", schema, pattern, errors)
293
+
294
+
295
+ def validate_rule(
296
+ value: Any,
297
+ path: str,
298
+ schema: dict[str, Any],
299
+ pattern: re.Pattern[str],
300
+ errors: list[dict[str, str]],
301
+ seen: set[str],
302
+ ) -> None:
303
+ spec = object_spec(schema, "rule")
304
+ if not strict_object(value, spec, path, errors):
305
+ return
306
+ register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
307
+ if "name" in value:
308
+ validate_string(value.get("name"), f"{path}.name", errors)
309
+ kind = value.get("kind")
310
+ if kind not in {"constraint", "decision"}:
311
+ add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.kind", "kind must be constraint or decision")
312
+ validate_source_refs(value.get("source_refs"), f"{path}.source_refs", pattern, errors)
313
+ if "input_refs" in value:
314
+ validate_id_list(value.get("input_refs"), f"{path}.input_refs", pattern, errors)
315
+ if kind == "decision":
316
+ if value.get("evaluation") != "first_match":
317
+ add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.evaluation", "decision evaluation must be first_match")
318
+ if "expression" in value:
319
+ add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.expression", "decision rule cannot define expression")
320
+ branches = value.get("branches")
321
+ if not isinstance(branches, list) or not branches:
322
+ add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.branches", "decision branches must be a non-empty array")
323
+ return
324
+ branch_spec = object_spec(schema, "branch")
325
+ defaults: list[int] = []
326
+ branch_ids: set[str] = set()
327
+ for index, branch in enumerate(branches):
328
+ branch_path = f"{path}.branches[{index}]"
329
+ if not strict_object(branch, branch_spec, branch_path, errors):
330
+ continue
331
+ branch_id = branch.get("id")
332
+ if validate_id(branch_id, f"{branch_path}.id", pattern, errors):
333
+ if branch_id in branch_ids:
334
+ add_error(errors, "BUSINESS_DUPLICATE_ID", f"{branch_path}.id", f"duplicate branch ID {branch_id}")
335
+ branch_ids.add(branch_id)
336
+ validate_id_list(branch.get("next_action_refs"), f"{branch_path}.next_action_refs", pattern, errors, min_items=1)
337
+ validate_source_refs(branch.get("source_refs"), f"{branch_path}.source_refs", pattern, errors)
338
+ if branch.get("default") is True:
339
+ defaults.append(index)
340
+ if "when" in branch:
341
+ add_error(errors, "BUSINESS_RULE_INVALID", f"{branch_path}.when", "default branch cannot define when")
342
+ else:
343
+ if branch.get("default") not in {None, False}:
344
+ add_error(errors, "BUSINESS_RULE_INVALID", f"{branch_path}.default", "default must be boolean")
345
+ validate_condition(branch.get("when"), f"{branch_path}.when", schema, pattern, errors)
346
+ if defaults != [len(branches) - 1]:
347
+ add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.branches", "exactly one default branch is required and it must be last")
348
+ elif kind == "constraint":
349
+ if "branches" in value or "evaluation" in value:
350
+ add_error(errors, "BUSINESS_RULE_INVALID", path, "constraint rule cannot define branches or evaluation")
351
+ validate_condition(value.get("expression"), f"{path}.expression", schema, pattern, errors)
352
+
353
+
354
+ def validate_routing(value: Any, path: str, schema: dict[str, Any], pattern: re.Pattern[str], errors: list[dict[str, str]]) -> None:
355
+ spec = object_spec(schema, "routing")
356
+ if not strict_object(value, spec, path, errors):
357
+ return
358
+ choices = 0
359
+ if "next_action_refs" in value:
360
+ validate_id_list(value.get("next_action_refs"), f"{path}.next_action_refs", pattern, errors, min_items=1)
361
+ choices += 1
362
+ if "decision_rule_ref" in value:
363
+ validate_id(value.get("decision_rule_ref"), f"{path}.decision_rule_ref", pattern, errors)
364
+ choices += 1
365
+ if "terminal" in value:
366
+ if value.get("terminal") is not True:
367
+ add_error(errors, "BUSINESS_ROUTING_INVALID", f"{path}.terminal", "terminal must be true when present")
368
+ choices += 1
369
+ if choices != 1:
370
+ add_error(errors, "BUSINESS_ROUTING_INVALID", path, "routing must choose exactly one of next_action_refs, decision_rule_ref, or terminal")
371
+
372
+
373
+ def validate_failure_mode(value: Any, path: str, schema: dict[str, Any], pattern: re.Pattern[str], errors: list[dict[str, str]]) -> None:
374
+ spec = object_spec(schema, "failure_mode")
375
+ if not strict_object(value, spec, path, errors):
376
+ return
377
+ validate_id(value.get("code"), f"{path}.code", pattern, errors)
378
+ validate_source_refs(value.get("source_refs"), f"{path}.source_refs", pattern, errors)
379
+ recovery = "recovery_action_ref" in value
380
+ terminal = value.get("terminal") is True and "terminal_state_ref" in value
381
+ if recovery == terminal:
382
+ add_error(errors, "BUSINESS_FAILURE_OUTCOME_MISSING", path, "failure must define either recovery_action_ref or terminal=true with terminal_state_ref")
383
+ if recovery:
384
+ validate_id(value.get("recovery_action_ref"), f"{path}.recovery_action_ref", pattern, errors)
385
+ if "terminal_state_ref" in value or "terminal" in value:
386
+ add_error(errors, "BUSINESS_FAILURE_OUTCOME_MISSING", path, "recoverable failure cannot also be terminal")
387
+ if terminal:
388
+ validate_id(value.get("terminal_state_ref"), f"{path}.terminal_state_ref", pattern, errors)
389
+
390
+
391
+ def validate_action(
392
+ value: Any,
393
+ path: str,
394
+ schema: dict[str, Any],
395
+ pattern: re.Pattern[str],
396
+ errors: list[dict[str, str]],
397
+ seen: set[str],
398
+ ) -> None:
399
+ spec = object_spec(schema, "action")
400
+ if not strict_object(value, spec, path, errors):
401
+ return
402
+ register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
403
+ if "name" in value:
404
+ validate_string(value.get("name"), f"{path}.name", errors)
405
+ for key in ("requirement_refs", "consumes", "produces", "constraint_rule_refs", "source_refs"):
406
+ validate_id_list(value.get(key), f"{path}.{key}", pattern, errors, min_items=1 if key in {"requirement_refs", "source_refs"} else 0)
407
+ for key in ("actor_ref", "owner_ref"):
408
+ validate_id(value.get(key), f"{path}.{key}", pattern, errors)
409
+ validate_string(value.get("trigger"), f"{path}.trigger", errors)
410
+ state_keys = {"entity_ref", "from_state_ref", "to_state_ref"}
411
+ present = state_keys.intersection(value)
412
+ if present and present != state_keys:
413
+ add_error(errors, "BUSINESS_STATE_REF_PARTIAL", path, "entity_ref, from_state_ref, and to_state_ref must be all present or all absent")
414
+ for key in present:
415
+ validate_id(value.get(key), f"{path}.{key}", pattern, errors)
416
+ validate_routing(value.get("routing"), f"{path}.routing", schema, pattern, errors)
417
+ failures = value.get("failure_modes")
418
+ if not isinstance(failures, list):
419
+ add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", f"{path}.failure_modes", "array required")
420
+ else:
421
+ failure_codes: set[str] = set()
422
+ for index, failure in enumerate(failures):
423
+ failure_path = f"{path}.failure_modes[{index}]"
424
+ validate_failure_mode(failure, failure_path, schema, pattern, errors)
425
+ if isinstance(failure, dict) and isinstance(failure.get("code"), str):
426
+ if failure["code"] in failure_codes:
427
+ add_error(errors, "BUSINESS_DUPLICATE_ID", f"{failure_path}.code", f"duplicate failure code {failure['code']}")
428
+ failure_codes.add(failure["code"])
429
+
430
+
431
+ def validate_assumption(value: Any, path: str, schema: dict[str, Any], pattern: re.Pattern[str], errors: list[dict[str, str]], seen: set[str]) -> None:
432
+ spec = object_spec(schema, "assumption")
433
+ if not strict_object(value, spec, path, errors):
434
+ return
435
+ register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
436
+ validate_string(value.get("statement"), f"{path}.statement", errors)
437
+ validate_string(value.get("proposed_by"), f"{path}.proposed_by", errors)
438
+ validate_id_list(value.get("affected_refs"), f"{path}.affected_refs", pattern, errors, min_items=1)
439
+ if value.get("status") != "unresolved":
440
+ add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", f"{path}.status", "AI proposal assumptions must remain unresolved")
441
+
442
+
443
+ def validate_conflict(value: Any, path: str, schema: dict[str, Any], pattern: re.Pattern[str], errors: list[dict[str, str]], seen: set[str]) -> None:
444
+ spec = object_spec(schema, "conflict")
445
+ if not strict_object(value, spec, path, errors):
446
+ return
447
+ register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
448
+ validate_id(value.get("subject_ref"), f"{path}.subject_ref", pattern, errors)
449
+ validate_string(value.get("field"), f"{path}.field", errors)
450
+ if value.get("status") not in {"unresolved", "resolved"}:
451
+ add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", f"{path}.status", "invalid conflict status")
452
+ alternatives = value.get("alternatives")
453
+ if not isinstance(alternatives, list) or len(alternatives) < 2:
454
+ add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", f"{path}.alternatives", "at least two alternatives are required")
455
+ return
456
+ alternative_spec = object_spec(schema, "alternative")
457
+ for index, alternative in enumerate(alternatives):
458
+ alt_path = f"{path}.alternatives[{index}]"
459
+ if strict_object(alternative, alternative_spec, alt_path, errors):
460
+ validate_string(alternative.get("proposal_ref"), f"{alt_path}.proposal_ref", errors)
461
+
462
+
463
+ def validate_model(data: Any, schema: dict[str, Any]) -> list[dict[str, str]]:
464
+ errors: list[dict[str, str]] = []
465
+ top = schema.get("top_level", {})
466
+ if not isinstance(data, dict):
467
+ add_error(errors, "BUSINESS_SCHEMA_INVALID", "$", "root must be an object")
468
+ return errors
469
+ allowed = set(top.get("properties", {}))
470
+ forbidden = set(top.get("forbidden_derived", []))
471
+ for key in data:
472
+ if key in forbidden:
473
+ add_error(errors, "BUSINESS_DERIVED_FIELD_FORBIDDEN", key, f"derived field {key} cannot be authored")
474
+ elif key not in allowed:
475
+ add_error(errors, "BUSINESS_UNKNOWN_FIELD", key, f"unknown field {key}")
476
+ for key in top.get("required", []):
477
+ if key not in data:
478
+ add_error(errors, "BUSINESS_REQUIRED_FIELD_MISSING", key, f"required field {key} is missing")
479
+ if data.get("schema_version") != schema.get("schema_version", 1):
480
+ add_error(errors, "BUSINESS_SCHEMA_VERSION_INVALID", "schema_version", "schema version must match the contract")
481
+ feature = data.get("feature")
482
+ validate_string(feature, "feature", errors)
483
+ if isinstance(feature, str) and (re.search(r"[\\/\x00-\x1F\x7F]", feature) or feature in {".", ".."}):
484
+ add_error(errors, "BUSINESS_FEATURE_INVALID", "feature", "feature must be a single safe path segment")
485
+ validate_string(data.get("source_registry_ref"), "source_registry_ref", errors)
486
+ pattern = re.compile(str(schema.get("id_pattern", r"^[A-Za-z][A-Za-z0-9_-]*$")))
487
+ seen: set[str] = set()
488
+ collection_handlers = {
489
+ "actors": lambda value, path: validate_simple_object(value, "actor", path, schema, pattern, errors, seen),
490
+ "entities": lambda value, path: validate_entity(value, path, schema, pattern, errors, seen),
491
+ "components": lambda value, path: validate_simple_object(value, "component", path, schema, pattern, errors, seen),
492
+ "boundaries": lambda value, path: validate_simple_object(value, "boundary", path, schema, pattern, errors, seen),
493
+ "resources": lambda value, path: validate_resource(value, path, schema, pattern, errors, seen),
494
+ "rules": lambda value, path: validate_rule(value, path, schema, pattern, errors, seen),
495
+ "actions": lambda value, path: validate_action(value, path, schema, pattern, errors, seen),
496
+ "assumptions": lambda value, path: validate_assumption(value, path, schema, pattern, errors, seen),
497
+ }
498
+ for collection, handler in collection_handlers.items():
499
+ values = data.get(collection, [])
500
+ if not isinstance(values, list):
501
+ add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", collection, "array required")
502
+ continue
503
+ for index, value in enumerate(values):
504
+ handler(value, f"{collection}[{index}]")
505
+ for key in ("requirement_refs", "decision_refs", "resolved_assumption_refs"):
506
+ validate_id_list(data.get(key), key, pattern, errors)
507
+ return errors
508
+
509
+
510
+ def normalize(value: Any) -> Any:
511
+ if isinstance(value, str):
512
+ return unicodedata.normalize("NFKC", value).rstrip()
513
+ if isinstance(value, list):
514
+ return [normalize(item) for item in value]
515
+ if isinstance(value, dict):
516
+ return {key: normalize(item) for key, item in value.items()}
517
+ return value
518
+
519
+
520
+ def canonical_order(data: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
521
+ canonical = schema.get("canonical", {})
522
+ orders = canonical.get("object_orders", {})
523
+
524
+ def ordered(value: Any, kind: str) -> Any:
525
+ if not isinstance(value, dict):
526
+ return value
527
+ order = orders.get(kind, [])
528
+ keys = [key for key in order if key in value]
529
+ keys.extend(sorted(key for key in value if key not in keys))
530
+ result: dict[str, Any] = {}
531
+ for key in keys:
532
+ item = value[key]
533
+ if kind == "entity" and key == "states":
534
+ result[key] = [ordered(x, "state") for x in item]
535
+ elif kind == "resource" and key == "origin":
536
+ result[key] = ordered(item, "resource_origin")
537
+ elif kind == "rule" and key == "branches":
538
+ result[key] = [ordered(x, "branch") for x in item]
539
+ elif kind in {"rule", "branch"} and key in {"expression", "when"}:
540
+ result[key] = ordered_condition(item)
541
+ elif kind == "action" and key == "routing":
542
+ result[key] = ordered(item, "routing")
543
+ elif kind == "action" and key == "failure_modes":
544
+ result[key] = [ordered(x, "failure_mode") for x in item]
545
+ elif kind == "conflict" and key == "alternatives":
546
+ result[key] = [ordered(x, "alternative") for x in item]
547
+ else:
548
+ result[key] = item
549
+ return result
550
+
551
+ def ordered_operand(value: Any) -> Any:
552
+ return ordered(value, "operand")
553
+
554
+ def ordered_condition(value: Any) -> Any:
555
+ if not isinstance(value, dict):
556
+ return value
557
+ order = orders.get("condition", [])
558
+ keys = [key for key in order if key in value]
559
+ keys.extend(sorted(key for key in value if key not in keys))
560
+ result: dict[str, Any] = {}
561
+ for key in keys:
562
+ item = value[key]
563
+ if key == "args":
564
+ result[key] = [ordered_condition(x) for x in item]
565
+ elif key in {"operand", "left", "right"}:
566
+ result[key] = ordered_operand(item)
567
+ else:
568
+ result[key] = item
569
+ return result
570
+
571
+ root_order = canonical.get("root_order", [])
572
+ keys = [key for key in root_order if key in data]
573
+ keys.extend(sorted(key for key in data if key not in keys))
574
+ collection_kinds = {
575
+ "actors": "actor",
576
+ "entities": "entity",
577
+ "components": "component",
578
+ "boundaries": "boundary",
579
+ "resources": "resource",
580
+ "rules": "rule",
581
+ "actions": "action",
582
+ "assumptions": "assumption",
583
+ }
584
+ result: dict[str, Any] = {}
585
+ for key in keys:
586
+ if key in collection_kinds:
587
+ result[key] = [ordered(item, collection_kinds[key]) for item in data[key]]
588
+ else:
589
+ result[key] = data[key]
590
+ return result
591
+
592
+
593
+ def canonical_payload(data: dict[str, Any], schema: dict[str, Any]) -> tuple[dict[str, Any], bytes, str]:
594
+ normalized = normalize(data)
595
+ ordered = canonical_order(normalized, schema)
596
+ encoded = (json.dumps(ordered, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
597
+ return ordered, encoded, "sha256:" + hashlib.sha256(encoded).hexdigest()
598
+
599
+
600
+ def add_violation(violations: list[dict[str, Any]], code: str, subject_ref: str, **facts: Any) -> None:
601
+ violations.append({"code": code, "subject_ref": subject_ref, "facts": facts})
602
+
603
+
604
+ def load_json(path: Path) -> Any:
605
+ return json.loads(path.read_text(encoding="utf-8"))
606
+
607
+
608
+ def requirement_nodes(root: Path, feature: str, violations: list[dict[str, Any]]) -> set[str]:
609
+ requirements_root = (root / ".planning" / "requirements").resolve()
610
+ path = (requirements_root / feature / "governance.json").resolve()
611
+ if not path.is_relative_to(requirements_root):
612
+ add_violation(violations, "BUSINESS_REQUIREMENT_GRAPH_INVALID", feature, path=str(path), message="feature escapes requirements directory")
613
+ return set()
614
+ try:
615
+ graph = load_json(path)
616
+ except (OSError, json.JSONDecodeError) as exc:
617
+ add_violation(violations, "BUSINESS_REQUIREMENT_GRAPH_INVALID", feature, path=str(path), message=str(exc))
618
+ return set()
619
+ return {
620
+ node.get("id")
621
+ for node in graph.get("nodes", [])
622
+ if isinstance(node, dict)
623
+ and node.get("kind") == "requirement"
624
+ and node.get("status") in {"confirmed", "approved"}
625
+ and isinstance(node.get("id"), str)
626
+ }
627
+
628
+
629
+ def resolve_sources(root: Path, feature: str, registry_ref: str, violations: list[dict[str, Any]]) -> set[str]:
630
+ result = subprocess.run(
631
+ ["ruby", str(SOURCE_RESOLVER), "--root", str(root), "--feature", feature, "--registry", registry_ref, "--json"],
632
+ text=True,
633
+ capture_output=True,
634
+ check=False,
635
+ )
636
+ try:
637
+ payload = json.loads(result.stdout)
638
+ except json.JSONDecodeError:
639
+ add_violation(
640
+ violations,
641
+ "BUSINESS_PROVENANCE_SOURCE_INVALID",
642
+ "SOURCE-REGISTRY",
643
+ resolver_error=result.stderr.strip() or "invalid resolver output",
644
+ )
645
+ return set()
646
+ if result.returncode != 0 or not payload.get("ok"):
647
+ for error in payload.get("errors", []):
648
+ add_violation(
649
+ violations,
650
+ "BUSINESS_PROVENANCE_SOURCE_INVALID",
651
+ error.get("source_id") or "SOURCE-REGISTRY",
652
+ resolver_code=error.get("code"),
653
+ message=error.get("message"),
654
+ path=error.get("path"),
655
+ )
656
+ return set()
657
+ registry_path = root / registry_ref
658
+ try:
659
+ registry = load_yaml(registry_path)
660
+ except (OSError, ValueError) as exc:
661
+ add_violation(violations, "BUSINESS_PROVENANCE_SOURCE_INVALID", "SOURCE-REGISTRY", message=str(exc))
662
+ return set()
663
+ return {item.get("id") for item in registry.get("sources", []) if isinstance(item, dict) and isinstance(item.get("id"), str)}
664
+
665
+
666
+ def all_sourced_facts(model: dict[str, Any]):
667
+ for collection in ("actors", "components", "boundaries", "resources", "rules", "actions"):
668
+ for item in model.get(collection, []):
669
+ if isinstance(item, dict):
670
+ yield item.get("id", collection), item.get("source_refs", []), f"{collection}.{item.get('id', '?')}"
671
+ for entity in model.get("entities", []):
672
+ if not isinstance(entity, dict):
673
+ continue
674
+ yield entity.get("id", "entities"), entity.get("source_refs", []), f"entities.{entity.get('id', '?')}"
675
+ for state in entity.get("states", []):
676
+ if isinstance(state, dict):
677
+ yield state.get("id", "state"), state.get("source_refs", []), f"states.{state.get('id', '?')}"
678
+ for rule in model.get("rules", []):
679
+ if isinstance(rule, dict):
680
+ for branch in rule.get("branches", []):
681
+ if isinstance(branch, dict):
682
+ yield branch.get("id", "branch"), branch.get("source_refs", []), f"branches.{branch.get('id', '?')}"
683
+ for action in model.get("actions", []):
684
+ if isinstance(action, dict):
685
+ for failure in action.get("failure_modes", []):
686
+ if isinstance(failure, dict):
687
+ subject = f"{action.get('id', '?')}#{failure.get('code', '?')}"
688
+ yield subject, failure.get("source_refs", []), f"failure_modes.{subject}"
689
+
690
+
691
+ def condition_refs(value: Any):
692
+ if not isinstance(value, dict):
693
+ return
694
+ for key, expected in (
695
+ ("resource_ref", "resource"),
696
+ ("entity_ref", "entity"),
697
+ ("state_ref", "state"),
698
+ ("actor_ref", "actor"),
699
+ ):
700
+ if isinstance(value.get(key), str):
701
+ yield value[key], expected, key
702
+ for key in ("args",):
703
+ for item in value.get(key, []) if isinstance(value.get(key), list) else []:
704
+ yield from condition_refs(item)
705
+ for key in ("operand", "left", "right"):
706
+ if key in value:
707
+ yield from condition_refs(value[key])
708
+
709
+
710
+ def check_reference_closure(model: dict[str, Any], violations: list[dict[str, Any]]) -> dict[str, Any]:
711
+ actors = {item["id"] for item in model.get("actors", [])}
712
+ entities = {item["id"] for item in model.get("entities", [])}
713
+ components = {item["id"] for item in model.get("components", [])}
714
+ boundaries = {item["id"] for item in model.get("boundaries", [])}
715
+ resources = {item["id"] for item in model.get("resources", [])}
716
+ rules = {item["id"] for item in model.get("rules", [])}
717
+ actions = {item["id"] for item in model.get("actions", [])}
718
+ state_owner: dict[str, str] = {}
719
+ state_terminal: dict[str, bool] = {}
720
+ for entity in model.get("entities", []):
721
+ for state in entity.get("states", []):
722
+ state_owner[state["id"]] = entity["id"]
723
+ state_terminal[state["id"]] = state.get("terminal") is True
724
+ namespaces = {
725
+ "actor": actors,
726
+ "entity": entities,
727
+ "component": components,
728
+ "boundary": boundaries,
729
+ "resource": resources,
730
+ "rule": rules,
731
+ "action": actions,
732
+ "state": set(state_owner),
733
+ }
734
+
735
+ def require(subject: str, field: str, ref: Any, expected: str):
736
+ if ref not in namespaces[expected]:
737
+ add_violation(violations, "BUSINESS_REF_NOT_FOUND", subject, field=field, ref=ref, expected=expected)
738
+
739
+ for resource in model.get("resources", []):
740
+ origin = resource["origin"]
741
+ kind = origin["kind"]
742
+ subject = resource["id"]
743
+ if kind == "produced_in_flow":
744
+ require(subject, "origin.lifecycle_owner_ref", origin.get("lifecycle_owner_ref"), "component")
745
+ elif kind == "user_supplied":
746
+ require(subject, "origin.actor_ref", origin.get("actor_ref"), "actor")
747
+ require(subject, "origin.intake_action_ref", origin.get("intake_action_ref"), "action")
748
+ elif kind == "external_system":
749
+ require(subject, "origin.boundary_ref", origin.get("boundary_ref"), "boundary")
750
+ require(subject, "origin.receive_action_ref", origin.get("receive_action_ref"), "action")
751
+ elif kind == "preexisting_project":
752
+ require(subject, "origin.lifecycle_owner_ref", origin.get("lifecycle_owner_ref"), "component")
753
+
754
+ for rule in model.get("rules", []):
755
+ subject = rule["id"]
756
+ for ref in rule.get("input_refs", []):
757
+ require(subject, "input_refs", ref, "resource")
758
+ for branch in rule.get("branches", []):
759
+ for ref in branch.get("next_action_refs", []):
760
+ require(branch["id"], "next_action_refs", ref, "action")
761
+ for ref, expected, field in condition_refs(branch.get("when")):
762
+ require(branch["id"], field, ref, expected)
763
+ for ref, expected, field in condition_refs(rule.get("expression")):
764
+ require(subject, field, ref, expected)
765
+
766
+ for action in model.get("actions", []):
767
+ subject = action["id"]
768
+ require(subject, "actor_ref", action.get("actor_ref"), "actor")
769
+ require(subject, "owner_ref", action.get("owner_ref"), "component")
770
+ for field in ("consumes", "produces"):
771
+ for ref in action.get(field, []):
772
+ require(subject, field, ref, "resource")
773
+ for ref in action.get("constraint_rule_refs", []):
774
+ require(subject, "constraint_rule_refs", ref, "rule")
775
+ routing = action.get("routing", {})
776
+ for ref in routing.get("next_action_refs", []):
777
+ require(subject, "routing.next_action_refs", ref, "action")
778
+ if routing.get("decision_rule_ref"):
779
+ require(subject, "routing.decision_rule_ref", routing["decision_rule_ref"], "rule")
780
+ if action.get("entity_ref"):
781
+ require(subject, "entity_ref", action["entity_ref"], "entity")
782
+ require(subject, "from_state_ref", action["from_state_ref"], "state")
783
+ require(subject, "to_state_ref", action["to_state_ref"], "state")
784
+ for field in ("from_state_ref", "to_state_ref"):
785
+ ref = action[field]
786
+ if ref in state_owner and state_owner[ref] != action["entity_ref"]:
787
+ add_violation(violations, "BUSINESS_STATE_ENTITY_MISMATCH", subject, field=field, state_ref=ref, entity_ref=action["entity_ref"])
788
+ for failure in action.get("failure_modes", []):
789
+ if failure.get("recovery_action_ref"):
790
+ require(subject, "failure.recovery_action_ref", failure["recovery_action_ref"], "action")
791
+ if failure.get("terminal_state_ref"):
792
+ require(subject, "failure.terminal_state_ref", failure["terminal_state_ref"], "state")
793
+ terminal = failure["terminal_state_ref"]
794
+ if action.get("entity_ref") and terminal in state_owner and state_owner[terminal] != action["entity_ref"]:
795
+ add_violation(
796
+ violations,
797
+ "BUSINESS_STATE_ENTITY_MISMATCH",
798
+ subject,
799
+ field="failure.terminal_state_ref",
800
+ state_ref=terminal,
801
+ entity_ref=action["entity_ref"],
802
+ )
803
+ return {
804
+ "actors": actors,
805
+ "entities": entities,
806
+ "components": components,
807
+ "boundaries": boundaries,
808
+ "resources": resources,
809
+ "rules": rules,
810
+ "actions": actions,
811
+ "state_owner": state_owner,
812
+ "state_terminal": state_terminal,
813
+ }
814
+
815
+
816
+ def check_requirement_coverage(model: dict[str, Any], known: set[str], violations: list[dict[str, Any]]) -> None:
817
+ declared = set(model.get("requirement_refs", []))
818
+ covered: set[str] = set()
819
+ for action in model.get("actions", []):
820
+ covered.update(action.get("requirement_refs", []))
821
+ for ref in action.get("requirement_refs", []):
822
+ if ref not in known:
823
+ add_violation(violations, "BUSINESS_REQUIREMENT_REF_INVALID", action["id"], requirement_ref=ref)
824
+ for ref in declared:
825
+ if ref not in known:
826
+ add_violation(violations, "BUSINESS_REQUIREMENT_REF_INVALID", ref, requirement_ref=ref)
827
+ elif ref not in covered:
828
+ add_violation(violations, "BUSINESS_REQUIREMENT_UNCOVERED", ref, requirement_ref=ref, covering_actions=[])
829
+
830
+
831
+ def check_provenance(model: dict[str, Any], known_sources: set[str], violations: list[dict[str, Any]]) -> None:
832
+ for subject, refs, path in all_sourced_facts(model):
833
+ missing = [ref for ref in refs if ref not in known_sources]
834
+ if missing:
835
+ add_violation(violations, "BUSINESS_PROVENANCE_SOURCE_INVALID", subject, path=path, missing_source_refs=missing)
836
+ for resource in model.get("resources", []):
837
+ origin = resource.get("origin", {})
838
+ code_source_ref = origin.get("code_source_ref") if origin.get("kind") == "preexisting_project" else None
839
+ if code_source_ref and code_source_ref not in known_sources:
840
+ add_violation(
841
+ violations,
842
+ "BUSINESS_PROVENANCE_SOURCE_INVALID",
843
+ resource["id"],
844
+ path="origin.code_source_ref",
845
+ missing_source_refs=[code_source_ref],
846
+ )
847
+
848
+
849
+ def check_resources(model: dict[str, Any], violations: list[dict[str, Any]]) -> None:
850
+ producers: dict[str, list[str]] = {}
851
+ consumers: dict[str, list[str]] = {}
852
+ actions = {action["id"]: action for action in model.get("actions", [])}
853
+ for action in model.get("actions", []):
854
+ for ref in action.get("produces", []):
855
+ producers.setdefault(ref, []).append(action["id"])
856
+ for ref in action.get("consumes", []):
857
+ consumers.setdefault(ref, []).append(action["id"])
858
+ for resource in model.get("resources", []):
859
+ ref = resource["id"]
860
+ origin = resource["origin"]
861
+ kind = origin["kind"]
862
+ produced = producers.get(ref, [])
863
+ consumed = consumers.get(ref, [])
864
+ if kind == "produced_in_flow" and not produced:
865
+ add_violation(violations, "BUSINESS_RESOURCE_PRODUCER_MISSING", ref, consumers=consumed, producers=[])
866
+ if not consumed:
867
+ add_violation(violations, "BUSINESS_RESOURCE_CONSUMER_MISSING", ref, consumers=[], producers=produced)
868
+ if kind == "user_supplied":
869
+ intake = actions.get(origin.get("intake_action_ref"))
870
+ if not intake or ref not in intake.get("produces", []):
871
+ add_violation(violations, "BUSINESS_RESOURCE_INTAKE_INVALID", ref, intake_action_ref=origin.get("intake_action_ref"))
872
+ elif kind == "external_system":
873
+ receiver = actions.get(origin.get("receive_action_ref"))
874
+ if not receiver or ref not in receiver.get("consumes", []):
875
+ add_violation(violations, "BUSINESS_EXTERNAL_BOUNDARY_INVALID", ref, receive_action_ref=origin.get("receive_action_ref"))
876
+
877
+
878
+ def check_rules(model: dict[str, Any], violations: list[dict[str, Any]]) -> None:
879
+ for rule in model.get("rules", []):
880
+ if rule.get("kind") != "decision":
881
+ continue
882
+ seen: dict[str, str] = {}
883
+ for branch in rule.get("branches", []):
884
+ if branch.get("default") is True:
885
+ continue
886
+ key = json.dumps(normalize(branch.get("when")), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
887
+ if key in seen:
888
+ add_violation(
889
+ violations,
890
+ "BUSINESS_RULE_BRANCH_DUPLICATE_CONDITION",
891
+ rule["id"],
892
+ branch_ids=[seen[key], branch["id"]],
893
+ )
894
+ else:
895
+ seen[key] = branch["id"]
896
+
897
+
898
+ def check_states(model: dict[str, Any], namespaces: dict[str, Any], violations: list[dict[str, Any]]) -> None:
899
+ outgoing: dict[str, list[str]] = {state: [] for state in namespaces["state_owner"]}
900
+ incoming: dict[str, list[str]] = {state: [] for state in namespaces["state_owner"]}
901
+ for action in model.get("actions", []):
902
+ source = action.get("from_state_ref")
903
+ target = action.get("to_state_ref")
904
+ if source in outgoing and target in incoming:
905
+ outgoing[source].append(target)
906
+ incoming[target].append(source)
907
+ if source in outgoing:
908
+ for failure in action.get("failure_modes", []):
909
+ terminal = failure.get("terminal_state_ref")
910
+ if terminal in incoming:
911
+ outgoing[source].append(terminal)
912
+ incoming[terminal].append(source)
913
+ for state, terminal in namespaces["state_terminal"].items():
914
+ if not terminal and not outgoing[state]:
915
+ add_violation(violations, "BUSINESS_STATE_OUTGOING_MISSING", state, outgoing=[])
916
+ if not outgoing:
917
+ return
918
+ starts = [state for state in outgoing if not incoming[state]]
919
+ if not starts:
920
+ add_violation(violations, "BUSINESS_STATE_START_MISSING", "STATE-MACHINE", states=sorted(outgoing))
921
+ return
922
+ reachable = set(starts)
923
+ queue = list(starts)
924
+ while queue:
925
+ state = queue.pop(0)
926
+ for target in outgoing[state]:
927
+ if target not in reachable:
928
+ reachable.add(target)
929
+ queue.append(target)
930
+ for state in outgoing:
931
+ if state not in reachable:
932
+ add_violation(violations, "BUSINESS_STATE_UNREACHABLE", state, start_states=starts)
933
+
934
+
935
+ def flow_edges(model: dict[str, Any]) -> tuple[dict[str, list[str]], set[str]]:
936
+ rules = {rule["id"]: rule for rule in model.get("rules", [])}
937
+ edges = {action["id"]: [] for action in model.get("actions", [])}
938
+ terminals: set[str] = set()
939
+ for action in model.get("actions", []):
940
+ routing = action.get("routing", {})
941
+ if routing.get("terminal") is True:
942
+ terminals.add(action["id"])
943
+ edges[action["id"]].extend(routing.get("next_action_refs", []))
944
+ rule = rules.get(routing.get("decision_rule_ref"))
945
+ if rule:
946
+ for branch in rule.get("branches", []):
947
+ edges[action["id"]].extend(branch.get("next_action_refs", []))
948
+ for failure in action.get("failure_modes", []):
949
+ if failure.get("recovery_action_ref"):
950
+ edges[action["id"]].append(failure["recovery_action_ref"])
951
+ return edges, terminals
952
+
953
+
954
+ def check_flow(model: dict[str, Any], violations: list[dict[str, Any]]) -> None:
955
+ edges, terminals = flow_edges(model)
956
+ if not edges:
957
+ return
958
+ incoming = {action: [] for action in edges}
959
+ for source, targets in edges.items():
960
+ for target in targets:
961
+ if target in incoming:
962
+ incoming[target].append(source)
963
+ starts = [action for action in edges if not incoming[action]]
964
+ if not starts:
965
+ add_violation(violations, "BUSINESS_FLOW_START_MISSING", "BUSINESS-FLOW", actions=sorted(edges))
966
+ reachable = set(starts)
967
+ queue = list(starts)
968
+ while queue:
969
+ action = queue.pop(0)
970
+ for target in edges[action]:
971
+ if target in edges and target not in reachable:
972
+ reachable.add(target)
973
+ queue.append(target)
974
+ for action in edges:
975
+ if action not in reachable:
976
+ add_violation(violations, "BUSINESS_FLOW_ACTION_UNREACHABLE", action, start_actions=starts)
977
+ reverse = {action: [] for action in edges}
978
+ for source, targets in edges.items():
979
+ for target in targets:
980
+ if target in reverse:
981
+ reverse[target].append(source)
982
+ can_finish = set(terminals)
983
+ queue = list(terminals)
984
+ while queue:
985
+ action = queue.pop(0)
986
+ for source in reverse[action]:
987
+ if source not in can_finish:
988
+ can_finish.add(source)
989
+ queue.append(source)
990
+ for action in edges:
991
+ if action not in can_finish:
992
+ add_violation(violations, "BUSINESS_FLOW_NO_TERMINAL_PATH", action, terminal_actions=sorted(terminals))
993
+
994
+
995
+ def check_business_closure(model: dict[str, Any], root: Path) -> list[dict[str, Any]]:
996
+ violations: list[dict[str, Any]] = []
997
+ feature = model["feature"]
998
+ known_sources = resolve_sources(root, feature, model["source_registry_ref"], violations)
999
+ known_requirements = requirement_nodes(root, feature, violations)
1000
+ namespaces = check_reference_closure(model, violations)
1001
+ check_provenance(model, known_sources, violations)
1002
+ check_requirement_coverage(model, known_requirements, violations)
1003
+ check_resources(model, violations)
1004
+ check_rules(model, violations)
1005
+ check_states(model, namespaces, violations)
1006
+ check_flow(model, violations)
1007
+ for assumption in model.get("assumptions", []):
1008
+ add_violation(violations, "BUSINESS_ASSUMPTION_UNRESOLVED", assumption["id"], affected_refs=assumption.get("affected_refs", []))
1009
+ return violations
1010
+
1011
+
1012
+ def main() -> int:
1013
+ parser = argparse.ArgumentParser()
1014
+ parser.add_argument("--proposal", type=Path, required=True)
1015
+ parser.add_argument("--schema", type=Path, default=DEFAULT_SCHEMA)
1016
+ parser.add_argument("--canonical-out", type=Path)
1017
+ parser.add_argument("--check-closure", action="store_true")
1018
+ parser.add_argument("--root", type=Path)
1019
+ args = parser.parse_args()
1020
+ try:
1021
+ schema = load_yaml(args.schema)
1022
+ data = load_yaml(args.proposal)
1023
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
1024
+ payload = {
1025
+ "valid": False,
1026
+ "verdict": "block_unsafe",
1027
+ "errors": [{"code": "BUSINESS_YAML_INVALID", "path": "$", "message": str(exc)}],
1028
+ }
1029
+ print(json.dumps(payload, ensure_ascii=False))
1030
+ return 1
1031
+ errors = validate_model(data, schema)
1032
+ if errors:
1033
+ print(json.dumps({"valid": False, "verdict": "block_unsafe", "errors": errors}, ensure_ascii=False))
1034
+ return 1
1035
+ canonical, encoded, fingerprint = canonical_payload(data, schema)
1036
+ violations: list[dict[str, Any]] = []
1037
+ if args.check_closure:
1038
+ if args.root is None:
1039
+ violations = [{"code": "BUSINESS_CHECKER_CONFIGURATION_INVALID", "subject_ref": "BUSINESS-MODEL", "facts": {"message": "--root is required with --check-closure"}}]
1040
+ else:
1041
+ violations = check_business_closure(canonical, args.root.resolve())
1042
+ if args.canonical_out:
1043
+ args.canonical_out.parent.mkdir(parents=True, exist_ok=True)
1044
+ args.canonical_out.write_bytes(encoded)
1045
+ print(
1046
+ json.dumps(
1047
+ {
1048
+ "valid": True,
1049
+ "verdict": "allow",
1050
+ "errors": [],
1051
+ "closure_ok": not violations,
1052
+ "violations": violations,
1053
+ "canonical": canonical,
1054
+ "canonical_fingerprint": fingerprint,
1055
+ },
1056
+ ensure_ascii=False,
1057
+ )
1058
+ )
1059
+ return 0 if not violations else 1
1060
+
1061
+
1062
+ if __name__ == "__main__":
1063
+ sys.exit(main())