@stalfh233/omc-cli 0.0.0-stage → 0.4.10

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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +293 -3
  3. package/coverage/m1-coverage-manifest-v1.json +1278 -0
  4. package/dist/approval-token.js +102 -0
  5. package/dist/args.js +25 -0
  6. package/dist/artifacts.js +119 -0
  7. package/dist/bench/call-face-eval.js +256 -0
  8. package/dist/bench/context-attribution.js +151 -0
  9. package/dist/bench/discovery-cost-eval.js +230 -0
  10. package/dist/bench/driver.js +81 -0
  11. package/dist/bench/evals.js +236 -0
  12. package/dist/bench/fake-http-server.js +65 -0
  13. package/dist/bench/instrument.js +87 -0
  14. package/dist/bench/intent-face-eval.js +343 -0
  15. package/dist/bench/run.js +166 -0
  16. package/dist/bench/scenario.js +343 -0
  17. package/dist/bench/types.js +76 -0
  18. package/dist/bi-wire.js +41 -0
  19. package/dist/bizservice-config.js +581 -0
  20. package/dist/call.js +153 -0
  21. package/dist/capability-absences.js +23 -0
  22. package/dist/capability-overview.js +492 -0
  23. package/dist/capability-shape.js +154 -0
  24. package/dist/cli-contract.js +70 -0
  25. package/dist/cli-output.js +73 -0
  26. package/dist/cli.js +1418 -0
  27. package/dist/code-rules.js +69 -0
  28. package/dist/command-transport.js +155 -0
  29. package/dist/config-store.js +195 -0
  30. package/dist/context.js +21 -0
  31. package/dist/contract-consistency.js +66 -0
  32. package/dist/contract-resources.js +62 -0
  33. package/dist/coverage-consistency.js +62 -0
  34. package/dist/coverage-registry.js +76 -0
  35. package/dist/coverage.js +130 -0
  36. package/dist/data-list-filter.js +114 -0
  37. package/dist/discovery.js +390 -0
  38. package/dist/endpoints.js +154 -0
  39. package/dist/environment-policy.js +26 -0
  40. package/dist/execution-metadata.js +1092 -0
  41. package/dist/fake/app.js +45 -0
  42. package/dist/fake/b2-registration.js +594 -0
  43. package/dist/fake/businessrule.js +242 -0
  44. package/dist/fake/datarule.js +72 -0
  45. package/dist/fake/dictionary.js +108 -0
  46. package/dist/fake/environment.js +30 -0
  47. package/dist/fake/field.js +167 -0
  48. package/dist/fake/form.js +101 -0
  49. package/dist/fake/index.js +121 -0
  50. package/dist/fake/list-view.js +289 -0
  51. package/dist/fake/model.js +136 -0
  52. package/dist/fake/online-js.js +18 -0
  53. package/dist/fake/report.js +253 -0
  54. package/dist/fake/routes.js +47 -0
  55. package/dist/fake/rule-lifecycle.js +37 -0
  56. package/dist/fake/runtime-data.js +367 -0
  57. package/dist/fake/state.js +67 -0
  58. package/dist/fake/workflow.js +364 -0
  59. package/dist/field-change.js +200 -0
  60. package/dist/field-families.js +896 -0
  61. package/dist/form-layout.js +111 -0
  62. package/dist/form-support.js +821 -0
  63. package/dist/goal-routes.js +468 -0
  64. package/dist/governed-execution.js +87 -0
  65. package/dist/human-summary.js +212 -0
  66. package/dist/identity.js +62 -0
  67. package/dist/intent/baseline.js +57 -0
  68. package/dist/intent/capabilities/bizservice.js +274 -0
  69. package/dist/intent/capabilities/businessrule.js +493 -0
  70. package/dist/intent/capabilities/datarule.js +187 -0
  71. package/dist/intent/capabilities/field.js +545 -0
  72. package/dist/intent/capabilities/form.js +136 -0
  73. package/dist/intent/capabilities/index.js +64 -0
  74. package/dist/intent/capabilities/listview.js +157 -0
  75. package/dist/intent/capabilities/model.js +100 -0
  76. package/dist/intent/capabilities/onlinejs.js +108 -0
  77. package/dist/intent/capabilities/report.js +355 -0
  78. package/dist/intent/capabilities/workflow.js +458 -0
  79. package/dist/intent/capability.js +6 -0
  80. package/dist/intent/cli.js +91 -0
  81. package/dist/intent/compare.js +56 -0
  82. package/dist/intent/compiler.js +79 -0
  83. package/dist/intent/dsl.js +129 -0
  84. package/dist/intent/plan-file.js +63 -0
  85. package/dist/intent/readback.js +65 -0
  86. package/dist/intent/schema.js +158 -0
  87. package/dist/intent/validation.js +30 -0
  88. package/dist/intent/yaml.js +315 -0
  89. package/dist/json-column.js +68 -0
  90. package/dist/lanes/app-contract.js +95 -0
  91. package/dist/lanes/app-coverage.js +16 -0
  92. package/dist/lanes/app.js +174 -0
  93. package/dist/lanes/apply-changes.js +231 -0
  94. package/dist/lanes/b2-registration-contract.js +292 -0
  95. package/dist/lanes/b2-registration-coverage.js +48 -0
  96. package/dist/lanes/b2-registration.js +1187 -0
  97. package/dist/lanes/businessrule-contract.js +232 -0
  98. package/dist/lanes/businessrule-coverage.js +16 -0
  99. package/dist/lanes/businessrule.js +221 -0
  100. package/dist/lanes/contract-support.js +65 -0
  101. package/dist/lanes/coverage-declaration.js +9 -0
  102. package/dist/lanes/datarule-contract.js +155 -0
  103. package/dist/lanes/datarule-coverage.js +19 -0
  104. package/dist/lanes/datarule-protocol.js +308 -0
  105. package/dist/lanes/datarule.js +818 -0
  106. package/dist/lanes/dictionary-contract.js +78 -0
  107. package/dist/lanes/dictionary-coverage.js +24 -0
  108. package/dist/lanes/dictionary.js +235 -0
  109. package/dist/lanes/environment-contract.js +70 -0
  110. package/dist/lanes/environment-coverage.js +14 -0
  111. package/dist/lanes/environment.js +163 -0
  112. package/dist/lanes/field-contract.js +223 -0
  113. package/dist/lanes/field-coverage.js +27 -0
  114. package/dist/lanes/field.js +374 -0
  115. package/dist/lanes/form-contract.js +97 -0
  116. package/dist/lanes/form-coverage.js +16 -0
  117. package/dist/lanes/form.js +185 -0
  118. package/dist/lanes/lane-ids.js +34 -0
  119. package/dist/lanes/list-view-contract.js +175 -0
  120. package/dist/lanes/list-view-coverage.js +20 -0
  121. package/dist/lanes/list-view-shapes.js +1207 -0
  122. package/dist/lanes/list-view.js +578 -0
  123. package/dist/lanes/meta-contract.js +85 -0
  124. package/dist/lanes/meta.js +255 -0
  125. package/dist/lanes/model-contract.js +168 -0
  126. package/dist/lanes/model-coverage.js +20 -0
  127. package/dist/lanes/model.js +986 -0
  128. package/dist/lanes/online-js-contract.js +99 -0
  129. package/dist/lanes/online-js-coverage.js +28 -0
  130. package/dist/lanes/online-js.js +127 -0
  131. package/dist/lanes/report-contract.js +144 -0
  132. package/dist/lanes/report-coverage.js +21 -0
  133. package/dist/lanes/report.js +476 -0
  134. package/dist/lanes/rule-graph.js +1846 -0
  135. package/dist/lanes/rule-lifecycle-contract.js +92 -0
  136. package/dist/lanes/rule-lifecycle-coverage.js +20 -0
  137. package/dist/lanes/rule-lifecycle.js +176 -0
  138. package/dist/lanes/runtime-data-contract.js +264 -0
  139. package/dist/lanes/runtime-data-coverage.js +25 -0
  140. package/dist/lanes/runtime-data.js +1054 -0
  141. package/dist/lanes/workflow-contract.js +223 -0
  142. package/dist/lanes/workflow-coverage.js +40 -0
  143. package/dist/lanes/workflow.js +1813 -0
  144. package/dist/online-js-layout.js +58 -0
  145. package/dist/online-js-source.js +276 -0
  146. package/dist/package-tool.js +51 -0
  147. package/dist/package.js +73 -0
  148. package/dist/plan.js +73 -0
  149. package/dist/read.js +144 -0
  150. package/dist/redact.js +28 -0
  151. package/dist/rule-support.js +587 -0
  152. package/dist/runtime-support.js +134 -0
  153. package/dist/server.js +92 -0
  154. package/dist/session-manager.js +30 -0
  155. package/dist/session.js +149 -0
  156. package/dist/skills.js +112 -0
  157. package/dist/support.js +98 -0
  158. package/dist/tool-types.js +127 -0
  159. package/dist/tools.js +59 -0
  160. package/dist/usage-log.js +197 -0
  161. package/dist/wire.js +287 -0
  162. package/dist/workflow-support.js +99 -0
  163. package/dist/write-lease.js +26 -0
  164. package/dist/write-lock.js +109 -0
  165. package/dist/write.js +176 -0
  166. package/dist/zip.js +156 -0
  167. package/docs/tool-surface-map.md +42 -0
  168. package/package.json +71 -6
  169. package/skills/omc-acceptance-criteria.md +47 -0
  170. package/skills/omc-business-configuration.md +257 -0
  171. package/skills/omc-capabilities.md +197 -0
  172. package/skills/omc-capability-scouting.md +55 -0
  173. package/skills/omc-config-draft-review.md +171 -0
  174. package/skills/omc-five-piece-flow.md +35 -0
  175. package/skills/omc-glossary.md +86 -0
  176. package/skills/omc-refusals.md +110 -0
  177. package/skills/omc-requirement-analysis.md +161 -0
  178. package/skills/omc-requirement-vocabulary.md +51 -0
  179. package/skills/omc-start-here.md +82 -0
  180. package/skills/omc-tool-selection.md +119 -0
  181. package/skills/omc-write-hazards.md +87 -0
