@stalfh233/omc-cli 0.6.7 → 0.6.11

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 (67) hide show
  1. package/README.md +2 -1
  2. package/dist/approval-flow.js +5 -3
  3. package/dist/bizservice-config.js +26 -10
  4. package/dist/call.js +47 -0
  5. package/dist/capability-overview.js +47 -24
  6. package/dist/cli-contract.js +73 -26
  7. package/dist/cli-output.js +41 -2
  8. package/dist/cli-stdout.js +74 -0
  9. package/dist/cli.js +135 -52
  10. package/dist/command-transport.js +8 -1
  11. package/dist/config-store.js +17 -2
  12. package/dist/discovery.js +62 -3
  13. package/dist/field-families.js +35 -6
  14. package/dist/goal-routes.js +118 -11
  15. package/dist/human-summary.js +11 -0
  16. package/dist/lanes/app-contract.js +12 -5
  17. package/dist/lanes/app.js +221 -134
  18. package/dist/lanes/apply-changes.js +2 -1
  19. package/dist/lanes/b2-registration-contract.js +3 -3
  20. package/dist/lanes/b2-registration.js +890 -693
  21. package/dist/lanes/businessrule.js +75 -51
  22. package/dist/lanes/custom-page.js +166 -115
  23. package/dist/lanes/datarule-contract.js +2 -2
  24. package/dist/lanes/datarule.js +238 -154
  25. package/dist/lanes/dictionary.js +253 -110
  26. package/dist/lanes/environment.js +57 -9
  27. package/dist/lanes/field.js +160 -107
  28. package/dist/lanes/folder-contract.js +7 -3
  29. package/dist/lanes/folder.js +249 -118
  30. package/dist/lanes/form-contract.js +1 -1
  31. package/dist/lanes/form.js +188 -64
  32. package/dist/lanes/list-view.js +405 -369
  33. package/dist/lanes/meta.js +31 -6
  34. package/dist/lanes/model-contract.js +1 -1
  35. package/dist/lanes/model.js +610 -512
  36. package/dist/lanes/online-js-contract.js +1 -1
  37. package/dist/lanes/online-js.js +160 -146
  38. package/dist/lanes/organization.js +905 -640
  39. package/dist/lanes/programming.js +274 -136
  40. package/dist/lanes/report-contract.js +2 -2
  41. package/dist/lanes/report.js +354 -198
  42. package/dist/lanes/rule-lifecycle.js +294 -140
  43. package/dist/lanes/runtime-data-contract.js +4 -4
  44. package/dist/lanes/runtime-data.js +869 -745
  45. package/dist/lanes/workflow-contract.js +3 -3
  46. package/dist/lanes/workflow.js +732 -605
  47. package/dist/model-scaffold.js +26 -5
  48. package/dist/path-projection.js +196 -14
  49. package/dist/plan.js +25 -0
  50. package/dist/programming-workflow.js +55 -3
  51. package/dist/read-shapes.js +146 -0
  52. package/dist/read.js +40 -9
  53. package/dist/recipes/businessrule.js +74 -24
  54. package/dist/recipes/dictionary.js +7 -4
  55. package/dist/recipes/form.js +46 -0
  56. package/dist/recipes/runtime-data.js +3 -2
  57. package/dist/refusal-guidance.js +29 -5
  58. package/dist/rule-support.js +14 -1
  59. package/dist/rule-when.js +2 -2
  60. package/dist/tool-types.js +9 -3
  61. package/dist/version-family-gaps.js +101 -0
  62. package/dist/write.js +2 -1
  63. package/package.json +6 -4
  64. package/skills/omc-glossary.md +13 -0
  65. package/skills/omc-refusals.md +13 -5
  66. package/skills/omc-tool-selection.md +2 -1
  67. package/skills/omc-write-hazards.md +5 -2
@@ -9,6 +9,7 @@ import { writeTool } from "../write.js";
9
9
  import { defineTool } from "../tool-types.js";
10
10
  import { wireCall, wireRows, WireError } from "../wire.js";
11
11
  import { CHILD_TABLE_PROPERTY_TYPE, diagnoseApprovalSubmitIdentity, runtimeWriteWithAppMapping } from "../runtime-support.js";
12
+ import { closestMatch } from "../closest-match.js";
12
13
  import { getReplayToken, searchWorkItems, wireString } from "../workflow-support.js";
13
14
  import { ENGINE_OWNED_KEYS, RUNTIME_DATA_LANE_CONTRACT, RUNTIME_DATA_WIRE, runtimeFormDataArg, } from "./runtime-data-contract.js";
14
15
  import { SYSTEM_FIELD_CODES } from "../field-families.js";
@@ -771,7 +772,7 @@ export function registerRuntimeDataLane(tools) {
771
772
  // `Expected object, received array`). An array is the caller's AND-list,
772
773
  // so accept it and treat it as `filters` rather than costing a retry
773
774
  // (dogfood R1 round17).
774
- filter: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).optional().describe('one clause { op, field, value? } (or an array of clauses = AND); op 取值见过滤语义:eq/neq/gt/lt/gte/lte/like/notlike/in/notin/between/notbetween/isnull/isnotnull(camelCase 别名 equally valid: equals/notEquals/contains/isNull/isNotNull)。**like/contains 是子串包含(String.includes),不是前缀匹配,`%` / `_` 是字面量而非通配符**;按前缀分片请用 gte/lt 区间或更长的前缀+本地去重,别假设 like:"0%" 只命中 0 开头(GitLab #18 P1-3 实测 4 个"前缀分片"互相重叠 2258 条)'),
775
+ filter: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).optional().describe('one clause { op, field, value? } (or an array of clauses = AND); op 取值见过滤语义:eq/neq/gt/lt/gte/lte/like/notlike/in/notin/between/notbetween/isnull/isnotnull(camelCase 别名 equally valid: equals/notEquals/contains/isNull/isNotNull)。**like/contains 是子串包含(String.includes),不是前缀匹配,`%` / `_` 是字面量而非通配符**;按前缀分片请用 gte/lt 区间或更长的前缀+本地去重,别假设 like:"0%" 只命中 0 开头(GitLab #18 P1-3 实测 4 个"前缀分片"互相重叠 2258 条)。field 须是**已发布**字段 code——写错时拒绝信封直接带 publishedFields/didYouMean,无需再查 field.list'),
775
776
  filters: z.array(z.record(z.string(), z.unknown())).optional().describe("clauses combined with AND; a malformed clause is refused with blocker invalid-filter-DSL"),
776
777
  filterGroup: z.record(z.string(), z.unknown()).optional().describe('nested group { op:"and"|"or", clauses:[clause|group] }'),
777
778
  rawCondition: z.unknown().optional(),
@@ -834,11 +835,29 @@ export function registerRuntimeDataLane(tools) {
834
835
  // an explicit projection came back all-null, so a normal read that
835
836
  // yields values performs zero metadata requests.
836
837
  let publishedTypes = null;
838
+ // 运行面 filter 的「未知字段」此前只报字段名,调用方还得再发一跳 field.list
839
+ // (HANDOFF §三.1-2)。把已发布字段目录随拒绝一起带出:前 20 个 code(+名称)、
840
+ // 全量总数、拼写 did-you-mean;目录复用上面这次元数据读取,零新增 wire。
841
+ const FILTER_FIELD_SAMPLE_LIMIT = 20;
842
+ let publishedCatalog = null;
843
+ const publishedFields = async () => {
844
+ if (publishedCatalog)
845
+ return publishedCatalog;
846
+ const meta = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })).data);
847
+ publishedCatalog = meta
848
+ .map((row) => {
849
+ const code = String(row.code ?? row.propertyCode ?? "");
850
+ const rawName = row.name ?? row.propertyName;
851
+ const name = typeof rawName === "string" && rawName.length > 0 && rawName !== code ? rawName : undefined;
852
+ return { code, ...(name !== undefined ? { name } : {}), type: Number(row.propertyType ?? row.type) };
853
+ })
854
+ .filter((row) => row.code.length > 0);
855
+ return publishedCatalog;
856
+ };
837
857
  const publishedTypeByCode = async () => {
838
858
  if (publishedTypes)
839
859
  return publishedTypes;
840
- const meta = wireRows((await wireCall(session, { path: ENDPOINTS.fieldList, query: { schemaCode: args.schemaCode, isPublish: "true" } })).data);
841
- publishedTypes = new Map(meta.map((row) => [String(row.code ?? row.propertyCode), Number(row.propertyType ?? row.type)]));
860
+ publishedTypes = new Map((await publishedFields()).map((row) => [row.code, row.type]));
842
861
  return publishedTypes;
843
862
  };
844
863
  if (fromDsl) {
@@ -847,13 +866,36 @@ export function registerRuntimeDataLane(tools) {
847
866
  const type = typeByCode.get(code);
848
867
  return Number.isInteger(type) ? type : undefined;
849
868
  });
850
- if (!injected.ok)
869
+ if (!injected.ok) {
870
+ const catalog = await publishedFields();
871
+ const codes = catalog.map((row) => row.code);
872
+ const sample = catalog.slice(0, FILTER_FIELD_SAMPLE_LIMIT);
873
+ const didYouMean = Object.fromEntries(injected.missing
874
+ .map((code) => [code, closestMatch(code, codes)])
875
+ .filter((entry) => entry[1] !== undefined));
876
+ const didYouMeanHint = Object.entries(didYouMean).map(([from, to]) => `;${from} 是否想写 ${to}`).join("");
877
+ // r26b 读回体积审计(2026-11-05):目录此前在**同一个信封里被写三遍**
878
+ // ——`publishedFields`(结构化,~930 B)+ `message` 里逐条再列一遍
879
+ // (~810 B)+ `error.message` 是 result.message 的镜像(又 ~810 B)。
880
+ // 一条「字段名拼错」回执因此 3.2 KB,而回答只需要 missing + didYouMean。
881
+ // message 改为只给判据与指针,目录只留结构化那一份。
882
+ const catalogHint = catalog.length === 0
883
+ ? ";该模型当前没有已发布字段(先 field.create + field.publish 再过滤)"
884
+ : `;已发布字段共 ${catalog.length} 个,code/名称见 publishedFields${catalog.length > sample.length ? `(仅列前 ${sample.length} 个,其余用 field.list 查)` : ""}`;
851
885
  return {
852
886
  status: "refused",
853
887
  blocker: "unknown-filter-field",
854
888
  missing: injected.missing,
855
- message: `filter fields not found in published field metadata: ${injected.missing.join(", ")}; the runtime query requires each leaf's propertyType from field metadata (no guessing); system/foreign fields may use rawCondition` + choiceKeySuffixHint(injected.missing, typeByCode),
889
+ publishedFields: sample.map((row) => ({ code: row.code, ...(row.name !== undefined ? { name: row.name } : {}) })),
890
+ publishedFieldsTotal: catalog.length,
891
+ ...(Object.keys(didYouMean).length > 0 ? { didYouMean } : {}),
892
+ // acceptedValues 是「合法取值全集」:截断时故意不给,避免把第 21 个
893
+ // 之后真实存在的字段误判成非法。
894
+ ...(catalog.length > 0 && catalog.length <= FILTER_FIELD_SAMPLE_LIMIT ? { acceptedValues: { "filter.field": codes } } : {}),
895
+ message: `filter 字段不在已发布元数据里:${injected.missing.join("、")}(运行面过滤要用每个叶子的 propertyType,不做猜测;系统/外部字段改用 rawCondition)` + choiceKeySuffixHint(injected.missing, typeByCode) + didYouMeanHint + catalogHint,
896
+ ...(catalog.length > 0 ? { nextCommand: `omc call field.list --env <env> --args '{"schemaCode":"${args.schemaCode}","published":true}'` } : {}),
856
897
  };
898
+ }
857
899
  condition = injected.condition;
858
900
  // A2(GitLab #15):单选/字典字段按显示值过滤会静默 0 条(平台查询按
859
901
  // 字典 key 匹配)。带出这些字段,total:0 时给写法提示,防"查不到数据"
@@ -956,6 +998,14 @@ export function registerRuntimeDataLane(tools) {
956
998
  page: "all",
957
999
  size: collected.length,
958
1000
  pagesFetched: pageIndex,
1001
+ // 与普通读同一个口径(r28b 真机):草稿策略必须**从结果集实际统计**,
1002
+ // 不能按"有没有查询条件"静态声明——静态声明会与 items 自相矛盾。
1003
+ filter: (() => {
1004
+ const draftRows = items.filter((item) => String(item.fields.sequenceStatus ?? "") === "DRAFT").length;
1005
+ return draftRows > 0
1006
+ ? { draftPolicy: "included", draftRows, hint: `本次结果含 ${draftRows} 条 DRAFT 行——total 含草稿,不要当"已提交数据量";判定状态读 data.load 的 sequenceStatus` }
1007
+ : { draftPolicy: "unspecified", hint: "本次结果无 DRAFT 行;判定状态以 data.load 的 sequenceStatus 为准" };
1008
+ })(),
959
1009
  // 形状常量:脚本按 result.items 取数(与内联态一致),itemsCount 与
960
1010
  // total 的差就是被截断的条数——不再只靠 size 反推。
961
1011
  itemsCount: collected.length,
@@ -1033,11 +1083,29 @@ export function registerRuntimeDataLane(tools) {
1033
1083
  ...(projectionEmpty ? { projectionWarning: { reason: "business-columns-null", hint: "模型可能未配置列表视图:data.list 只投影列表列,业务列会全为 null;核对写入结果请用 data.load(或先建列表视图)" } } : {}),
1034
1084
  ...(args.queryCode !== undefined ? { queryCode: args.queryCode } : {}),
1035
1085
  total: args.showTotal === false ? null : page.total,
1036
- // This branch is the non-`all` read: the platform's default filter
1037
- // hides DRAFTs and the total excludes them too — say so explicitly
1038
- // (live 2026-10-18: total:0 was read as "no residue" while 9 drafts
1039
- // existed).
1040
- filter: { excludedDraft: true, hint: "默认不含 DRAFT(total 亦仅计非草稿);核对/清理残留请加 all:true" },
1086
+ // This branch is the non-`all` read. `excludedDraft` used to be
1087
+ // unconditional, but r26 真机实证(2026-11-05):**带查询条件时**草稿行
1088
+ // 被排除(total 也只计非草稿),**不带条件时**同一个 data.save 的 DRAFT
1089
+ // r28b 真机(2026-11-05):上一版按「有无查询条件」**静态声明**草稿策略,
1090
+ // 探针抓到自相矛盾——回执写 `excludedDraft:true`,返回的 items 里却是
1091
+ // DRAFT 行。静态 hint 永远可能和真实结果打架,`total:0` 被读成「写失败」
1092
+ // 就是这么来的。
1093
+ // 现在**从结果集实际统计**得出:items 里出现 DRAFT 就如实说 included。
1094
+ // 统一一个枚举键 `draftPolicy`(included | excluded | unspecified),
1095
+ // 不再用两个不同键名表达同一件事(那要调用方写两套分支)。
1096
+ ...(() => {
1097
+ const draftRows = items.filter((item) => String(item.fields.sequenceStatus ?? "") === "DRAFT").length;
1098
+ const policy = draftRows > 0
1099
+ ? {
1100
+ draftPolicy: "included",
1101
+ draftRows,
1102
+ hint: `本次结果含 ${draftRows} 条 DRAFT 行(total 也计入)——**不要**把 total 当"已提交数据量",判定状态读 data.load 的 sequenceStatus`,
1103
+ }
1104
+ : condition !== undefined
1105
+ ? { draftPolicy: "excluded", hint: "本次结果无 DRAFT 行;判定状态仍以 data.load 的 sequenceStatus 为准" }
1106
+ : { draftPolicy: "unspecified", hint: "本次结果无 DRAFT 行,但本面是否过滤草稿不能据此断定;判定状态读 data.load 的 sequenceStatus" };
1107
+ return { filter: policy };
1108
+ })(),
1041
1109
  ...(choiceFilterFields.length > 0 && Number(page.total ?? 0) === 0 ? { filterValueHint: choiceFilterValueHint(choiceFilterFields) } : {}),
1042
1110
  totalPages: page.totalPages,
1043
1111
  page: page.page ?? args.page ?? 0,
@@ -1171,266 +1239,282 @@ export function registerRuntimeDataLane(tools) {
1171
1239
  }, { full: args.full === true, target: `data.load:${args.schemaCode}:${args.bizObjectId}` });
1172
1240
  },
1173
1241
  }));
1174
- writeTool(tools, "data.save", RUNTIME_LANE, RUNTIME_TOOLS["data.save"].scope, RUNTIME_TOOLS["data.save"].description, {
1175
- schemaCode: z.string().min(1),
1176
- sheetCode: z.string().optional(),
1177
- formData: runtimeFormDataArg,
1178
- workflowCode: z.string().optional().describe("portal draft-save shape: create the row AND a DRAFT workflow instance + start workItem (finishStart=FALSE); submit later via data.submit with the returned workItemId. formData must NOT carry an id — in-place workflow start has no REST surface in 8.6.26"),
1179
- submit: z.boolean().optional().describe("true=保存后立即直提(无流程模型 DRAFT→COMPLETED,触发 Create 规则);与 workflowCode 互斥。**按业务约定正式数据优先直接 data.submit(无 id=新建并提交一调完成)——本参数是便捷别名,save 的定位是草稿/暂存**"),
1180
- allowStaffOnSubmit: z.boolean().optional().describe("submit:true 时显式允许携带人员字段(issue#10:人员字段提交面两种形状都可能 50000,可靠链路是 save 落库后提交时省略;个别平台构建接受提交面携带,自担风险)"),
1181
- ...readbackProjectionArgs,
1182
- }, async (session, args) => {
1183
- // F-A (self-audit): the combo used to be silently ignored (the workflow
1184
- // draft branch returns without honoring submit). Refuse before any wire.
1185
- if (args.submit === true && typeof args.workflowCode === "string" && args.workflowCode.trim())
1186
- throw new ToolRefusalError({
1187
- status: "refused",
1188
- blocker: "submit-workflow-code-conflict",
1189
- schemaCode: args.schemaCode,
1190
- message: "submit:true 与 workflowCode 互斥——workflowCode 是流程草稿两段式的第一段(返回 workItemId 后用 data.submit 提交);无流程模型的一调完成态用 submit:true 且不带 workflowCode",
1191
- });
1192
- const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
1193
- const rawSaveData = args.formData;
1194
- const saveIsUpdate = typeof rawSaveData.id === "string" && rawSaveData.id.trim().length > 0;
1195
- const formData = (await prepareRuntimeFormData(session, args.schemaCode, rawSaveData, { isUpdate: saveIsUpdate })).formData;
1196
- // issue#9 C1 + issue#10 勘误:提交面(submit:true / data.submit)的人员字段
1197
- // 两种已知形状都不安全——对象数组有历史 50000 证据(UserBizField.fillClientContent,
1198
- // 本闸拦截),字符串 id 数组在 8.6.41 实测同样 50000(#10 D)。可靠链路只有一条:
1199
- // data.save 人员字段用对象数组 [{id,type,name}] 落库 → 提交时**省略人员字段**(值已持久化)。
1200
- if (args.submit === true && args.allowStaffOnSubmit !== true) {
1201
- const staffOnSubmit = await staffFieldsPresent(session, args.schemaCode, formData).catch(() => []);
1202
- if (staffOnSubmit.length > 0)
1203
- throw new ToolRefusalError({
1204
- status: "refused",
1205
- blocker: "submit-person-field-shape",
1206
- schemaCode: args.schemaCode,
1207
- fields: staffOnSubmit,
1208
- message: `提交携带了人员字段:${staffOnSubmit.map((entry) => `${entry.code}(${entry.got})`).join("、")}。提交面的两种形状都不安全(对象数组触发 UserBizField.fillClientContent 50000;字符串 id 在 8.6.41 实测同样 50000——issue#10 复现矩阵)。` +
1209
- `可靠链路:data.save 时人员字段用对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门)落库,提交(submit:true / data.submit)时**省略人员字段**——值已持久化,提交只推进流程。` +
1210
- `确要在提交携带人员字段(个别平台构建接受),显式带 allowStaffOnSubmit:true 自担。`,
1211
- });
1212
- }
1213
- // issue#10 A:save 面的字符串 id 才是错误形状(对象数组才是正解 B)——
1214
- // 裸字符串/字符串数组在 save 面直落平台换一个无诊断的 50000,fail-closed。
1215
- if (args.submit !== true) {
1216
- const staffStringSave = await staffStringShapeViolations(session, args.schemaCode, formData).catch(() => []);
1217
- if (staffStringSave.length > 0)
1242
+ writeTool({
1243
+ tools,
1244
+ name: "data.save",
1245
+ lane: RUNTIME_LANE,
1246
+ scope: RUNTIME_TOOLS["data.save"].scope,
1247
+ description: RUNTIME_TOOLS["data.save"].description,
1248
+ inputSchema: {
1249
+ schemaCode: z.string().min(1),
1250
+ sheetCode: z.string().optional(),
1251
+ formData: runtimeFormDataArg,
1252
+ workflowCode: z.string().optional().describe("portal draft-save shape: create the row AND a DRAFT workflow instance + start workItem (finishStart=FALSE); submit later via data.submit with the returned workItemId. formData must NOT carry an id — in-place workflow start has no REST surface in 8.6.26"),
1253
+ submit: z.boolean().optional().describe("true=保存后立即直提(无流程模型 DRAFT→COMPLETED,触发 Create 规则);与 workflowCode 互斥。**按业务约定正式数据优先直接 data.submit(无 id=新建并提交一调完成)——本参数是便捷别名,save 的定位是草稿/暂存**"),
1254
+ allowStaffOnSubmit: z.boolean().optional().describe("submit:true 时显式允许携带人员字段(issue#10:人员字段提交面两种形状都可能 50000,可靠链路是 save 落库后提交时省略;个别平台构建接受提交面携带,自担风险)"),
1255
+ ...readbackProjectionArgs,
1256
+ },
1257
+ execute: async (session, args) => {
1258
+ // F-A (self-audit): the combo used to be silently ignored (the workflow
1259
+ // draft branch returns without honoring submit). Refuse before any wire.
1260
+ if (args.submit === true && typeof args.workflowCode === "string" && args.workflowCode.trim())
1218
1261
  throw new ToolRefusalError({
1219
1262
  status: "refused",
1220
- blocker: "save-person-field-shape",
1263
+ blocker: "submit-workflow-code-conflict",
1221
1264
  schemaCode: args.schemaCode,
1222
- fields: staffStringSave,
1223
- message: `data.save 的人员字段用了字符串 id:${staffStringSave.map((entry) => `${entry.code}(${entry.got})`).join("、")}——save 面的正确形状是对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门;issue#10 实测矩阵:字符串 id 50000、对象数组成功)。读回值 [{id,type,name,…}] 可直接回写。`,
1265
+ message: "submit:true 与 workflowCode 互斥——workflowCode 是流程草稿两段式的第一段(返回 workItemId 后用 data.submit 提交);无流程模型的一调完成态用 submit:true 且不带 workflowCode",
1224
1266
  });
1225
- }
1226
- if (typeof args.workflowCode === "string" && args.workflowCode.trim()) {
1227
- // Portal draft-save shape. The row id travels in data.id (the portal
1228
- // form SDK receives a pre-generated id from form/load and echoes it);
1229
- // BizObjectModel.getId() reads data["id"], so a client-chosen id is
1230
- // echoed back as FormSaveVO.objectId. An id that already exists in
1231
- // the store would hit the create-path INSERT duplicate (40000), so an
1232
- // existing id is refused up front.
1233
- const suppliedId = typeof formData.id === "string" ? formData.id.trim() : "";
1234
- if (suppliedId) {
1235
- const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, suppliedId);
1236
- if (existing) {
1267
+ const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
1268
+ const rawSaveData = args.formData;
1269
+ const saveIsUpdate = typeof rawSaveData.id === "string" && rawSaveData.id.trim().length > 0;
1270
+ const formData = (await prepareRuntimeFormData(session, args.schemaCode, rawSaveData, { isUpdate: saveIsUpdate })).formData;
1271
+ // issue#9 C1 + issue#10 勘误:提交面(submit:true / data.submit)的人员字段
1272
+ // 两种已知形状都不安全——对象数组有历史 50000 证据(UserBizField.fillClientContent,
1273
+ // 本闸拦截),字符串 id 数组在 8.6.41 实测同样 50000(#10 D)。可靠链路只有一条:
1274
+ // data.save 人员字段用对象数组 [{id,type,name}] 落库 → 提交时**省略人员字段**(值已持久化)。
1275
+ if (args.submit === true && args.allowStaffOnSubmit !== true) {
1276
+ const staffOnSubmit = await staffFieldsPresent(session, args.schemaCode, formData).catch(() => []);
1277
+ if (staffOnSubmit.length > 0)
1237
1278
  throw new ToolRefusalError({
1238
1279
  status: "refused",
1239
- blocker: "save-workflow-draft-with-id-unsupported",
1280
+ blocker: "submit-person-field-shape",
1240
1281
  schemaCode: args.schemaCode,
1241
- bizObjectId: suppliedId,
1282
+ fields: staffOnSubmit,
1283
+ message: `提交携带了人员字段:${staffOnSubmit.map((entry) => `${entry.code}(${entry.got})`).join("、")}。提交面的两种形状都不安全(对象数组触发 UserBizField.fillClientContent 50000;字符串 id 在 8.6.41 实测同样 50000——issue#10 复现矩阵)。` +
1284
+ `可靠链路:data.save 时人员字段用对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门)落库,提交(submit:true / data.submit)时**省略人员字段**——值已持久化,提交只推进流程。` +
1285
+ `确要在提交携带人员字段(个别平台构建接受),显式带 allowStaffOnSubmit:true 自担。`,
1286
+ });
1287
+ }
1288
+ // issue#10 A:save 面的字符串 id 才是错误形状(对象数组才是正解 B)——
1289
+ // 裸字符串/字符串数组在 save 面直落平台换一个无诊断的 50000,fail-closed。
1290
+ if (args.submit !== true) {
1291
+ const staffStringSave = await staffStringShapeViolations(session, args.schemaCode, formData).catch(() => []);
1292
+ if (staffStringSave.length > 0)
1293
+ throw new ToolRefusalError({
1294
+ status: "refused",
1295
+ blocker: "save-person-field-shape",
1296
+ schemaCode: args.schemaCode,
1297
+ fields: staffStringSave,
1298
+ message: `data.save 的人员字段用了字符串 id:${staffStringSave.map((entry) => `${entry.code}(${entry.got})`).join("、")}——save 面的正确形状是对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门;issue#10 实测矩阵:字符串 id 50000、对象数组成功)。读回值 [{id,type,name,…}] 可直接回写。`,
1299
+ });
1300
+ }
1301
+ if (typeof args.workflowCode === "string" && args.workflowCode.trim()) {
1302
+ // Portal draft-save shape. The row id travels in data.id (the portal
1303
+ // form SDK receives a pre-generated id from form/load and echoes it);
1304
+ // BizObjectModel.getId() reads data["id"], so a client-chosen id is
1305
+ // echoed back as FormSaveVO.objectId. An id that already exists in
1306
+ // the store would hit the create-path INSERT duplicate (40000), so an
1307
+ // existing id is refused up front.
1308
+ const suppliedId = typeof formData.id === "string" ? formData.id.trim() : "";
1309
+ if (suppliedId) {
1310
+ const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, suppliedId);
1311
+ if (existing) {
1312
+ throw new ToolRefusalError({
1313
+ status: "refused",
1314
+ blocker: "save-workflow-draft-with-id-unsupported",
1315
+ schemaCode: args.schemaCode,
1316
+ bizObjectId: suppliedId,
1317
+ workflowCode: args.workflowCode,
1318
+ message: "workflow draft-save always INSERTs in 8.6.26 (no REST in-place start for an existing row); use a fresh id or no id, or submit an existing workflow draft via data.submit with its workItemId",
1319
+ });
1320
+ }
1321
+ }
1322
+ const objectId = suppliedId || randomUUID().replaceAll("-", "");
1323
+ // isStartWf branch runs verifyReplay; the instance is DRAFT only when
1324
+ // data carries sequenceStatus DRAFT (an empty default builds PROCESSING).
1325
+ const replayToken = await getReplayToken(session);
1326
+ let performed;
1327
+ try {
1328
+ performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.save 运行时写稳定失败(50000)", () => wireCall(session, {
1329
+ path: ENDPOINTS.runtimeFormSave,
1330
+ method: "POST",
1331
+ body: {
1332
+ workflowCode: args.workflowCode,
1333
+ ...(replayToken ? { replayToken } : {}),
1334
+ bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: objectId, sequenceStatus: "DRAFT" } },
1335
+ },
1336
+ }), formData, args.workflowCode);
1337
+ }
1338
+ catch (error) {
1339
+ await diagnoseDbException(session, args.schemaCode, error);
1340
+ throw error;
1341
+ }
1342
+ const vo = (performed.data ?? {});
1343
+ const returnedId = typeof vo.objectId === "string" && vo.objectId ? vo.objectId : typeof vo.bizObjectId === "string" && vo.bizObjectId ? vo.bizObjectId : objectId;
1344
+ const workflowInstanceId = typeof vo.workflowInstanceId === "string" ? vo.workflowInstanceId : null;
1345
+ // saveWorkflow resolves the start workItem via getWorkItem INSIDE the
1346
+ // still-uncommitted request transaction (autoCommit:false), so the live
1347
+ // FormSaveVO usually omits workItem. The committed row is visible to a
1348
+ // follow-up search_workitems read; resolve the pending start todo by
1349
+ // exact instanceId (the server filter is a NOT-equal exclusion).
1350
+ let workItem = vo.workItem;
1351
+ let workItemId = workItem && typeof workItem.id === "string" ? workItem.id : null;
1352
+ let workItemSource = workItemId ? "form-save" : null;
1353
+ if (!workItemId && workflowInstanceId) {
1354
+ // search_workitems via the shared workflow seam (ticket 21): exact
1355
+ // instanceId match is client-side (the server filter is a NOT-equal
1356
+ // exclusion); the seam's default size 200 is the unified scan width.
1357
+ const found = await searchWorkItems(session, {
1242
1358
  workflowCode: args.workflowCode,
1243
- message: "workflow draft-save always INSERTs in 8.6.26 (no REST in-place start for an existing row); use a fresh id or no id, or submit an existing workflow draft via data.submit with its workItemId",
1359
+ instanceId: workflowInstanceId,
1244
1360
  });
1361
+ const matchId = found.items.length > 0 ? wireString(found.items[0].id) : null;
1362
+ if (matchId) {
1363
+ workItemId = matchId;
1364
+ workItemSource = "workitem-search";
1365
+ }
1245
1366
  }
1367
+ let readbackData = null;
1368
+ let sequenceStatus = null;
1369
+ readbackData = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: returnedId } })).data;
1370
+ sequenceStatus = extractSequenceStatus((readbackData ?? {}));
1371
+ return {
1372
+ performed: { errcode: performed.errcode, bizObjectId: returnedId, workflowInstanceId, workItemId, workItemSource, sheetCode, resolvedFrom, formType: "save-draft-workflow" },
1373
+ readback: { authoritative: projectReadbackRecord(normalizeLoadedRecord((readbackData ?? {})), args), sequenceStatus, expectedSequenceStatus: "DRAFT" },
1374
+ };
1246
1375
  }