@@ -0,0 +1,1846 @@
1
+ /**
2
+ * Rule graph single source of truth — shared by the businessrule contract
3
+ * (shape templates surfaced through `omc contract businessrule`), the
4
+ * rule.save pre-flight dry lint, and the fake evaluator. Every rule here is
5
+ * evidence-backed by .scratch/omc-app-management/evidence/production-rule-shapes-20260911.md
6
+ * (production capture, ticket 09) plus tickets 02/09 live findings.
7
+ *
8
+ * The validator NEVER repairs: a violation is a structured blocker naming the
9
+ * node, the key, and what is missing. It runs client-side before any wire
10
+ * call (dry lint) so a mis-shaped graph cannot reach the platform.
11
+ */
12
+ import { decodeJsonColumn, JSON_COLUMN_NULL_COALESCE } from "../json-column.js";
13
+ export const RULE_VALUE_TYPES = ["DYNAMIC", "FIXED", "EMPTY", "VARIABLE", "EXPRESSION", "SYSTEM", "AGGREGATION"];
14
+ /**
15
+ * Allowed `ruleValueType` for a CONDITION LEAF (filter/exit/dataCondition).
16
+ * Superset of a data-action's set with the frontend condition editor's dynamic
17
+ * DATE value types (admin 12404/19497 enum: SpecifyDate/`specifyDate`,
18
+ * Today/`today`, Yesterday/`yesterday`, Tomorrow/`tomorrow`,
19
+ * SpecifyRange/`specifyRange`, DynamicRange/`dynamicRange` with value like
20
+ * `thisWeek`/`thisMonth`/`latelyDay`..., SELF_P/SELF_D). `SYSTEM` is NOT valid in
21
+ * a condition (it is a CREATE/UPDATE target system value) — a leaf
22
+ * `ruleValueType:"SYSTEM"` with empty value rendered 「字段 小于 (空)」 live
23
+ * 2026-10-08. A date comparison must use FIXED (literal `yyyy-MM-dd HH:mm:ss`),
24
+ * `today`/`yesterday`/`tomorrow`, or `dynamicRange`+value.
25
+ */
26
+ export const RULE_CONDITION_VALUE_TYPES = [...RULE_VALUE_TYPES, "today", "yesterday", "tomorrow", "dynamicRange", "specifyDate", "specifyRange", "SELF_P", "SELF_D"];
27
+ export const RULE_ACTION_TYPES = ["EQUALS", "INCREASE", "DECREASE"];
28
+ export const RULE_CONDITION_TYPES = ["EQ", "NEQ", "GT", "LT", "GTEQ", "LTEQ", "CT", "NCT", "EP", "NEP"];
29
+ /**
30
+ * Node types are the 8.6.26 `RuleNodeType` enum (RuleNodeType.java:16-43) restricted to the
31
+ * system/timing/flow-event families added by ticket 27 on top of the ticket-14 data/logic core.
32
+ * `EXECUTE_CODE`/`ONLINE_DEVELOP` stay out (permanent boundary: platform on-device code).
33
+ */
34
+ export const RULE_NODE_TYPES = ["START", "END", "UPDATE", "CREATE", "DELETE", "QUERY", "BRANCH", "ASSIGN", "MESSAGE", "BIZ_ACTION", "CUSTOM_NODE", "SYSTEM_CREATE", "SYSTEM_UPDATE", "SYSTEM_DELETE", "GET_LIST", "LOAD", "AVAILABLE", "CANCEL", "SCHEDULER", "EXCEPTION", "HINT"];
35
+ /** 8.6.26 `DataSourceType` enum names (DataSourceType.java:13-17); the node stores the name string (production shape). */
36
+ export const RULE_DATA_SOURCE_TYPES = ["GET_LIST", "BIZ_SERVICE", "SELECT_DATA", "NONE"];
37
+ /**
38
+ * CFG-26 — route match semantics. `matchType` is a route-level enum
39
+ * (research/desensitized-config-shapes.md §1.4; live default rule carries
40
+ * `"ANY"` on plain sequential edges, BRANCH exits carry ANY/ALL/CUSTOM).
41
+ */
42
+ export const RULE_ROUTE_MATCH_TYPES = ["ANY", "ALL", "CUSTOM"];
43
+ /**
44
+ * 8.6.26 子表触发数据范围(节点 `childTriggerConditionType`)。平台 i18n 权威标签
45
+ * (HANDOFF C.2 / evidence/form-conditions-and-rules-truth.md §4):
46
+ * `ADD/MODIFY/DELETE` = 子表数据新增/修改/删除时触发。
47
+ */
48
+ export const RULE_CHILD_TRIGGER_CONDITION_TYPES = ["ADD", "MODIFY", "DELETE"];
49
+ /**
50
+ * 8.6.26 `CodeType` enum (metadata/bizmodel/CodeType.java:23-31), serialized as the int index by
51
+ * both fastjson (CodeTypeSerializer) and jackson (@JsonValue). A methodMapping input/output
52
+ * element with codeType PROPERTY(1) maps a schema data item (bizCode+propertyName); CONST(0)
53
+ * maps a constant. Ticket 35: the old template prose "codeType(0=字段)" was wrong — 0 is CONST.
54
+ */
55
+ export const RULE_CODE_TYPES = ["CONST", "PROPERTY", "LIST", "OBJECT", "RULE_VARIABLE", "MAIN_TABLE", "SYSTEM", "EXPRESSION"];
56
+ export const RULE_POINT = /^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$/;
57
+ /**
58
+ * Semantic keys of a methodMapping input/output element — the ONLY keys the
59
+ * validator polices (8.6.26 `MappingModel`, MappingModel.java:19-34):
60
+ * `codeType` (int 0-7), `serviceMethodParameterCode` (non-empty), `bizCode`
61
+ * (required when codeType=1 PROPERTY). Ticket 96: every OTHER key is
62
+ * NON-semantic passthrough — the tool neither adds, removes, nor reorders
63
+ * them by platform version (8.6.26 fastjson ignores unknown keys; the fwb
64
+ * v2 `index/displayName/required/bizCodeName` ride along verbatim). The set
65
+ * below stays exported as the documentary union of keys evidenced across the
66
+ * 8.6 family; `required` is mirrored from the method parameter face by
67
+ * rule-support before the write, never guessed locally.
68
+ */
69
+ export const RULE_MAPPING_ELEMENT_KEYS = new Set([
70
+ "propertyName", "bizCode", "bizPropertyType", "externParameterType", "codeType", "serviceMethodParameterCode", "subParameters",
71
+ "id", "remarks", "createdTime", "modifiedTime", "deleted", "createdBy", "modifiedBy",
72
+ "index", "displayName", "required", "bizCodeName",
73
+ ]);
74
+ /** 8.6.26 `SchedulerSetting.TriggerTimeType` enum names (SchedulerSetting.java:372-376). */
75
+ export const RULE_SCHEDULER_TRIGGER_TIME_TYPES = ["SELECTED_TIME", "DATE_FIELD"];
76
+ /** 8.6.26 `SchedulerExecuteType` enum names (SchedulerExecuteType.java); @JsonValue index 0/1 accepted too. */
77
+ export const RULE_SCHEDULER_EXECUTE_TYPES = ["POLLING", "CYCLE", "0", "1"];
78
+ /** 8.6.26 `CycleType` enum names (CycleType.java:8-16). */
79
+ export const RULE_SCHEDULER_CYCLE_TYPES = ["NEVER", "HOUR", "DAY", "WEEK", "MONTH", "YEAR", "LAST_DAY_OF_MONTH", "CUSTOM"];
80
+ /** 8.6.26 `EndType` enum names (EndType.java:8-11). */
81
+ export const RULE_SCHEDULER_END_TYPES = ["NEVER", "CUSTOM"];
82
+ export const RULE_NODE_TYPES_SET = new Set(RULE_NODE_TYPES);
83
+ export const RULE_TRIGGER_INPUT_PARAM = "_input";
84
+ export const RULE_OUTPUT_PARAM_SUFFIX = "_output";
85
+ /**
86
+ * Node types whose platform behavior resolves its input schema from `inputParam`
87
+ * (e.g. `RuleBranchQuoteImpl.getCurrentBizSchema → getBizSchemaByArg(inputParam)
88
+ * → getBizSchemaByPath(null)`); a missing/empty value makes
89
+ * `businessrule/publish` refuse with errcode 300010
90
+ * (`BIZ_SCHEMA_CODE_EMPTY`「数据模型编码不能为空」).
91
+ *
92
+ * BRANCH/UPDATE were already defaulted. Confirmed live 2026-10-08 (MES sandbox):
93
+ * a hand-authored graph whose **CREATE** node dropped `inputParam` refused
94
+ * publish with 300010 while the byte-identical graph with `inputParam:"_input"`
95
+ * succeeded — and the agent misread that platform errcode as a capability
96
+ * boundary ("must go 二开"). `prepareRuleGraph` now defaults `"_input"` for every
97
+ * node type in this set, so no node template's required key can silently drop
98
+ * and no errcode can be mistaken for a capability limit.
99
+ */
100
+ export const RULE_NODES_NEEDING_INPUT_PARAM = Object.freeze([
101
+ "BRANCH",
102
+ "UPDATE",
103
+ "CREATE",
104
+ "DELETE",
105
+ "ASSIGN",
106
+ "BIZ_ACTION",
107
+ "MESSAGE",
108
+ "QUERY",
109
+ ]);
110
+ export const DYNAMIC_CONDITION_SOURCE_KEY = "currentSchemaDataItem";
111
+ export const DYNAMIC_ACTION_SOURCE_KEY = "currentDataItemValue";
112
+ /**
113
+ * BRANCH `exitConditionGroups` deserialize into `DataFilterCondition`, whose
114
+ * operator property is `ruleDataConditionType` and whose left/source property
115
+ * is `currentSchemaDataItem` (Condition.java:16,42; DataFilterCondition.java).
116
+ * The UI-shaped `ruleConditionType` is `@JsonIgnore`'d and `targetSchemaDataItem`
117
+ * is not a DataFilterCondition property — using them passes save+publish but the
118
+ * engine reads a null source/operator and `data.submit` 50000s (live 2026-09-25).
119
+ */
120
+ export const BRANCH_EXIT_OPERATOR_KEY = "ruleDataConditionType";
121
+ export const NODE_TYPE_TO_DATA_TRIGGER = Object.freeze({
122
+ CREATE: "CREATE",
123
+ UPDATE: "UPDATE",
124
+ DELETE: "DELETE",
125
+ });
126
+ /**
127
+ * Platform preset trigger rules persist the trigger row through a SYSTEM node
128
+ * (`SYSTEM_CREATE`/`SYSTEM_UPDATE`/`SYSTEM_DELETE`) that the rule executor runs
129
+ * as part of the graph (DefaultBusinessRule.createDefaultNode/createDefaultRoute,
130
+ * cloudpivot-metadata 8.6.26: Start → SYSTEM_* → End). Overwriting the graph
131
+ * without that node silently drops the row write.
132
+ *
133
+ * Live evidence (2026-09-12): a declaration submitted while bound to a Create
134
+ * rule whose graph was Start → UPDATE_1 → End stayed `DRAFT` forever — the
135
+ * engine executed the rule (docker logs `BizRuleLog ruleCode=Create` for every
136
+ * node) but the DRAFT→PROCESSING/COMPLETED write never happened because
137
+ * `DomainServiceImpl.batchUpdate` routes the update through the rule graph
138
+ * (`updateOverriding`), and the graph no longer contained SYSTEM_CREATE.
139
+ */
140
+ export const DEFAULT_SYSTEM_NODE_BY_RULE_CODE = Object.freeze({
141
+ Create: { nodeCode: "Create", nodeName: "新增数据", nodeType: "SYSTEM_CREATE" },
142
+ Update: { nodeCode: "Update", nodeName: "更新数据", nodeType: "SYSTEM_UPDATE" },
143
+ Delete: { nodeCode: "Delete", nodeName: "删除数据", nodeType: "SYSTEM_DELETE" },
144
+ });
145
+ /**
146
+ * Ticket 80 — the platform trigger rule codes (rule-node graph description):
147
+ * data-event and system trigger rules addressed by ruleCode. They are NOT
148
+ * button/flow rules, so they never get the CUSTOM_NODE trigger synthesized.
149
+ */
150
+ export const PLATFORM_TRIGGER_RULE_CODES = new Set(["Create", "Update", "Delete", "GetList", "Load", "Available", "Cancel"]);
151
+ /**
152
+ * Tolerant node/route array coercion shared by the graph adapters (nodes and
153
+ * routes may arrive as objects or pre-serialized JSON strings; non-object
154
+ * entries are dropped rather than crashing the lint).
155
+ */
156
+ function asRecordArray(value) {
157
+ return (Array.isArray(value) ? value : []).filter((entry) => Boolean(entry && typeof entry === "object"));
158
+ }
159
+ /**
160
+ * Shared system-node splice: when the trigger node is absent, insert the
161
+ * supplied node immediately after START and re-route through it (explicit
162
+ * routes are re-pointed; empty routes become the implied-linear chain over the
163
+ * new order). Returns the graph untouched when the node is already present or
164
+ * the graph has no START.
165
+ */
166
+ /**
167
+ * Seat `node` immediately AFTER the LAST node of `anchorType`, re-pointing that
168
+ * node's outgoing edge through the new one. Used when the injected node must
169
+ * follow a persistence-sensitive action (ASSIGN / BIZ_ACTION) rather than sit
170
+ * right after START (see injectNodeAfterStart).
171
+ */
172
+ function injectAfterNodeOfType(nodeRecords, routeRecords, anchorType, node) {
173
+ const anchorIndex = nodeRecords.map((entry) => String(entry.nodeType ?? "")).lastIndexOf(anchorType);
174
+ if (anchorIndex < 0)
175
+ return { nodes: nodeRecords, routes: routeRecords, injected: false };
176
+ const anchor = nodeRecords[anchorIndex];
177
+ const anchorCode = String(anchor.nodeCode ?? "");
178
+ const numberOr = (value, fallback) => (Number.isFinite(Number(value)) ? Number(value) : fallback);
179
+ node.x = numberOr(anchor.x, RULE_CANONICAL_LAYOUT.x);
180
+ node.y = numberOr(anchor.y, RULE_CANONICAL_LAYOUT.y0) + RULE_CANONICAL_LAYOUT.gap;
181
+ node.width = RULE_CANONICAL_LAYOUT.width;
182
+ node.height = RULE_CANONICAL_LAYOUT.height;
183
+ const nodeCode = String(node.nodeCode ?? "node");
184
+ const nextNodes = [...nodeRecords.slice(0, anchorIndex + 1), node, ...nodeRecords.slice(anchorIndex + 1)];
185
+ if (routeRecords.length > 0) {
186
+ const outRoute = routeRecords.find((route) => route.preNode === anchorCode);
187
+ const after = outRoute ? String(outRoute.postNode ?? "End") : "End";
188
+ const nextRoutes = routeRecords
189
+ .filter((route) => !(route.preNode === anchorCode && route.postNode === after))
190
+ .concat([{ preNode: anchorCode, postNode: nodeCode }, { preNode: nodeCode, postNode: after }]);
191
+ return { nodes: nextNodes, routes: nextRoutes, injected: true };
192
+ }
193
+ const codes = nextNodes.map((entry, index) => String(entry.nodeCode ?? `node_${index}`));
194
+ const linearRoutes = codes.slice(0, -1).map((pre, index) => ({ preNode: pre, postNode: codes[index + 1] }));
195
+ return { nodes: nextNodes, routes: linearRoutes, injected: true };
196
+ }
197
+ function injectNodeAfterStart(nodesInput, routesInput, alreadyPresent, makeNode, afterTypes = []) {
198
+ const nodeRecords = asRecordArray(parseMaybeJson(nodesInput));
199
+ const routeRecords = asRecordArray(parseMaybeJson(routesInput));
200
+ if (alreadyPresent(nodeRecords))
201
+ return { nodes: nodeRecords, routes: routeRecords, injected: false };
202
+ // Some action nodes must run BEFORE the persistence node for their result to be
203
+ // saved: ASSIGN merges into the input the system node persists, and BIZ_ACTION
204
+ // outputMappings likewise (live 2026-09-28: an ASSIGN after SYSTEM_CREATE read
205
+ // back no change). For those graphs the system node is seated AFTER the last
206
+ // such action node, not right after START.
207
+ const anchorType = [...afterTypes].reverse().find((type) => nodeRecords.some((entry) => entry.nodeType === type));
208
+ if (anchorType !== undefined)
209
+ return injectAfterNodeOfType(nodeRecords, routeRecords, anchorType, makeNode());
210
+ const startIndex = nodeRecords.findIndex((node) => node.nodeType === "START");
211
+ if (startIndex < 0)
212
+ return { nodes: nodeRecords, routes: routeRecords, injected: false };
213
+ const startCode = String(nodeRecords[startIndex].nodeCode ?? "Start");
214
+ const node = makeNode();
215
+ const nodeCode = String(node.nodeCode ?? "node");
216
+ // CFG-26 follow-up (live 2026-09-17): a node injected off-column with no
217
+ // size (the CUSTOM_NODE used to hardcode x=558/y=233) is neither "missing
218
+ // geometry" nor "overlapping", so `canonicalizeNodeLayout` left it there and
219
+ // the route Start→it drew a diagonal across the canvas — the exact broken
220
+ // wiring a user had to hand-fix. Seat it one slot below START in START's own
221
+ // column (so a deliberate layout keeps its column) with platform box size.
222
+ //
223
+ // For a LINEAR chain on the canonical column that seat overlaps the next
224
+ // node, which is the trigger `canonicalizeNodeLayout` needs to re-lay the
225
+ // whole chain out canonically. A BRANCH graph is never repositioned by that
226
+ // step, so there the injection must make room itself by pushing later nodes
227
+ // down.
228
+ const numberOr = (value, fallback) => (Number.isFinite(Number(value)) ? Number(value) : fallback);
229
+ const startX = numberOr(nodeRecords[startIndex].x, RULE_CANONICAL_LAYOUT.x);
230
+ const startY = numberOr(nodeRecords[startIndex].y, RULE_CANONICAL_LAYOUT.y0);
231
+ const insertedY = startY + RULE_CANONICAL_LAYOUT.gap;
232
+ node.x = startX;
233
+ node.y = insertedY;
234
+ node.width = RULE_CANONICAL_LAYOUT.width;
235
+ node.height = RULE_CANONICAL_LAYOUT.height;
236
+ const hasBranch = nodeRecords.some((entry) => entry.nodeType === "BRANCH");
237
+ const shifted = hasBranch
238
+ ? nodeRecords.map((entry) => entry !== nodeRecords[startIndex] && numberOr(entry.y, -Infinity) >= insertedY
239
+ ? { ...entry, y: numberOr(entry.y, insertedY) + RULE_CANONICAL_LAYOUT.gap }
240
+ : entry)
241
+ : nodeRecords;
242
+ if (routeRecords.length > 0) {
243
+ const startRoute = routeRecords.find((route) => route.preNode === startCode);
244
+ const after = startRoute ? String(startRoute.postNode ?? "End") : "End";
245
+ const nextNodes = [...shifted.slice(0, startIndex + 1), node, ...shifted.slice(startIndex + 1)];
246
+ const nextRoutes = routeRecords
247
+ .filter((route) => !(route.preNode === startCode && route.postNode === after))
248
+ .concat([{ preNode: startCode, postNode: nodeCode }, { preNode: nodeCode, postNode: after }]);
249
+ return { nodes: nextNodes, routes: nextRoutes, injected: true };
250
+ }
251
+ // Empty routes = platform implied-linear order (node-array order).
252
+ const ordered = [...shifted.slice(0, startIndex + 1), node, ...shifted.slice(startIndex + 1)];
253
+ const codes = ordered.map((entry, index) => String(entry.nodeCode ?? `node_${index}`));
254
+ const linearRoutes = codes.slice(0, -1).map((pre, index) => ({ preNode: pre, postNode: codes[index + 1] }));
255
+ return { nodes: ordered, routes: linearRoutes, injected: true };
256
+ }
257
+ /**
258
+ * Ensure a preset trigger rule graph keeps the platform system node. If the
259
+ * caller already provided it, the graph is returned untouched; otherwise the
260
+ * node is spliced immediately after START (the platform's canonical position)
261
+ * and the START route is re-pointed through it, preserving every other edge.
262
+ * Non-preset rule codes are returned untouched. This is an adapter, not a
263
+ * repair of caller mistakes: the injected node is exactly the platform default.
264
+ */
265
+ export function injectDefaultSystemNode(ruleCode, nodesInput, routesInput) {
266
+ const spec = DEFAULT_SYSTEM_NODE_BY_RULE_CODE[ruleCode];
267
+ if (!spec)
268
+ return { nodes: asRecordArray(parseMaybeJson(nodesInput)), routes: asRecordArray(parseMaybeJson(routesInput)), injected: false };
269
+ return injectNodeAfterStart(nodesInput, routesInput, (nodes) => nodes.some((node) => node.nodeType === spec.nodeType),
270
+ // Geometry is assigned by injectNodeAfterStart (START column, later nodes
271
+ // pushed down); a hardcoded seat here is what produced off-column nodes.
272
+ () => ({ nodeCode: spec.nodeCode, nodeName: spec.nodeName, nodeType: spec.nodeType, isSystemNode: true }),
273
+ // ASSIGN / BIZ_ACTION merge their result into the input the system node
274
+ // persists, so they must run BEFORE it (live 2026-09-28). Seat the injected
275
+ // system node after the last such action node instead of right after START.
276
+ ["ASSIGN", "BIZ_ACTION"]);
277
+ }
278
+ /**
279
+ * Ticket 80 — the platform's button/flow trigger node. A rule invoked from a
280
+ * custom button or a workflow/button handler (not a data event) carries a
281
+ * CUSTOM_NODE 「自定义按钮/流程触发」 system node right after START, named after
282
+ * the rule code (live capture: Start x684/y122 → CUSTOM_NODE x558/y233 →
283
+ * actions → End, vertical string-coordinate routes). Node keys follow the
284
+ * yst/fwb instance shape (§2.2: nodeCode, nodeName, nodeType,
285
+ * isSystemNode:true, nodeCategory:"systemNode" — 43+102 published rules).
286
+ * rule.save synthesizes it by default for custom rule codes and lets
287
+ * `customTrigger:false` opt out.
288
+ */
289
+ export function injectCustomTriggerNode(ruleCode, nodesInput, routesInput) {
290
+ return injectNodeAfterStart(nodesInput, routesInput, (nodes) => nodes.some((node) => node.nodeType === "CUSTOM_NODE"), () => ({ nodeCode: ruleCode, nodeName: ruleCode, nodeType: "CUSTOM_NODE", isSystemNode: true, nodeCategory: "systemNode" }));
291
+ }
292
+ /**
293
+ * Ticket 80 + CFG-26 — route layout fidelity. The platform standard route is
294
+ * `{ preNode, postNode, points:["x, y", ...], routeCondition:true|false,
295
+ * matchType:"ANY", lineName? }` (research/desensitized-config-shapes.md §1.4;
296
+ * live default rule `9ef75fea…` carries all three on every edge). Missing
297
+ * `points` breaks the designer canvas lines; missing `routeCondition`/
298
+ * `matchType` persists a non-canonical shape (CFG-26: routes written by an
299
+ * older build kept neither key).
300
+ *
301
+ * Adapter contract: only ABSENT keys are defaulted (`routeCondition:true`,
302
+ * `matchType:"ANY"`); an explicit value — including `routeCondition:false` for
303
+ * an else exit and a real branch `matchType` — is preserved verbatim.
304
+ *
305
+ * Geometry (live-verified against the platform default rule `9ef75fea…`):
306
+ * a vertical edge's endpoints are the SOURCE node's bottom-center and the
307
+ * TARGET node's top-center — NOT the node origin. Example: Start x327/y36
308
+ * 158×40 → Update x327/y116 gives points ["406, 76","406, 116"] where
309
+ * 406 = 327 + 158/2, 76 = 36 + 40, 116 = the target's y. Deriving points from
310
+ * the node origin (the pre-fix behaviour) drew lines from the top-left corners
311
+ * and looked visibly offset. Same-row/upward edges fall back to the facing
312
+ * side-center points. Node box defaults: 158×40 (the platform's standard).
313
+ */
314
+ export function ensureRouteShape(nodesInput, routesInput, forceDerivePoints = false) {
315
+ const nodeRecords = asRecordArray(parseMaybeJson(nodesInput));
316
+ const routeRecords = asRecordArray(parseMaybeJson(routesInput));
317
+ const numberOrDefault = (value, fallback) => (Number.isFinite(Number(value)) ? Number(value) : fallback);
318
+ const boxOf = (nodeCode) => {
319
+ const node = nodeRecords.find((candidate) => String(candidate.nodeCode ?? "") === nodeCode);
320
+ if (!node)
321
+ return { x: 0, y: 0, width: 158, height: 40 };
322
+ return {
323
+ x: numberOrDefault(node.x, 0),
324
+ y: numberOrDefault(node.y, 0),
325
+ width: numberOrDefault(node.width, 158),
326
+ height: numberOrDefault(node.height, 40),
327
+ };
328
+ };
329
+ const edgePoints = (pre, post) => {
330
+ const s = boxOf(pre);
331
+ const t = boxOf(post);
332
+ const scx = s.x + s.width / 2;
333
+ const tcx = t.x + t.width / 2;
334
+ const scy = s.y + s.height / 2;
335
+ const tcy = t.y + t.height / 2;
336
+ if (t.y > s.y)
337
+ return [`${scx}, ${s.y + s.height}`, `${tcx}, ${t.y}`]; // downward: source bottom → target top (platform standard)
338
+ if (t.y < s.y)
339
+ return [`${scx}, ${s.y}`, `${tcx}, ${t.y + t.height}`]; // upward
340
+ if (t.x >= s.x)
341
+ return [`${s.x + s.width}, ${scy}`, `${t.x}, ${tcy}`]; // same row, rightward
342
+ return [`${s.x}, ${scy}`, `${t.x + t.width}, ${tcy}`]; // same row, leftward
343
+ };
344
+ const validPoints = (points) => Array.isArray(points) && points.length > 0 && points.every((point) => typeof point === "string" && point.trim().length > 0);
345
+ return routeRecords.map((route) => {
346
+ const pre = String(route.preNode ?? "");
347
+ const post = String(route.postNode ?? "");
348
+ return {
349
+ ...route,
350
+ // `forceDerivePoints` is set when the tool just re-laid-out the nodes: any
351
+ // points the caller echoed from a previous (origin-collapsed) read are
352
+ // stale and must be re-derived from the new geometry.
353
+ ...(!forceDerivePoints && validPoints(route.points) ? {} : { points: edgePoints(pre, post) }),
354
+ ...(route.routeCondition === undefined || route.routeCondition === null ? { routeCondition: true } : {}),
355
+ ...(route.matchType === undefined || route.matchType === null || String(route.matchType).trim() === "" ? { matchType: "ANY" } : {}),
356
+ };
357
+ });
358
+ }
359
+ /**
360
+ * CFG-26 — the platform's canonical single-column node layout. Live-verified
361
+ * against the platform default rule `9ef75fea…`: every node sits in one column
362
+ * at x=327, 158×40, spaced 80px down (y = 36, 116, 196, …). A graph that is
363
+ * submitted with missing geometry or overlapping boxes renders wrong in the
364
+ * designer (nodes piled up, lines crossing) — the tool must emit the canonical
365
+ * layout itself instead of requiring a hand-fix afterwards.
366
+ */
367
+ export const RULE_CANONICAL_LAYOUT = Object.freeze({ x: 327, y0: 36, gap: 80, width: 158, height: 40 });
368
+ /**
369
+ * The identity match for a self-update: "target row's id == the row being
370
+ * processed". It is the only sane filter for the common case — an UPDATE node
371
+ * whose `targetObjectCode` is the rule's own model. `prepareRuleGraph` defaults
372
+ * it (with a warning) when omitted; a cross-model UPDATE must pass one explicitly.
373
+ */
374
+ export const RULE_IDENTITY_FILTER = Object.freeze({
375
+ mainConditions: Object.freeze([
376
+ Object.freeze([Object.freeze({ targetSchemaDataItem: "id", ruleConditionType: "EQ", ruleValueType: "DYNAMIC", currentSchemaDataItem: "id" })]),
377
+ ]),
378
+ sheetConditions: Object.freeze([]),
379
+ });
380
+ /**
381
+ * Order a linear chain from START through every node, or null when the graph is
382
+ * not a simple chain (a BRANCH node, a split, a cycle, unreachable/missing
383
+ * nodes). Only linear graphs are auto-laid-out; branch canvases keep whatever
384
+ * layout the author designed (their geometry is meaningful).
385
+ */
386
+ function linearChainOrder(nodes, routes) {
387
+ const codes = nodes.map((node) => String(node.nodeCode ?? ""));
388
+ if (codes.some((code) => !code) || new Set(codes).size !== codes.length)
389
+ return null;
390
+ if (codes.length !== routes.length + 1)
391
+ return null;
392
+ if (nodes.some((node) => node.nodeType === "BRANCH"))
393
+ return null;
394
+ const codeSet = new Set(codes);
395
+ if (nodes.filter((node) => node.nodeType === "START").length !== 1)
396
+ return null;
397
+ const outgoing = new Map();
398
+ const incoming = new Map();
399
+ for (const route of routes) {
400
+ const pre = String(route.preNode ?? "");
401
+ const post = String(route.postNode ?? "");
402
+ if (!codeSet.has(pre) || !codeSet.has(post))
403
+ return null;
404
+ const list = outgoing.get(pre) ?? [];
405
+ list.push(post);
406
+ outgoing.set(pre, list);
407
+ incoming.set(post, (incoming.get(post) ?? 0) + 1);
408
+ }
409
+ if ([...outgoing.values()].some((targets) => targets.length > 1))
410
+ return null;
411
+ const startCode = String(nodes.find((node) => node.nodeType === "START").nodeCode);
412
+ const order = [];
413
+ let cursor = startCode;
414
+ while (cursor !== undefined) {
415
+ if (order.includes(cursor))
416
+ return null; // cycle
417
+ order.push(cursor);
418
+ cursor = outgoing.get(cursor)?.[0];
419
+ }
420
+ if (order.length !== codes.length)
421
+ return null;
422
+ // A chain has exactly one sink: END must be last and every other node feeds forward.
423
+ const endCode = String(nodes.find((node) => node.nodeType === "END")?.nodeCode ?? "");
424
+ if (!endCode || order[order.length - 1] !== endCode)
425
+ return null;
426
+ for (const [code, count] of incoming)
427
+ if (count !== 1)
428
+ return null;
429
+ return order;
430
+ }
431
+ /**
432
+ * CFG-26 — normalize node geometry. A graph whose geometry is MISSING (any node
433
+ * without x/y) is laid out by the tool: a linear chain in the platform's
434
+ * canonical single column, a branch/DAG in a layered tree. A deliberate,
435
+ * renderable layout is preserved verbatim; a branch graph that already has
436
+ * geometry is never repositioned (its geometry is meaningful).
437
+ *
438
+ * Live 2026-09-28 (bench60): an intent's 3-tier branch (compact `actions` cannot
439
+ * express branches, so the author sent a full nodeGraph) arrived with NO geometry
440
+ * at all. The old code skipped every BRANCH graph, so the rule persisted with all
441
+ * nodes at the origin and every route point the same fallback `["158, 20","0, 20"]`
442
+ * — the designer piled the boxes up and drew garbled lines. A branch graph with
443
+ * missing geometry MUST be laid out, not left to the platform's default origin.
444
+ */
445
+ export function canonicalizeNodeLayout(nodesInput, routesInput) {
446
+ const nodes = asRecordArray(parseMaybeJson(nodesInput));
447
+ const routes = asRecordArray(parseMaybeJson(routesInput));
448
+ const boxOf = (node) => ({
449
+ x: Number.isFinite(Number(node.x)) ? Number(node.x) : Number.NaN,
450
+ y: Number.isFinite(Number(node.y)) ? Number(node.y) : Number.NaN,
451
+ width: Number.isFinite(Number(node.width)) ? Number(node.width) : RULE_CANONICAL_LAYOUT.width,
452
+ height: Number.isFinite(Number(node.height)) ? Number(node.height) : RULE_CANONICAL_LAYOUT.height,
453
+ });
454
+ const boxes = nodes.map(boxOf);
455
+ const missingGeometry = boxes.some((box) => Number.isNaN(box.x) || Number.isNaN(box.y));
456
+ const overlap = boxes.some((a, ai) => boxes.some((b, bi) => bi > ai && !Number.isNaN(a.x) && !Number.isNaN(b.x) && a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height));
457
+ const order = linearChainOrder(nodes, routes);
458
+ if (missingGeometry) {
459
+ // Missing geometry: lay the whole graph out. A linear chain keeps the
460
+ // platform's single column; a branch/DAG gets a layered tree.
461
+ if (order)
462
+ return layLinearColumn(nodes, order);
463
+ return layLayeredDag(nodes, routes);
464
+ }
465
+ // Geometry present: preserve it unless a linear chain's boxes collide (the
466
+ // platform's own renderer needs a clean column); never reposition a branch
467
+ // graph that already carries geometry.
468
+ if (!order || !overlap)
469
+ return nodes;
470
+ return layLinearColumn(nodes, order);
471
+ }
472
+ function layLinearColumn(nodes, order) {
473
+ return nodes.map((node) => {
474
+ const index = order.indexOf(String(node.nodeCode ?? ""));
475
+ return {
476
+ ...node,
477
+ x: RULE_CANONICAL_LAYOUT.x,
478
+ y: RULE_CANONICAL_LAYOUT.y0 + index * RULE_CANONICAL_LAYOUT.gap,
479
+ width: RULE_CANONICAL_LAYOUT.width,
480
+ height: RULE_CANONICAL_LAYOUT.height,
481
+ };
482
+ });
483
+ }
484
+ /**
485
+ * Layered auto-layout for a branch/DAG with missing geometry. Layer = longest
486
+ * path from a root (START / indegree-0), so a branch and its reconvergence land
487
+ * on consecutive rows; within a layer nodes spread horizontally around the
488
+ * canonical column center. Deterministic (input order breaks ties), so the same
489
+ * graph always yields the same planHash.
490
+ */
491
+ function layLayeredDag(nodes, routes) {
492
+ const codes = nodes.map((node) => String(node.nodeCode ?? ""));
493
+ if (codes.some((code) => code.length === 0) || new Set(codes).size !== codes.length)
494
+ return nodes;
495
+ const codeSet = new Set(codes);
496
+ const outgoing = new Map();
497
+ const indegree = new Map();
498
+ for (const code of codes) {
499
+ outgoing.set(code, []);
500
+ indegree.set(code, 0);
501
+ }
502
+ for (const route of routes) {
503
+ const pre = String(route.preNode ?? "");
504
+ const post = String(route.postNode ?? "");
505
+ if (!codeSet.has(pre) || !codeSet.has(post))
506
+ continue;
507
+ outgoing.get(pre).push(post);
508
+ indegree.set(post, (indegree.get(post) ?? 0) + 1);
509
+ }
510
+ // Kahn longest-path layering; a cycle leaves nodes unqueued and they fall to a
511
+ // trailing layer (still rendered, never dropped).
512
+ const layer = new Map();
513
+ const queue = codes.filter((code) => (indegree.get(code) ?? 0) === 0);
514
+ for (const code of queue)
515
+ layer.set(code, 0);
516
+ const remaining = new Map(indegree);
517
+ let head = 0;
518
+ while (head < queue.length) {
519
+ const code = queue[head++];
520
+ for (const post of outgoing.get(code) ?? []) {
521
+ layer.set(post, Math.max(layer.get(post) ?? 0, (layer.get(code) ?? 0) + 1));
522
+ remaining.set(post, (remaining.get(post) ?? 1) - 1);
523
+ if (remaining.get(post) === 0)
524
+ queue.push(post);
525
+ }
526
+ }
527
+ const maxLayer = Math.max(0, ...[...layer.values()]);
528
+ for (const code of codes)
529
+ if (!layer.has(code))
530
+ layer.set(code, maxLayer + 1);
531
+ const byLayer = new Map();
532
+ for (const code of codes) {
533
+ const depth = layer.get(code);
534
+ byLayer.set(depth, [...(byLayer.get(depth) ?? []), code]);
535
+ }
536
+ const { x: columnX, y0, gap, width, height } = RULE_CANONICAL_LAYOUT;
537
+ const centerX = columnX + width / 2;
538
+ const hgap = 60;
539
+ const position = new Map();
540
+ for (const [depth, members] of byLayer) {
541
+ const total = members.length;
542
+ members.forEach((code, index) => {
543
+ const x = Math.round(centerX - ((total - 1) / 2) * (width + hgap) + index * (width + hgap));
544
+ position.set(code, { x, y: y0 + depth * gap });
545
+ });
546
+ }
547
+ return nodes.map((node) => {
548
+ const spot = position.get(String(node.nodeCode ?? ""));
549
+ return { ...node, x: spot.x, y: spot.y, width, height };
550
+ });
551
+ }
552
+ /**
553
+ * True when a route's stored points do NOT sit on the current node boxes — i.e.
554
+ * they are stale (echoed from a prior origin-collapsed read where every node was
555
+ * at 0,0, producing `["158, 20","0, 20"]`). The platform draws a straight edge
556
+ * between node borders, so a point that is not on its endpoint box's perimeter
557
+ * cannot be valid for the geometry and must be re-derived. Legitimate hand-drawn
558
+ * multi-point routes keep their endpoints on the boxes, so they are preserved.
559
+ */
560
+ function routePointsConsistentWithGeometry(nodes, routes) {
561
+ const boxOf = (code) => {
562
+ const node = nodes.find((candidate) => String(candidate.nodeCode ?? "") === code);
563
+ if (!node || !Number.isFinite(Number(node.x)) || !Number.isFinite(Number(node.y)))
564
+ return null;
565
+ return {
566
+ x: Number(node.x),
567
+ y: Number(node.y),
568
+ width: Number.isFinite(Number(node.width)) ? Number(node.width) : RULE_CANONICAL_LAYOUT.width,
569
+ height: Number.isFinite(Number(node.height)) ? Number(node.height) : RULE_CANONICAL_LAYOUT.height,
570
+ };
571
+ };
572
+ const onBox = (point, box) => {
573
+ if (typeof point !== "string")
574
+ return false;
575
+ const match = /^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/.exec(point.trim());
576
+ if (!match)
577
+ return false;
578
+ const px = Number(match[1]);
579
+ const py = Number(match[2]);
580
+ const eps = 1.5;
581
+ const withinX = px >= box.x - eps && px <= box.x + box.width + eps;
582
+ const withinY = py >= box.y - eps && py <= box.y + box.height + eps;
583
+ const onEdge = Math.abs(px - box.x) <= eps || Math.abs(px - (box.x + box.width)) <= eps || Math.abs(py - box.y) <= eps || Math.abs(py - (box.y + box.height)) <= eps;
584
+ return withinX && withinY && onEdge;
585
+ };
586
+ for (const route of routes) {
587
+ const points = route.points;
588
+ if (!Array.isArray(points) || points.length < 2)
589
+ continue;
590
+ const s = boxOf(String(route.preNode ?? ""));
591
+ const t = boxOf(String(route.postNode ?? ""));
592
+ if (!s || !t)
593
+ continue;
594
+ if (!onBox(points[0], s) || !onBox(points[points.length - 1], t))
595
+ return false;
596
+ }
597
+ return true;
598
+ }
599
+ export function graphGeometryNeedsLayout(nodesInput, routesInput) {
600
+ const nodes = asRecordArray(parseMaybeJson(nodesInput));
601
+ const routes = asRecordArray(parseMaybeJson(routesInput));
602
+ const hasMissing = nodes.some((node) => !Number.isFinite(Number(node.x)) || !Number.isFinite(Number(node.y)));
603
+ if (hasMissing)
604
+ return true;
605
+ const order = linearChainOrder(nodes, routes);
606
+ if (!order)
607
+ return false;
608
+ const boxOf = (node) => ({
609
+ x: Number(node.x),
610
+ y: Number(node.y),
611
+ width: Number.isFinite(Number(node.width)) ? Number(node.width) : RULE_CANONICAL_LAYOUT.width,
612
+ height: Number.isFinite(Number(node.height)) ? Number(node.height) : RULE_CANONICAL_LAYOUT.height,
613
+ });
614
+ const boxes = nodes.map(boxOf);
615
+ return boxes.some((a, ai) => boxes.some((b, bi) => bi > ai && a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height));
616
+ }
617
+ /**
618
+ * The single rule-graph normalization used by BOTH the write preflight and the
619
+ * executor, so preview and execute validate the SAME prepared graph (CFG-02
620
+ * class: a mismatched preflight either waves through a graph the executor would
621
+ * reject, or refuses one the executor would have normalized).
622
+ *
623
+ * Steps (order matters): inject the preset system node → inject the
624
+ * button/flow CUSTOM_NODE trigger for custom rule codes → default missing node
625
+ * names from the platform's own `RuleNodeType.getName()` vocabulary →
626
+ * canonicalize node geometry → derive route points/keys. Pure: no wire, no I/O.
627
+ *
628
+ * Missing `nodeName` is adapter-shaped, not a caller error: the platform stores
629
+ * the name verbatim and never defaults it, so an unnamed node publishes with a
630
+ * blank canvas label. Every defaulted name is surfaced as a `warning` blocker
631
+ * in `warnings` (never gating) so the caller can replace it with a domain name.
632
+ */
633
+ export function prepareRuleGraph(ruleCode, graph, customTrigger, schemaCode) {
634
+ const injected = injectDefaultSystemNode(ruleCode, graph.nodes ?? graph.node, graph.routes ?? graph.route);
635
+ let finalNodes = injected.nodes;
636
+ let finalRoutes = injected.routes;
637
+ const warnings = [];
638
+ // SCHEDULER ⇒ TIMING_TASK: the platform dispatches a schedule ONLY when the
639
+ // RULE ROW's bizRuleType = 2 (BizRuleScheduling.java:202-205; see
640
+ // lanes/businessrule-contract.ts:42). A caller who authors a SCHEDULER node but
641
+ // omits bizRuleType would otherwise get bizRuleType 0 (data-event) PLUS a
642
+ // synthesized CUSTOM_NODE trigger — the rule saves and enables but the timer
643
+ // never fires it. Observed live 2026-10-06 (sandbox): the agent read the
644
+ // contract and passed bizRuleType:2 by hand. Infer it here so the authored
645
+ // graph carries the timing face without the caller having to know the code.
646
+ const hasSchedulerNode = finalNodes.some((node) => String(node.nodeType ?? "") === "SCHEDULER");
647
+ const explicitBizRuleType = typeof graph.bizRuleType === "number" ? graph.bizRuleType : undefined;
648
+ if (hasSchedulerNode && explicitBizRuleType === undefined) {
649
+ warnings.push({ code: "rule-graph-bizruletype-inferred-timing-task", key: "bizRuleType", severity: "warning", message: "图含 SCHEDULER 节点但未传 bizRuleType,已按定时入口推断 bizRuleType=2(TIMING_TASK);平台只对 bizRuleType=2 的行建调度任务(BizRuleScheduling.java:202-205)" });
650
+ }
651
+ const effectiveBizRuleType = hasSchedulerNode && explicitBizRuleType === undefined ? 2 : explicitBizRuleType ?? 0;
652
+ const wantsCustomTrigger = customTrigger !== false && effectiveBizRuleType === 0 && !PLATFORM_TRIGGER_RULE_CODES.has(ruleCode) && !hasSchedulerNode;
653
+ if (wantsCustomTrigger) {
654
+ const triggered = injectCustomTriggerNode(ruleCode, finalNodes, finalRoutes);
655
+ finalNodes = triggered.nodes;
656
+ finalRoutes = triggered.routes;
657
+ }
658
+ // CONST codeType=0: the platform reads the literal from `bizCode`
659
+ // (MapOps.getBizCode; designer 0===codeType → componentValue=bizCode), NOT
660
+ // from `value`/`expressionValue` (both arrive as literal "null" at runtime —
661
+ // 2026-10-06 live). Agents naturally write `value`/an expression, so fill it
662
+ // into `bizCode` rather than refusing: a value under value/expressionValue is
663
+ // moved to bizCode; only an element with NO value anywhere is a hard error
664
+ // (raised by the lint below).
665
+ finalNodes = finalNodes.map((node) => {
666
+ const mapping = node?.methodMapping;
667
+ if (!mapping || typeof mapping !== "object")
668
+ return node;
669
+ let changed = false;
670
+ for (const listKey of ["inputMappings", "outputMappings"]) {
671
+ const list = mapping[listKey];
672
+ if (!Array.isArray(list))
673
+ continue;
674
+ mapping[listKey] = list.map((element) => {
675
+ if (!element || typeof element !== "object" || Array.isArray(element))
676
+ return element;
677
+ const row = element;
678
+ if (row.codeType !== 0)
679
+ return row;
680
+ const hasBizCode = typeof row.bizCode === "string" && row.bizCode.trim().length > 0;
681
+ if (hasBizCode)
682
+ return row;
683
+ const literal = row.value !== undefined ? row.value : row.expressionValue;
684
+ if (literal === undefined)
685
+ return row;
686
+ changed = true;
687
+ const next = { ...row, bizCode: typeof literal === "string" ? literal : String(literal) };
688
+ delete next.value;
689
+ return next;
690
+ });
691
+ }
692
+ if (changed)
693
+ warnings.push({ code: "rule-graph-const-value-moved-to-bizcode", nodeCode: String(node.nodeCode ?? ""), key: "methodMapping", severity: "warning", message: "codeType=0(CONST) 的值已从 value/expressionValue 移到 bizCode(平台 CONST 只读 bizCode;value/expressionValue 运行时会变字面 null)" });
694
+ return node;
695
+ });
696
+ finalNodes = finalNodes.map((node) => {
697
+ const nodeType = String(node.nodeType ?? "");
698
+ if (nodeType === "START" || nodeType === "END" || node.isSystemNode === true)
699
+ return node;
700
+ // Apply the constant-key defaults first, THEN fall through to the nodeName
701
+ // default — the earlier early-return skipped nodeName, which the platform
702
+ // needs (a node without nodeName was silently dropped on save).
703
+ let current = node;
704
+ if (nodeType === "BRANCH") {
705
+ if (current.inputParam === undefined || current.inputParam === null || String(current.inputParam) === "") {
706
+ current = { ...current, inputParam: "_input" };
707
+ warnings.push({ code: "rule-graph-branch-input-param-defaulted", nodeCode: String(node.nodeCode ?? ""), key: "inputParam", severity: "warning", message: `BRANCH "${String(node.nodeCode ?? "")}" 缺 inputParam,已默认补 "_input"` });
708
+ }
709
+ if (current.judgeMethod === undefined || current.judgeMethod === null || String(current.judgeMethod) === "") {
710
+ current = { ...current, judgeMethod: "DEFAULT" };
711
+ warnings.push({ code: "rule-graph-branch-judge-method-defaulted", nodeCode: String(node.nodeCode ?? ""), key: "judgeMethod", severity: "warning", message: `BRANCH "${String(node.nodeCode ?? "")}" 缺 judgeMethod,已默认补 "DEFAULT"` });
712
+ }
713
+ }
714
+ if (nodeType === "UPDATE") {
715
+ if (current.inputParam === undefined || current.inputParam === null || String(current.inputParam) === "") {
716
+ current = { ...current, inputParam: "_input" };
717
+ warnings.push({ code: "rule-graph-update-input-param-defaulted", nodeCode: String(node.nodeCode ?? ""), key: "inputParam", severity: "warning", message: `UPDATE "${String(node.nodeCode ?? "")}" 缺 inputParam,已默认补 "_input"` });
718
+ }
719
+ if (schemaCode !== undefined && String(node.targetObjectCode ?? "") === schemaCode && (current.filterCondition === undefined || current.filterCondition === null)) {
720
+ warnings.push({ code: "rule-graph-filter-defaulted", nodeCode: String(node.nodeCode ?? ""), key: "filterCondition", severity: "warning", message: `UPDATE "${String(node.nodeCode ?? "")}" 缺 filterCondition,已按自更新身份匹配默认补` });
721
+ current = { ...current, filterCondition: RULE_IDENTITY_FILTER };
722
+ }
723
+ }
724
+ // Every other action/behavior node (CREATE/DELETE/ASSIGN/BIZ_ACTION/MESSAGE/
725
+ // QUERY) also resolves its input schema from `inputParam`; a missing value
726
+ // refuses publish with 300010 (BIZ_SCHEMA_CODE_EMPTY). Default it so a
727
+ // hand-authored graph cannot drop the key (live 2026-10-08: CREATE without
728
+ // `inputParam` → 300010; with `"_input"` → errcode 0).
729
+ if (nodeType !== "BRANCH" && nodeType !== "UPDATE" && RULE_NODES_NEEDING_INPUT_PARAM.includes(nodeType)) {
730
+ if (current.inputParam === undefined || current.inputParam === null || String(current.inputParam) === "") {
731
+ current = { ...current, inputParam: "_input" };
732
+ warnings.push({ code: "rule-graph-input-param-defaulted", nodeCode: String(node.nodeCode ?? ""), key: "inputParam", severity: "warning", message: `${nodeType} "${String(node.nodeCode ?? "")}" 缺 inputParam,已默认补 "_input"(平台按 inputParam 解析节点输入 schema,缺则 publish 300010「数据模型编码不能为空」)` });
733
+ }
734
+ }
735
+ if (String(current.nodeName ?? "").trim())
736
+ return current;
737
+ const nodeCode = String(current.nodeCode ?? "");
738
+ const fallback = RULE_NODE_DEFAULT_NAMES[nodeType] ?? nodeCode;
739
+ warnings.push({
740
+ code: "rule-graph-node-name-defaulted",
741
+ nodeCode,
742
+ key: "nodeName",
743
+ severity: "warning",
744
+ message: `节点 "${nodeCode}"(${nodeType})缺 nodeName,已按平台 RuleNodeType 默认名补为 "${fallback}";如需业务语义名称请在 nodeGraph 显式传 nodeName`,
745
+ });
746
+ return { ...current, nodeName: fallback };
747
+ });
748
+ const relayout = graphGeometryNeedsLayout(finalNodes, finalRoutes);
749
+ finalNodes = canonicalizeNodeLayout(finalNodes, finalRoutes);
750
+ // Re-derive points when the tool laid the nodes out, OR when the stored points
751
+ // are stale (not on the current node boxes — the origin-collapsed echo).
752
+ const forcePoints = relayout || !routePointsConsistentWithGeometry(finalNodes, finalRoutes);
753
+ finalRoutes = ensureRouteShape(finalNodes, finalRoutes, forcePoints);
754
+ return { graph: { ...graph, ...(hasSchedulerNode && explicitBizRuleType === undefined ? { bizRuleType: 2 } : {}), nodes: finalNodes, routes: finalRoutes }, nodes: finalNodes, routes: finalRoutes, warnings };
755
+ }
756
+ /**
757
+ * Rule-level scheduler shape (8.6.26 `SchedulerSetting.java:27-52`). The
758
+ * SCHEDULER node itself carries NO timing config — `GetListNode`/`ScheduleNode`
759
+ * are bare `Node` subclasses; the schedule lives in the rule row's
760
+ * `schedulerSetting` JSON string (BusinessRuleModel.java:52-53) and only applies
761
+ * when `bizRuleType = 2` (TIMING_TASK). Transcribed from the DTO field list plus
762
+ * the desensitized observed keys (research/desensitized-config-shapes.md §1.5).
763
+ */
764
+ export const RULE_SCHEDULER_SETTING_TEMPLATE = Object.freeze({
765
+ triggerTimeType: "SELECTED_TIME",
766
+ cycleType: "DAY",
767
+ executeType: "CYCLE",
768
+ execCycle: 1,
769
+ hour: 0,
770
+ minute: 0,
771
+ second: 0,
772
+ startTime: "<yyyy-MM-dd HH:mm:ss>",
773
+ endType: "NEVER",
774
+ dataSourceType: "NONE",
775
+ filterConditionGroups: [],
776
+ // latestVersion MUST be true: JobModelBuilder.buildJob routes on it — false
777
+ // takes the legacy builder (buildJobByRule) which reads startDate/endDate
778
+ // (null in the new-schedule shape) → NPE/无调度; true takes
779
+ // buildJobByNewScheduleSetting which reads startTime/endTime + cron. The old
780
+ // template shipped false and agents blindly copied it, hitting a
781
+ // businessrule/publish 50000 NPE (2026-10-06 live). The SCHEDULER node
782
+ // template already carried true; this aligns the rule-level setting.
783
+ latestVersion: true,
784
+ consolidation: false,
785
+ });
786
+ /**
787
+ * Platform default node display names per nodeType, transcribed verbatim from
788
+ * `RuleNodeType.getName()` (cloudpivot-metadata-8.6.26.jar, javap 2026-09-18).
789
+ * The platform stores whatever name the caller sends and does NOT default it —
790
+ * a graph published without nodeName keeps a null label. Every authored node
791
+ * must carry a name; this map is the platform's own vocabulary for the
792
+ * refusal message.
793
+ */
794
+ export const RULE_NODE_DEFAULT_NAMES = Object.freeze({
795
+ START: "开始",
796
+ END: "结束",
797
+ MESSAGE: "消息",
798
+ LOGIC_VERIFY: "逻辑验证",
799
+ DATA_VERIFY: "数据验证",
800
+ DELETE: "数据删除",
801
+ UPDATE: "数据更新",
802
+ CREATE: "数据新增",
803
+ BIZ_ACTION: "业务动作",
804
+ GET_LIST: "获取集合",
805
+ LOAD: "查询数据",
806
+ CANCEL: "流程作废",
807
+ AVAILABLE: "流程生效",
808
+ SCHEDULER: "定时作业",
809
+ SYSTEM_DELETE: "删除数据",
810
+ SYSTEM_UPDATE: "更新数据",
811
+ SYSTEM_CREATE: "新增数据",
812
+ EXECUTE_CODE: "代码执行",
813
+ CUSTOM_NODE: "自定义节点",
814
+ DATA_CHECK: "数据校验",
815
+ ASSIGN: "数据赋值",
816
+ EXIST_VERIFY: "存在校验",
817
+ ONLINE_DEVELOP: "在线开发",
818
+ EXCEPTION: "异常中止",
819
+ BRANCH: "分支",
820
+ HINT: "提示信息",
821
+ QUERY: "查询节点",
822
+ });
823
+ /** Canonical node shapes per nodeType, transcribed from the production capture. */
824
+ export const RULE_NODE_TEMPLATES = Object.freeze({
825
+ UPDATE: {
826
+ description: "数据更新:`filterCondition` = **查找满足条件的主表数据**(要更新哪条;DYNAMIC 过滤来源=currentSchemaDataItem,value 不出现);**空 mainConditions = 无条件下更新整表**(bench23 病灶,干 lint 给 warning)。`filterSubCondition` = 查找满足条件的子表数据。`dataActions` 赋值(DYNAMIC 来源=currentDataItemValue,value 留空;**EXPRESSION 公式赋值**:`ruleValueType:\"EXPRESSION\"` + `value` 为含非空 `formula` 的对象(可带 `editorText`/`editorMark`,平台编辑器解析要用到)。字段引用语法是 `{字段code}`,如 `{\"formula\":\"{amount}-{cost}\"}`;日期差用 **`DATEDIF({end},{begin},\"<unit>\")`**(unit 小写:`y` 年 / `M` 月 / `d` 日 / `h` 时 / `m` 分 / `s` 秒;两个日期字段直接相减不成立)。公式语言**支持 IF/比较运算符**(真机 2026-10-07:`IF(DATEDIF({actual},{eta},\"h\")>0,INTDOWN(DATEDIF({actual},{eta},\"h\")),0)` 计算正确);取整用 `INTDOWN`(floor)/`INTUP`(ceil)/`ROUND`;**`MAX`/`MIN` 是集合聚合函数,不是逐值函数**——`MAX(0,x)` 报 `Wrong number of args (2) passed to: MAX` 50000,两值取大用 `IF(x>y,x,y)`。无效公式会让整个 rule.save/update 50000(非静默)。公式只在**数据更新(Update)** 触发链持久化,Create 触发的自更新本 build 不落库;同批更新内公式读**更新前**值——`UPDATE 算 X → BRANCH 判 X` 这类同链两步也读旧值,不成立);`dataCondition` = 输入数据满足条件时才执行;`childTriggerConditionType` = 子表触发范围 ADD/MODIFY/DELETE。inputParam 必须是 \"_input\"(或 \"_<QUERY节点>_output\")。平台标签证据:i18n 模块 196233 节点属性表 + evidence/form-conditions-and-rules-truth.md §4。",
827
+ template: {
828
+ nodeCode: "UPDATE_1", nodeName: "数据更新", nodeType: "UPDATE", targetObjectCode: "<target schemaCode>", inputParam: "_input", updateNodeType: "UPDATE_MODEL", execFormMapping: false,
829
+ filterCondition: { mainConditions: [[{ targetSchemaDataItem: "<target field>", ruleConditionType: "EQ", ruleValueType: "DYNAMIC", currentSchemaDataItem: "<trigger field>" }]], sheetConditions: [] },
830
+ dataActions: [{ targetDataItem: "<target field>", ruleValueType: "DYNAMIC", value: "", ruleActionType: "EQUALS", currentDataItemValue: "<trigger field>" }],
831
+ },
832
+ },
833
+ QUERY: {
834
+ description: "查找数据:targetSchemaCode 可带子表(schemaCode.sheetCode),targetConditionGroups 与 UPDATE 的 filterCondition 同构;下游节点以 inputParam:\"_<本节点nodeCode>_output\" 消费查询输出。**要按「查询到的值」做分支/判断,把比较直接写进 targetConditionGroups(查询条件)**,或让下游节点 inputParam 指向本节点输出后用 FIXED/DYNAMIC 比该数据集字段;**条件叶不要用 ruleValueType:\"VARIABLE\" 引用查询结果**——save/publish 会过但运行时 NPE(SingletonDataset.getBizObjectProperty)、data.submit 50000(真机 2026-10-08;工具 fail closed rule-graph-condition-variable-unsupported)。",
835
+ template: {
836
+ nodeCode: "SearchData_1", nodeName: "查询节点", nodeType: "QUERY", inputParam: "_input", targetSchemaCode: "<schemaCode 或 schemaCode.sheetCode>",
837
+ targetConditionGroups: { mainConditions: [[{ targetSchemaDataItem: "<field>", ruleConditionType: "EQ", ruleValueType: "FIXED", value: "<literal>" }]], sheetConditions: [] },
838
+ joinConditions: [],
839
+ },
840
+ },
841
+ BRANCH: {
842
+ description: "数据判断(条件分支)。**运行时可用(2026-09-25 8.6.26 真机验证):预置 Create 行 enabled 后,`Start→SYSTEM_CREATE→BRANCH→UPDATE→End` + 一条 else 路由,提交命中阈值的走条件分支、未命中走 else。** 铁律——**出口条件叶是 `DataFilterCondition` 形状**:左值键 `currentSchemaDataItem`、操作符键 `ruleDataConditionType`(`EQ/NEQ/GT/LT/GTEQ/LTEQ/...`)、`ruleValueType`、`value`;**不能**用 UI/连线形状的 `targetSchemaDataItem` + `ruleConditionType`——save+publish 会通过(未知键被忽略),但引擎读到 null 来源/操作符,`data.submit` 稳定 50000 且回滚(真机前后对照已证)。`judgeMethod:DEFAULT`=判断当前/输入数据(`value` 走 FIXED 或 DYNAMIC);`OTHER`=判断其它模型(配 `targetInputMethod`/`targetConditionGroups`)。`sourceInputMethod` 必填(PRE_OUT/OTHER_MODEL,缺即 550018);**出口叶 `ruleValueType:DYNAMIC`(字段比字段)在本 build 真机 `data.update` 稳定 50000**(真机 2026-10-06,任意字段类型);要按字段间关系分支,别用 DYNAMIC 叶——改用 `FIXED` 值比较、或先由公式/接口把结果落入一个数值字段再 `FIXED` 比它、或落二开;**`inputParam`(\"_input\")与 `judgeMethod`(DEFAULT)可省——工具会默认补并发 warning(真机验证);若手工绕过工具,缺它们平台 enable/publish 会 300010**。`SYSTEM_CREATE` 必须显式(工具注入对分支图不可靠)。出路由用布尔 `routeCondition`:条件分支默认 `true`(可省)、兜底 else `false`(引擎按 `matchAll`:全部输入命中走 true,否则走 false;多条条件=多个 true 路由 + 一个 false 兜底)。只做「满足条件才执行某动作」也可用动作节点自身 `filterCondition`(单分支更省事)。**出口是「全部命中即走」不是「首个命中即停」**:多个 `true` 出口的条件若可能同时成立,全部路由、动作按顺序执行(后者覆盖前者;真机 2026-10-06 风险分级 高/中 同时命中→写成中)。分级互斥(高/中/低)要用**嵌套 BRANCH**:外层条件出口 + else 兜底进入下一层,而不是并列多个条件组。**BRANCH 本身可用、别轻易判定不可用**(真机 2026-10-06:2/3 出口图 `rule.save` errcode 0 可发布可运行):若 `rule.save` 报 50000,多半是**形状错**——对照 `describe`/`--example business-rule-branch-conditional` 配方(出口固定值 `value` 用数字而非字符串、UPDATE 节点别多带 `inputParam`/`updateNodeType`/`filterCondition`、别漏 `START`),先读 lint/refusal 修正,**不要据此转二开**。",
843
+ template: {
844
+ nodeCode: "Branch_1", nodeName: "分支", nodeType: "BRANCH", matchType: "ANY", sourceInputMethod: "PRE_OUT", inputParam: "_input", judgeMethod: "DEFAULT",
845
+ exitConditionGroups: [[{ currentSchemaDataItem: "<field>", ruleDataConditionType: "EQ", ruleValueType: "FIXED", value: "<literal>" }]],
846
+ },
847
+ },
848
+ CREATE: {
849
+ description: "新增数据(含跨表新增):**字段映射 = `dataActions`**(UI「主表字段值设置→目标字段/选择数据项」,每目标字段一条叶,DYNAMIC 用 `currentDataItemValue` 指来源字段 / FIXED 用 `value`)。**自触发禁区:`Create` 触发规则里的 CREATE 节点若目标又是本模型,每新增一行都会再次触发本 Create 规则 → 无界递归 → 运行时 550009「业务规则执行异常」(工具 fail closed `rule-graph-self-trigger-create-loop`);CREATE 写别的模型才安全,或把该步移出 Create 规则。跨模型 CREATE(含 QUERY 取列表后逐条新增,即 N→N 扇出)真机验证可用(2026-10-07:QUERY 查 N 行→CREATE 生成 N 行,同/跨模型、TIMING/Create 触发均 OK)**——`execFormMapping` 只是展示开关,**不是**映射来源,空 `dataActions` 会让新增行业务字段全为 null(工具 fail closed `rule-graph-create-mapping-missing`)。`findMainData` = **查找满足条件的主表数据**(有没有符合的只影响「是否新增」;[[叶]],DYNAMIC 叶同过滤规则);`filterCondition` = **若存在符合条件的主表数据,则不新增**(`<模型>存在则不新增`);`aggregation` = 主表数据新增方式:true=分组新增 / false=单条依次新增(存在时必须是布尔)。originatorValueType FIXED(完整用户对象 JSON 串)/DYNAMIC(字段 code) + originatorValue;startWorkflow:true 必须带 targetWorkflowCode。**下游记录要带齐业务字段**:`dataActions` 要把需求需要的字段(编号/日期/金额/满意度/状态/备注…)**逐条映射**,别只挂一个关联 id(真机 2026-10-08:只映射了关联字段,下游记录其余业务字段全 null,属残缺生成)。**originatorValueType:DYNAMIC 必须指向人员字段**(STAFF_SELECTOR 等),指向文本/编号字段会致发起人错误或为空(真机 2026-10-08)。**`SYSTEM` 值按目标字段类型取系统值**:日期字段用 `value:\"triggerTime\"`(触发时间)、人员字段用 `value:\"triggerPerson\"`(触发者)(前端源码 cloudpivot-form dateOptions/staffOptions 核验);`SYSTEM`+空 `value` 会落空(真机 2026-10-08:回访日期用 triggerTime 才落值)。平台标签证据:i18n 模块 196233 节点属性表 + evidence/form-conditions-and-rules-truth.md §4。",
850
+ template: {
851
+ nodeCode: "Create_1", nodeName: "数据新增", nodeType: "CREATE", targetObjectCode: "<target schemaCode>", inputParam: "_input",
852
+ originatorValueType: "DYNAMIC", originatorValue: "<trigger field code>", startWorkflow: false,
853
+ execFormMapping: false, aggregation: false, finishStartActivity: false,
854
+ dataActions: [{ targetDataItem: "<target field>", ruleValueType: "DYNAMIC", value: "", ruleActionType: "EQUALS", currentDataItemValue: "<source field>" }],
855
+ },
856
+ },
857
+ DELETE: {
858
+ description: "删除数据:dataTriggerType:\"DELETE\",仅 filterCondition(sheetConditions 删子表行),dataActions 必须为空。",
859
+ template: {
860
+ nodeCode: "Delete_1", nodeName: "数据删除", nodeType: "DELETE", dataTriggerType: "DELETE", targetObjectCode: "<target schemaCode>", inputParam: "_input",
861
+ filterCondition: { mainConditions: [[{ targetSchemaDataItem: "<field>", ruleConditionType: "EQ", ruleValueType: "FIXED", value: "<literal>" }]], sheetConditions: [] },
862
+ dataActions: [],
863
+ },
864
+ },
865
+ ASSIGN: {
866
+ description: "数据赋值(真机 2026-09-28 复核:可发布、可运行)。**`dataCondition` = 执行门**(输入数据满足条件才执行该节点,\"输入数据满足条件时触发执行动作\");**`filterCondition` = 按条件在输入数据源中匹配要取值的行**(\"根据此条件在输入数据源中进行匹配。匹配到多条数据时,会按第一条数据执行赋值\")——**两者是两码事,别混**:要\"满足条件才赋值\"用 `dataCondition`,不是 `filterCondition`(后者不拦执行)。`triggerType` ∈ CURRENT(当前数据)/RULE_VARIABLE(规则变量);CURRENT 时 `targetObjectCode` 默认 `\"_input\"`(=当前/输入对象),dataActions 写目标字段;RULE_VARIABLE 时 targetObjectCode 隐藏、dataActions 写 `_var_*`、`orderBys` 有默认 `[{code:\"createdTime\",direction:\"ASC\"}]`。**CURRENT+dataActions 必须排在系统持久化节点(SYSTEM_CREATE/SYSTEM_UPDATE)之前**(Start→ASSIGN→SYSTEM_CREATE→End),否则赋值合并进 _input 晚于持久化、读回无变化(工具 fail closed `rule-graph-assign-persistence-order`;工具注入系统节点时也会自动排到 ASSIGN/BIZ_ACTION 之后)。平台设计器键(SHEET_ASSIGN)required:nodeCode/nodeName/targetObjectCode/ruleVariableDataActions/dataActions/orderBys/addChildDataActions;dataCondition 默认 `{conditionGroup:[]}`、filterCondition 默认 `{mainConditions:[],sheetConditions:[]}`、nonMatchingAdded 默认 false、children/addChildDataActions 默认 []。",
867
+ template: {
868
+ nodeCode: "Assign_1", nodeName: "数据赋值", nodeType: "ASSIGN", nodeCategory: "actionNode", triggerType: "CURRENT", inputParam: "_input",
869
+ dataCondition: { conditionGroup: [] }, dataConditionVisible: false,
870
+ ruleVariableDataActions: [], orderBys: [], targetObjectCode: "_input",
871
+ filterCondition: { mainConditions: [], sheetConditions: [] },
872
+ dataActions: [{ targetDataItem: "<当前模型字段 code>", ruleValueType: "FIXED", value: "<字面量>", ruleActionType: "EQUALS" }],
873
+ children: [], nonMatchingAdded: false, addChildDataActions: [],
874
+ },
875
+ },
876
+ MESSAGE: {
877
+ description: "消息通知。**receiver 是 MessageReceiver(8.6.26 MessageNode.java),键是 users/departments/roles/selections**(不是 receiverType/receiverValue——那是旧模板的错误形状,平台无此字段):users/departments 放组织选择对象 `[{id,type,unitType,name}]`,**`type`/`unitType` 必须是 UnitType 的整数索引(不是枚举名字符串)**——`Selection$UnitTypeCodec` 用 `Integer.parseInt` 反序列化,传 `\"USER\"` 会 NumberFormatException→50000(2026-09-30 R5 实测);**用户=3、部门=1**。roles 放角色码,selections 放选择码。站内消息在 `stationSetting`:`linkType`(INTERNAL_LINKS/FORM_LINKS/EXTERNAL_LINKS)、`title`/`content` 是 `MessageDataItem[]`,**元素键是 `{type, code}`——`type` 是变量类型整数(0=固定文案 STRING、1=字段引用 DATA_ITEM;前端 formula/variable 编辑器 77877:STRING=0/DATA_ITEM=1/VARIABLE=2),`code` 是字段 code 或文案**,另可带 `sheetCode`/`url`。**别用 `isDataItem` 作键**(真机 2026-10-08:agent 写 `{code,isDataItem}`,设计器「站内消息通知」点开**渲染空白**——`parseStringToNode` 按 `type` 查字段变量、查不到即丢弃该 token;后端 `MessageSetting.toJson` 反写的 `isDataItem` 只是运行时字符串形态,不是节点对象的键)。**必须带 `stationSettingVisible:true`**(MessageNode.getStationSettingVisible:顶层 title/content 均非空才默认 true,否则 null=FALSE→`StationDispatcherImpl.distributeMessage` 直接跳过、站内信**静默不发**;真机 2026-10-08:节点跑成功但 h_im_message_station 0 行)。`messageChannel` 可选(如 [\"station\"])。可用 dataCondition 作节点执行门。**静态运行支持已证(ActivityBehaviorFactory→MessageActivityBehavior→MessageDispatcherImpl),但端到端送达未验证**——写后须真实触发并读回收件箱,别只凭 save 成功断言已通知。",
878
+ template: {
879
+ nodeCode: "Message_1", nodeName: "消息", nodeType: "MESSAGE", inputParam: "_input",
880
+ receiver: { users: [{ id: "<user id>", type: 3, unitType: 3, name: "<user name>" }], departments: [], roles: [], selections: [] },
881
+ messageChannel: ["station"],
882
+ stationSettingVisible: true,
883
+ stationSetting: {
884
+ linkType: "INTERNAL_LINKS",
885
+ title: [{ type: 0, code: "业务规则通知" }],
886
+ content: [{ type: 1, code: "<field code>" }, { type: 0, code: "<固定文案>" }],
887
+ sheetCode: "<schemaCode>",
888
+ },
889
+ },
890
+ },
891
+ BIZ_ACTION: {
892
+ description: "BizMethod 绑定(二开动作路径):`execConditionGroups` = **满足条件时执行业务方法**(条件组与 filterCondition 同构:[[{targetSchemaDataItem,ruleConditionType,ruleValueType,...}]])。serviceType:\"BIZ_METHOD\" + methodMapping{schemaCode,serviceCode,serviceName?,serviceMethodCode,serviceMethodName?,methodCode?,inputMappings[],outputMappings[]};serviceCode+serviceMethodCode 指向集成服务方法(存在性校验用这对),methodCode 是模型级 BizMethod 注册槽。映射元素 = 8.6.26 MappingModel{propertyName,bizCode,bizPropertyType?,externParameterType?,codeType,serviceMethodParameterCode,subParameters?,expressionValue?},codeType 按 int 序列化:0=CONST 常量 / 1=PROPERTY 字段(须带 bizCode;quote 只消费 PROPERTY 的 bizCode+propertyName)/ 2-7=LIST/OBJECT/RULE_VARIABLE/MAIN_TABLE/SYSTEM/EXPRESSION。**常量值放 `bizCode`**:codeType=0(CONST) 时平台 `MapOps` 取 `mapping.getBizCode()`、设计器写 `componentValue=bizCode`——**不要用 value/expressionValue**(平台对 CONST 不读它们,运行时静默变字面 null;expressionValue 仅 codeType=7 EXPRESSION 用)。映射元素只校验语义键,其余键原样透传(工具不按版本增删);`required` 以方法参数面为权威自动镜像(面缺席则映射保持缺席)。**codeType=1 的 `bizCode` 必须是当前模型的源字段 code(不是 schemaCode!)**。**outputMappings 落库(真机 2026-10-06)**:方法出参经 SYSTEM_UPDATE 回写时,映射元素的 `serviceMethodParameterCode` 必须**同时**命中①集成服务方法已声明的出参 code、②扩展接口响应**根级**键;`bizCode` 是目标字段 code。三者任一不符平台以 550018「业务模型字段不存在」类错误拒绝(真机:把响应根级键 `cumOutput` 写成别的键、或目标字段 code 与声明出参不一致都被拒)。注意 Controller 若用 `getOkResponseResult` 返回 `ResponseResult{errcode,data}` 信封,出参被包在 `data` 里、根级无业务键 ⇒ **静默不回写**;要走出参映射回写,接口需根级平铺业务键,或改用「服务内部自回写」由 Java 直接落库(后者不依赖 outputMappings)。**另观察到(真机两次复现,2026-10-06)**:目标字段 code 含 `output`(如 `big_cum_output`、`big_total_output`)作规则写入目标时被平台以 550018 拒,改名去掉 `output`(如 `big_prod_sum`)后通过——语义未明;遇 550018 且目标字段 code 含 `output` 时,优先改名排查(源码字段 code 与目标字段 code 都试)。平台设计器产出的范例形状(真机核验,供对齐;缺 serviceName/serviceMethodName/methodCode/bizCodeName+bizPropertyType 时设计器「绑定业务方法」弹窗可能显示空映射):node 级带 nodeName:\"业务方法\"、nodeCategory:\"actionNode\"、serviceType:\"BIZ_METHOD\"、invoiceBusiness:\"\"、invoiceSetting:null;映射元素带 index/propertyName(字段类型名)/bizPropertyType/displayName/required/bizCodeName(源字段名)。证据 8.6.26 BizActionNode.java:19 + BizMethodMappingModel.java + MappingModel.java:19-34 + CodeType.java:23-31 + MapOps.java(getBizMethodValue)。",
893
+ template: {
894
+ nodeCode: "BizAction_1", nodeName: "业务方法", nodeType: "BIZ_ACTION", nodeCategory: "actionNode", inputParam: "_input",
895
+ serviceType: "BIZ_METHOD", invoiceBusiness: "", invoiceSetting: null,
896
+ methodMapping: {
897
+ schemaCode: "<schemaCode>", serviceCode: "<serviceCode>", serviceName: "<serviceName>",
898
+ serviceMethodCode: "<serviceMethodCode>", serviceMethodName: "<serviceMethodName>", methodCode: "<methodCode>",
899
+ inputMappings: [{ index: 1, propertyName: "<字段类型名>", bizCode: "<源字段code>", bizPropertyType: 0, codeType: 1, serviceMethodParameterCode: "<param code>", displayName: "<显示名>", required: true, bizCodeName: "<源字段名>" }],
900
+ outputMappings: [],
901
+ },
902
+ },
903
+ },
904
+ CUSTOM_NODE: {
905
+ description: "自定义按钮触发:nodeType CUSTOM_NODE + isSystemNode:true(nodeCategory systemNode);由页面按钮而非数据事件触发。",
906
+ template: { nodeCode: "Button_1", nodeName: "自定义节点", nodeType: "CUSTOM_NODE", isSystemNode: true, nodeCategory: "systemNode" },
907
+ },
908
+ GET_LIST: {
909
+ description: "获取集合(平台预置 GetList 规则的数据源节点):dataSourceType:\"GET_LIST\" 走原生列表查询,绑已发布 BizMethod 作自定义数据源时带 methodMapping(DataSourceType.BIZ_SERVICE);isSystemNode:true。证据 8.6.26 GetListNode.java:14-59 + BizObjectRuleUtils.isOperateDbForQuery:84-96。",
910
+ template: { nodeCode: "GetList", nodeName: "获取集合数据", nodeType: "GET_LIST", dataSourceType: "GET_LIST", isSystemNode: true },
911
+ },
912
+ LOAD: {
913
+ description: "加载表单详情(表单/流程系统节点):仅 isSystemNode:true + nodeCategory:\"systemNode\";平台预置 Load 规则默认图 Start→End,节点按需加入。证据 LoadNode.java:9-35。",
914
+ template: { nodeCode: "Load", nodeName: "加载表单详情", nodeType: "LOAD", isSystemNode: true, nodeCategory: "systemNode", originHeight: 68, edit: true },
915
+ },
916
+ AVAILABLE: {
917
+ description: "流程生效(流程事件系统节点):仅 isSystemNode:true + nodeCategory:\"systemNode\"。证据 FlowAvailableNode.java:9-35 + fwb 53 条。",
918
+ template: { nodeCode: "Available", nodeName: "流程生效时", nodeType: "AVAILABLE", isSystemNode: true, nodeCategory: "systemNode", originHeight: 68, edit: true },
919
+ },
920
+ CANCEL: {
921
+ description: "流程作废(流程事件系统节点):仅 isSystemNode:true + nodeCategory:\"systemNode\"。证据 CancelNode.java:9-35 + fwb 9 条。",
922
+ template: { nodeCode: "Cancel", nodeName: "流程作废时", nodeType: "CANCEL", isSystemNode: true, nodeCategory: "systemNode", originHeight: 68, edit: true },
923
+ },
924
+ SCHEDULER: {
925
+ description: "定时作业(规则级 bizRuleType=2):节点仅 isSystemNode:true;调度参数不在节点上,必须在 nodeGraph.schedulerSetting 传规则级形状。**关键:`dataSourceType` 决定「规则触发数据源」**(SchedulerSetting.dataSourceType + filterConditionGroups,6.26)——`GET_LIST`=**以本模型为数据源**(即「扫描本表」;`filterConditionGroups` 是扫描过滤条件,JSON 二维数组,与 filterCondition 同构),输入数据即被过滤出的行,下游直接以 `inputParam:\"_input\"` 消费其字段;`BIZ_SERVICE`=以业务方法返回集合为源(配 methodMapping);`NONE`=无触发数据(下游需自建 QUERY 节点取数)。**「定时扫描某表按条件动作」用 `GET_LIST` + filterConditionGroups**(不是 NONE + QUERY);`NONE` 时整图无输入数据集,下游 MESSAGE 的字段变量解析不到、设计器映射框选不出字段。证据 ScheduleNode.java:9-33 + SchedulerSetting.java:27-52。",
926
+ template: { nodeCode: "Scheduler_1", nodeName: "定时触发", nodeType: "SCHEDULER", isSystemNode: true, nodeCategory: "systemNode", latestVersion: true, edit: true },
927
+ },
928
+ EXCEPTION: {
929
+ description: "异常中止:errMsg 必填(异常文案,@JSONField jsonDirect 字符串);可选 dataCondition 执行门与 inputParam:\"_input\"。证据 ExceptionNode.java:13-46 + fwb 2 条(desensitized-config-shapes.md §1.2)。",
930
+ template: { nodeCode: "ExceptionAbort_1", nodeName: "异常终止", nodeType: "EXCEPTION", nodeCategory: "logicNode", errMsg: "<异常中止提示文案>", dataConditionVisible: false, disabled: false, inputParam: "_input" },
931
+ },
932
+ HINT: {
933
+ description: "提示信息:content 必填,且必须是 **JSON 数组**的字符串(HintNode.content @JSONField(jsonDirect) 按数组反序列化;裸串/对象/JSON 字符串在 businessrule/publish 稳定 50000,2026-09-20 实测)。数组元素 [{type:0=固定文案|1=字段code, value}],最小 [] 亦可。可选 promptType。",
934
+ template: { nodeCode: "Hint_1", nodeName: "提示信息", nodeType: "HINT", content: '[{"type":0,"value":"<提示文案>"}]', promptType: "", inputParam: "_input" },
935
+ },
936
+ });
937
+ // ---------------------------------------------------------------------------
938
+ // Parsing (nodes/routes may arrive as objects or pre-serialized JSON strings)
939
+ // ---------------------------------------------------------------------------
940
+ export function parseMaybeJson(value) {
941
+ // nodes/routes/schedulerSetting may arrive as objects or pre-serialized JSON
942
+ // strings; policy: non-strings null-coalesce, unparseable strings pass through.
943
+ return decodeJsonColumn(value, JSON_COLUMN_NULL_COALESCE);
944
+ }
945
+ // ---------------------------------------------------------------------------
946
+ // Leaf validators — one definition, used by filterCondition/sheetConditions/
947
+ // findMainData/targetConditionGroups/exitConditionGroups and shared with fake
948
+ // ---------------------------------------------------------------------------
949
+ function isLeaf(value) {
950
+ return Boolean(value && typeof value === "object" && !Array.isArray(value) && ("ruleValueType" in value || "targetSchemaDataItem" in value || "ruleConditionType" in value || BRANCH_EXIT_OPERATOR_KEY in value || "currentSchemaDataItem" in value));
951
+ }
952
+ /** Walk every condition leaf in the evidence-nested group shapes ([[..]]/[[[..]]]). */
953
+ export function walkConditionLeaves(container, visit, where = "conditions") {
954
+ if (Array.isArray(container)) {
955
+ for (const entry of container)
956
+ walkConditionLeaves(entry, visit, where);
957
+ return;
958
+ }
959
+ if (container && typeof container === "object") {
960
+ const record = container;
961
+ if (isLeaf(record)) {
962
+ visit(record, where);
963
+ return;
964
+ }
965
+ // Condition-group containers evidenced across the 8.6.26 node families
966
+ // (HANDOFF C.2 / evidence/form-conditions-and-rules-truth.md §4):
967
+ // filterCondition (UPDATE/DELETE 查找/删除满足条件的主表数据)
968
+ // findMainData / targetConditionGroups / exitConditionGroups /
969
+ // sourceConditionGroups / execConditionGroups / dataCondition.
970
+ // Walking them here means every node family gets leaf validation with the
971
+ // same wire contract (value types + DYNAMIC key placement).
972
+ for (const key of [
973
+ "mainConditions",
974
+ "sheetConditions",
975
+ "conditionGroup",
976
+ "findMainData",
977
+ "targetConditionGroups",
978
+ "exitConditionGroups",
979
+ "sourceConditionGroups",
980
+ "execConditionGroups",
981
+ "dataCondition",
982
+ ]) {
983
+ if (record[key] !== undefined)
984
+ walkConditionLeaves(record[key], visit, `${where}.${key}`);
985
+ }
986
+ }
987
+ }
988
+ /** Count condition leaves nested anywhere in a group container (0 = empty group). */
989
+ export function countConditionLeaves(container) {
990
+ let count = 0;
991
+ walkConditionLeaves(container, () => { count += 1; });
992
+ return count;
993
+ }
994
+ /** Validate one condition leaf; returns blockers (empty = clean). */
995
+ export function validateConditionLeaf(leaf, nodeCode, where) {
996
+ const blockers = [];
997
+ const valueType = String(leaf.ruleValueType ?? "");
998
+ if (!RULE_CONDITION_VALUE_TYPES.includes(valueType))
999
+ blockers.push({ code: "rule-graph-condition-value-type", nodeCode, key: "ruleValueType", message: `${where}: ruleValueType "${valueType}" 不在 ${RULE_CONDITION_VALUE_TYPES.join("/")}(当前叶:${JSON.stringify(leaf)})` });
1000
+ // Condition-leaf "VARIABLE" is a runtime NPE: save+publish pass, but the engine
1001
+ // deserializes the leaf into DataFilterCondition, reads the source as a
1002
+ // SingletonDataset with no object, and NPEs at
1003
+ // SingletonDataset.getBizObjectProperty → RelationParameter.getBizObjectProperty
1004
+ // (live 2026-10-08: BRANCH exit leaf {currentSchemaDataItem:"defect_qty",
1005
+ // ruleDataConditionType:"GT", ruleValueType:"VARIABLE",
1006
+ // value:"SearchData_1.threshold_value"} → data.submit 50000). To consume a
1007
+ // QUERY result, set the downstream node's inputParam to "_<QUERYnodeCode>_output"
1008
+ // and compare FIXED/DYNAMIC on that dataset — or fold the comparison into the
1009
+ // QUERY's own targetConditionGroups. Fail closed before the wire.
1010
+ if (valueType === "VARIABLE")
1011
+ blockers.push({ code: "rule-graph-condition-variable-unsupported", nodeCode, key: "ruleValueType", message: `${where}: 条件叶不支持 ruleValueType "VARIABLE"——save/publish 会过,但引擎运行时 NPE(SingletonDataset.getBizObjectProperty)令 data.submit 50000(真机 2026-10-08)。要按"查询到的值"分支:把比较直接写进 QUERY 的 targetConditionGroups,或把下游节点 inputParam 设为 "_<QUERYnodeCode>_output" 后在此叶用 FIXED/DYNAMIC 比较该数据集字段。` });
1012
+ // `SYSTEM` is NOT a condition value type. The frontend condition editor's
1013
+ // value enum is {fixedValue, dynamicValue, today, yesterday, tomorrow,
1014
+ // specifyDate, specifyRange, dynamicRange, VARIABLE, EXPRESSION, EMPTY,
1015
+ // SELF_P, SELF_D, ...} (admin 12404/19497); `SYSTEM` belongs to CREATE/UPDATE
1016
+ // dataActions' TARGET system value. A leaf like
1017
+ // {targetSchemaDataItem:"pay_apply_date", ruleConditionType:"LT",
1018
+ // ruleValueType:"SYSTEM", value:""} (live 2026-10-08 SCM sandbox) saves and
1019
+ // renders in the designer as 「申请日期 小于 (empty)」 — no right-hand value.
1020
+ if (valueType === "SYSTEM")
1021
+ blockers.push({ code: "rule-graph-condition-system-empty", nodeCode, key: "ruleValueType", message: `${where}: 条件叶不能用 ruleValueType "SYSTEM"(SYSTEM 是 CREATE/UPDATE 目标字段的系统值类型,不是条件值;写它设计器渲成「字段 小于 空值」—真机 2026-10-08)。日期比较要用**动态日期值**:ruleValueType "today" + currentSchemaDataItem:"<offset>"(如 "0"=今天、"-7"=7天前、"30"=30天后,可带单位 "-1-week");"yesterday"/"tomorrow"(无需 source);固定日期用 FIXED + "yyyy-MM-dd HH:mm:ss"。` });
1022
+ // `today` reads its day-offset from `currentSchemaDataItem` (Condition.source,
1023
+ // DateFieldFilter.create: Integer.parseInt(source) — null/non-numeric throws
1024
+ // NumberFormatException at query time, the scheduled scan then matches 0 rows
1025
+ // and the rule "never fires" (live 2026-10-08: a today leaf without source
1026
+ // silently killed the GET_LIST scan). Fail closed on the offset shape.
1027
+ if (valueType === "today") {
1028
+ const offset = leaf[DYNAMIC_CONDITION_SOURCE_KEY];
1029
+ if (typeof offset !== "string" || !/^-?\d+(-(hour|day|week|month))?$/.test(offset))
1030
+ blockers.push({ code: "rule-graph-condition-today-offset-missing", nodeCode, key: DYNAMIC_CONDITION_SOURCE_KEY, message: `${where}: ruleValueType "today" 的日期偏移必须放在 ${DYNAMIC_CONDITION_SOURCE_KEY}(运行时 Integer.parseInt(source))——如 "0"=今天、"-7"=7天前、"30"=30天后、"-1-week"/"2-month" 带单位;缺失或非数字会在查询时抛 NumberFormatException,扫描 0 行、规则「永不触发」(真机 2026-10-08)。` });
1031
+ }
1032
+ if (leaf.ruleConditionType !== undefined && !RULE_CONDITION_TYPES.includes(String(leaf.ruleConditionType)))
1033
+ blockers.push({ code: "rule-graph-condition-type", nodeCode, key: "ruleConditionType", message: `${where}: ruleConditionType "${String(leaf.ruleConditionType)}}" 不在 ${RULE_CONDITION_TYPES.join("/")}` });
1034
+ if (leaf[BRANCH_EXIT_OPERATOR_KEY] !== undefined && !RULE_CONDITION_TYPES.includes(String(leaf[BRANCH_EXIT_OPERATOR_KEY])))
1035
+ blockers.push({ code: "rule-graph-condition-type", nodeCode, key: BRANCH_EXIT_OPERATOR_KEY, message: `${where}: ${BRANCH_EXIT_OPERATOR_KEY} "${String(leaf[BRANCH_EXIT_OPERATOR_KEY])}" 不在 ${RULE_CONDITION_TYPES.join("/")}` });
1036
+ if (valueType === "DYNAMIC") {
1037
+ // Ticket 09 病灶回归保护:过滤来源必须在 currentSchemaDataItem,value 不得承载来源字段。
1038
+ if (typeof leaf[DYNAMIC_CONDITION_SOURCE_KEY] !== "string" || !leaf[DYNAMIC_CONDITION_SOURCE_KEY].trim())
1039
+ blockers.push({ code: "rule-graph-dynamic-condition-source-missing", nodeCode, key: DYNAMIC_CONDITION_SOURCE_KEY, message: `${where}: DYNAMIC 过滤叶必须带 ${DYNAMIC_CONDITION_SOURCE_KEY}(触发模型来源字段 code);来源字段放 value 是 ticket 09 病灶(引擎按字面量匹配,静默空转)` });
1040
+ if (leaf.value !== undefined && leaf.value !== "")
1041
+ blockers.push({ code: "rule-graph-dynamic-condition-value-present", nodeCode, key: "value", message: `${where}: DYNAMIC 过滤叶不得携带 value(只允许 ${DYNAMIC_CONDITION_SOURCE_KEY})` });
1042
+ }
1043
+ return blockers;
1044
+ }
1045
+ /** Validate one dataAction; returns blockers (empty = clean). */
1046
+ export function validateDataAction(action, nodeCode, index) {
1047
+ const blockers = [];
1048
+ const where = `dataActions[${index}]`;
1049
+ const valueType = String(action.ruleValueType ?? "");
1050
+ if (!RULE_VALUE_TYPES.includes(valueType))
1051
+ blockers.push({ code: "rule-graph-action-value-type", nodeCode, key: "ruleValueType", message: `${where}: ruleValueType "${valueType}" 不在 ${RULE_VALUE_TYPES.join("/")}` });
1052
+ if (typeof action.targetDataItem !== "string" || !action.targetDataItem.trim())
1053
+ blockers.push({ code: "rule-graph-action-target-missing", nodeCode, key: "targetDataItem", message: `${where}: 缺 targetDataItem(目标字段 code)` });
1054
+ if (valueType === "DYNAMIC") {
1055
+ if (typeof action[DYNAMIC_ACTION_SOURCE_KEY] !== "string" || !action[DYNAMIC_ACTION_SOURCE_KEY].trim())
1056
+ blockers.push({ code: "rule-graph-dynamic-action-source-missing", nodeCode, key: DYNAMIC_ACTION_SOURCE_KEY, message: `${where}: DYNAMIC 赋值必须带 ${DYNAMIC_ACTION_SOURCE_KEY}(来源字段 code);来源字段放 value 是 ticket 09 病灶` });
1057
+ if (action.value !== undefined && action.value !== "")
1058
+ blockers.push({ code: "rule-graph-dynamic-action-value-present", nodeCode, key: "value", message: `${where}: DYNAMIC 赋值的 value 必须留空串(来源走 ${DYNAMIC_ACTION_SOURCE_KEY})` });
1059
+ }
1060
+ const expressionValue = action.value && typeof action.value === "object" && !Array.isArray(action.value) ? action.value : undefined;
1061
+ if (valueType === "EXPRESSION" && !(typeof expressionValue?.formula === "string" && expressionValue.formula.trim().length > 0))
1062
+ blockers.push({ code: "rule-graph-expression-value-shape", nodeCode, key: "value", message: `${where}: EXPRESSION 值必须是带非空 formula 的 {formula,...} 对象;字段引用语法是 {字段code}(如 {"formula":"{amount}-{cost}"});此门不复用 FORMULA(100) 解析器` });
1063
+ if (valueType === "FIXED" && action.value !== undefined && action.value !== null && typeof action.value === "object")
1064
+ blockers.push({ code: "rule-graph-fixed-value-type", nodeCode, key: "value", message: `${where}: FIXED 值必须是分型标量——下拉=JSON 数组串、数值/布尔=原生、文本=裸字符串(生产形状 §1),对象不合法` });
1065
+ return blockers;
1066
+ }
1067
+ // ---------------------------------------------------------------------------
1068
+ // Graph validator — Start→End connectivity, no dangling nodes/edges,
1069
+ // inputParam chaining, nodeType↔dataTriggerType consistency, per-node shapes
1070
+ // ---------------------------------------------------------------------------
1071
+ /**
1072
+ * Validate the rule-level `schedulerSetting` (object or pre-serialized JSON
1073
+ * string) for a SCHEDULER rule. Shape-only: the DTO defaults most values, so a
1074
+ * partial object is legal; we only pin the enums we can prove and the
1075
+ * DATE_FIELD→triggerDataItem invariant (SchedulerSetting.java:60-64,114).
1076
+ */
1077
+ /**
1078
+ * r4-fin 2026-10-09 — masked/placeholder value guard. Exact-match patterns
1079
+ * only (whole trimmed string), so partial business text is never flagged:
1080
+ * a masked corpId (RELEVANCE-<all zeros>), an all-zero id (designer/readback
1081
+ * desensitised display), a whole-value `<...>` recipe placeholder, or an
1082
+ * unresolved `${var}` template variable. All four arrive verbatim on the
1083
+ * wire because neither the server rule API nor anything else validates
1084
+ * values — the designer's interception only guards its own form.
1085
+ */
1086
+ const MASKED_CORP_RE = /^RELEVANCE-0{6,}$/i;
1087
+ const ALL_ZERO_ID_RE = /^0{16,}$/;
1088
+ const WHOLE_PLACEHOLDER_RE = /^<[^<>]{1,80}>$/;
1089
+ const TEMPLATE_VAR_RE = /\$\{[A-Za-z_][A-Za-z0-9_.-]*\}/;
1090
+ function scanMaskedOrPlaceholderValues(value, path, nodeCode, blockers, seen) {
1091
+ if (typeof value === "string") {
1092
+ const t = value.trim();
1093
+ let why = "";
1094
+ if (MASKED_CORP_RE.test(t))
1095
+ why = "掩码 corpId(RELEVANCE- 后全 0——页面回显/示例脱敏值)";
1096
+ else if (ALL_ZERO_ID_RE.test(t))
1097
+ why = "全 0 id(读回/列表回显的脱敏值,非真实 id)";
1098
+ else if (WHOLE_PLACEHOLDER_RE.test(t))
1099
+ why = "整值 <…> 占位符(未替换的配方/模板参数)";
1100
+ else if (TEMPLATE_VAR_RE.test(t))
1101
+ why = "未替换的 ${var} 模板变量";
1102
+ if (why)
1103
+ blockers.push({
1104
+ code: "rule-graph-masked-or-placeholder-value",
1105
+ nodeCode,
1106
+ key: path,
1107
+ message: `${path}=${JSON.stringify(t)} 是${why}——服务端规则 API 不校验值、前端拦截只管设计器表单,直写会把这种值原样发布并在运行时空转/50000(r4-fin 真机:BIZ_ACTION 常量映射带全 0 corpId 发布成功)。替换为真实值后再写。`,
1108
+ });
1109
+ return;
1110
+ }
1111
+ if (Array.isArray(value)) {
1112
+ value.forEach((v, i) => scanMaskedOrPlaceholderValues(v, `${path}[${i}]`, nodeCode, blockers, seen));
1113
+ return;
1114
+ }
1115
+ if (value && typeof value === "object") {
1116
+ const obj = value;
1117
+ if (seen.has(obj))
1118
+ return;
1119
+ seen.add(obj);
1120
+ for (const [k, v] of Object.entries(value))
1121
+ scanMaskedOrPlaceholderValues(v, `${path}.${k}`, nodeCode, blockers, seen);
1122
+ }
1123
+ }
1124
+ export function validateSchedulerSetting(value) {
1125
+ const blockers = [];
1126
+ const parsed = parseMaybeJson(value);
1127
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1128
+ return [{ code: "rule-graph-scheduler-setting-shape", key: "schedulerSetting", message: "schedulerSetting 必须是对象或 JSON 字符串(SchedulerSetting.java;BusinessRuleModel 该列是字符串)" }];
1129
+ }
1130
+ const settings = parsed;
1131
+ const enumChecks = [
1132
+ ["triggerTimeType", RULE_SCHEDULER_TRIGGER_TIME_TYPES],
1133
+ ["executeType", RULE_SCHEDULER_EXECUTE_TYPES],
1134
+ ["cycleType", RULE_SCHEDULER_CYCLE_TYPES],
1135
+ ["endType", RULE_SCHEDULER_END_TYPES],
1136
+ ["dataSourceType", RULE_DATA_SOURCE_TYPES],
1137
+ ];
1138
+ for (const [key, allowed] of enumChecks) {
1139
+ if (settings[key] !== undefined && !allowed.includes(String(settings[key])))
1140
+ blockers.push({ code: "rule-graph-scheduler-setting-enum", key: `schedulerSetting.${key}`, message: `schedulerSetting.${key} "${String(settings[key])}" 不在 ${allowed.join("/")}(8.6.26 枚举)` });
1141
+ }
1142
+ if (settings.triggerTime !== undefined && !Array.isArray(settings.triggerTime))
1143
+ blockers.push({ code: "rule-graph-scheduler-setting-trigger-time", key: "schedulerSetting.triggerTime", message: "schedulerSetting.triggerTime 必须是数组(TriggerTime[])" });
1144
+ if (String(settings.triggerTimeType ?? "") === "DATE_FIELD" && (typeof settings.triggerDataItem !== "string" || !settings.triggerDataItem.trim()))
1145
+ blockers.push({ code: "rule-graph-scheduler-setting-trigger-data-item", key: "schedulerSetting.triggerDataItem", message: "triggerTimeType=DATE_FIELD 必须带 triggerDataItem(触发日期字段 code)" });
1146
+ if (settings.filterConditionGroups !== undefined && !Array.isArray(settings.filterConditionGroups))
1147
+ blockers.push({ code: "rule-graph-scheduler-setting-filter-groups", key: "schedulerSetting.filterConditionGroups", message: "schedulerSetting.filterConditionGroups 必须是二维数组(TableCondition[][])" });
1148
+ return blockers;
1149
+ }
1150
+ /**
1151
+ * Validate a BIZ_ACTION / GET_LIST(BIZ_SERVICE) `methodMapping` against the
1152
+ * 8.6.26 DTO (BizMethodMappingModel + MappingModel). Shape-only: the wire
1153
+ * existence check (service+method must resolve) lives in rule-support, which
1154
+ * runs after this lint on the same nodes.
1155
+ */
1156
+ export function validateMethodMapping(mapping, nodeCode) {
1157
+ const blockers = [];
1158
+ if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) {
1159
+ return [{ code: "rule-graph-bizaction-mapping", nodeCode, key: "methodMapping", message: "BIZ_ACTION 节点缺 methodMapping{schemaCode,serviceCode,serviceMethodCode,methodCode?,inputMappings[],outputMappings[]}" }];
1160
+ }
1161
+ const m = mapping;
1162
+ for (const key of ["schemaCode", "serviceCode", "serviceMethodCode"]) {
1163
+ if (typeof m[key] !== "string" || !m[key].trim())
1164
+ blockers.push({ code: "rule-graph-bizaction-mapping-key", nodeCode, key: `methodMapping.${key}`, message: `methodMapping.${key} 必须是非空字符串(8.6.26 BizMethodMappingModel;serviceCode+serviceMethodCode 是存在性校验对)` });
1165
+ }
1166
+ const mappingArrays = [["inputMappings", m.inputMappings], ["outputMappings", m.outputMappings]];
1167
+ for (const [listKey, list] of mappingArrays) {
1168
+ if (list === undefined)
1169
+ continue;
1170
+ if (!Array.isArray(list)) {
1171
+ blockers.push({ code: "rule-graph-bizaction-list", nodeCode, key: `methodMapping.${listKey}`, message: `methodMapping.${listKey} 必须是数组(MappingModel[])` });
1172
+ continue;
1173
+ }
1174
+ list.forEach((element, index) => {
1175
+ if (!element || typeof element !== "object" || Array.isArray(element)) {
1176
+ blockers.push({ code: "rule-graph-bizaction-mapping-element", nodeCode, key: `methodMapping.${listKey}[${index}]`, message: `${listKey}[${index}] 必须是对象(8.6.26 MappingModel)` });
1177
+ return;
1178
+ }
1179
+ const row = element;
1180
+ // Ticket 96: only the SEMANTIC keys are policed (codeType /
1181
+ // serviceMethodParameterCode / bizCode below). Every other key is
1182
+ // non-semantic passthrough — forwarded verbatim, never added, dropped,
1183
+ // or reordered by the tool regardless of platform version.
1184
+ if (typeof row.codeType !== "number" || !Number.isInteger(row.codeType) || row.codeType < 0 || row.codeType > RULE_CODE_TYPES.length - 1)
1185
+ blockers.push({ code: "rule-graph-bizaction-mapping-codetype", nodeCode, key: `methodMapping.${listKey}[${index}].codeType`, message: `${listKey}[${index}].codeType 必须是 0-${RULE_CODE_TYPES.length - 1} 的整数(CodeType int 序列化:${RULE_CODE_TYPES.map((t, i) => `${i}=${t}`).join("/")})` });
1186
+ if (typeof row.serviceMethodParameterCode !== "string" || !row.serviceMethodParameterCode.trim())
1187
+ blockers.push({ code: "rule-graph-bizaction-mapping-param", nodeCode, key: `methodMapping.${listKey}[${index}].serviceMethodParameterCode`, message: `${listKey}[${index}] 缺 serviceMethodParameterCode(集成方法参数 code;quote 以它对账方法参数表)` });
1188
+ if (row.codeType === 1 && (typeof row.bizCode !== "string" || !row.bizCode.trim()))
1189
+ blockers.push({ code: "rule-graph-bizaction-mapping-bizcode", nodeCode, key: `methodMapping.${listKey}[${index}].bizCode`, message: `${listKey}[${index}].codeType=1(PROPERTY) 必须带 bizCode(RuleBizActionQuoteImpl 只消费 PROPERTY 映射的 bizCode+propertyName)` });
1190
+ // CONST(0): the literal value must live in `bizCode` by the time it is
1191
+ // written (MapOps reads getBizCode for CONST; the designer writes
1192
+ // componentValue=bizCode). prepareRuleGraph moves a value found under
1193
+ // `value`/`expressionValue` into bizCode, so accept any of the three here
1194
+ // and only refuse when the element carries no value at all (which would
1195
+ // arrive as literal "null" at runtime).
1196
+ if (row.codeType === 0 && !(typeof row.bizCode === "string" && row.bizCode.trim()) && row.value === undefined && row.expressionValue === undefined)
1197
+ blockers.push({ code: "rule-graph-bizaction-mapping-const-value", nodeCode, key: `methodMapping.${listKey}[${index}].bizCode`, message: `${listKey}[${index}].codeType=0(CONST) 缺常量值——放 bizCode(或 value,工具会移入 bizCode);平台 CONST 只读 bizCode,缺值运行时会取到字面 null` });
1198
+ // Ticket 113: restore unknown-key POSITIONING without touching the v2
1199
+ // passthrough semantics — keys outside the evidenced union surface as
1200
+ // a warning (never gates the write), so typos like "codeTyp" are seen
1201
+ // instead of silently riding to a dead mapping at runtime.
1202
+ const unknownKeys = Object.keys(row).filter((key) => !MAPPING_ELEMENT_KNOWN_KEYS.has(key));
1203
+ if (unknownKeys.length > 0)
1204
+ blockers.push({
1205
+ code: "rule-graph-mapping-unknown-key",
1206
+ nodeCode,
1207
+ key: `methodMapping.${listKey}[${index}].${unknownKeys.join(",")}`,
1208
+ message: `${listKey}[${index}] 携带未知键 [${unknownKeys.join(", ")}]——不在 8.6.26 MappingModel 语义键(MappingModel.java:26-40)与已取证 v2 透传键之列;若是拼写错误请修正,平台会静默忽略未知键(透传语义不变,仅提示)`,
1209
+ severity: "warning",
1210
+ });
1211
+ });
1212
+ }
1213
+ return blockers;
1214
+ }
1215
+ /**
1216
+ * Ticket 113 — the evidenced mapping-element key union: 8.6.26 MappingModel
1217
+ * semantic/model fields (MappingModel.java:26-40: propertyName/bizCode/
1218
+ * bizPropertyType/externParameterType/codeType/serviceMethodParameterCode/
1219
+ * subParameters) plus the designer v2 passthrough keys evidenced riding real
1220
+ * payloads (index/required/displayName/bizCodeName — tool schema note,
1221
+ * ticket 96; serviceMethodParameterName — the method-param display name, a
1222
+ * natural parallel to bizCodeName that agents emit and the platform ignores;
1223
+ * bizCodeName — the source field display name the designer emits alongside
1224
+ * bizCode; value/expressionValue — accepted input aliases for a codeType:0
1225
+ * CONST literal that prepareRuleGraph moves into bizCode (the key the platform
1226
+ * actually reads for CONST); expressionValue also serves codeType:7 EXPRESSION.
1227
+ * Anything else reports as a warning-positioned unknown key.
1228
+ */
1229
+ const MAPPING_ELEMENT_KNOWN_KEYS = new Set([
1230
+ "codeType",
1231
+ "serviceMethodParameterCode",
1232
+ "serviceMethodParameterName",
1233
+ "bizCode",
1234
+ "propertyName",
1235
+ "bizPropertyType",
1236
+ "externParameterType",
1237
+ "subParameters",
1238
+ "index",
1239
+ "required",
1240
+ "displayName",
1241
+ "bizCodeName",
1242
+ "expressionValue",
1243
+ "value",
1244
+ ]);
1245
+ /**
1246
+ * Police the IDENTITY of a preset trigger rule's own system persistence node.
1247
+ *
1248
+ * A structurally valid graph can still carry a semantically wrong node: the
1249
+ * 2026-09-26 incident hand-wrote the Create rule's node as
1250
+ * `nodeCode:"SYSTEM_CREATE"` (the TYPE name) and omitted `nodeName`. Both passed
1251
+ * dry-run, the save overwrote the platform preset node, and the system
1252
+ * persistence node was lost. This is deliberately NOT a normalizer: silently
1253
+ * rewriting nodeCode would dangle the routes that reference the old code. It
1254
+ * fails closed, and only when the caller supplied a node of the preset's own
1255
+ * type — a missing system node is fine (prepareRuleGraph injects the correct
1256
+ * one). Returns [] for non-preset rule codes, which have no such contract.
1257
+ */
1258
+ export function validatePresetSystemNodeIdentity(nodesInput, ruleCode) {
1259
+ const spec = ruleCode ? DEFAULT_SYSTEM_NODE_BY_RULE_CODE[ruleCode] : undefined;
1260
+ if (!spec)
1261
+ return [];
1262
+ const nodes = asRecordArray(parseMaybeJson(nodesInput));
1263
+ const blockers = [];
1264
+ for (const node of nodes) {
1265
+ const type = String(node.nodeType ?? "");
1266
+ // Only the preset's OWN system type is policed here; a mismatched SYSTEM_*
1267
+ // type is reported separately by `rule-graph-preset-system-node-mismatch`.
1268
+ if (type !== spec.nodeType)
1269
+ continue;
1270
+ const nodeCode = String(node.nodeCode ?? "");
1271
+ if (nodeCode !== spec.nodeCode)
1272
+ blockers.push({
1273
+ code: "rule-graph-preset-system-node-code-mismatch",
1274
+ nodeCode,
1275
+ key: "nodes",
1276
+ message: `预置触发规则 ${ruleCode} 的系统节点 nodeCode 必须是平台预置编码 "${spec.nodeCode}",不能是 "${nodeCode}"(常见错误:把 nodeType 名当成 nodeCode)。用 \`omc call rule.get --env <env> --args '{"schemaCode":"<模型>","ruleCode":"${ruleCode}"}'\` 取真实基线,或删掉该系统节点让工具自动注入 "${spec.nodeCode}"(2026-09-26 真机事故:nodeCode 写错覆盖了预置节点、系统节点丢失)`,
1277
+ });
1278
+ if (!String(node.nodeName ?? "").trim())
1279
+ blockers.push({
1280
+ code: "rule-graph-preset-system-node-name-missing",
1281
+ nodeCode,
1282
+ key: "nodeName",
1283
+ message: `预置触发规则 ${ruleCode} 的系统节点(nodeType ${spec.nodeType})缺 nodeName(平台存原值、不补默认名,设计器会显示空白)。平台预置名是 "${spec.nodeName}";勿删 nodeName`,
1284
+ });
1285
+ }
1286
+ return blockers;
1287
+ }
1288
+ export function validateRuleNodeGraph(graph, ruleCode, schemaCode) {
1289
+ const blockers = [];
1290
+ const nodes = parseMaybeJson(graph.nodes ?? graph.node);
1291
+ const routes = parseMaybeJson(graph.routes ?? graph.route);
1292
+ if (!Array.isArray(nodes) || nodes.length === 0) {
1293
+ return [{ code: "rule-graph-empty", message: "nodeGraph.nodes 不能为空" }];
1294
+ }
1295
+ const nodeRecords = nodes.filter((node) => Boolean(node && typeof node === "object"));
1296
+ if (nodeRecords.length !== nodes.length)
1297
+ blockers.push({ code: "rule-graph-node-not-object", message: "nodes 中存在非对象元素" });
1298
+ // Cross-model write-back: SUPPORTED (verified live 2026-09-28). An UPDATE whose
1299
+ // `targetObjectCode` is a DIFFERENT model than the rule's own model writes back
1300
+ // correctly once TWO prerequisites hold — a 2026-09-27 probe that reported
1301
+ // "cross-model inert" had missed BOTH:
1302
+ // 1. The rule must actually FIRE. A bare `Update` rule fires only on a
1303
+ // data.update operation, NOT on create/submit; put the write-back node in
1304
+ // the model's `Create` rule (fires on submit) or in a rule bound to the
1305
+ // workflow event (e.g. the flow `end` handler).
1306
+ // 2. The target row must be COMMITTED. The target query does not match a
1307
+ // DRAFT row, so a write-back aimed at a row still in draft (`data.save`
1308
+ // without a submit) silently writes nothing — commit it first.
1309
+ // The graph shape (DYNAMIC filter `currentSchemaDataItem` → target `id`;
1310
+ // DYNAMIC action `currentDataItemValue`) is correct as-is. Surface the two
1311
+ // prerequisites as a non-gating note rather than the false "unverified" claim.
1312
+ if (schemaCode !== undefined && schemaCode.length > 0) {
1313
+ const writeTypes = new Set(["UPDATE", "CREATE", "DELETE"]);
1314
+ const crossModel = nodeRecords.filter((node) => {
1315
+ const type = String(node.nodeType ?? "");
1316
+ const target = String(node.targetObjectCode ?? "");
1317
+ return writeTypes.has(type) && target.length > 0 && target !== schemaCode;
1318
+ });
1319
+ if (crossModel.length > 0) {
1320
+ // A cross-model write-back sitting in a preset update-only trigger rule is
1321
+ // the classic silent no-op: it compiles + enables, but create/submit never
1322
+ // runs it. Flag THAT (the real failure) specifically.
1323
+ const updateOnlyTrigger = ruleCode !== undefined && DEFAULT_SYSTEM_NODE_BY_RULE_CODE[ruleCode]?.nodeType === "SYSTEM_UPDATE";
1324
+ blockers.push({
1325
+ code: updateOnlyTrigger ? "rule-graph-cross-model-update-trigger-inert" : "rule-graph-cross-model-prerequisites",
1326
+ nodeCode: String(crossModel[0].nodeCode ?? ""),
1327
+ key: "targetObjectCode",
1328
+ severity: "warning",
1329
+ message: updateOnlyTrigger
1330
+ ? `跨模型写回节点位于预置 Update 规则中:Update 规则只在 data.update 时触发,create/submit 不会执行它(真机 2026-09-28 复核)。要让回写在订单提交时生效,请把该 UPDATE 节点放进本模型的 Create 规则,或放进绑定到流程事件(如流程 end)的规则。`
1331
+ : `跨模型写回(目标 "${String(crossModel[0].targetObjectCode)}"):平台支持,运行时生效的两个前提——① 承载它的规则必须真的触发(Create 在提交时触发,Update 只在 data.update 触发;也可绑定流程事件);② 目标行必须已提交(COMMITTED),目标查询不匹配 DRAFT 行,data.save 未提交的行不会被回写。`,
1332
+ });
1333
+ }
1334
+ }
1335
+ // Self-triggering CREATE loop: a CREATE action node in the model's own `Create`
1336
+ // rule that writes INTO the same model. Every inserted row re-fires the Create
1337
+ // rule → unbounded recursion → the engine throws BUSINESS_RULE_EXCEPTION
1338
+ // (550009 “业务规则执行异常”) at runtime (live 2026-10-07: the stack is an
1339
+ // infinite `DataActivityBehavior.flushInsert → DomainServiceImpl.create →
1340
+ // RuleExecutorImpl.execute → … → CREATE again`; the platform sometimes refuses
1341
+ // the same graph at publish with 550018). Fail closed. Cross-model CREATE is
1342
+ // fine; this only fires when target == the rule's own schemaCode under a
1343
+ // Create-event trigger.
1344
+ if (schemaCode !== undefined && schemaCode.length > 0 && ruleCode === "Create") {
1345
+ for (const node of nodeRecords) {
1346
+ if (String(node.nodeType ?? "") === "CREATE" && String(node.targetObjectCode ?? "") === schemaCode)
1347
+ blockers.push({ code: "rule-graph-self-trigger-create-loop", nodeCode: String(node.nodeCode ?? ""), key: "targetObjectCode", message: `Create 规则里的 CREATE 节点目标又是本模型 "${schemaCode}"——每新增一行都会再次触发本 Create 规则,形成无界递归,运行时抛 550009「业务规则执行异常」(真机 2026-10-07 栈为 flushInsert→create→execute 无限递归;平台有时在 publish 直接 550018)。改法:CREATE 写别的模型(跨模型允许),或把这步移出 Create 触发规则(定时/按钮/流程事件),或加终止条件。` });
1348
+ }
1349
+ }
1350
+ const presetSystemNode = ruleCode ? DEFAULT_SYSTEM_NODE_BY_RULE_CODE[ruleCode] : undefined;
1351
+ if (presetSystemNode && !nodeRecords.some((node) => node.nodeType === presetSystemNode.nodeType))
1352
+ blockers.push({ code: "rule-graph-preset-system-node-missing", key: "nodes", message: `预置触发规则 ${ruleCode} 的图必须保留平台系统节点 ${presetSystemNode.nodeType}(nodeCode "${presetSystemNode.nodeCode}")——它是触发行的持久化节点,缺失会静默丢弃状态写入(DefaultBusinessRule;2026-09-12 live 铁证)` });
1353
+ // A preset trigger rule must use ITS system persistence node. A mismatched
1354
+ // SYSTEM_* node (e.g. SYSTEM_UPDATE inside the Create rule) is not a no-op: it
1355
+ // drives the wrong trigger-row write and makes data.submit answer 50000
1356
+ // (live 2026-09-25 sandbox18: Create graph with SYSTEM_UPDATE → submit 50000;
1357
+ // no explicit system node / SYSTEM_CREATE → ok). Fail closed.
1358
+ if (presetSystemNode) {
1359
+ const systemTypes = new Set(["SYSTEM_CREATE", "SYSTEM_UPDATE", "SYSTEM_DELETE"]);
1360
+ for (const node of nodeRecords) {
1361
+ const type = String(node.nodeType ?? "");
1362
+ if (systemTypes.has(type) && type !== presetSystemNode.nodeType)
1363
+ blockers.push({ code: "rule-graph-preset-system-node-mismatch", nodeCode: String(node.nodeCode ?? ""), key: "nodes", message: `${ruleCode} 是预置触发规则,系统持久化节点必须是 ${presetSystemNode.nodeType},不能是 ${type}——错配会让触发行写错、data.submit 报 50000(真机 2026-09-25)。删掉 ${type}(工具会自动注入 ${presetSystemNode.nodeType} 到 START 之后),或改成 ${presetSystemNode.nodeType}。` });
1364
+ }
1365
+ // The preset's own system node must carry the platform's preset identity
1366
+ // (nodeCode + nodeName). A structurally-legal but semantically-wrong node
1367
+ // passed dry-run and silently overwrote the preset node (2026-09-26).
1368
+ blockers.push(...validatePresetSystemNodeIdentity(nodeRecords, ruleCode));
1369
+ }
1370
+ // Scheduler rules carry the timing config at rule level, never on the node
1371
+ // (ScheduleNode is a bare Node; SchedulerSetting is a rule-row column).
1372
+ const schedulerSetting = parseMaybeJson(graph.schedulerSetting ?? graph.scheduler ?? undefined);
1373
+ if (nodeRecords.some((node) => node.nodeType === "SCHEDULER") && (schedulerSetting === undefined || schedulerSetting === null))
1374
+ blockers.push({ code: "rule-graph-scheduler-setting-missing", key: "schedulerSetting", message: "SCHEDULER 节点必须带规则级 schedulerSetting(调度参数不在节点上;否则规则保存成功但永不触发——ScheduleNode.java 无任何时机字段)" });
1375
+ if (schedulerSetting !== undefined && schedulerSetting !== null)
1376
+ blockers.push(...validateSchedulerSetting(schedulerSetting));
1377
+ // r4-fin 2026-10-09: the server-side rule API does not validate VALUES and
1378
+ // the designer's interception is bypassed by direct writes — a published
1379
+ // BIZ_ACTION carried a masked corpId (`RELEVANCE-00000000000000000000000000000000`)
1380
+ // that would 50000 at every self-call. Refuse masked/placeholder values
1381
+ // anywhere in the graph (nodes + routes + schedulerSetting).
1382
+ const maskedSeen = new Set();
1383
+ for (const node of nodeRecords)
1384
+ scanMaskedOrPlaceholderValues(node, `nodes[${String(node.nodeCode ?? "?")}]`, String(node.nodeCode ?? ""), blockers, maskedSeen);
1385
+ const graphRoutes = parseMaybeJson(graph.routes);
1386
+ if (graphRoutes !== undefined)
1387
+ scanMaskedOrPlaceholderValues(graphRoutes, "routes", "", blockers, maskedSeen);
1388
+ if (schedulerSetting !== undefined && schedulerSetting !== null)
1389
+ scanMaskedOrPlaceholderValues(schedulerSetting, "schedulerSetting", "", blockers, maskedSeen);
1390
+ const byCode = new Map();
1391
+ for (const node of nodeRecords) {
1392
+ const nodeCode = String(node.nodeCode ?? "");
1393
+ if (!nodeCode.trim()) {
1394
+ blockers.push({ code: "rule-graph-node-code-missing", message: `节点缺 nodeCode(nodeType=${String(node.nodeType ?? "?")})` });
1395
+ continue;
1396
+ }
1397
+ if (byCode.has(nodeCode))
1398
+ blockers.push({ code: "rule-graph-node-code-duplicate", nodeCode, message: `nodeCode "${nodeCode}" 重复` });
1399
+ byCode.set(nodeCode, node);
1400
+ const nodeType = String(node.nodeType ?? "");
1401
+ if (!RULE_NODE_TYPES_SET.has(nodeType))
1402
+ blockers.push({ code: "rule-graph-node-type-unknown", nodeCode, key: "nodeType", message: `nodeType "${nodeType}" 不在已知集 ${[...RULE_NODE_TYPES_SET].join("/")}(有证据才新增,勿猜)` });
1403
+ }
1404
+ const startNodes = nodeRecords.filter((node) => node.nodeType === "START");
1405
+ const endNodes = nodeRecords.filter((node) => node.nodeType === "END");
1406
+ if (startNodes.length !== 1)
1407
+ blockers.push({ code: "rule-graph-start-count", message: `必须恰好 1 个 START 节点(当前 ${startNodes.length})` });
1408
+ if (endNodes.length < 1)
1409
+ blockers.push({ code: "rule-graph-end-missing", message: "至少需要 1 个 END 节点" });
1410
+ const systemPersistenceTypes = new Set(["SYSTEM_CREATE", "SYSTEM_UPDATE"]);
1411
+ for (const systemNode of nodeRecords.filter((node) => systemPersistenceTypes.has(String(node.nodeType)))) {
1412
+ const systemIndex = nodeRecords.indexOf(systemNode);
1413
+ const actionIndex = nodeRecords.findIndex((node) => String(node.nodeType) === "BIZ_ACTION" && Array.isArray(node.methodMapping?.outputMappings) && node.methodMapping.outputMappings.length > 0);
1414
+ if (actionIndex >= 0 && actionIndex > systemIndex)
1415
+ blockers.push({ code: "rule-graph-bizaction-persistence-order", nodeCode: String(systemNode.nodeCode ?? ""), key: "nodes", message: `BIZ_ACTION outputMappings 必须排在 ${String(systemNode.nodeType)} 之前,否则动作结果不会持久化;将 BIZ_ACTION 放到系统持久化节点前,或移除无输出 mapping` });
1416
+ // ASSIGN(CURRENT) with dataActions merges into the input the system node
1417
+ // persists → it must run BEFORE the system node (live 2026-09-28: an ASSIGN
1418
+ // after SYSTEM_CREATE read back no change; before it, the write landed).
1419
+ if (String(systemNode.nodeType ?? "") === "SYSTEM_CREATE" || String(systemNode.nodeType ?? "") === "SYSTEM_UPDATE") {
1420
+ const assignIndex = nodeRecords.findIndex((node) => String(node.nodeType) === "ASSIGN" && String(node.triggerType ?? "CURRENT") !== "RULE_VARIABLE" && Array.isArray(node.dataActions) && node.dataActions.length > 0);
1421
+ if (assignIndex >= 0 && assignIndex > systemIndex)
1422
+ blockers.push({ code: "rule-graph-assign-persistence-order", nodeCode: String(systemNode.nodeCode ?? ""), key: "nodes", message: `ASSIGN(CURRENT) 的赋值必须排在 ${String(systemNode.nodeType)} 之前(Start→ASSIGN→${String(systemNode.nodeType)}→End),否则赋值合并进 _input 的时机晚于持久化,读回无变化(真机 2026-09-28)。将 ASSIGN 移到系统持久化节点前。` });
1423
+ }
1424
+ }
1425
+ // inputParam chaining: only "_input" or "_<existing nodeCode>_output".
1426
+ for (const node of nodeRecords) {
1427
+ const nodeCode = String(node.nodeCode ?? "");
1428
+ const inputParam = node.inputParam;
1429
+ if (inputParam === undefined)
1430
+ continue;
1431
+ if (typeof inputParam !== "string" || !inputParam.trim()) {
1432
+ blockers.push({ code: "rule-graph-input-param-empty", nodeCode, key: "inputParam", message: "inputParam 不能为空串" });
1433
+ continue;
1434
+ }
1435
+ if (inputParam === RULE_TRIGGER_INPUT_PARAM)
1436
+ continue;
1437
+ const match = /^_(.+)_output$/.exec(inputParam);
1438
+ const source = match?.[1];
1439
+ if (!source || !byCode.has(source))
1440
+ blockers.push({ code: "rule-graph-input-param-chain", nodeCode, key: "inputParam", message: `inputParam 只允许 "${RULE_TRIGGER_INPUT_PARAM}"(触发数据注册名,ticket 02 铁律)或 "_<nodeCode>_output"(本图存在的节点,当前引用 "${inputParam}" 无源节点)` });
1441
+ }
1442
+ // Per-node shape checks.
1443
+ for (const node of nodeRecords) {
1444
+ const nodeCode = String(node.nodeCode ?? "");
1445
+ const nodeType = String(node.nodeType ?? "");
1446
+ // nodeType ↔ dataTriggerType consistency (evidence §7; preset trigger rules).
1447
+ if (node.dataTriggerType !== undefined && NODE_TYPE_TO_DATA_TRIGGER[nodeType] !== undefined && String(node.dataTriggerType) !== NODE_TYPE_TO_DATA_TRIGGER[nodeType])
1448
+ blockers.push({ code: "rule-graph-trigger-type-mismatch", nodeCode, key: "dataTriggerType", message: `nodeType ${nodeType} 的 dataTriggerType 必须是 "${NODE_TYPE_TO_DATA_TRIGGER[nodeType]}"(当前 "${String(node.dataTriggerType)}")` });
1449
+ if (nodeType === "UPDATE" || nodeType === "DELETE") {
1450
+ if (typeof node.targetObjectCode !== "string" || !node.targetObjectCode.trim())
1451
+ blockers.push({ code: "rule-graph-target-missing", nodeCode, key: "targetObjectCode", message: `${nodeType} 节点缺 targetObjectCode` });
1452
+ const filter = node.filterCondition;
1453
+ if (!filter || typeof filter !== "object")
1454
+ blockers.push({ code: "rule-graph-filter-missing", nodeCode, key: "filterCondition", message: `${nodeType} 节点缺 filterCondition(mainConditions/sheetConditions)` });
1455
+ else {
1456
+ walkConditionLeaves(filter, (leaf, where) => blockers.push(...validateConditionLeaf(leaf, nodeCode, where)));
1457
+ // Platform label (HANDOFF C.2 / evidence §4): UPDATE.filterCondition =
1458
+ // 「查找满足条件的主表数据」(which row to update), DELETE.filterCondition =
1459
+ // 「删除满足条件的数据」. An EMPTY mainConditions therefore means the node
1460
+ // touches the whole table — the bench23 defect (unconditional update).
1461
+ // Advisory (never gates): the write is legal at the wire but almost
1462
+ // always a modelling error.
1463
+ if (countConditionLeaves(filter) === 0)
1464
+ blockers.push({
1465
+ code: "rule-graph-filter-empty-main",
1466
+ nodeCode,
1467
+ key: "filterCondition",
1468
+ severity: "warning",
1469
+ message: `${nodeType} 节点 filterCondition 为空条件组——平台语义是「查找满足条件的主表数据」,空条件即无条件下操作整表(bench23 的病灶)。请补 mainConditions 定位目标行(如 targetSchemaDataItem:"id" + DYNAMIC currentSchemaDataItem:"id"),或确认确实要操作整表。`,
1470
+ });
1471
+ }
1472
+ }
1473
+ if (nodeType === "DELETE" && Array.isArray(node.dataActions) && node.dataActions.length > 0)
1474
+ blockers.push({ code: "rule-graph-delete-with-actions", nodeCode, key: "dataActions", message: "DELETE 节点 dataActions 必须为空(生产形状 §7:仅 filterCondition)" });
1475
+ if (nodeType === "UPDATE") {
1476
+ const actions = Array.isArray(node.dataActions) ? node.dataActions : [];
1477
+ if (actions.length === 0)
1478
+ blockers.push({ code: "rule-graph-update-without-actions", nodeCode, key: "dataActions", message: "UPDATE 节点至少需要 1 个 dataAction" });
1479
+ actions.forEach((action, index) => {
1480
+ if (action && typeof action === "object")
1481
+ blockers.push(...validateDataAction(action, nodeCode, index));
1482
+ else
1483
+ blockers.push({ code: "rule-graph-action-not-object", nodeCode, key: `dataActions[${index}]`, message: "dataAction 必须是对象" });
1484
+ });
1485
+ }
1486
+ if (nodeType === "QUERY") {
1487
+ if (typeof node.targetSchemaCode !== "string" || !node.targetSchemaCode.trim())
1488
+ blockers.push({ code: "rule-graph-query-target-schema", nodeCode, key: "targetSchemaCode", message: "QUERY 节点缺 targetSchemaCode(可带子表 schemaCode.sheetCode)" });
1489
+ // targetConditionGroups is a TableConditionGroup OBJECT
1490
+ // `{mainConditions:[[…]],sheetConditions:[[…]]}` (TableConditionGroup.java:9-12),
1491
+ // NOT the bare array `[[…]]` that filterCondition/exitConditionGroups use.
1492
+ // A bare array deserializes wrong and the live server 50000s on
1493
+ // businessrule/update (live 2026-10-07). Fail closed before the wire.
1494
+ if (node.targetConditionGroups !== undefined) {
1495
+ if (Array.isArray(node.targetConditionGroups))
1496
+ blockers.push({ code: "rule-graph-query-condition-group-shape", nodeCode, key: "targetConditionGroups", message: "QUERY 的 targetConditionGroups 必须是对象 {mainConditions:[[叶…]],sheetConditions:[[叶…]]}(TableConditionGroup.java),不能是裸数组 [[…]](那是 filterCondition/exitConditionGroups 的形状)——裸数组 save 通过但 live businessrule/update 50000(真机 2026-10-07)。" });
1497
+ walkConditionLeaves(node.targetConditionGroups, (leaf, where) => blockers.push(...validateConditionLeaf(leaf, nodeCode, where)));
1498
+ }
1499
+ }
1500
+ if (nodeType === "BRANCH") {
1501
+ // Source evidence: RuleBranchQuoteImpl.getCurrentBizSchema throws
1502
+ // BUSINESS_RULE_PARAM_ERROR (550018) "缺少[参与判断的数据]配置" when
1503
+ // sourceInputMethod is null — live-confirmed 2026-09-12.
1504
+ if (node.sourceInputMethod === undefined || node.sourceInputMethod === null || String(node.sourceInputMethod) === "")
1505
+ blockers.push({ code: "rule-graph-branch-source-input-method", nodeCode, key: "sourceInputMethod", message: "BRANCH 节点必须带 sourceInputMethod(PRE_OUT=当前/前序数据、OTHER_MODEL=其它模型)——平台 publish 干门 550018「数据判断节点缺少[参与判断的数据]配置」(RuleBranchQuoteImpl.getCurrentBizSchema)" });
1506
+ // inputParam: RuleBranchQuoteImpl.getCurrentBizSchema → getBizSchemaByArg(inputParam)
1507
+ // → getBizSchemaByPath(null) → BIZ_SCHEMA_CODE_EMPTY (300010 「数据模型编码不能为空」)
1508
+ // at publish. The template carries "_input"; a hand-built node that drops it
1509
+ // passed the old dry lint but failed enable (live 2026-09-25 sandbox14).
1510
+ if (typeof node.inputParam !== "string" || !node.inputParam.trim())
1511
+ blockers.push({ code: "rule-graph-branch-input-param", nodeCode, key: "inputParam", message: "BRANCH 节点缺 inputParam——prepareRuleGraph 会自动补 \"_input\"(真机验证);直连本校验器(未过 prepare)时缺了平台 enable/publish 拒 errcode 300010。" });
1512
+ // judgeMethod: null != DEFAULT, so getTargetSchema() takes the OTHER branch and
1513
+ // builds a target schema from the (also missing) targetSchemaCode → 300010.
1514
+ const judgeMethod = String(node.judgeMethod ?? "");
1515
+ if (!judgeMethod)
1516
+ blockers.push({ code: "rule-graph-branch-judge-method", nodeCode, key: "judgeMethod", message: "BRANCH 节点缺 judgeMethod——prepareRuleGraph 会自动补 \"DEFAULT\"(真机验证);直连本校验器(未过 prepare)时缺了平台按非 DEFAULT 走 targetSchemaCode 报 300010。" });
1517
+ else if (judgeMethod !== "DEFAULT" && judgeMethod !== "OTHER")
1518
+ blockers.push({ code: "rule-graph-branch-judge-method", nodeCode, key: "judgeMethod", message: `BRANCH judgeMethod "${judgeMethod}" 不在 DEFAULT/OTHER(RuleDataJudgeType.java)` });
1519
+ // Ordering: a BRANCH sitting directly after START (with a preset system
1520
+ // node placed elsewhere, which suppresses auto-injection) evaluates before
1521
+ // the trigger input is staged and silently no-ops at runtime (live
1522
+ // 2026-09-25 sandbox14/17: Start→BRANCH→UPDATE persisted nothing;
1523
+ // Start→SYSTEM_CREATE→BRANCH→UPDATE worked). Silent no-op ⇒ fail closed.
1524
+ // When SYSTEM_* is absent, injectDefaultSystemNode splices it after START
1525
+ // automatically, so this case needs no refusal.
1526
+ if (presetSystemNode && nodeRecords.some((entry) => entry.nodeType === presetSystemNode.nodeType) && Array.isArray(routes)) {
1527
+ const startCode = String(startNodes[0]?.nodeCode ?? "Start");
1528
+ const startsAtBranch = routes.some((route) => String(route?.preNode) === startCode && String(route?.postNode) === nodeCode);
1529
+ if (startsAtBranch)
1530
+ blockers.push({ code: "rule-graph-branch-before-system-node", nodeCode, message: `BRANCH 直接接在 START 后、且图里已有 ${presetSystemNode.nodeType}——运行时分支静默不生效(输入未就绪,实测提交后动作不回写)。改成 Start→${presetSystemNode.nodeType}→BRANCH→动作→End;或删掉 SYSTEM_* 让工具自动注入到 START 之后。` });
1531
+ }
1532
+ if (!Array.isArray(node.exitConditionGroups) || node.exitConditionGroups.length === 0)
1533
+ blockers.push({ code: "rule-graph-branch-conditions", nodeCode, key: "exitConditionGroups", message: "BRANCH 节点缺 exitConditionGroups([[叶]] 与或组)" });
1534
+ else {
1535
+ walkConditionLeaves(node.exitConditionGroups, (leaf, where) => {
1536
+ blockers.push(...validateConditionLeaf(leaf, nodeCode, where));
1537
+ // 8.6.26: exitConditionGroups deserialize into DataFilterCondition, whose
1538
+ // left/source key is currentSchemaDataItem and operator key is
1539
+ // ruleDataConditionType. The UI/line-shaped targetSchemaDataItem +
1540
+ // ruleConditionType are UNKNOWN properties there: save+publish silently
1541
+ // accept them, but the engine then reads a null source/operator and
1542
+ // data.submit 50000s + rolls back (live 2026-09-25 前后对照已证). Fail
1543
+ // closed before the wire.
1544
+ if (leaf.targetSchemaDataItem !== undefined || leaf.ruleConditionType !== undefined)
1545
+ blockers.push({
1546
+ code: "rule-graph-branch-condition-keys",
1547
+ nodeCode,
1548
+ key: "exitConditionGroups",
1549
+ message: `${where}: BRANCH 出口条件叶用了 UI/连线键 targetSchemaDataItem/ruleConditionType——平台 save+publish 会过,但 exitConditionGroups 反序列化成 DataFilterCondition,运行时读不到来源/操作符,data.submit 稳定 50000 且回滚。改用 currentSchemaDataItem(左值字段)+ ${BRANCH_EXIT_OPERATOR_KEY}(操作符):{currentSchemaDataItem,${BRANCH_EXIT_OPERATOR_KEY},ruleValueType,value}。`,
1550
+ });
1551
+ });
1552
+ // Tiered if/else hazard: CloudPivot routes EVERY exit group whose
1553
+ // conditions match (independent groups, matchType ANY) — it does NOT stop
1554
+ // at the first match. Two groups that can both be true (e.g.
1555
+ // amount>=1e8 AND amount>=1e7) BOTH fire, and the later branch's action
1556
+ // overwrites the earlier one (live 2026-10-06 sandbox: risk tiering wrote
1557
+ // 高 then 中). Advisory: for exclusive tiers, nest branches (condition +
1558
+ // else into the next level) instead of sibling condition groups.
1559
+ if (Array.isArray(node.exitConditionGroups)) {
1560
+ const fieldGroups = new Map();
1561
+ node.exitConditionGroups.forEach((group, gi) => {
1562
+ if (!Array.isArray(group))
1563
+ return;
1564
+ for (const leaf of group) {
1565
+ if (!leaf || typeof leaf !== "object")
1566
+ continue;
1567
+ const field = leaf.currentSchemaDataItem;
1568
+ if (typeof field !== "string" || !field)
1569
+ continue;
1570
+ const seen = fieldGroups.get(field) ?? [];
1571
+ if (!seen.includes(gi))
1572
+ seen.push(gi);
1573
+ fieldGroups.set(field, seen);
1574
+ }
1575
+ });
1576
+ const overlap = [...fieldGroups.entries()].filter(([, groups]) => groups.length > 1).map(([field]) => field);
1577
+ if (overlap.length > 0)
1578
+ blockers.push({
1579
+ code: "rule-graph-branch-exits-may-overlap",
1580
+ nodeCode,
1581
+ key: "exitConditionGroups",
1582
+ severity: "warning",
1583
+ message: `BRANCH 多个出口组引用同一字段(${overlap.join("、")})——平台对每个条件成立的出口都会路由并执行其后动作(不是首个命中即停);若这些条件可能同时成立,后者分支会覆盖前者(真机 2026-10-06 风险分级:高、中同时命中→最终写成中)。要表达互斥的分级 if/else,请用嵌套 BRANCH(条件出口 + else 兜底进入下一层)。`,
1584
+ });
1585
+ }
1586
+ }
1587
+ }
1588
+ if (nodeType === "CREATE") {
1589
+ const originatorType = String(node.originatorValueType ?? "");
1590
+ if (originatorType !== "FIXED" && originatorType !== "DYNAMIC")
1591
+ blockers.push({ code: "rule-graph-create-originator", nodeCode, key: "originatorValueType", message: `CREATE 节点 originatorValueType 必须是 FIXED(完整用户对象 JSON 串)/DYNAMIC(字段 code)(当前 "${originatorType}")` });
1592
+ else if (node.originatorValue === undefined || node.originatorValue === "")
1593
+ blockers.push({ code: "rule-graph-create-originator-value", nodeCode, key: "originatorValue", message: "CREATE 节点缺 originatorValue" });
1594
+ if (node.startWorkflow === true && (typeof node.targetWorkflowCode !== "string" || !node.targetWorkflowCode.trim()))
1595
+ blockers.push({ code: "rule-graph-create-workflow-code", nodeCode, key: "targetWorkflowCode", message: "startWorkflow:true 必须带 targetWorkflowCode" });
1596
+ // Platform labels (HANDOFF C.2): CREATE.findMainData = 「查找满足条件的主表数据」
1597
+ // (its presence only affects *whether* a row is added);
1598
+ // CREATE.filterCondition = 「若存在符合条件的主表数据,则不新增」;
1599
+ // CREATE.aggregation = 主表新增方式 true=分组新增 / false=单条依次新增.
1600
+ if (node.aggregation !== undefined && typeof node.aggregation !== "boolean")
1601
+ blockers.push({ code: "rule-graph-create-aggregation", nodeCode, key: "aggregation", message: "CREATE 节点 aggregation 存在时必须是布尔(true=分组新增 / false=单条依次新增)" });
1602
+ if (node.findMainData !== undefined)
1603
+ walkConditionLeaves(node.findMainData, (leaf, where) => blockers.push(...validateConditionLeaf(leaf, nodeCode, where)));
1604
+ if (node.filterCondition !== undefined)
1605
+ walkConditionLeaves(node.filterCondition, (leaf, where) => blockers.push(...validateConditionLeaf(leaf, nodeCode, where)));
1606
+ // The field mapping (UI「主表字段值设置 → 目标字段 / 选择数据项」) is
1607
+ // `dataActions` — one leaf per target field. `execFormMapping` is a DISPLAY
1608
+ // toggle, NOT the mapping source: a CREATE node with `execFormMapping:true`
1609
+ // and no `dataActions` saves + publishes cleanly but the designer shows an
1610
+ // empty mapping and the created row has every business field null
1611
+ // (2026-09-27: bench44 agent inferred the mapping from the template and hit
1612
+ // exactly this). `execFormMapping:true` + empty mapping is the wrong
1613
+ // assumption, so fail closed; a bare empty mapping (system-fields-only
1614
+ // create, as the live-acceptance fixture uses) only warns. Real field-copy
1615
+ // examples (purchase_request / purchase_apply `Create`) carry one DYNAMIC
1616
+ // leaf per field (`currentDataItemValue` = source field).
1617
+ const createActions = Array.isArray(node.dataActions) ? node.dataActions : [];
1618
+ if (createActions.length === 0) {
1619
+ const assumedSwitchMaps = node.execFormMapping === true;
1620
+ blockers.push({
1621
+ code: assumedSwitchMaps ? "rule-graph-create-mapping-missing" : "rule-graph-create-mapping-empty",
1622
+ nodeCode,
1623
+ key: "dataActions",
1624
+ ...(assumedSwitchMaps ? {} : { severity: "warning" }),
1625
+ message: assumedSwitchMaps
1626
+ ? "CREATE 节点 execFormMapping:true 但缺字段映射 dataActions——execFormMapping 只是展示开关,不是映射来源;「主表字段值设置」会为空,新增行业务字段全为 null。补 dataActions:[{targetDataItem:\"<目标字段>\",ruleValueType:\"DYNAMIC\",value:\"\",ruleActionType:\"EQUALS\",currentDataItemValue:\"<来源字段>\"}](或 FIXED + value)。"
1627
+ : "CREATE 节点无 dataActions:若需把来源字段带到新增行,必须逐字段声明 dataActions(DYNAMIC + currentDataItemValue / FIXED + value);若只新增系统字段行可忽略本告警。",
1628
+ });
1629
+ }
1630
+ if (Array.isArray(node.dataActions))
1631
+ node.dataActions.forEach((action, index) => {
1632
+ if (action && typeof action === "object")
1633
+ blockers.push(...validateDataAction(action, nodeCode, index));
1634
+ });
1635
+ }
1636
+ if (nodeType === "ASSIGN" && Array.isArray(node.dataActions)) {
1637
+ node.dataActions.forEach((action, index) => {
1638
+ if (action && typeof action === "object")
1639
+ blockers.push(...validateDataAction(action, nodeCode, index));
1640
+ });
1641
+ }
1642
+ // ASSIGN.filterCondition = 「满足条件时触发赋值」(与 UPDATE 同构的定位/触发条件),
1643
+ // 也是「满足条件触发」的一种——walk 它(不依赖 dataActions 是否存在),避免畸形叶静默上送。
1644
+ if (nodeType === "ASSIGN" && node.filterCondition !== undefined)
1645
+ walkConditionLeaves(node.filterCondition, (leaf, where) => blockers.push(...validateConditionLeaf(leaf, nodeCode, where)));
1646
+ if (nodeType === "ASSIGN") {
1647
+ // ASSIGN(CURRENT) 写当前单据时,节点必须带 targetObjectCode + aggregation +
1648
+ // nonMatchingAdded,否则 businessrule/publish 稳定 50000 服务异常(实测
1649
+ // 2026-09-23:漏 targetObjectCode/aggregation/nonMatchingAdded → 50000;
1650
+ // 补齐后 publish 成功)。DATA_ASSIGN_NODE(DataAssignNode.java) 的
1651
+ // getTargetObjectCode() 对 CURRENT 回退 getTriggerObjectCode(),publish 期
1652
+ // 该回退可取到空 → getTargetSchema() 为 null。规则变量的 ASSIGN 不需要它。
1653
+ if (String(node.triggerType ?? "") === "CURRENT" && (typeof node.targetObjectCode !== "string" || !node.targetObjectCode.trim()))
1654
+ blockers.push({
1655
+ code: "rule-graph-assign-current-target",
1656
+ nodeCode,
1657
+ key: "targetObjectCode",
1658
+ severity: "warning",
1659
+ message: "ASSIGN(数据赋值) triggerType=CURRENT 未带 targetObjectCode:写当前单据/流程回写时平台 publish 可能稳定 50000(实测补齐 targetObjectCode+aggregation+nonMatchingAdded 后可发布)。补 targetObjectCode:\"<当前模型 schemaCode>\"、aggregation:false、nonMatchingAdded:false。",
1660
+ });
1661
+ }
1662
+ if (nodeType === "MESSAGE") {
1663
+ const receiver = node.receiver;
1664
+ if (receiver === undefined || typeof receiver !== "object" || Array.isArray(receiver)) {
1665
+ blockers.push({ code: "rule-graph-message-receiver", nodeCode, key: "receiver", message: "MESSAGE 节点缺 receiver(MessageReceiver 对象:{users,departments,roles,selections};不要用旧模板的 receiverType/receiverValue——平台 MessageNode 无此字段)" });
1666
+ }
1667
+ else {
1668
+ const hasRecipient = ["users", "departments", "roles", "selections"].some((k) => Array.isArray(receiver[k]) && receiver[k].length > 0);
1669
+ if (!hasRecipient)
1670
+ blockers.push({ code: "rule-graph-message-receiver-empty", nodeCode, key: "receiver", message: "MESSAGE receiver 未指定任何收件人:在 users/departments/roles/selections 之一给值(users/departments=[{id,type,unitType,name}],type/unitType 用整数:用户=3、部门=1)" });
1671
+ if (receiver.receiverType !== undefined || receiver.receiverValue !== undefined)
1672
+ blockers.push({ code: "rule-graph-message-receiver-legacy", nodeCode, key: "receiver", severity: "warning", message: "receiver 携带 receiverType/receiverValue——不是 8.6.26 MessageReceiver 的键,平台会忽略;改用 users/departments/roles/selections" });
1673
+ // type/unitType are UnitType deserialized by Selection$UnitTypeCodec via
1674
+ // Integer.parseInt — a string name ("USER") NumberFormatExceptions the
1675
+ // rule save (50000). Must be the integer index (USER=3, DEPARTMENT=1).
1676
+ for (const key of ["users", "departments"]) {
1677
+ const list = receiver[key];
1678
+ if (!Array.isArray(list))
1679
+ continue;
1680
+ list.forEach((entry, index) => {
1681
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
1682
+ const row = entry;
1683
+ for (const field of ["type", "unitType"]) {
1684
+ if (row[field] === undefined)
1685
+ continue;
1686
+ if (typeof row[field] !== "number" || !Number.isInteger(row[field]))
1687
+ blockers.push({ code: "rule-graph-message-unittype", nodeCode, key: `receiver.${key}[${index}].${field}`, message: `MESSAGE receiver.${key}[].${field} 必须是 UnitType 整数索引(用户=3、部门=1),不是枚举名字符串——平台 Selection$UnitTypeCodec 用 Integer.parseInt,传 "USER" 会 NumberFormatException 令规则保存 50000` });
1688
+ }
1689
+ }
1690
+ });
1691
+ }
1692
+ }
1693
+ const station = node.stationSetting;
1694
+ const messageItemCheck = (arr, key) => {
1695
+ if (!Array.isArray(arr))
1696
+ return;
1697
+ arr.forEach((item, index) => {
1698
+ if (!item || typeof item !== "object" || Array.isArray(item))
1699
+ return;
1700
+ const row = item;
1701
+ // The node object stores each title/content line as MessageDataItem
1702
+ // {type, code} — type 0=STRING(fixed text) / 1=DATA_ITEM(field ref).
1703
+ // The designer's variable editor (77877 parseStringToNode) resolves a
1704
+ // FIELD token by looking up `variableList.find(code===`${r.code}` &&
1705
+ // type===`${r.type}`)`; with no numeric `type` the token is dropped and
1706
+ // the 站内消息通知 panel renders BLANK (live 2026-10-08). A legacy
1707
+ // `isDataItem` key is the backend `MessageSetting.toJson` STRING form,
1708
+ // NOT the node-object key — treat it as an error, not an alias.
1709
+ if (row.type === undefined) {
1710
+ blockers.push({
1711
+ code: "rule-graph-message-item-type",
1712
+ nodeCode,
1713
+ key: `${key}[${index}]`,
1714
+ message: `消息条目缺 \`type\`(MessageDataItem 节点键是 {type,code};type 0=固定文案 / 1=字段引用 ${row.isDataItem !== undefined ? "——不要用 isDataItem,那是运行时字符串形态不是节点键" : ""};缺 type 设计器「站内消息通知」渲染空白)`,
1715
+ });
1716
+ }
1717
+ else if (typeof row.type !== "number") {
1718
+ blockers.push({ code: "rule-graph-message-item-type", nodeCode, key: `${key}[${index}].type`, message: `消息条目 type 必须是整数(0=固定文案 / 1=字段引用),收到 ${JSON.stringify(row.type)}` });
1719
+ }
1720
+ });
1721
+ };
1722
+ if (!station || !Array.isArray(station.content) || station.content.length === 0) {
1723
+ blockers.push({ code: "rule-graph-message-content", nodeCode, key: "stationSetting.content", message: "MESSAGE 节点缺 stationSetting.content(MessageDataItem[]:{type,code},type 1=字段 code、0=固定文案)" });
1724
+ }
1725
+ else {
1726
+ messageItemCheck(station.content, "stationSetting.content");
1727
+ messageItemCheck(station.title, "stationSetting.title");
1728
+ }
1729
+ // A station message is silently DROPPED unless visible: MessageNode
1730
+ // .getStationSettingVisible() defaults null→FALSE (only top-level
1731
+ // title+content imply TRUE), and StationDispatcherImpl.distributeMessage
1732
+ // skips on !visible (live 2026-10-08: node ran "successful" yet
1733
+ // h_im_message_station gained 0 rows).
1734
+ if (station && node.stationSettingVisible !== true && !(typeof node.title === "string" && node.title.trim() && typeof node.content === "string" && node.content.trim()))
1735
+ blockers.push({ code: "rule-graph-message-station-invisible", nodeCode, key: "stationSettingVisible", message: "MESSAGE 节点带 stationSetting 但未设 `stationSettingVisible:true`——平台默认 FALSE,站内信**静默不发**(MessageNode.getStationSettingVisible null→FALSE;StationDispatcherImpl 跳过;真机 2026-10-08 节点执行成功但收件箱 0 行)。补 `stationSettingVisible:true`。" });
1736
+ }
1737
+ if (nodeType === "BIZ_ACTION") {
1738
+ blockers.push(...validateMethodMapping(node.methodMapping, nodeCode));
1739
+ }
1740
+ if (nodeType === "GET_LIST") {
1741
+ if (node.dataSourceType !== undefined && !RULE_DATA_SOURCE_TYPES.includes(String(node.dataSourceType)))
1742
+ blockers.push({ code: "rule-graph-getlist-datasource-type", nodeCode, key: "dataSourceType", message: `GET_LIST 节点 dataSourceType "${String(node.dataSourceType)}" 不在 ${RULE_DATA_SOURCE_TYPES.join("/")}(DataSourceType.java:13-17;节点存枚举名)` });
1743
+ if (node.methodMapping !== undefined)
1744
+ blockers.push(...validateMethodMapping(node.methodMapping, nodeCode));
1745
+ }
1746
+ if (nodeType === "EXCEPTION" && (typeof node.errMsg !== "string" || !node.errMsg.trim()))
1747
+ blockers.push({ code: "rule-graph-exception-errmsg", nodeCode, key: "errMsg", message: "EXCEPTION 节点必须带非空 errMsg(异常中止文案,ExceptionNode.errMsg @JSONField jsonDirect)" });
1748
+ if (nodeType === "HINT") {
1749
+ // HintNode.content is @JSONField(jsonDirect) and the platform parses it
1750
+ // as a JSON ARRAY; a bare string / object / JSON-string is accepted by
1751
+ // save but 50000s at publish (2026-09-20 live). Refuse it before the wire.
1752
+ const parsedContent = typeof node.content === "string" ? parseMaybeJson(node.content) : node.content;
1753
+ if (!Array.isArray(parsedContent))
1754
+ blockers.push({
1755
+ code: "rule-graph-hint-content",
1756
+ nodeCode,
1757
+ key: "content",
1758
+ message: 'HINT 节点 content 必须是 JSON 数组(字符串形式),如 [{"type":0,"value":"提示文案"}] 或 [];裸串/对象/JSON 字符串在 publish 稳定 50000(HintNode.content @JSONField jsonDirect)',
1759
+ });
1760
+ }
1761
+ // ── Common trigger-condition keys (HANDOFF C.2/C.3; evidence §4) ──────────
1762
+ // childTriggerConditionType: 子表触发数据范围 ADD/MODIFY/DELETE.
1763
+ if (node.childTriggerConditionType !== undefined) {
1764
+ const value = String(node.childTriggerConditionType);
1765
+ if (!RULE_CHILD_TRIGGER_CONDITION_TYPES.includes(value))
1766
+ blockers.push({ code: "rule-graph-child-trigger-condition-type", nodeCode, key: "childTriggerConditionType", message: `节点 childTriggerConditionType "${value}" 不在 ${RULE_CHILD_TRIGGER_CONDITION_TYPES.join("/")}(子表触发数据范围:新增/修改/删除)` });
1767
+ }
1768
+ // dataCondition: 输入数据满足条件时触发执行动作(通用执行门)。
1769
+ if (node.dataCondition !== undefined)
1770
+ walkConditionLeaves(node.dataCondition, (leaf, where) => blockers.push(...validateConditionLeaf(leaf, nodeCode, where)));
1771
+ // execConditionGroups: 满足条件时执行业务方法(BIZ_ACTION)。
1772
+ if (node.execConditionGroups !== undefined)
1773
+ walkConditionLeaves(node.execConditionGroups, (leaf, where) => blockers.push(...validateConditionLeaf(leaf, nodeCode, where)));
1774
+ }
1775
+ // Routes: reference integrity + reachability.
1776
+ const edgeList = [];
1777
+ if (routes !== undefined && routes !== null) {
1778
+ if (!Array.isArray(routes))
1779
+ blockers.push({ code: "rule-graph-routes-shape", message: "routes 必须是数组(或 JSON 字符串)" });
1780
+ else
1781
+ for (const route of routes) {
1782
+ if (!route || typeof route !== "object") {
1783
+ blockers.push({ code: "rule-graph-route-not-object", message: "route 必须是对象" });
1784
+ continue;
1785
+ }
1786
+ const record = route;
1787
+ const pre = String(record.preNode ?? "");
1788
+ const post = String(record.postNode ?? "");
1789
+ const where = `route ${pre || "?"}→${post || "?"}`;
1790
+ if (!byCode.has(pre))
1791
+ blockers.push({ code: "rule-graph-route-dangling", key: "preNode", message: `路由引用不存在的 preNode "${pre}"` });
1792
+ if (!byCode.has(post))
1793
+ blockers.push({ code: "rule-graph-route-dangling", key: "postNode", message: `路由引用不存在的 postNode "${post}"` });
1794
+ // CFG-26: missing/empty `points` is BLOCKED — live evidence (P4,
1795
+ // 2026-09-13) is a blank designer canvas caused exactly by routes with
1796
+ // no geometry. `routeCondition`/`matchType`: the adapter
1797
+ // (ensureRouteShape) fills them when absent, so the save path always
1798
+ // persists them; there is no evidence that their absence alone breaks
1799
+ // execution (the live missing-key Create rule ran and wrote back
1800
+ // correctly). This lint therefore only rejects a PRESENT but invalid
1801
+ // value — absence is the adapter's job, not a caller error.
1802
+ const points = record.points;
1803
+ if (!(Array.isArray(points) && points.length > 0 && points.every((point) => typeof point === "string" && RULE_POINT.test(point.trim()))))
1804
+ blockers.push({ code: "rule-graph-route-points", key: "points", message: `${where}: 必须带非空字符串坐标数组 points(画布连线靠它渲染;平台默认形如 ["406, 76","406, 116"])` });
1805
+ if (record.routeCondition !== undefined && typeof record.routeCondition !== "boolean")
1806
+ blockers.push({ code: "rule-graph-route-condition", key: "routeCondition", message: `${where}: routeCondition 存在时必须是布尔(true=满足分支条件执行,false=不满足其他分支时执行的 else 出口);缺失由适配器补 true` });
1807
+ const matchType = record.matchType;
1808
+ if (matchType !== undefined && matchType !== null && String(matchType).trim() !== "" && !RULE_ROUTE_MATCH_TYPES.includes(String(matchType)))
1809
+ blockers.push({ code: "rule-graph-route-match-type", key: "matchType", message: `${where}: matchType "${String(matchType)}" 不在 ${RULE_ROUTE_MATCH_TYPES.join("/")};缺失由适配器补 "ANY"` });
1810
+ if (pre && post && byCode.has(pre) && byCode.has(post))
1811
+ edgeList.push({ pre, post });
1812
+ }
1813
+ }
1814
+ // Reachability only when the graph carries explicit routes: an empty
1815
+ // routes array is the platform's implied-linear sequence (ticket 09 live
1816
+ // save shipped routes:[] and the engine executed Start→…→End in order);
1817
+ // with edges present, every node must be reachable from START and some
1818
+ // path must reach END.
1819
+ if (startNodes.length === 1 && edgeList.length > 0) {
1820
+ const adjacency = new Map();
1821
+ for (const { pre, post } of edgeList)
1822
+ adjacency.set(pre, [...(adjacency.get(pre) ?? []), post]);
1823
+ const startCode = startNodes.length === 1 ? String(startNodes[0].nodeCode ?? "") : "";
1824
+ if (startCode && byCode.has(startCode)) {
1825
+ const seen = new Set([startCode]);
1826
+ const queue = [startCode];
1827
+ while (queue.length > 0) {
1828
+ const current = queue.shift();
1829
+ for (const next of adjacency.get(current) ?? [])
1830
+ if (!seen.has(next)) {
1831
+ seen.add(next);
1832
+ queue.push(next);
1833
+ }
1834
+ }
1835
+ for (const [code] of byCode) {
1836
+ if (!seen.has(code))
1837
+ blockers.push({ code: "rule-graph-node-unreachable", nodeCode: code, message: `节点 "${code}" 从 START 不可达(悬空节点)` });
1838
+ }
1839
+ const reachesEnd = [...seen].some((code) => byCode.get(code)?.nodeType === "END");
1840
+ if (!reachesEnd)
1841
+ blockers.push({ code: "rule-graph-no-path-to-end", message: "START 到 END 不可达(图不连通)" });
1842
+ }
1843
+ }
1844
+ return blockers;
1845
+ }
1846
+ //# sourceMappingURL=rule-graph.js.map