1247
- const objectId = suppliedId || randomUUID().replaceAll("-", "");
1248
- // isStartWf branch runs verifyReplay; the instance is DRAFT only when
1249
- // data carries sequenceStatus DRAFT (an empty default builds PROCESSING).
1250
- const replayToken = await getReplayToken(session);
1251
1376
  let performed;
1252
1377
  try {
1253
1378
  performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.save 运行时写稳定失败(50000)", () => wireCall(session, {
1254
1379
  path: ENDPOINTS.runtimeFormSave,
1255
1380
  method: "POST",
1256
- body: {
1257
- workflowCode: args.workflowCode,
1258
- ...(replayToken ? { replayToken } : {}),
1259
- bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: objectId, sequenceStatus: "DRAFT" } },
1260
- },
1261
- }), formData, args.workflowCode);
1381
+ body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: formData } },
1382
+ }), formData);
1262
1383
  }
1263
1384
  catch (error) {
1264
1385
  await diagnoseDbException(session, args.schemaCode, error);
1265
1386
  throw error;
1266
1387
  }
1267
- const vo = (performed.data ?? {});
1268
- const returnedId = typeof vo.objectId === "string" && vo.objectId ? vo.objectId : typeof vo.bizObjectId === "string" && vo.bizObjectId ? vo.bizObjectId : objectId;
1269
- const workflowInstanceId = typeof vo.workflowInstanceId === "string" ? vo.workflowInstanceId : null;
1270
- // saveWorkflow resolves the start workItem via getWorkItem INSIDE the
1271
- // still-uncommitted request transaction (autoCommit:false), so the live
1272
- // FormSaveVO usually omits workItem. The committed row is visible to a
1273
- // follow-up search_workitems read; resolve the pending start todo by
1274
- // exact instanceId (the server filter is a NOT-equal exclusion).
1275
- let workItem = vo.workItem;
1276
- let workItemId = workItem && typeof workItem.id === "string" ? workItem.id : null;
1277
- let workItemSource = workItemId ? "form-save" : null;
1278
- if (!workItemId && workflowInstanceId) {
1279
- // search_workitems via the shared workflow seam (ticket 21): exact
1280
- // instanceId match is client-side (the server filter is a NOT-equal
1281
- // exclusion); the seam's default size 200 is the unified scan width.
1282
- const found = await searchWorkItems(session, {
1283
- workflowCode: args.workflowCode,
1284
- instanceId: workflowInstanceId,
1285
- });
1286
- const matchId = found.items.length > 0 ? wireString(found.items[0].id) : null;
1287
- if (matchId) {
1288
- workItemId = matchId;
1289
- workItemSource = "workitem-search";
1290
- }
1291
- }
1388
+ const bizObjectId = performed.data?.bizObjectId ?? performed.data?.objectId ?? performed.data;
1292
1389
  let readbackData = null;
1293
- let sequenceStatus = null;
1294
- readbackData = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: returnedId } })).data;
1295
- sequenceStatus = extractSequenceStatus((readbackData ?? {}));
1296
- return {
1297
- performed: { errcode: performed.errcode, bizObjectId: returnedId, workflowInstanceId, workItemId, workItemSource, sheetCode, resolvedFrom, formType: "save-draft-workflow" },
1298
- readback: { authoritative: projectReadbackRecord(normalizeLoadedRecord((readbackData ?? {})), args), sequenceStatus, expectedSequenceStatus: "DRAFT" },
1390
+ if (typeof bizObjectId === "string" && bizObjectId) {
1391
+ readbackData = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId } })).data;
1392
+ }
1393
+ // Ticket 111: a plain save lands as sequenceStatus DRAFT, and DRAFT
1394
+ // rows SKIP the Create/Update rule dispatch entirely
1395
+ // (DomainServiceImpl.java:240-248 buckets drafts before the rule
1396
+ // branch) — business rules only run once data.submit advances the
1397
+ // row. Saying so on every plain-save readback prevents the
1398
+ // "rule didn't fire" misdiagnosis.
1399
+ const savedRecord = normalizeLoadedRecord((readbackData ?? {}));
1400
+ const savedEmptyFields = readbackEmptyFields(formData, savedRecord);
1401
+ const plainReadback = {
1402
+ authoritative: projectReadbackRecord(savedRecord, args),
1403
+ ...(savedEmptyFields.length > 0 ? { readbackWarning: readbackWarningFor(savedEmptyFields, "data.save") } : {}),
1404
+ draftRuleHint: "草稿态(DRAFT)保存不触发业务规则——DRAFT 行跳过 Create/Update 分发(DomainServiceImpl.java:240-248);要一调完成态本调用加 submit:true;已存草稿直提形状:data.submit {schemaCode, bizObjectId: 本读回的 bizObjectId, formData}(submit 自持久化)",
1299
1405
  };
1300
- }
1301
- let performed;
1302
- try {
1303
- performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.save 运行时写稳定失败(50000)", () => wireCall(session, {
1304
- path: ENDPOINTS.runtimeFormSave,
1305
- method: "POST",
1306
- body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: formData } },
1307
- }), formData);
1308
- }
1309
- catch (error) {
1310
- await diagnoseDbException(session, args.schemaCode, error);
1311
- throw error;
1312
- }
1313
- const bizObjectId = performed.data?.bizObjectId ?? performed.data?.objectId ?? performed.data;
1314
- let readbackData = null;
1315
- if (typeof bizObjectId === "string" && bizObjectId) {
1316
- readbackData = (await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId } })).data;
1317
- }
1318
- // Ticket 111: a plain save lands as sequenceStatus DRAFT, and DRAFT
1319
- // rows SKIP the Create/Update rule dispatch entirely
1320
- // (DomainServiceImpl.java:240-248 buckets drafts before the rule
1321
- // branch) — business rules only run once data.submit advances the
1322
- // row. Saying so on every plain-save readback prevents the
1323
- // "rule didn't fire" misdiagnosis.
1324
- const savedRecord = normalizeLoadedRecord((readbackData ?? {}));
1325
- const savedEmptyFields = readbackEmptyFields(formData, savedRecord);
1326
- const plainReadback = {
1327
- authoritative: projectReadbackRecord(savedRecord, args),
1328
- ...(savedEmptyFields.length > 0 ? { readbackWarning: readbackWarningFor(savedEmptyFields, "data.save") } : {}),
1329
- draftRuleHint: "草稿态(DRAFT)保存不触发业务规则——DRAFT 行跳过 Create/Update 分发(DomainServiceImpl.java:240-248);要一调完成态本调用加 submit:true;已存草稿直提形状:data.submit {schemaCode, bizObjectId: 本读回的 bizObjectId, formData}(submit 自持久化)",
1330
- };
1331
- // r18 retro (G3): one-call COMPLETED creation — the agent goal is usually
1332
- // "record AND rule effect", not "a draft". Chain the no-flow submit in
1333
- // the same governed write (the same sibling execute apply_changes uses).
1334
- if (args.submit === true) {
1335
- // F-B (self-audit): data.submit AUTO-STARTS a published workflow
1336
- // (resolvePublishedWorkflowCode → formType 1 → PROCESSING, not
1337
- // COMPLETED) — the one-call chain's ruleHint would lie. Workflow-
1338
- // backed models keep the two-phase chain.
1339
- const publishedWorkflow = await resolvePublishedWorkflowCode(session, args.schemaCode);
1340
- if (publishedWorkflow) {
1406
+ // r18 retro (G3): one-call COMPLETED creation — the agent goal is usually
1407
+ // "record AND rule effect", not "a draft". Chain the no-flow submit in
1408
+ // the same governed write (the same sibling execute apply_changes uses).
1409
+ if (args.submit === true) {
1410
+ // F-B (self-audit): data.submit AUTO-STARTS a published workflow
1411
+ // (resolvePublishedWorkflowCode → formType 1 → PROCESSING, not
1412
+ // COMPLETED) — the one-call chain's ruleHint would lie. Workflow-
1413
+ // backed models keep the two-phase chain.
1414
+ const publishedWorkflow = await resolvePublishedWorkflowCode(session, args.schemaCode);
1415
+ if (publishedWorkflow) {
1416
+ return {
1417
+ performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom, submitted: false },
1418
+ readback: {
1419
+ ...plainReadback,
1420
+ submitRefused: {
1421
+ blocker: "submit-model-has-workflow",
1422
+ workflowCode: publishedWorkflow,
1423
+ message: `本模型有已发布流程 ${publishedWorkflow}:submit:true 的直提只适用无流程模型(流程模型会自动起流程得到 PROCESSING 而非完成态,Create 规则语义也不同)。草稿已保存;正确链路 = data.save {workflowCode} 建流程草稿(返回 workItemId)→ data.submit {bizObjectId, workflowInstanceId, workItemId}(见 describe data.submit --example workflow-backed-data-submit)`,
1424
+ },
1425
+ },
1426
+ };
1427
+ }
1428
+ const submitTool = tools.find((entry) => entry.name === "data.submit");
1429
+ const submitOutcome = submitTool?.writeExecute
1430
+ ? await submitTool.writeExecute(session, { schemaCode: args.schemaCode, bizObjectId, formData: args.formData })
1431
+ : { status: "error", message: "data.submit 工具不可用" };
1432
+ if ("status" in submitOutcome && typeof submitOutcome.status === "string" && submitOutcome.status !== "ok") {
1433
+ // The save itself DELIVERED — report it honestly with the submit
1434
+ // refusal attached, never as a plain refusal.
1435
+ const envelope = "envelope" in submitOutcome ? submitOutcome.envelope : submitOutcome;
1436
+ return {
1437
+ performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom, submitted: false },
1438
+ readback: { ...plainReadback, submitRefused: envelope },
1439
+ };
1440
+ }
1441
+ const okOutcome = submitOutcome;
1341
1442
  return {
1342
- performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom, submitted: false },
1443
+ performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom, submitted: true },
1343
1444
  readback: {
1344
- ...plainReadback,
1345
- submitRefused: {
1346
- blocker: "submit-model-has-workflow",
1347
- workflowCode: publishedWorkflow,
1348
- message: `本模型有已发布流程 ${publishedWorkflow}:submit:true 的直提只适用无流程模型(流程模型会自动起流程得到 PROCESSING 而非完成态,Create 规则语义也不同)。草稿已保存;正确链路 = data.save {workflowCode} 建流程草稿(返回 workItemId)→ data.submit {bizObjectId, workflowInstanceId, workItemId}(见 describe data.submit --example workflow-backed-data-submit)`,
1349
- },
1445
+ ...okOutcome.readback,
1446
+ submitted: true,
1447
+ ruleHint: "已直提 COMPLETED——Create 业务规则本次已分发;规则效果以本读回字段值为准",
1350
1448
  },
1351
1449
  };
1352
1450
  }
1353
- const submitTool = tools.find((entry) => entry.name === "data.submit");
1354
- const submitOutcome = submitTool?.writeExecute
1355
- ? await submitTool.writeExecute(session, { schemaCode: args.schemaCode, bizObjectId, formData: args.formData })
1356
- : { status: "error", message: "data.submit 工具不可用" };
1357
- if ("status" in submitOutcome && typeof submitOutcome.status === "string" && submitOutcome.status !== "ok") {
1358
- // The save itself DELIVERED — report it honestly with the submit
1359
- // refusal attached, never as a plain refusal.
1360
- const envelope = "envelope" in submitOutcome ? submitOutcome.envelope : submitOutcome;
1361
- return {
1362
- performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom, submitted: false },
1363
- readback: { ...plainReadback, submitRefused: envelope },
1364
- };
1365
- }
1366
- const okOutcome = submitOutcome;
1367
1451
  return {
1368
- performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom, submitted: true },
1369
- readback: {
1370
- ...okOutcome.readback,
1371
- submitted: true,
1372
- ruleHint: "已直提 COMPLETED——Create 业务规则本次已分发;规则效果以本读回字段值为准",
1373
- },
1452
+ performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom },
1453
+ readback: plainReadback,
1374
1454
  };
1375
- }
1376
- return {
1377
- performed: { errcode: performed.errcode, bizObjectId, sheetCode, resolvedFrom },
1378
- readback: plainReadback,
1379
- };
1455
+ },
1380
1456
  });
1381
- writeTool(tools, "data.update", RUNTIME_LANE, RUNTIME_TOOLS["data.update"].scope, RUNTIME_TOOLS["data.update"].description, {
1382
- schemaCode: z.string().min(1),
1383
- sheetCode: z.string().optional(),
1384
- bizObjectId: z.string().min(1).describe("id of the EXISTING record to edit; the tool refuses when it is absent (never silently inserts)"),
1385
- formData: runtimeFormDataArg.describe("fields to change, keyed by field code; the platform merges them onto the stored record, so omitted fields keep their value (patch semantics)"),
1386
- ...readbackProjectionArgs,
1387
- }, async (session, args) => {
1388
- const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
1389
- const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, args.bizObjectId);
1390
- if (!existing) {
1391
- // The platform's save-with-id path silently inserts when the id is
1392
- // unknown (BizObjectFacadeImpl.createBizObject). Refuse instead of
1393
- // turning an edit into an accidental create.
1394
- throw new ToolRefusalError({
1395
- status: "refused",
1396
- blocker: "update-target-absent",
1397
- errcode: RUNTIME_DATA_WIRE.absenceErrcode,
1398
- schemaCode: args.schemaCode,
1399
- bizObjectId: args.bizObjectId,
1400
- sheetCode,
1401
- nextCommand: `omc call data.list --args '${JSON.stringify({ schemaCode: args.schemaCode })}'`,
1402
- message: "no live business object with this id; refusing to update (the platform would otherwise insert a new row)",
1403
- });
1404
- }
1405
- const formData = (await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: true })).formData;
1406
- let performed;
1407
- try {
1408
- performed = await wireCall(session, {
1409
- path: ENDPOINTS.runtimeFormSave,
1410
- method: "POST",
1411
- // Live-proven shape: BizObjectModel.getId() reads data["id"], so the
1412
- // target id MUST travel inside `data`; a top-level bizObject.id is
1413
- // ignored and the platform silently creates a new row.
1414
- body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: args.bizObjectId } } },
1415
- });
1416
- }
1417
- catch (error) {
1418
- await diagnoseDbException(session, args.schemaCode, error);
1419
- throw error;
1420
- }
1421
- const performedData = (performed.data ?? {});
1422
- const returnedId = typeof performedData.objectId === "string" ? performedData.objectId : typeof performedData.bizObjectId === "string" ? performedData.bizObjectId : args.bizObjectId;
1423
- const readbackData = await loadExistingBizObject(session, args.schemaCode, sheetCode, returnedId);
1424
- const sequenceStatus = extractSequenceStatus(readbackData);
1425
- const updatedRecord = normalizeLoadedRecord(readbackData);
1426
- const updatedEmptyFields = readbackEmptyFields(formData, updatedRecord);
1427
- return {
1428
- performed: { errcode: performed.errcode, bizObjectId: returnedId, sheetCode, resolvedFrom },
1429
- // Flat record contract (ticket 17): live FormLoadVO normalizes to the
1430
- // fake's flat field shape so `authoritative.<fieldCode>` reads work on
1431
- // both faces.
1432
- readback: { authoritative: projectReadbackRecord(updatedRecord, args), sequenceStatus, ...(updatedEmptyFields.length > 0 ? { readbackWarning: readbackWarningFor(updatedEmptyFields, "data.update") } : {}) },
1433
- };
1457
+ writeTool({
1458
+ tools,
1459
+ name: "data.update",
1460
+ lane: RUNTIME_LANE,
1461
+ scope: RUNTIME_TOOLS["data.update"].scope,
1462
+ description: RUNTIME_TOOLS["data.update"].description,
1463
+ inputSchema: {
1464
+ schemaCode: z.string().min(1),
1465
+ sheetCode: z.string().optional(),
1466
+ bizObjectId: z.string().min(1).describe("id of the EXISTING record to edit; the tool refuses when it is absent (never silently inserts)"),
1467
+ formData: runtimeFormDataArg.describe("fields to change, keyed by field code; the platform merges them onto the stored record, so omitted fields keep their value (patch semantics)"),
1468
+ ...readbackProjectionArgs,
1469
+ },
1470
+ execute: async (session, args) => {
1471
+ const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
1472
+ const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, args.bizObjectId);
1473
+ if (!existing) {
1474
+ // The platform's save-with-id path silently inserts when the id is
1475
+ // unknown (BizObjectFacadeImpl.createBizObject). Refuse instead of
1476
+ // turning an edit into an accidental create.
1477
+ throw new ToolRefusalError({
1478
+ status: "refused",
1479
+ blocker: "update-target-absent",
1480
+ errcode: RUNTIME_DATA_WIRE.absenceErrcode,
1481
+ schemaCode: args.schemaCode,
1482
+ bizObjectId: args.bizObjectId,
1483
+ sheetCode,
1484
+ nextCommand: `omc call data.list --args '${JSON.stringify({ schemaCode: args.schemaCode })}'`,
1485
+ message: "no live business object with this id; refusing to update (the platform would otherwise insert a new row)",
1486
+ });
1487
+ }
1488
+ const formData = (await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: true })).formData;
1489
+ let performed;
1490
+ try {
1491
+ performed = await wireCall(session, {
1492
+ path: ENDPOINTS.runtimeFormSave,
1493
+ method: "POST",
1494
+ // Live-proven shape: BizObjectModel.getId() reads data["id"], so the
1495
+ // target id MUST travel inside `data`; a top-level bizObject.id is
1496
+ // ignored and the platform silently creates a new row.
1497
+ body: { bizObject: { schemaCode: args.schemaCode, sheetCode, data: { ...formData, id: args.bizObjectId } } },
1498
+ });
1499
+ }
1500
+ catch (error) {
1501
+ await diagnoseDbException(session, args.schemaCode, error);
1502
+ throw error;
1503
+ }
1504
+ const performedData = (performed.data ?? {});
1505
+ const returnedId = typeof performedData.objectId === "string" ? performedData.objectId : typeof performedData.bizObjectId === "string" ? performedData.bizObjectId : args.bizObjectId;
1506
+ const readbackData = await loadExistingBizObject(session, args.schemaCode, sheetCode, returnedId);
1507
+ const sequenceStatus = extractSequenceStatus(readbackData);
1508
+ const updatedRecord = normalizeLoadedRecord(readbackData);
1509
+ const updatedEmptyFields = readbackEmptyFields(formData, updatedRecord);
1510
+ return {
1511
+ performed: { errcode: performed.errcode, bizObjectId: returnedId, sheetCode, resolvedFrom },
1512
+ // Flat record contract (ticket 17): live FormLoadVO normalizes to the
1513
+ // fake's flat field shape so `authoritative.<fieldCode>` reads work on
1514
+ // both faces.
1515
+ readback: { authoritative: projectReadbackRecord(updatedRecord, args), sequenceStatus, ...(updatedEmptyFields.length > 0 ? { readbackWarning: readbackWarningFor(updatedEmptyFields, "data.update") } : {}) },
1516
+ };
1517
+ },
1434
1518
  });
1435
1519
  /**
1436
1520
  * Round 16b (dogfood R1-R3): `data.delete`'s `absent:true` only proves THAT id is
@@ -1455,549 +1539,589 @@ export function registerRuntimeDataLane(tools) {
1455
1539
  return { modelResidue: { total: null, clean: null, sheetCode, modelResidueError: error instanceof Error ? error.message : String(error) } };
1456
1540
  }
1457
1541
  }
1458
- writeTool(tools, "data.delete", RUNTIME_LANE, RUNTIME_TOOLS["data.delete"].scope, RUNTIME_TOOLS["data.delete"].description, { schemaCode: z.string().min(1), sheetCode: z.string().optional(), bizObjectId: z.string().min(1).optional(), bizObjectIds: z.array(z.string().min(1)).optional().describe("批量删除多个业务对象 id(等价于逐个 data.delete,但只花一次往返;返回 deleted 列表)") }, async (session, args) => {
1459
- const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
1460
- const ids = args.bizObjectIds && args.bizObjectIds.length > 0 ? args.bizObjectIds : args.bizObjectId ? [args.bizObjectId] : [];
1461
- if (ids.length === 0)
1462
- throw new ToolInputError({ status: "error", blocker: "tool-input-invalid", tool: "data.delete", message: "需要 bizObjectId(单个)或 bizObjectIds(数组,批量)" });
1463
- // r30 (2026-10-31, 1.1.25 real machine): the batch form fake-succeeds —
1464
- // DELIVERED + absent:true + a DECREASING modelResidue.total while the
1465
- // rows are still queryable (data.list unchanged, data.load exists:true).
1466
- // Re-issuing the same ids one at a time deleted them for real. Refuse
1467
- // the batch shape and teach the per-id loop until the platform path is
1468
- // understood; single-id deletes go through the exact same deleteOne.
1469
- if (ids.length > 1)
1470
- throw new ToolRefusalError({
1471
- status: "refused",
1472
- blocker: "data-delete-batch-fake-success",
1473
- message: "批量删除(bizObjectIds 多个)在 1.1.25 真机实证为假成功:回执 DELIVERED + absent:true + modelResidue.total 递减,但行仍在(data.list 不变、data.load exists:true);同样的 id 改单条逐个删才真删掉。请用 bizObjectId(单个)逐个删,每条删完用 data.load 确认 exists:false(absent:true 不是删除成功的证明)。",
1474
- });
1475
- const deleteOne = async (bizObjectId) => {
1476
- // Delete is destructive: probe the authoritative object first so an
1477
- // absent id is an idempotent no-op, never a blind runtime delete.
1478
- const before = await loadExistingBizObject(session, args.schemaCode, sheetCode, bizObjectId);
1479
- if (!before)
1480
- return { bizObjectId, skipped: "already-absent", absent: true };
1481
- const performed = await wireCall(session, {
1482
- path: ENDPOINTS.runtimeFormDelete,
1483
- method: "POST",
1484
- query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId },
1485
- });
1486
- // The absence readback: a missing object surfaces either as an empty
1487
- // payload or as a business error (304001 业务对象不存在) from form/load.
1488
- // Both are authoritative absence, not a delete failure.
1489
- let absent = false;
1490
- try {
1491
- const readback = await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId } });
1492
- const value = readback.data;
1493
- absent = value == null || value === false;
1494
- }
1495
- catch (error) {
1496
- if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.absenceErrcode)
1497
- absent = true;
1498
- else
1499
- throw error;
1500
- }
1501
- return { bizObjectId, errcode: performed.errcode, absent };
1502
- };
1503
- if (ids.length === 1) {
1504
- const one = await deleteOne(ids[0]);
1505
- if (one.skipped === "already-absent")
1506
- return { performed: { skipped: "already-absent", sheetCode, resolvedFrom }, readback: { absent: true, recoveryHint: "id 不存在(already-absent≠删除成功):真实 id 必须取自 data.list 的 items[].id;工作流中心/门户页面回显的 id 会脱敏,shell 空值兜底出来的 id 同样删不掉任何东西。若 modelResidue.total>0,用 items[].id 重删。", ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
1507
- return { performed: { errcode: one.errcode, sheetCode, resolvedFrom }, readback: { absent: one.absent, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
1508
- }
1509
- const deleted = [];
1510
- for (const id of ids)
1511
- deleted.push(await deleteOne(id));
1512
- // `deleted`/`count` ride `readback` — the write envelope surfaces only
1513
- // performed/readback inline (extra top-level keys are artifact-only).
1514
- return { performed: { sheetCode, resolvedFrom }, readback: { absent: true, count: deleted.length, deleted, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
1515
- });
1516
- writeTool(tools, "data.submit", RUNTIME_LANE, RUNTIME_TOOLS["data.submit"].scope, RUNTIME_TOOLS["data.submit"].description, {
1517
- schemaCode: z.string().min(1),
1518
- sheetCode: z.string().optional(),
1519
- formData: runtimeFormDataArg,
1520
- workflowCode: z.string().optional(),
1521
- workflowInstanceId: z.string().optional(),
1522
- workItemId: z.string().optional(),
1523
- bizObjectId: z.string().optional().describe("existing record id; REQUIRED for workItem approval submits (formType=1 + workItemId) — the submit's bizObject must reference the in-flight record or the engine NPEs (DataChangeHandlerImpl.changeBizObject)"),
1524
- agree: z.boolean().optional(),
1525
- allowChildRowAppend: z.boolean().optional().describe("默认 false:审批提交(带 workItemId/workflowInstanceId)时 formData 含子表数组会被拒——平台按追加插行,每次提交重复一份。确实要追加子行时才置 true。"),
1526
- allowStaffOnSubmit: z.boolean().optional().describe("默认 false:提交携带人员字段会被拒(issue#10:两种形状都可能 50000;可靠链路=save 落库后提交省略人员字段)。个别平台构建接受提交面携带时置 true 自担。"),
1527
- approval: z.record(z.string(), z.unknown()).optional(),
1528
- runMode: z.string().optional(),
1529
- isSimulative: z.boolean().optional(),
1530
- ...readbackProjectionArgs,
1531
- }, async (session, args) => {
1532
- // r13 #5: an EMPTY STRING was forwarded verbatim, so the platform answered
1533
- // 100002「参数为空」with no hint which parameter. Absent and blank are not
1534
- // the same intent: refuse the blank one by name, zero wire.
1535
- const blank = ["workItemId", "workflowInstanceId", "workflowCode"].filter((key) => typeof args[key] === "string" && args[key].trim().length === 0 && args[key].length > 0);
1536
- if (blank.length > 0)
1537
- throw new ToolRefusalError({
1538
- status: "refused",
1539
- blocker: "submit-blank-parameter",
1540
- schemaCode: args.schemaCode,
1541
- parameters: blank,
1542
- message: `这些参数传了空白字符串:${blank.join("、")}。平台会把这个空白当"参数为空"回 100002(SYS_PARAMETER_EMPTY),` +
1543
- `而错误里不会说是哪一个。要么给出真实 id,要么整个省略该键(省略 ≠ 传空串)。`,
1544
- });
1545
- const hasWorkItem = typeof args.workItemId === "string" && args.workItemId.trim().length > 0;
1546
- // r18b: pure shape check — run before any read/wire so a wrong-result
1547
- // payload never costs a round trip.
1548
- if (args.approval && typeof args.approval === "object") {
1549
- const misplaced = args.approval;
1550
- if (misplaced.agree !== undefined)
1551
- throw new ToolRefusalError({
1552
- status: "refused",
1553
- blocker: "submit-approval-agree-misplaced",
1554
- schemaCode: args.schemaCode,
1555
- message: `approval.agree 不生效:平台只读**顶层** agree(true=通过 / false=驳回)。approval 对象里放 agree 会被**静默忽略并按通过处理**` +
1556
- `(r18b 真机:approval:{agree:false} 仍写入 result=APPROVE 并把流程走完)。改成顶层参数 {"agree":false,...}。`,
1542
+ writeTool({
1543
+ tools,
1544
+ name: "data.delete",
1545
+ lane: RUNTIME_LANE,
1546
+ scope: RUNTIME_TOOLS["data.delete"].scope,
1547
+ description: RUNTIME_TOOLS["data.delete"].description,
1548
+ inputSchema: { schemaCode: z.string().min(1), sheetCode: z.string().optional(), bizObjectId: z.string().min(1).optional(), bizObjectIds: z.array(z.string().min(1)).optional().describe("批量删除多个业务对象 id(等价于逐个 data.delete,但只花一次往返;返回 deleted 列表)") },
1549
+ execute: async (session, args) => {
1550
+ const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
1551
+ const ids = args.bizObjectIds && args.bizObjectIds.length > 0 ? args.bizObjectIds : args.bizObjectId ? [args.bizObjectId] : [];
1552
+ if (ids.length === 0)
1553
+ throw new ToolInputError({
1554
+ status: "error",
1555
+ blocker: "tool-input-invalid",
1556
+ tool: "data.delete",
1557
+ // r28b F10: describe is not the next action — the caller already
1558
+ // described. Point at list→id then a pasteable single-id delete.
1559
+ nextCommand: `omc call data.list --args '${JSON.stringify({ schemaCode: args.schemaCode, all: true })}'`,
1560
+ message: "需要 bizObjectId(单个)或 bizObjectIds(数组,批量)。先 data.list 取 items[].id,再:{\"schemaCode\":\"" +
1561
+ args.schemaCode +
1562
+ "\",\"bizObjectId\":\"<items[].id>\"}(1.1.25 上多个 id 的 bizObjectIds 会被拒——请逐个单删)。",
1557
1563
  });
1558
- if (misplaced.opinion !== undefined)
1564
+ // r30 (2026-10-31, 1.1.25 real machine): the batch form fake-succeeds —
1565
+ // DELIVERED + absent:true + a DECREASING modelResidue.total while the
1566
+ // rows are still queryable (data.list unchanged, data.load exists:true).
1567
+ // Re-issuing the same ids one at a time deleted them for real. Refuse
1568
+ // the batch shape and teach the per-id loop until the platform path is
1569
+ // understood; single-id deletes go through the exact same deleteOne.
1570
+ if (ids.length > 1)
1559
1571
  throw new ToolRefusalError({
1560
1572
  status: "refused",
1561
- blocker: "submit-approval-opinion-misplaced",
1562
- schemaCode: args.schemaCode,
1563
- message: `approval.opinion 不是平台键,会被静默丢弃(r18b 真机:意见不进 h_biz_comment,content 为 NULL)。` +
1564
- `审批意见(BizCommentModel)用 **approval.content**;附件 approval.resources;下一步审批人 approval.relUsers。`,
1573
+ blocker: "data-delete-batch-fake-success",
1574
+ // r28b F14: point at a concrete single-id call, not the methodology page.
1575
+ nextCommand: `omc call data.delete --args '${JSON.stringify({ schemaCode: args.schemaCode, bizObjectId: ids[0] })}'`,
1576
+ message: "批量删除(bizObjectIds 多个)在 1.1.25 真机实证为假成功:回执 DELIVERED + absent:true + modelResidue.total 递减,但行仍在(data.list 不变、data.load exists:true);同样的 id 改单条逐个删才真删掉。请用 bizObjectId(单个)逐个删,每条删完用 data.load 确认 exists:false(absent:true 不是删除成功的证明)。" +
1577
+ `下一步:把 ${ids.length} 个 id 拆成 ${ids.length} 次单删(示例已写入 nextCommand;其余 id 替换 bizObjectId 即可)。`,
1565
1578
  });
1566
- }
1567
- const hasInstance = typeof args.workflowInstanceId === "string" && args.workflowInstanceId.trim().length > 0;
1568
- const targetObjectId = typeof args.bizObjectId === "string" && args.bizObjectId.trim() ? args.bizObjectId.trim() : null;
1569
- if ((hasWorkItem || hasInstance) && !targetObjectId) {
1570
- throw new ToolRefusalError({
1571
- status: "refused",
1572
- blocker: "submit-biz-object-id-required",
1573
- schemaCode: args.schemaCode,
1574
- workItemId: args.workItemId,
1575
- workflowInstanceId: args.workflowInstanceId,
1576
- message: "workItemId or workflowInstanceId submit requires bizObjectId; refusing before runtime/form/submit",
1577
- });
1578
- }
1579
- const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
1580
- const stampedSubmit = await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: targetObjectId !== null });
1581
- const formData = stampedSubmit.formData;
1582
- // r5-asset 2026-10-20: the platform APPENDS child rows carried in an
1583
- // approval submit's formData (no upsert by row id, no error). Live: an
1584
- // approval that echoed the whole form twice turned 2 detail rows into 6
1585
- // and 30000 into 90000 while every call answered errcode 0 — node-level
1586
- // `editable:false` does not stop it server-side. Refuse before the wire.
1587
- if ((hasWorkItem || hasInstance) && args.allowChildRowAppend !== true) {
1588
- // Scan at ANY depth: callers wrap the form as {data:{...}} or
1589
- // {bizObject:{data:{...}}}, and a top-level-only check let those through
1590
- // (the platform appends whatever it finds). The child codes come from the
1591
- // model's own field list.
1592
- const carried = collectChildArrayPaths(args.formData, new Set(stampedSubmit.childCodes));
1593
- if (carried.length > 0)
1579
+ const deleteOne = async (bizObjectId) => {
1580
+ // Delete is destructive: probe the authoritative object first so an
1581
+ // absent id is an idempotent no-op, never a blind runtime delete.
1582
+ const before = await loadExistingBizObject(session, args.schemaCode, sheetCode, bizObjectId);
1583
+ if (!before)
1584
+ return { bizObjectId, skipped: "already-absent", absent: true };
1585
+ const performed = await wireCall(session, {
1586
+ path: ENDPOINTS.runtimeFormDelete,
1587
+ method: "POST",
1588
+ query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId },
1589
+ });
1590
+ // The absence readback: a missing object surfaces either as an empty
1591
+ // payload or as a business error (304001 业务对象不存在) from form/load.
1592
+ // Both are authoritative absence, not a delete failure.
1593
+ let absent = false;
1594
+ try {
1595
+ const readback = await wireCall(session, { path: ENDPOINTS.runtimeFormLoad, query: { schemaCode: args.schemaCode, sheetCode, objectId: bizObjectId } });
1596
+ const value = readback.data;
1597
+ absent = value == null || value === false;
1598
+ }
1599
+ catch (error) {
1600
+ if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.absenceErrcode)
1601
+ absent = true;
1602
+ else
1603
+ throw error;
1604
+ }
1605
+ return { bizObjectId, errcode: performed.errcode, absent };
1606
+ };
1607
+ if (ids.length === 1) {
1608
+ const one = await deleteOne(ids[0]);
1609
+ if (one.skipped === "already-absent")
1610
+ return { performed: { skipped: "already-absent", sheetCode, resolvedFrom }, readback: { absent: true, recoveryHint: "id 不存在(already-absent≠删除成功):真实 id 必须取自 data.list 的 items[].id;工作流中心/门户页面回显的 id 会脱敏,shell 空值兜底出来的 id 同样删不掉任何东西。若 modelResidue.total>0,用 items[].id 重删。", ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
1611
+ return { performed: { errcode: one.errcode, sheetCode, resolvedFrom }, readback: { absent: one.absent, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
1612
+ }
1613
+ const deleted = [];
1614
+ for (const id of ids)
1615
+ deleted.push(await deleteOne(id));
1616
+ // `deleted`/`count` ride `readback` — the write envelope surfaces only
1617
+ // performed/readback inline (extra top-level keys are artifact-only).
1618
+ return { performed: { sheetCode, resolvedFrom }, readback: { absent: true, count: deleted.length, deleted, ...(await modelResidue(session, args.schemaCode, sheetCode)) } };
1619
+ },
1620
+ });
1621
+ writeTool({
1622
+ tools,
1623
+ name: "data.submit",
1624
+ lane: RUNTIME_LANE,
1625
+ scope: RUNTIME_TOOLS["data.submit"].scope,
1626
+ description: RUNTIME_TOOLS["data.submit"].description,
1627
+ inputSchema: {
1628
+ schemaCode: z.string().min(1),
1629
+ sheetCode: z.string().optional(),
1630
+ formData: runtimeFormDataArg,
1631
+ workflowCode: z.string().optional(),
1632
+ workflowInstanceId: z.string().optional(),
1633
+ workItemId: z.string().optional(),
1634
+ bizObjectId: z.string().optional().describe("existing record id; REQUIRED for workItem approval submits (formType=1 + workItemId) — the submit's bizObject must reference the in-flight record or the engine NPEs (DataChangeHandlerImpl.changeBizObject)"),
1635
+ agree: z.boolean().optional(),
1636
+ allowChildRowAppend: z.boolean().optional().describe("默认 false:审批提交(带 workItemId/workflowInstanceId)时 formData 含子表数组会被拒——平台按追加插行,每次提交重复一份。确实要追加子行时才置 true。"),
1637
+ allowStaffOnSubmit: z.boolean().optional().describe("默认 false:提交携带人员字段会被拒(issue#10:两种形状都可能 50000;可靠链路=save 落库后提交省略人员字段)。个别平台构建接受提交面携带时置 true 自担。"),
1638
+ approval: z.record(z.string(), z.unknown()).optional(),
1639
+ runMode: z.string().optional(),
1640
+ isSimulative: z.boolean().optional(),
1641
+ ...readbackProjectionArgs,
1642
+ },
1643
+ execute: async (session, args) => {
1644
+ // r13 #5: an EMPTY STRING was forwarded verbatim, so the platform answered
1645
+ // 100002「参数为空」with no hint which parameter. Absent and blank are not
1646
+ // the same intent: refuse the blank one by name, zero wire.
1647
+ const blank = ["workItemId", "workflowInstanceId", "workflowCode"].filter((key) => typeof args[key] === "string" && args[key].trim().length === 0 && args[key].length > 0);
1648
+ if (blank.length > 0)
1594
1649
  throw new ToolRefusalError({
1595
1650
  status: "refused",
1596
- blocker: "submit-approval-child-rows",
1651
+ blocker: "submit-blank-parameter",
1597
1652
  schemaCode: args.schemaCode,
1598
- childTables: carried,
1599
- message: `审批提交(workItemId/workflowInstanceId)的 formData 不要带子表数组:${carried.join("、")}。平台按「追加行」处理——每提交一次就再插一份(真机:2 行 → 6 行,30000 → 90000),且不报错、审批节点的 editable:false 也拦不住。只传主表字段;确实要在审批中追加子行时显式传 allowChildRowAppend:true。`,
1653
+ parameters: blank,
1654
+ message: `这些参数传了空白字符串:${blank.join("、")}。平台会把这个空白当"参数为空"回 100002(SYS_PARAMETER_EMPTY),` +
1655
+ `而错误里不会说是哪一个。要么给出真实 id,要么整个省略该键(省略 ≠ 传空串)。`,
1600
1656
  });
1601
- }
1602
- // Portal required-field semantics: the REST submit does NOT enforce them,
1603
- // so a missing required value must be REPORTED, not silently accepted as a
1604
- // portal-valid submit (dogfood R3). Best-effort read; a failure never
1605
- // blocks the submit.
1606
- // r16 P5:人员字段读回来是对象数组 `[{id,type,name,…}]`,写回去必须是**字符串 id**
1607
- // ——写成对象会让 UserBizField.fillClientContent 断言失败,平台只回 50000。
1608
- // 读写不对称且 schema 无法表达(formData 是 additionalProperties),所以在
1609
- // 写前按已发布字段类型做一次浅校验,把这类形状错误变成一条指名道姓的拒绝。
1610
- // issue#10(8.6.41 复现矩阵 + 本仓 1.1.25 复验):旧提示方向反了——
1611
- // "写入只接受字符串 id"会让调用者按提示改成字符串数组,然后平台 50000
1612
- // (submit 面 D)。提交面两种已知形状都不安全,可靠链路只有:save 时用
1613
- // 对象数组落库 → 提交时省略人员字段(值已持久化)。
1614
- if (args.allowStaffOnSubmit !== true) {
1615
- const staffShaped = await staffFieldsPresent(session, args.schemaCode, formData).catch(() => []);
1616
- if (staffShaped.length > 0)
1657
+ const hasWorkItem = typeof args.workItemId === "string" && args.workItemId.trim().length > 0;
1658
+ // r18b: pure shape check — run before any read/wire so a wrong-result
1659
+ // payload never costs a round trip.
1660
+ if (args.approval && typeof args.approval === "object") {
1661
+ const misplaced = args.approval;
1662
+ if (misplaced.agree !== undefined)
1663
+ throw new ToolRefusalError({
1664
+ status: "refused",
1665
+ blocker: "submit-approval-agree-misplaced",
1666
+ schemaCode: args.schemaCode,
1667
+ message: `approval.agree 不生效:平台只读**顶层** agree(true=通过 / false=驳回)。approval 对象里放 agree 会被**静默忽略并按通过处理**` +
1668
+ `(r18b 真机:approval:{agree:false} 仍写入 result=APPROVE 并把流程走完)。改成顶层参数 {"agree":false,...}。`,
1669
+ });
1670
+ if (misplaced.opinion !== undefined)
1671
+ throw new ToolRefusalError({
1672
+ status: "refused",
1673
+ blocker: "submit-approval-opinion-misplaced",
1674
+ schemaCode: args.schemaCode,
1675
+ message: `approval.opinion 不是平台键,会被静默丢弃(r18b 真机:意见不进 h_biz_comment,content 为 NULL)。` +
1676
+ `审批意见(BizCommentModel)用 **approval.content**;附件 approval.resources;下一步审批人 approval.relUsers。`,
1677
+ });
1678
+ }
1679
+ const hasInstance = typeof args.workflowInstanceId === "string" && args.workflowInstanceId.trim().length > 0;
1680
+ const targetObjectId = typeof args.bizObjectId === "string" && args.bizObjectId.trim() ? args.bizObjectId.trim() : null;
1681
+ if ((hasWorkItem || hasInstance) && !targetObjectId) {
1617
1682
  throw new ToolRefusalError({
1618
1683
  status: "refused",
1619
- blocker: "submit-person-field-shape",
1684
+ blocker: "submit-biz-object-id-required",
1620
1685
  schemaCode: args.schemaCode,
1621
- fields: staffShaped,
1622
- message: `提交携带了人员字段:${staffShaped.map((entry) => `${entry.code}(${entry.got})`).join("、")}。提交面的两种形状都可能 50000(对象数组触发 UserBizField.fillClientContent 断言;字符串 id 数组在 8.6.41/1.1.25 均实测 50000——issue#10 复现矩阵)。` +
1623
- `可靠链路:data.save 时人员字段用对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门)落库,提交时**省略人员字段**——值已持久化,提交只推进流程。` +
1624
- `确要在提交携带人员字段(个别平台构建接受),显式带 allowStaffOnSubmit:true 自担。`,
1686
+ workItemId: args.workItemId,
1687
+ workflowInstanceId: args.workflowInstanceId,
1688
+ message: "workItemId or workflowInstanceId submit requires bizObjectId; refusing before runtime/form/submit",
1625
1689
  });
1626
- }
1627
- const validation = await portalRequiredViolations(session, args.schemaCode, sheetCode, args.formData).catch(() => ({ requiredFields: [], missingRequired: [] }));
1628
- // Directive: a workflow-backed model's submit must START the workflow, not
1629
- // merely save a sheet row. When the caller names no workflow params, detect
1630
- // the model's workflow and start it (formType=1 + workflowCode); a model
1631
- // with no workflow stays a direct sheet submit (formType=2 -> COMPLETED).
1632
- let explicitWorkflowCode = typeof args.workflowCode === "string" && args.workflowCode.trim().length > 0 ? args.workflowCode.trim() : "";
1633
- let autoStarted = false;
1634
- // r16 P4: a second submit on the same work item answers 100005
1635
- // SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68 — the SAME numeric code
1636
- // as TEST_CONNECTION_FAIL, so the text is the only discriminator). The
1637
- // platform rate-limits per work item, not per request body, so retrying
1638
- // with a different payload changes nothing; the caller needs to know
1639
- // whether the FIRST submit actually landed. Diagnose before the wire.
1640
- if (hasWorkItem) {
1641
- try {
1642
- const activity = (await wireCall(session, {
1643
- path: ENDPOINTS.workflowListInstanceActivity,
1644
- query: { workflowInstanceId: String(args.workflowInstanceId ?? "") },
1645
- })).data;
1646
- const rows = Array.isArray(activity?.activities) ? activity.activities : wireRows(activity);
1647
- const mine = rows.filter((row) => String(row.workItemId ?? "") === args.workItemId);
1648
- const finished = mine.length > 0 && mine.every((row) => /FINISHED|COMPLETED/i.test(String(row.state ?? row.status ?? "")));
1649
- if (finished)
1690
+ }
1691
+ const { sheetCode, resolvedFrom } = await resolveFormSheetCode(session, { schemaCode: args.schemaCode, sheetCode: args.sheetCode });
1692
+ const stampedSubmit = await prepareRuntimeFormData(session, args.schemaCode, args.formData, { isUpdate: targetObjectId !== null });
1693
+ const formData = stampedSubmit.formData;
1694
+ // r5-asset 2026-10-20: the platform APPENDS child rows carried in an
1695
+ // approval submit's formData (no upsert by row id, no error). Live: an
1696
+ // approval that echoed the whole form twice turned 2 detail rows into 6
1697
+ // and 30000 into 90000 while every call answered errcode 0 — node-level
1698
+ // `editable:false` does not stop it server-side. Refuse before the wire.
1699
+ if ((hasWorkItem || hasInstance) && args.allowChildRowAppend !== true) {
1700
+ // Scan at ANY depth: callers wrap the form as {data:{...}} or
1701
+ // {bizObject:{data:{...}}}, and a top-level-only check let those through
1702
+ // (the platform appends whatever it finds). The child codes come from the
1703
+ // model's own field list.
1704
+ const carried = collectChildArrayPaths(args.formData, new Set(stampedSubmit.childCodes));
1705
+ if (carried.length > 0)
1650
1706
  throw new ToolRefusalError({
1651
1707
  status: "refused",
1652
- blocker: "submit-workitem-already-finished",
1708
+ blocker: "submit-approval-child-rows",
1653
1709
  schemaCode: args.schemaCode,
1654
- workItemId: args.workItemId,
1655
- nextCommand: `omc call workitem.list --args '${JSON.stringify({ workflowInstanceId: String(args.workflowInstanceId ?? "") })}'`,
1656
- message: `待办 ${String(args.workItemId)} 已经是完成状态——本次提交**没有发生**,平台会回 100005「频繁重复提交!」(SYS_REPLAY_ERROR,按 workItem 维度限流,换载荷/等待都无效)。` +
1657
- `这不是失败,是"已经提交过了"。用 workitem.list 看下一级待办,或 data.load 看当前 sequenceStatus,不要重复提交。`,
1710
+ childTables: carried,
1711
+ message: `审批提交(workItemId/workflowInstanceId)的 formData 不要带子表数组:${carried.join("、")}。平台按「追加行」处理——每提交一次就再插一份(真机:2 行 → 6 行,30000 → 90000),且不报错、审批节点的 editable:false 也拦不住。只传主表字段;确实要在审批中追加子行时显式传 allowChildRowAppend:true。`,
1658
1712
  });
1659
1713
  }
1660
- catch (error) {
1661
- if (error instanceof ToolRefusalError)
1662
- throw error;
1663
- // A read failure must not block the submit (fail open).
1714
+ // Portal required-field semantics: the REST submit does NOT enforce them,
1715
+ // so a missing required value must be REPORTED, not silently accepted as a
1716
+ // portal-valid submit (dogfood R3). Best-effort read; a failure never
1717
+ // blocks the submit.
1718
+ // r16 P5:人员字段读回来是对象数组 `[{id,type,name,…}]`,写回去必须是**字符串 id**
1719
+ // ——写成对象会让 UserBizField.fillClientContent 断言失败,平台只回 50000。
1720
+ // 读写不对称且 schema 无法表达(formData 是 additionalProperties),所以在
1721
+ // 写前按已发布字段类型做一次浅校验,把这类形状错误变成一条指名道姓的拒绝。
1722
+ // issue#10(8.6.41 复现矩阵 + 本仓 1.1.25 复验):旧提示方向反了——
1723
+ // "写入只接受字符串 id"会让调用者按提示改成字符串数组,然后平台 50000
1724
+ // (submit 面 D)。提交面两种已知形状都不安全,可靠链路只有:save 时用
1725
+ // 对象数组落库 → 提交时省略人员字段(值已持久化)。
1726
+ if (args.allowStaffOnSubmit !== true) {
1727
+ const staffShaped = await staffFieldsPresent(session, args.schemaCode, formData).catch(() => []);
1728
+ if (staffShaped.length > 0)
1729
+ throw new ToolRefusalError({
1730
+ status: "refused",
1731
+ blocker: "submit-person-field-shape",
1732
+ schemaCode: args.schemaCode,
1733
+ fields: staffShaped,
1734
+ message: `提交携带了人员字段:${staffShaped.map((entry) => `${entry.code}(${entry.got})`).join("、")}。提交面的两种形状都可能 50000(对象数组触发 UserBizField.fillClientContent 断言;字符串 id 数组在 8.6.41/1.1.25 均实测 50000——issue#10 复现矩阵)。` +
1735
+ `可靠链路:data.save 时人员字段用对象数组 [{"id":"<userId>","type":3,"name":"<名>"}](type:3=用户/1=部门)落库,提交时**省略人员字段**——值已持久化,提交只推进流程。` +
1736
+ `确要在提交携带人员字段(个别平台构建接受),显式带 allowStaffOnSubmit:true 自担。`,
1737
+ });
1664
1738
  }
1665
- }
1666
- if (!explicitWorkflowCode && !hasWorkItem && !hasInstance) {
1667
- const resolved = await resolvePublishedWorkflowCode(session, args.schemaCode);
1668
- if (resolved) {
1669
- explicitWorkflowCode = resolved;
1670
- autoStarted = true;
1739
+ const validation = await portalRequiredViolations(session, args.schemaCode, sheetCode, args.formData).catch(() => ({ requiredFields: [], missingRequired: [] }));
1740
+ // Directive: a workflow-backed model's submit must START the workflow, not
1741
+ // merely save a sheet row. When the caller names no workflow params, detect
1742
+ // the model's workflow and start it (formType=1 + workflowCode); a model
1743
+ // with no workflow stays a direct sheet submit (formType=2 -> COMPLETED).
1744
+ let explicitWorkflowCode = typeof args.workflowCode === "string" && args.workflowCode.trim().length > 0 ? args.workflowCode.trim() : "";
1745
+ let autoStarted = false;
1746
+ // r16 P4: a second submit on the same work item answers 100005
1747
+ // SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68 — the SAME numeric code
1748
+ // as TEST_CONNECTION_FAIL, so the text is the only discriminator). The
1749
+ // platform rate-limits per work item, not per request body, so retrying
1750
+ // with a different payload changes nothing; the caller needs to know
1751
+ // whether the FIRST submit actually landed. Diagnose before the wire.
1752
+ if (hasWorkItem) {
1753
+ try {
1754
+ const activity = (await wireCall(session, {
1755
+ path: ENDPOINTS.workflowListInstanceActivity,
1756
+ query: { workflowInstanceId: String(args.workflowInstanceId ?? "") },
1757
+ })).data;
1758
+ const rows = Array.isArray(activity?.activities) ? activity.activities : wireRows(activity);
1759
+ const mine = rows.filter((row) => String(row.workItemId ?? "") === args.workItemId);
1760
+ const finished = mine.length > 0 && mine.every((row) => /FINISHED|COMPLETED/i.test(String(row.state ?? row.status ?? "")));
1761
+ if (finished)
1762
+ throw new ToolRefusalError({
1763
+ status: "refused",
1764
+ blocker: "submit-workitem-already-finished",
1765
+ schemaCode: args.schemaCode,
1766
+ workItemId: args.workItemId,
1767
+ nextCommand: `omc call workitem.list --args '${JSON.stringify({ workflowInstanceId: String(args.workflowInstanceId ?? "") })}'`,
1768
+ message: `待办 ${String(args.workItemId)} 已经是完成状态——本次提交**没有发生**,平台会回 100005「频繁重复提交!」(SYS_REPLAY_ERROR,按 workItem 维度限流,换载荷/等待都无效)。` +
1769
+ `这不是失败,是"已经提交过了"。用 workitem.list 看下一级待办,或 data.load 看当前 sequenceStatus,不要重复提交。`,
1770
+ });
1771
+ }
1772
+ catch (error) {
1773
+ if (error instanceof ToolRefusalError)
1774
+ throw error;
1775
+ // A read failure must not block the submit (fail open).
1776
+ }
1671
1777
  }
1672
- }
1673
- const hasWorkflowCode = explicitWorkflowCode.length > 0;
1674
- const hasWorkflow = hasWorkflowCode || hasWorkItem || hasInstance;
1675
- const formType = hasWorkflow ? "1" : "2";
1676
- const payload = {
1677
- formType,
1678
- bizObject: {
1679
- schemaCode: args.schemaCode,
1680
- sheetCode,
1681
- data: args.formData,
1682
- },
1683
- agree: args.agree !== false,
1684
- };
1685
- let draftObjectId = null;
1686
- if (targetObjectId && hasWorkflowCode && !hasWorkItem && !hasInstance) {
1687
- throw new ToolRefusalError({
1688
- status: "refused",
1689
- blocker: "submit-existing-workflow-unsupported",
1690
- schemaCode: args.schemaCode,
1691
- bizObjectId: targetObjectId,
1692
- workflowCode: explicitWorkflowCode,
1693
- nextCommand: "omc describe data.submit --example workflow-backed-data-submit",
1694
- message: "8.6.26 has no REST in-place workflow start for an existing no-instance row (submitWorkflowByWorkflowCode -> createAndGetBizObject INSERTs -> 40000 duplicate primary key; the load+update starter is facade-only). Supported shape: create workflow drafts via data.save with workflowCode (returns same-id row + DRAFT instance + start workItemId), then data.submit with that workItemId + workflowInstanceId + bizObjectId",
1695
- });
1696
- }
1697
- let preSubmitExisting = null;
1698
- if (targetObjectId) {
1699
- const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, targetObjectId);
1700
- preSubmitExisting = existing;
1701
- if (!existing) {
1702
- throw new ToolRefusalError({
1703
- status: "refused",
1704
- blocker: "submit-target-absent",
1705
- errcode: RUNTIME_DATA_WIRE.absenceErrcode,
1778
+ if (!explicitWorkflowCode && !hasWorkItem && !hasInstance) {
1779
+ const resolved = await resolvePublishedWorkflowCode(session, args.schemaCode);
1780
+ if (resolved) {
1781
+ explicitWorkflowCode = resolved;
1782
+ autoStarted = true;
1783
+ }
1784
+ }
1785
+ const hasWorkflowCode = explicitWorkflowCode.length > 0;
1786
+ const hasWorkflow = hasWorkflowCode || hasWorkItem || hasInstance;
1787
+ const formType = hasWorkflow ? "1" : "2";
1788
+ const payload = {
1789
+ formType,
1790
+ bizObject: {
1706
1791
  schemaCode: args.schemaCode,
1707
- bizObjectId: targetObjectId,
1708
1792
  sheetCode,
1709
- nextCommand: `omc call data.list --args '${JSON.stringify({ schemaCode: args.schemaCode })}'`,
1710
- message: "no live business object with this id; refusing to submit (the platform would otherwise create a new row)",
1711
- });
1712
- }
1713
- // Direct-submit chain (ticket 18): form/submit persists bizObject.data
1714
- // itself — formType=2 submitBizSheet -> saveBizObject (sets COMPLETED,
1715
- // merges via updateBizObject.mergeObject); formType=1 workItem ->
1716
- // SubmitDataChangeHandlerImpl.updateBizObject — so the historical
1717
- // pre-submit form/save + form/load round-trip is redundant wire. The
1718
- // submit bizObject is synthesized from the authoritative pre-check
1719
- // load overlaid with the caller's formData.
1720
- const flat = normalizeLoadedRecord(existing) ?? {};
1721
- payload.bizObject = {
1722
- id: targetObjectId,
1723
- schemaCode: args.schemaCode,
1724
- sheetCode,
1725
- ownerId: typeof flat.ownerId === "string" ? flat.ownerId : undefined,
1726
- // Engine-owned keys must never be echoed back: the loaded row's
1727
- // `sequenceStatus: DRAFT` would be persisted verbatim and pin the
1728
- // row to DRAFT across submits (the portal client never resubmits
1729
- // the status column; live TID 10e6bc65 evidence, ticket 17).
1730
- data: stripEngineOwnedKeys({ ...flat, ...formData }),
1731
- };
1732
- if (formType === "2")
1733
- payload.actionCode = "submit";
1734
- draftObjectId = targetObjectId;
1735
- }
1736
- else if (formType === "2") {
1737
- // Live 1.1.25 (r7-vehicle 2026-10-21): the previous chain did
1738
- // form/save (creating a DRAFT row) and then submitted with that id —
1739
- // the platform INSERTED A SECOND ROW instead of completing the first,
1740
- // leaving one COMPLETED + one orphan DRAFT per submit (verified in
1741
- // ia38g_veh_vehicle: same plateNo, same second, statuses COMPLETED and
1742
- // DRAFT). form/submit persists bizObject.data itself (ticket 18), so
1743
- // skip the pre-save and let the platform create+complete in one call.
1744
- payload.bizObject = {
1745
- schemaCode: args.schemaCode,
1746
- sheetCode,
1747
- data: stripEngineOwnedKeys(formData),
1793
+ data: args.formData,
1794
+ },
1795
+ agree: args.agree !== false,
1748
1796
  };
1749
- payload.actionCode = "submit";
1750
- }
1751
- let replayToken;
1752
- if (hasWorkflowCode && !hasWorkItem && !hasInstance) {
1753
- // Starting a workflow (formType=1 + workflowCode, no workItem) requires
1754
- // a replayToken pre-registered in Redis via GET /runtime/form/getReplayToken
1755
- // (AbstractFormRuntimeController.verifyReplay -> SYS_REPLAY_ERROR 100005).
1756
- replayToken = await getReplayToken(session);
1757
- }
1758
- if (hasWorkflowCode)
1759
- payload.workflowCode = explicitWorkflowCode;
1760
- if (replayToken)
1761
- payload.replayToken = replayToken;
1762
- if (typeof args.workflowInstanceId === "string")
1763
- payload.workflowInstanceId = args.workflowInstanceId;
1764
- if (typeof args.workItemId === "string")
1765
- payload.workItemId = args.workItemId;
1766
- if (args.approval && typeof args.approval === "object")
1767
- payload.approval = args.approval;
1768
- if (typeof args.runMode === "string")
1769
- payload.runMode = args.runMode;
1770
- if (typeof args.isSimulative === "boolean")
1771
- payload.isSimulative = args.isSimulative;
1772
- // Approval submits are resolved against the LOGGED-IN user (r13: six
1773
- // blind 40005s). Name the boundary before the write instead of letting the
1774
- // platform answer "权限不足", which reads like a misconfiguration.
1775
- if (hasWorkItem)
1776
- await diagnoseApprovalSubmitIdentity(session, { workflowInstanceId: args.workflowInstanceId, workItemId: args.workItemId });
1777
- let performed;
1778
- try {
1779
- performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.submit 运行时提交稳定失败(50000)", () => wireCall(session, {
1780
- path: ENDPOINTS.runtimeFormSubmit,
1781
- method: "POST",
1782
- body: payload,
1783
- }), undefined, hasWorkflowCode ? explicitWorkflowCode : undefined);
1784
- }
1785
- catch (error) {
1786
- await diagnoseDbException(session, args.schemaCode, error);
1787
- // r16 P4: 100005 is SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68 —
1788
- // the same numeric code as TEST_CONNECTION_FAIL, so only the text
1789
- // separates them). The platform rate-limits per WORK ITEM, so a different
1790
- // payload or a longer wait changes nothing. The actionable question is
1791
- // "did the earlier submit land?", which the todo list answers.
1792
- if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.replayTokenErrcode) {
1793
- const stillMine = await workItemStillOpen(session, args.workflowInstanceId, args.workItemId).catch(() => null);
1797
+ let draftObjectId = null;
1798
+ if (targetObjectId && hasWorkflowCode && !hasWorkItem && !hasInstance) {
1794
1799
  throw new ToolRefusalError({
1795
1800
  status: "refused",
1796
- blocker: stillMine === false ? "submit-workitem-already-finished" : "submit-rate-limited",
1801
+ blocker: "submit-existing-workflow-unsupported",
1797
1802
  schemaCode: args.schemaCode,
1798
- workItemId: args.workItemId ?? null,
1799
- workflowInstanceId: args.workflowInstanceId ?? null,
1800
- nextCommand: `omc call workitem.list --args '${JSON.stringify({ workflowInstanceId: String(args.workflowInstanceId ?? "") })}'`,
1801
- message: stillMine === false
1802
- ? `100005「频繁重复提交!」在这里的真实含义:待办 ${String(args.workItemId ?? "(未提供)")} **已经提交过了**。限流按 workItem 维度,换载荷、等待、换 requestId 都无效——这不是失败。用 workitem.list 看下一级待办,或 data.load 看 sequenceStatus,不要重试本次提交。`
1803
- : `100005 = 平台 SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68,与"测试连接不通过"共用该数值码,只能靠文案区分)。` +
1804
- `两种成因:① 该待办已经提交过;② startWorkflow 路径的 replayToken 未刷新。` +
1805
- `${stillMine === null ? "(待办状态读取失败,无法判定)" : stillMine ? "该待办仍在——多半是限流窗口未过。" : ""}` +
1806
- `先 workitem.list 核对当前待办,确认没提交成功再等一会儿重试;改 payload 不会绕过它。`,
1803
+ bizObjectId: targetObjectId,
1804
+ workflowCode: explicitWorkflowCode,
1805
+ nextCommand: "omc describe data.submit --example workflow-backed-data-submit",
1806
+ message: "8.6.26 has no REST in-place workflow start for an existing no-instance row (submitWorkflowByWorkflowCode -> createAndGetBizObject INSERTs -> 40000 duplicate primary key; the load+update starter is facade-only). Supported shape: create workflow drafts via data.save with workflowCode (returns same-id row + DRAFT instance + start workItemId), then data.submit with that workItemId + workflowInstanceId + bizObjectId",
1807
1807
  });
1808
1808
  }
1809
- throw error;
1810
- }
1811
- const performedData = (performed.data ?? {});
1812
- let readback = { formType };
1813
- if (formType === "2") {
1814
- const objectId = typeof performedData.objectId === "string" ? performedData.objectId : null;
1815
- if (!objectId) {
1816
- // Source contract: submitBizSheet ALWAYS returns objectId.
1817
- // Absence is a transport / protocol deviation — never paper over
1818
- // it with a fabricated sequenceStatus:COMPLETED.
1819
- return {
1820
- performed: { errcode: performed.errcode, sheetCode, resolvedFrom, formType, draftObjectId },
1821
- readback: { formType, sequenceStatus: null, missingObjectId: true },
1809
+ let preSubmitExisting = null;
1810
+ if (targetObjectId) {
1811
+ const existing = await loadExistingBizObject(session, args.schemaCode, sheetCode, targetObjectId);
1812
+ preSubmitExisting = existing;
1813
+ if (!existing) {
1814
+ throw new ToolRefusalError({
1815
+ status: "refused",
1816
+ blocker: "submit-target-absent",
1817
+ errcode: RUNTIME_DATA_WIRE.absenceErrcode,
1818
+ schemaCode: args.schemaCode,
1819
+ bizObjectId: targetObjectId,
1820
+ sheetCode,
1821
+ nextCommand: `omc call data.list --args '${JSON.stringify({ schemaCode: args.schemaCode })}'`,
1822
+ message: "no live business object with this id; refusing to submit (the platform would otherwise create a new row)",
1823
+ });
1824
+ }
1825
+ // Direct-submit chain (ticket 18): form/submit persists bizObject.data
1826
+ // itself — formType=2 submitBizSheet -> saveBizObject (sets COMPLETED,
1827
+ // merges via updateBizObject.mergeObject); formType=1 workItem ->
1828
+ // SubmitDataChangeHandlerImpl.updateBizObject — so the historical
1829
+ // pre-submit form/save + form/load round-trip is redundant wire. The
1830
+ // submit bizObject is synthesized from the authoritative pre-check
1831
+ // load overlaid with the caller's formData.
1832
+ const flat = normalizeLoadedRecord(existing) ?? {};
1833
+ payload.bizObject = {
1834
+ id: targetObjectId,
1835
+ schemaCode: args.schemaCode,
1836
+ sheetCode,
1837
+ ownerId: typeof flat.ownerId === "string" ? flat.ownerId : undefined,
1838
+ // Engine-owned keys must never be echoed back: the loaded row's
1839
+ // `sequenceStatus: DRAFT` would be persisted verbatim and pin the
1840
+ // row to DRAFT across submits (the portal client never resubmits
1841
+ // the status column; live TID 10e6bc65 evidence, ticket 17).
1842
+ data: stripEngineOwnedKeys({ ...flat, ...formData }),
1822
1843
  };
1844
+ if (formType === "2")
1845
+ payload.actionCode = "submit";
1846
+ draftObjectId = targetObjectId;
1823
1847
  }
1824
- if (targetObjectId && objectId !== targetObjectId) {
1825
- throw new ToolRefusalError({
1826
- status: "refused",
1827
- blocker: "submit-target-id-changed",
1828
- expectedBizObjectId: targetObjectId,
1829
- actualBizObjectId: objectId,
1830
- message: "runtime/form/submit returned a different object id; refusing to accept a new row",
1831
- });
1848
+ else if (formType === "2") {
1849
+ // Live 1.1.25 (r7-vehicle 2026-10-21): the previous chain did
1850
+ // form/save (creating a DRAFT row) and then submitted with that id —
1851
+ // the platform INSERTED A SECOND ROW instead of completing the first,
1852
+ // leaving one COMPLETED + one orphan DRAFT per submit (verified in
1853
+ // ia38g_veh_vehicle: same plateNo, same second, statuses COMPLETED and
1854
+ // DRAFT). form/submit persists bizObject.data itself (ticket 18), so
1855
+ // skip the pre-save and let the platform create+complete in one call.
1856
+ payload.bizObject = {
1857
+ schemaCode: args.schemaCode,
1858
+ sheetCode,
1859
+ data: stripEngineOwnedKeys(formData),
1860
+ };
1861
+ payload.actionCode = "submit";
1832
1862
  }
1833
- const load = await wireCall(session, {
1834
- path: ENDPOINTS.runtimeFormLoad,
1835
- query: { schemaCode: args.schemaCode, sheetCode, objectId },
1836
- });
1837
- const loaded = (load.data ?? {});
1838
- const record = normalizeLoadedRecord(loaded) ?? {};
1839
- const sequenceStatus = extractSequenceStatus(record);
1840
- readback = {
1841
- ...readback,
1842
- objectId,
1843
- schemaCode: typeof performedData.schemaCode === "string" ? performedData.schemaCode : args.schemaCode,
1844
- formCode: typeof performedData.formCode === "string" ? performedData.formCode : sheetCode,
1845
- sequenceStatus,
1846
- ...(sequenceStatus === null ? { missingSequenceStatus: true } : {}),
1847
- submittedData: projectReadbackRecord(record, args),
1848
- };
1849
- }
1850
- else {
1851
- const returnedObjectId = typeof performedData.objectId === "string" ? performedData.objectId : null;
1852
- if (targetObjectId && returnedObjectId !== targetObjectId) {
1853
- throw new ToolRefusalError({
1854
- status: "refused",
1855
- blocker: "submit-target-id-changed",
1856
- expectedBizObjectId: targetObjectId,
1857
- actualBizObjectId: returnedObjectId,
1858
- message: "runtime/form/submit returned a different object id; refusing to accept a new row",
1859
- });
1863
+ let replayToken;
1864
+ if (hasWorkflowCode && !hasWorkItem && !hasInstance) {
1865
+ // Starting a workflow (formType=1 + workflowCode, no workItem) requires
1866
+ // a replayToken pre-registered in Redis via GET /runtime/form/getReplayToken
1867
+ // (AbstractFormRuntimeController.verifyReplay -> SYS_REPLAY_ERROR 100005).
1868
+ replayToken = await getReplayToken(session);
1860
1869
  }
1861
- const workflowInstanceId = typeof performedData.workflowInstanceId === "string" ? performedData.workflowInstanceId : null;
1862
- const objectId = returnedObjectId ?? targetObjectId;
1863
- // Workflow engine status writes can lag the submit response
1864
- // (autoCommit): an immediate authoritative form/load may still show
1865
- // the pre-submit status. Poll briefly when a workflow submit reads
1866
- // back unchanged — a genuinely unchanged mid-flow status costs one
1867
- // short bounded wait, a lagged boundary transition resolves.
1868
- const preSubmitStatus = preSubmitExisting ? extractSequenceStatus(preSubmitExisting) : null;
1869
- let loaded = objectId ? await loadExistingBizObject(session, args.schemaCode, sheetCode, objectId) : null;
1870
- let sequenceStatus = extractSequenceStatus(loaded);
1871
- for (let attempt = 0; objectId && sequenceStatus !== null && sequenceStatus === preSubmitStatus && attempt < 3; attempt += 1) {
1872
- await new Promise((resolve) => setTimeout(resolve, 400));
1873
- loaded = await loadExistingBizObject(session, args.schemaCode, sheetCode, objectId);
1874
- sequenceStatus = extractSequenceStatus(loaded);
1870
+ if (hasWorkflowCode)
1871
+ payload.workflowCode = explicitWorkflowCode;
1872
+ if (replayToken)
1873
+ payload.replayToken = replayToken;
1874
+ if (typeof args.workflowInstanceId === "string")
1875
+ payload.workflowInstanceId = args.workflowInstanceId;
1876
+ if (typeof args.workItemId === "string")
1877
+ payload.workItemId = args.workItemId;
1878
+ if (args.approval && typeof args.approval === "object")
1879
+ payload.approval = args.approval;
1880
+ if (typeof args.runMode === "string")
1881
+ payload.runMode = args.runMode;
1882
+ if (typeof args.isSimulative === "boolean")
1883
+ payload.isSimulative = args.isSimulative;
1884
+ // Approval submits are resolved against the LOGGED-IN user (r13: six
1885
+ // blind 40005s). Name the boundary before the write instead of letting the
1886
+ // platform answer "权限不足", which reads like a misconfiguration.
1887
+ if (hasWorkItem)
1888
+ await diagnoseApprovalSubmitIdentity(session, { workflowInstanceId: args.workflowInstanceId, workItemId: args.workItemId });
1889
+ let performed;
1890
+ try {
1891
+ performed = await runtimeWriteWithAppMapping(session, args.schemaCode, 50000, "data.submit 运行时提交稳定失败(50000)", () => wireCall(session, {
1892
+ path: ENDPOINTS.runtimeFormSubmit,
1893
+ method: "POST",
1894
+ body: payload,
1895
+ }), undefined, hasWorkflowCode ? explicitWorkflowCode : undefined);
1875
1896
  }
1876
- const loadedRecord = normalizeLoadedRecord(loaded);
1877
- // The platform's submit response often omits workflowInstanceId (the fake
1878
- // start path returns only {result, objectId}); the authoritative loaded
1879
- // row carries it. Prefer the response, then the row.
1880
- const effectiveInstanceId = workflowInstanceId ?? extractWorkflowInstanceId(loaded) ?? (loadedRecord && typeof loadedRecord.workflowInstanceId === "string" && loadedRecord.workflowInstanceId ? loadedRecord.workflowInstanceId : null);
1881
- // An approval submit returns the status + the fields the caller submitted,
1882
- // not the whole row: r12's 25 approvals were the largest read face at
1883
- // 55.6 KB. Explicit summary/fields/full keep their contract.
1884
- // Pure-instance submits (workflowInstanceId without workItemId) go
1885
- // through the same engine path and deserve the same delta (item 11).
1886
- const approvalDelta = (hasWorkItem || hasInstance) && args.summary !== true && args.full !== true && !(Array.isArray(args.fields) && args.fields.length > 0);
1887
- readback = {
1888
- ...readback,
1889
- workflowInstanceId: effectiveInstanceId,
1890
- objectId,
1891
- workItemId: typeof performedData.workItemId === "string" ? performedData.workItemId : null,
1892
- sequenceStatus,
1893
- submittedData: approvalDelta ? projectApprovalReadback(loadedRecord, Object.keys(formData ?? {})) : projectReadbackRecord(loadedRecord, args),
1894
- ...(approvalDelta
1895
- ? { readbackShape: "approval-delta", readbackHint: "审批提交默认只回 sequenceStatus + 本次提交的字段;要整条记录(含子表/系统字段)加 full:true,要指定字段加 fields:[...]" }
1896
- : {}),
1897
- };
1898
- // Multi-level approval: after a submit advances the flow, the caller
1899
- // almost always needs the NEXT pending todo — bench52/53 paid an extra
1900
- // `workitem.list` wire call at every approval level because the submit
1901
- // response carried only the object id. Attach the pending work items for
1902
- // this instance while the flow is still in flight (best-effort: a lookup
1903
- // failure must never fail the submit).
1904
- if (hasWorkflow && effectiveInstanceId && sequenceStatus !== "COMPLETED") {
1905
- try {
1906
- const pending = await searchWorkItems(session, { instanceId: effectiveInstanceId });
1907
- const nextWorkItems = pending.items.slice(0, 5).map((item) => ({
1908
- workItemId: wireString(item.id),
1909
- activityCode: wireString(item.activityCode),
1910
- activityName: wireString(item.activityName),
1911
- state: wireString(item.state),
1912
- }));
1913
- if (nextWorkItems.length > 0) {
1914
- readback.nextWorkItems = nextWorkItems;
1915
- // The submit RESPONSE's own workItemId is null (it names the item
1916
- // just completed, not the next one). Expose the fully-formed args
1917
- // for the NEXT approval so a multi-level flow advances without an
1918
- // extra workitem.list round or guessing which id to use.
1919
- readback.nextSubmit = {
1920
- schemaCode: args.schemaCode,
1921
- workItemId: nextWorkItems[0].workItemId,
1922
- workflowInstanceId: effectiveInstanceId,
1923
- bizObjectId: objectId,
1924
- note: "下一级审批:用这些字段调 data.submit(同意传 agree:true / formData 回填)。",
1925
- };
1926
- }
1927
- else if (sequenceStatus === "PROCESSING") {
1928
- // r4-training 2026-10-20: an unresolvable participant (role with no
1929
- // members, member not attached to the originator's department, or a
1930
- // participant function that is not registered) makes the engine
1931
- // create NO next work item while the submit still answers errCode 0.
1932
- // Without this line the caller cannot tell "flow advanced" from
1933
- // "flow silently stalled" — surface it instead of staying quiet.
1934
- readback.flowWarning = {
1935
- code: "flow-no-next-workitem",
1936
- message: "提交成功但该实例没有生成下一节点待办,流程停在 PROCESSING。常见原因:审批参与人解析为空(角色无成员 / 成员未挂发起人部门 / 参与人函数未注册)。",
1937
- checks: [
1938
- `omc call workflow.instanceActivity --args '{"workflowInstanceId":"${effectiveInstanceId}"}'(看每个节点的 status 与 participants;无 status=1 即停住)`,
1939
- "容器日志搜 GetParticipantInfo error / CustomParticipleMethod{...} -> []",
1940
- "参与人优先用参与者对象数组(角色 id);内置函数只认 FindUserByRole(org, roleCode[, order])",
1941
- ],
1942
- };
1943
- }
1897
+ catch (error) {
1898
+ await diagnoseDbException(session, args.schemaCode, error);
1899
+ // r16 P4: 100005 is SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68 —
1900
+ // the same numeric code as TEST_CONNECTION_FAIL, so only the text
1901
+ // separates them). The platform rate-limits per WORK ITEM, so a different
1902
+ // payload or a longer wait changes nothing. The actionable question is
1903
+ // "did the earlier submit land?", which the todo list answers.
1904
+ if (error instanceof WireError && error.kind === "business" && error.errcode === RUNTIME_DATA_WIRE.replayTokenErrcode) {
1905
+ const stillMine = await workItemStillOpen(session, args.workflowInstanceId, args.workItemId).catch(() => null);
1906
+ throw new ToolRefusalError({
1907
+ status: "refused",
1908
+ blocker: stillMine === false ? "submit-workitem-already-finished" : "submit-rate-limited",
1909
+ schemaCode: args.schemaCode,
1910
+ workItemId: args.workItemId ?? null,
1911
+ workflowInstanceId: args.workflowInstanceId ?? null,
1912
+ nextCommand: `omc call workitem.list --args '${JSON.stringify({ workflowInstanceId: String(args.workflowInstanceId ?? "") })}'`,
1913
+ message: stillMine === false
1914
+ ? `100005「频繁重复提交!」在这里的真实含义:待办 ${String(args.workItemId ?? "(未提供)")} **已经提交过了**。限流按 workItem 维度,换载荷、等待、换 requestId 都无效——这不是失败。用 workitem.list 看下一级待办,或 data.load 看 sequenceStatus,不要重试本次提交。`
1915
+ : `100005 = 平台 SYS_REPLAY_ERROR「频繁重复提交!」(ErrorCode.java:68,与"测试连接不通过"共用该数值码,只能靠文案区分)。` +
1916
+ `两种成因:① 该待办已经提交过;② startWorkflow 路径的 replayToken 未刷新。` +
1917
+ `${stillMine === null ? "(待办状态读取失败,无法判定)" : stillMine ? "该待办仍在——多半是限流窗口未过。" : ""}` +
1918
+ `先 workitem.list 核对当前待办,确认没提交成功再等一会儿重试;改 payload 不会绕过它。`,
1919
+ });
1944
1920
  }
1945
- catch {
1946
- // best-effort
1921
+ throw error;
1922
+ }
1923
+ const performedData = (performed.data ?? {});
1924
+ let readback = { formType };
1925
+ if (formType === "2") {
1926
+ const objectId = typeof performedData.objectId === "string" ? performedData.objectId : null;
1927
+ if (!objectId) {
1928
+ // Source contract: submitBizSheet ALWAYS returns objectId.
1929
+ // Absence is a transport / protocol deviation — never paper over
1930
+ // it with a fabricated sequenceStatus:COMPLETED.
1931
+ return {
1932
+ performed: { errcode: performed.errcode, sheetCode, resolvedFrom, formType, draftObjectId },
1933
+ readback: { formType, sequenceStatus: null, missingObjectId: true },
1934
+ };
1947
1935
  }
1936
+ if (targetObjectId && objectId !== targetObjectId) {
1937
+ throw new ToolRefusalError({
1938
+ status: "refused",
1939
+ blocker: "submit-target-id-changed",
1940
+ expectedBizObjectId: targetObjectId,
1941
+ actualBizObjectId: objectId,
1942
+ message: "runtime/form/submit returned a different object id; refusing to accept a new row",
1943
+ });
1944
+ }
1945
+ const load = await wireCall(session, {
1946
+ path: ENDPOINTS.runtimeFormLoad,
1947
+ query: { schemaCode: args.schemaCode, sheetCode, objectId },
1948
+ });
1949
+ const loaded = (load.data ?? {});
1950
+ const record = normalizeLoadedRecord(loaded) ?? {};
1951
+ const sequenceStatus = extractSequenceStatus(record);
1952
+ readback = {
1953
+ ...readback,
1954
+ objectId,
1955
+ schemaCode: typeof performedData.schemaCode === "string" ? performedData.schemaCode : args.schemaCode,
1956
+ formCode: typeof performedData.formCode === "string" ? performedData.formCode : sheetCode,
1957
+ sequenceStatus,
1958
+ ...(sequenceStatus === null ? { missingSequenceStatus: true } : {}),
1959
+ // r28b F2(真机 2026-11-05 复现):模型没配业务流水号时,平台把
1960
+ // `sequenceNo` 直接回填成 objectId 本身——32 位 hex 长得像单号,
1961
+ // 读回却没法区分「真的生成了单号」和「回填了行 id」。
1962
+ // 相等即标注,不等时不加(不猜)。
1963
+ ...(typeof record.sequenceNo === "string" && String(record.sequenceNo) === objectId
1964
+ ? { sequenceNoSource: "object-id", sequenceNoNote: "本模型未配置业务流水号:sequenceNo 回填的是 objectId 本身(不是生成的单号)。要单号请在模型上配序列号字段。" }
1965
+ : {}),
1966
+ submittedData: projectReadbackRecord(record, args),
1967
+ };
1948
1968
  }
1949
- }
1950
- return {
1951
- performed: {
1952
- errcode: performed.errcode,
1953
- sheetCode,
1954
- resolvedFrom,
1955
- formType,
1956
- ...(draftObjectId ? { draftObjectId } : {}),
1957
- // The workflow was auto-detected and started (formType=1) rather than
1958
- // saved as a bare sheet row — surfaced so the caller knows the row is
1959
- // now a PROCESSING workflow instance, not an orphan sheet record.
1960
- ...(autoStarted ? { autoStartedWorkflow: explicitWorkflowCode } : {}),
1961
- },
1962
- readback: {
1963
- ...readback,
1964
- // Portal-required semantics are NOT enforced by the REST submit path;
1965
- // say exactly what the tool checked so `ok` is never mistaken for
1966
- // "the portal would have accepted this" (dogfood R3). This is a
1967
- // SEPARATE concern from server-side business rules (which DO run on
1968
- // submit) — name that so `portalRulesExecuted:false` is not misread as
1969
- // "no rule ran" (dogfood R2).
1970
- // r18b: an approval submit that omits `agree` is treated as APPROVE by
1971
- // the platform (`agree: args.agree !== false`). That default is right
1972
- // for the portal button, but for an API caller "I forgot the flag" and
1973
- // "I approve" must not look identical in the receipt.
1974
- ...(hasWorkItem && args.agree === undefined
1975
- ? {
1976
- approvalNote: "本次是审批提交但未传 agree——平台按**通过**处理(顶层 agree 缺省 true)。要驳回必须显式 agree:false;approval.agree 不生效。",
1969
+ else {
1970
+ const returnedObjectId = typeof performedData.objectId === "string" ? performedData.objectId : null;
1971
+ if (targetObjectId && returnedObjectId !== targetObjectId) {
1972
+ throw new ToolRefusalError({
1973
+ status: "refused",
1974
+ blocker: "submit-target-id-changed",
1975
+ expectedBizObjectId: targetObjectId,
1976
+ actualBizObjectId: returnedObjectId,
1977
+ message: "runtime/form/submit returned a different object id; refusing to accept a new row",
1978
+ });
1979
+ }
1980
+ const workflowInstanceId = typeof performedData.workflowInstanceId === "string" ? performedData.workflowInstanceId : null;
1981
+ const objectId = returnedObjectId ?? targetObjectId;
1982
+ // Workflow engine status writes can lag the submit response
1983
+ // (autoCommit): an immediate authoritative form/load may still show
1984
+ // the pre-submit status. Poll briefly when a workflow submit reads
1985
+ // back unchanged — a genuinely unchanged mid-flow status costs one
1986
+ // short bounded wait, a lagged boundary transition resolves.
1987
+ const preSubmitStatus = preSubmitExisting ? extractSequenceStatus(preSubmitExisting) : null;
1988
+ let loaded = objectId ? await loadExistingBizObject(session, args.schemaCode, sheetCode, objectId) : null;
1989
+ let sequenceStatus = extractSequenceStatus(loaded);
1990
+ for (let attempt = 0; objectId && sequenceStatus !== null && sequenceStatus === preSubmitStatus && attempt < 3; attempt += 1) {
1991
+ await new Promise((resolve) => setTimeout(resolve, 400));
1992
+ loaded = await loadExistingBizObject(session, args.schemaCode, sheetCode, objectId);
1993
+ sequenceStatus = extractSequenceStatus(loaded);
1994
+ }
1995
+ const loadedRecord = normalizeLoadedRecord(loaded);
1996
+ // The platform's submit response often omits workflowInstanceId (the fake
1997
+ // start path returns only {result, objectId}); the authoritative loaded
1998
+ // row carries it. Prefer the response, then the row.
1999
+ const effectiveInstanceId = workflowInstanceId ?? extractWorkflowInstanceId(loaded) ?? (loadedRecord && typeof loadedRecord.workflowInstanceId === "string" && loadedRecord.workflowInstanceId ? loadedRecord.workflowInstanceId : null);
2000
+ // An approval submit returns the status + the fields the caller submitted,
2001
+ // not the whole row: r12's 25 approvals were the largest read face at
2002
+ // 55.6 KB. Explicit summary/fields/full keep their contract.
2003
+ // Pure-instance submits (workflowInstanceId without workItemId) go
2004
+ // through the same engine path and deserve the same delta (item 11).
2005
+ const approvalDelta = (hasWorkItem || hasInstance) && args.summary !== true && args.full !== true && !(Array.isArray(args.fields) && args.fields.length > 0);
2006
+ readback = {
2007
+ ...readback,
2008
+ workflowInstanceId: effectiveInstanceId,
2009
+ objectId,
2010
+ workItemId: typeof performedData.workItemId === "string" ? performedData.workItemId : null,
2011
+ sequenceStatus,
2012
+ submittedData: approvalDelta ? projectApprovalReadback(loadedRecord, Object.keys(formData ?? {})) : projectReadbackRecord(loadedRecord, args),
2013
+ ...(approvalDelta
2014
+ ? { readbackShape: "approval-delta", readbackHint: "审批提交默认只回 sequenceStatus + 本次提交的字段;要整条记录(含子表/系统字段)加 full:true,要指定字段加 fields:[...]" }
2015
+ : {}),
2016
+ };
2017
+ // Multi-level approval: after a submit advances the flow, the caller
2018
+ // almost always needs the NEXT pending todo — bench52/53 paid an extra
2019
+ // `workitem.list` wire call at every approval level because the submit
2020
+ // response carried only the object id. Attach the pending work items for
2021
+ // this instance while the flow is still in flight (best-effort: a lookup
2022
+ // failure must never fail the submit).
2023
+ if (hasWorkflow && effectiveInstanceId && sequenceStatus !== "COMPLETED") {
2024
+ try {
2025
+ const pending = await searchWorkItems(session, { instanceId: effectiveInstanceId });
2026
+ const nextWorkItems = pending.items.slice(0, 5).map((item) => ({
2027
+ workItemId: wireString(item.id),
2028
+ activityCode: wireString(item.activityCode),
2029
+ activityName: wireString(item.activityName),
2030
+ state: wireString(item.state),
2031
+ }));
2032
+ if (nextWorkItems.length > 0) {
2033
+ readback.nextWorkItems = nextWorkItems;
2034
+ // The submit RESPONSE's own workItemId is null (it names the item
2035
+ // just completed, not the next one). Expose the fully-formed args
2036
+ // for the NEXT approval so a multi-level flow advances without an
2037
+ // extra workitem.list round or guessing which id to use.
2038
+ readback.nextSubmit = {
2039
+ schemaCode: args.schemaCode,
2040
+ workItemId: nextWorkItems[0].workItemId,
2041
+ workflowInstanceId: effectiveInstanceId,
2042
+ bizObjectId: objectId,
2043
+ note: "下一级审批:用这些字段调 data.submit(同意传 agree:true / formData 回填)。",
2044
+ };
2045
+ }
2046
+ else if (sequenceStatus === "PROCESSING") {
2047
+ // r4-training 2026-10-20: an unresolvable participant (role with no
2048
+ // members, member not attached to the originator's department, or a
2049
+ // participant function that is not registered) makes the engine
2050
+ // create NO next work item while the submit still answers errCode 0.
2051
+ // Without this line the caller cannot tell "flow advanced" from
2052
+ // "flow silently stalled" — surface it instead of staying quiet.
2053
+ readback.flowWarning = {
2054
+ code: "flow-no-next-workitem",
2055
+ message: "提交成功但该实例没有生成下一节点待办,流程停在 PROCESSING。常见原因:审批参与人解析为空(角色无成员 / 成员未挂发起人部门 / 参与人函数未注册)。",
2056
+ checks: [
2057
+ `omc call workflow.instanceActivity --args '{"workflowInstanceId":"${effectiveInstanceId}"}'(看每个节点的 status 与 participants;无 status=1 即停住)`,
2058
+ "容器日志搜 GetParticipantInfo error / CustomParticipleMethod{...} -> []",
2059
+ "参与人优先用参与者对象数组(角色 id);内置函数只认 FindUserByRole(org, roleCode[, order])",
2060
+ ],
2061
+ };
2062
+ }
1977
2063
  }
1978
- : {}),
1979
- portalRulesExecuted: false,
1980
- portalRulesNote: "portalRulesExecuted 只指门户客户端的必填/控件校验未在 REST 提交时执行;服务端业务规则(含流程 end 规则)照常运行。",
1981
- requiredFields: validation.requiredFields,
1982
- missingRequired: validation.missingRequired,
1983
- ...(validation.missingRequired.length > 0
1984
- ? {
1985
- portalValidationWarning: `已提交但**未执行门户必填校验**:${validation.missingRequired.join("、")} 在发布表单里是必填,本次 formData 为空/缺省。REST runtime/form/submit 不跑门户客户端校验;如需真正阻断,请在提交前自行校验,或在服务端规则里加必填约束。`,
2064
+ catch {
2065
+ // best-effort
1986
2066
  }
1987
- : {}),
1988
- // Round 16b (dogfood R1-R3): a submit can leave BOTH a draft row and a
1989
- // committed row, and deleting only the committed id leaves the draft
1990
- // behind (data.list still counts it). Name both ids so cleanup does not
1991
- // silently miss one. Round 17 (dogfood R2): when there is only ONE row
1992
- // (a workflow submit advances the same row), say so explicitly — the
1993
- // absence of a hint was itself read as an evidence gap.
1994
- ...(typeof readback.objectId === "string"
1995
- ? draftObjectId && readback.objectId !== draftObjectId
1996
- ? { cleanupHint: `本次提交产生 draft(${draftObjectId}) 与 committed(${String(readback.objectId)}) 两条记录;验收后两条都要 data.delete,直到 data.list total=0。` }
1997
- : { cleanupHint: `本次提交只有一条记录(${String(readback.objectId)});验收后 data.delete 该 id,直到 data.list total=0。` }
1998
- : {}),
1999
- },
2000
- };
2067
+ }
2068
+ }
2069
+ return {
2070
+ performed: {
2071
+ errcode: performed.errcode,
2072
+ sheetCode,
2073
+ resolvedFrom,
2074
+ formType,
2075
+ // r28b F2 追问(真机 2026-11-05):这里只有 `draftObjectId`,而**参数名**、
2076
+ // data.save/data.delete/data.update 的读回、以及 data.load 全叫 `bizObjectId`。
2077
+ // 同一个 id 换三个名字,调用方要把 submit 的回执接到下一步(读回/删除)
2078
+ // 时得先知道这个别名。两个键都给(draftObjectId 保留不破既有脚本)。
2079
+ ...(draftObjectId ? { bizObjectId: draftObjectId, draftObjectId } : {}),
2080
+ // The workflow was auto-detected and started (formType=1) rather than
2081
+ // saved as a bare sheet row — surfaced so the caller knows the row is
2082
+ // now a PROCESSING workflow instance, not an orphan sheet record.
2083
+ ...(autoStarted ? { autoStartedWorkflow: explicitWorkflowCode } : {}),
2084
+ },
2085
+ readback: {
2086
+ ...readback,
2087
+ // Portal-required semantics are NOT enforced by the REST submit path;
2088
+ // say exactly what the tool checked so `ok` is never mistaken for
2089
+ // "the portal would have accepted this" (dogfood R3). This is a
2090
+ // SEPARATE concern from server-side business rules (which DO run on
2091
+ // submit) — name that so `portalRulesExecuted:false` is not misread as
2092
+ // "no rule ran" (dogfood R2).
2093
+ // r18b: an approval submit that omits `agree` is treated as APPROVE by
2094
+ // the platform (`agree: args.agree !== false`). That default is right
2095
+ // for the portal button, but for an API caller "I forgot the flag" and
2096
+ // "I approve" must not look identical in the receipt.
2097
+ ...(hasWorkItem && args.agree === undefined
2098
+ ? {
2099
+ approvalNote: "本次是审批提交但未传 agree——平台按**通过**处理(顶层 agree 缺省 true)。要驳回必须显式 agree:false;approval.agree 不生效。",
2100
+ }
2101
+ : {}),
2102
+ portalRulesExecuted: false,
2103
+ portalRulesNote: "portalRulesExecuted 只指门户客户端的必填/控件校验未在 REST 提交时执行;服务端业务规则(含流程 end 规则)照常运行。",
2104
+ requiredFields: validation.requiredFields,
2105
+ missingRequired: validation.missingRequired,
2106
+ ...(validation.missingRequired.length > 0
2107
+ ? {
2108
+ portalValidationWarning: `已提交但**未执行门户必填校验**:${validation.missingRequired.join("、")} 在发布表单里是必填,本次 formData 为空/缺省。REST runtime/form/submit 不跑门户客户端校验;如需真正阻断,请在提交前自行校验,或在服务端规则里加必填约束。`,
2109
+ }
2110
+ : {}),
2111
+ // Round 16b (dogfood R1-R3): a submit can leave BOTH a draft row and a
2112
+ // committed row, and deleting only the committed id leaves the draft
2113
+ // behind (data.list still counts it). Name both ids so cleanup does not
2114
+ // silently miss one. Round 17 (dogfood R2): when there is only ONE row
2115
+ // (a workflow submit advances the same row), say so explicitly — the
2116
+ // absence of a hint was itself read as an evidence gap.
2117
+ ...(typeof readback.objectId === "string"
2118
+ ? draftObjectId && readback.objectId !== draftObjectId
2119
+ ? { cleanupHint: `本次提交产生 draft(${draftObjectId}) 与 committed(${String(readback.objectId)}) 两条记录;验收后两条都要 data.delete,直到 data.list total=0。` }
2120
+ : { cleanupHint: `本次提交只有一条记录(${String(readback.objectId)});验收后 data.delete 该 id,直到 data.list total=0。` }
2121
+ : {}),
2122
+ },
2123
+ };
2124
+ },
2001
2125
  });
2002
2126
  }
2003
2127
  //# sourceMappingURL=runtime-data.js.map