@stalfh233/omc-cli 0.6.12 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/coverage/m1-coverage-manifest-v1.json +2 -0
  2. package/dist/approval-flow.js +84 -3
  3. package/dist/batch-call.js +32 -2
  4. package/dist/bench/call-face-eval.js +2 -2
  5. package/dist/call.js +35 -2
  6. package/dist/capability-overview.js +21 -7
  7. package/dist/cli-contract.js +8 -2
  8. package/dist/cli.js +279 -67
  9. package/dist/describe-cache.js +8 -4
  10. package/dist/environment-policy.js +20 -0
  11. package/dist/field-families.js +62 -4
  12. package/dist/form-support.js +293 -6
  13. package/dist/goal-routes.js +137 -22
  14. package/dist/identity.js +63 -11
  15. package/dist/lanes/app-contract.js +4 -4
  16. package/dist/lanes/app.js +19 -1
  17. package/dist/lanes/dictionary-contract.js +16 -5
  18. package/dist/lanes/dictionary-coverage.js +1 -0
  19. package/dist/lanes/dictionary.js +200 -8
  20. package/dist/lanes/environment-contract.js +18 -6
  21. package/dist/lanes/environment.js +129 -16
  22. package/dist/lanes/field-contract.js +2 -1
  23. package/dist/lanes/field.js +707 -59
  24. package/dist/lanes/form.js +52 -6
  25. package/dist/lanes/list-view-contract.js +2 -2
  26. package/dist/lanes/list-view.js +98 -2
  27. package/dist/lanes/meta.js +36 -10
  28. package/dist/lanes/model-contract.js +7 -6
  29. package/dist/lanes/model.js +154 -10
  30. package/dist/lanes/organization.js +67 -7
  31. package/dist/lanes/runtime-data-contract.js +4 -2
  32. package/dist/lanes/runtime-data.js +395 -41
  33. package/dist/lanes/workflow-contract.js +2 -2
  34. package/dist/lanes/workflow.js +381 -11
  35. package/dist/model-scaffold.js +11 -3
  36. package/dist/path-projection.js +168 -31
  37. package/dist/read-shapes.js +31 -11
  38. package/dist/recipes/field.js +16 -3
  39. package/dist/recipes/form.js +2 -1
  40. package/dist/recipes/runtime-data.js +58 -8
  41. package/dist/recipes/workflow.js +86 -1
  42. package/dist/refusal-guidance.js +18 -6
  43. package/dist/rollback-spec.js +41 -2
  44. package/dist/rollback.js +49 -11
  45. package/dist/script.js +72 -12
  46. package/dist/skills.js +91 -3
  47. package/dist/tools.js +113 -1
  48. package/dist/wire.js +13 -1
  49. package/dist/write-lock.js +92 -4
  50. package/dist/write.js +129 -16
  51. package/package.json +3 -3
  52. package/skills/omc-business-configuration.md +1 -1
  53. package/skills/omc-capabilities.md +5 -4
  54. package/skills/omc-playbook.md +41 -0
  55. package/skills/omc-refusals.md +58 -6
  56. package/skills/omc-tool-selection.md +2 -0
  57. package/skills/omc-write-hazards.md +3 -2
@@ -11,16 +11,72 @@ function lockTarget(environment) {
11
11
  function ownerFile(environment) {
12
12
  return `${lockTarget(environment)}.lock.owner.json`;
13
13
  }
14
+ /** Extract `data.save` from `… cli.js call data.save …`, or a top-level verb. */
15
+ export function reduceLockCommand(commandLine) {
16
+ const tokens = commandLine.trim().split(/\s+/).filter(Boolean);
17
+ const callAt = tokens.lastIndexOf("call");
18
+ if (callAt >= 0 && tokens[callAt + 1] && !tokens[callAt + 1].startsWith("-")) {
19
+ const tool = tokens[callAt + 1];
20
+ return { command: tool, tool };
21
+ }
22
+ const intentAt = tokens.lastIndexOf("intent");
23
+ if (intentAt >= 0 && tokens[intentAt + 1] && !tokens[intentAt + 1].startsWith("-")) {
24
+ return { command: `intent ${tokens[intentAt + 1]}` };
25
+ }
26
+ // Fall back to the last non-flag token that looks like a CLI verb / tool.
27
+ for (let i = tokens.length - 1; i >= 0; i -= 1) {
28
+ const token = tokens[i];
29
+ if (token.startsWith("-"))
30
+ continue;
31
+ if (token.includes("/") || token.endsWith(".js") || token === "node")
32
+ continue;
33
+ return { command: token };
34
+ }
35
+ return { command: commandLine };
36
+ }
37
+ function normalizeOwner(raw) {
38
+ if (!raw || typeof raw.pid !== "number")
39
+ return null;
40
+ const startedAt = typeof raw.startedAt === "string" ? raw.startedAt : "";
41
+ const legacyCommand = typeof raw.command === "string" ? raw.command : "";
42
+ const commandLine = typeof raw.commandLine === "string" && raw.commandLine.length > 0
43
+ ? raw.commandLine
44
+ : legacyCommand;
45
+ const reduced = typeof raw.tool === "string" && raw.tool.length > 0
46
+ ? { command: raw.tool, tool: raw.tool }
47
+ : reduceLockCommand(commandLine || legacyCommand);
48
+ // Prefer an already-reduced `command` when it is short (new writers); otherwise
49
+ // re-reduce from the command line so old ~110-char owners still shrink.
50
+ const command = typeof raw.command === "string" && raw.command.length > 0 && raw.command.length <= 64 && !raw.command.includes("/")
51
+ ? raw.command
52
+ : reduced.command;
53
+ return {
54
+ pid: raw.pid,
55
+ startedAt,
56
+ command,
57
+ commandLine: commandLine || command,
58
+ ...(reduced.tool !== undefined ? { tool: reduced.tool } : typeof raw.tool === "string" ? { tool: raw.tool } : {}),
59
+ };
60
+ }
14
61
  function readOwner(environment) {
15
62
  try {
16
- return JSON.parse(readFileSync(ownerFile(environment), "utf8"));
63
+ return normalizeOwner(JSON.parse(readFileSync(ownerFile(environment), "utf8")));
17
64
  }
18
65
  catch {
19
66
  return null;
20
67
  }
21
68
  }
22
69
  function writeOwner(environment) {
23
- const owner = { pid: process.pid, startedAt: new Date().toISOString(), command: process.argv.slice(0, 4).join(" ") };
70
+ // Persist the full argv so forensics keep the path; reduce for the `command` face.
71
+ const commandLine = process.argv.join(" ");
72
+ const reduced = reduceLockCommand(commandLine);
73
+ const owner = {
74
+ pid: process.pid,
75
+ startedAt: new Date().toISOString(),
76
+ command: reduced.command,
77
+ commandLine,
78
+ ...(reduced.tool !== undefined ? { tool: reduced.tool } : {}),
79
+ };
24
80
  try {
25
81
  writeFileSync(ownerFile(environment), JSON.stringify(owner, null, 2) + "\n", { mode: 0o600 });
26
82
  }
@@ -78,17 +134,23 @@ async function tryAcquire(environment) {
78
134
  }
79
135
  const lockDir = `${lockTarget(environment)}.lock`;
80
136
  const ownerDead = owner !== null && Number.isInteger(owner.pid) && !pidAlive(owner.pid);
137
+ const ownerLabel = owner
138
+ ? `owner pid=${owner.pid} started=${owner.startedAt} command=${owner.command}`
139
+ : "owner metadata missing (the holder predates lock metadata or crashed while acquiring)";
81
140
  return {
82
141
  held: false,
83
142
  reason: "conflict",
84
143
  owner,
85
144
  recovery: [
86
145
  `another process holds the write lock for environment ${environment}`,
87
- owner ? `owner pid=${owner.pid} started=${owner.startedAt} command=${owner.command}` : "owner metadata missing (the holder predates lock metadata or crashed while acquiring)",
146
+ ownerLabel,
88
147
  `lock path: ${lockDir}`,
89
148
  ...(ownerDead
90
149
  ? ["owner pid is NOT alive → this is a STALE lock; re-running the same command now recovers automatically (or delete the lock path above)"]
91
- : ["the holder is alive → wait for it to finish, then retry (retryable:true); do NOT delete the lock path while the holder runs"]),
150
+ : [
151
+ "the holder is alive → wait for it to finish, then retry (retryable:true); do NOT delete the lock path while the holder runs",
152
+ "不要写 shell 重试循环;用 --wait <ms> 让 CLI 内部排队(例:omc call <tool> --wait 30000)",
153
+ ]),
92
154
  `to force recovery when the owner is dead: rm -rf "${lockDir}" then retry`,
93
155
  ],
94
156
  };
@@ -106,4 +168,30 @@ export function staleLockPresent(environment) {
106
168
  const owner = readOwner(environment);
107
169
  return owner !== null && Number.isInteger(owner.pid) && !pidAlive(owner.pid);
108
170
  }
171
+ /** Structured face for a write-lock-held refusal (r31b F2). */
172
+ export function writeLockHeldFace(options) {
173
+ const wait = Math.max(1_000, options.waitMs && options.waitMs > 0 ? options.waitMs : 30_000);
174
+ const tool = options.refusedTool && options.refusedTool.length > 0 ? options.refusedTool : "<tool>";
175
+ const lockOwnerFace = options.owner
176
+ ? {
177
+ tool: options.owner.tool ?? options.owner.command,
178
+ pid: options.owner.pid,
179
+ startedAt: options.owner.startedAt,
180
+ command: options.owner.command,
181
+ commandLine: options.owner.commandLine,
182
+ }
183
+ : null;
184
+ return {
185
+ status: "refused",
186
+ blocker: "write-lock-held",
187
+ environment: options.environment,
188
+ retryable: true,
189
+ owner: options.owner,
190
+ lockOwner: lockOwnerFace,
191
+ nextCommand: `omc call ${tool} --env ${options.environment} --wait ${wait}`,
192
+ note: "不要写 shell 重试循环;用 --wait 排队",
193
+ message: `another process holds the write lock for environment ${options.environment}(retryable:true;用 --wait 排队,不要写 shell 重试循环)`,
194
+ recovery: options.recovery,
195
+ };
196
+ }
109
197
  //# sourceMappingURL=write-lock.js.map
package/dist/write.js CHANGED
@@ -50,7 +50,9 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload, readOnlyEs
50
50
  // A face may supply a session-aware dry-run projection (e.g. model.delete's
51
51
  // reference scan) so the preview carries facts that only live reads can
52
52
  // establish — never a second write path.
53
- dryRunResult: dryRunPayload ? (plan, session) => dryRunPayload(session, args).then((payload) => ({ status: "dry-run", wouldChange: true, plan, idempotency: idempotencyDeclaration(tool.name), ...payload, ...(args.requestId ? { requestId: args.requestId } : {}) })) : (plan) => ({ status: "dry-run", wouldChange: true, plan, idempotency: idempotencyDeclaration(tool.name), ...(args.requestId ? { requestId: args.requestId } : {}) }),
53
+ // First-write / dry-run receipts use replay:"none" — the duplicate tag only
54
+ // appears on a true same-fingerprint replay (r30a F1).
55
+ dryRunResult: dryRunPayload ? (plan, session) => dryRunPayload(session, args).then((payload) => ({ status: "dry-run", wouldChange: true, plan, idempotency: idempotencyDeclaration(tool.name, "none"), ...payload, ...(args.requestId ? { requestId: args.requestId } : {}) })) : (plan) => ({ status: "dry-run", wouldChange: true, plan, idempotency: idempotencyDeclaration(tool.name, "none"), ...(args.requestId ? { requestId: args.requestId } : {}) }),
54
56
  replay: (plan) => (requestFingerprint ? findCompleted(ctx.artifactsDir, args.requestId, tool.name, plan.environment, requestFingerprint) : undefined),
55
57
  });
56
58
  if (outcome.kind === "settled")
@@ -105,6 +107,18 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload, readOnlyEs
105
107
  message: `${error.message}——这是平台 ORM 的对象状态错误(Serializer.update/delete 要求实体先被装载),不是入参或规则图的形状问题。` +
106
108
  "同一份入参再保存一次通常即恢复。持续复现就换行:rule.delete 后重建(rule.save 会铸新记录),或用 rule.get 读回完整行再 rule.node.update 改一处——不要继续重试同一份 payload。",
107
109
  };
110
+ // r30a F10:errcode 40000「调用数据库发生异常」是平台侧故障,不是入参形状问题。
111
+ // 标 platform-fault,避免脚本把原样透出的业务码当成「改参数再试」。
112
+ if (error.errcode === 40000 || /调用数据库发生异常/.test(error.message))
113
+ return {
114
+ status: "refused",
115
+ blocker: `wire-${error.kind}`,
116
+ errcode: error.errcode,
117
+ "platform-fault": true,
118
+ message: `${error.message}——这是平台侧异常(errcode ${error.errcode}),不是入参形状问题;勿改参数硬重试,稍后重试或查平台日志。`,
119
+ nextAction: "稍后重试同一请求;持续复现请查平台 DB/ORM 日志,不要把 40000 当参数错误",
120
+ ...(error.diagnostic ? { diagnostic: error.diagnostic } : {}),
121
+ };
108
122
  return { status: "refused", blocker: `wire-${error.kind}`, errcode: error.errcode, message: error.message, ...(error.diagnostic ? { diagnostic: error.diagnostic } : {}) };
109
123
  }
110
124
  throw error;
@@ -129,7 +143,7 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload, readOnlyEs
129
143
  const artifactRef = writeArtifact(ctx, `${tool.name}-${randomUUID().slice(0, 8)}.json`, artifact);
130
144
  return { status: executed.status, steps: executed.steps, failedStep: executed.failedStep, ...executed.envelope, ...(args.requestId ? { requestId: args.requestId } : {}), artifactFingerprint: artifactRef.detailSha256 };
131
145
  }
132
- const { performed, readback, steps, halfSuccess } = executed;
146
+ const { performed, readback, steps, halfSuccess, irreversible } = executed;
133
147
  // Readback gate (live 2026-10-17): a tool that reports `readback.present:false`
134
148
  // has NOT proven its own write — the platform accepted the request but the
135
149
  // object is absent from the authoritative face. "Called without error" must
@@ -165,8 +179,13 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload, readOnlyEs
165
179
  const artifactRef = writeArtifact(ctx, `${tool.name}-${randomUUID().slice(0, 8)}.json`, artifact);
166
180
  // Journal AFTER the artifact is durable: one append-only line per
167
181
  // delivered governed config write, feeding `omc rollback list/plan/apply`.
182
+ // r30a F22: already-absent / noop writes must NOT pollute the rollback
183
+ // timeline (reverting a no-op delete would try to "restore" something that
184
+ // never existed in this session).
185
+ const skipped = typeof performed.skipped === "string" ? performed.skipped : undefined;
186
+ const isNoopWrite = performed.noop === true || skipped === "already-absent" || skipped === "stale-record-cache";
168
187
  let rollbackRef;
169
- if (tool.rollback) {
188
+ if (tool.rollback && !isNoopWrite) {
170
189
  const entry = appendJournalEntry(ctx.artifactsDir, {
171
190
  v: 1,
172
191
  environment: session.environmentName,
@@ -182,7 +201,25 @@ async function governedWrite(ctx, tool, args, execute, dryRunPayload, readOnlyEs
182
201
  });
183
202
  rollbackRef = { seq: entry.seq, hint: `omc rollback plan ${entry.seq}(--env ${entry.environment})` };
184
203
  }
185
- return { status: "ok", deliveryState, ...(readOnlyEscapeNote !== undefined ? { readOnlyEscape: readOnlyEscapeNote } : {}), performed, readback, ...(readbackWarning ? { readbackWarning } : {}), ...(steps !== undefined ? { steps } : {}), ...(halfSuccess !== undefined ? { halfSuccess } : {}), ...(args.requestId ? { requestId: args.requestId, idempotency: idempotencyDeclaration(tool.name) } : {}), ...(productionConfirmed ? { productionConfirmed: true } : {}), ...(rollbackRef ? { rollback: rollbackRef } : {}), summary: writeSummary(tool.name, args, { performed, readback }, "done"), detailFile: artifactRef.detailFile, detailSha256: artifactRef.detailSha256, artifactFingerprint: artifactRef.detailSha256 };
204
+ return {
205
+ status: "ok",
206
+ deliveryState,
207
+ ...(readOnlyEscapeNote !== undefined ? { readOnlyEscape: readOnlyEscapeNote } : {}),
208
+ performed,
209
+ readback,
210
+ ...(readbackWarning ? { readbackWarning } : {}),
211
+ ...(steps !== undefined ? { steps } : {}),
212
+ ...(halfSuccess !== undefined ? { halfSuccess } : {}),
213
+ ...(typeof irreversible === "string" ? { irreversible } : {}),
214
+ // r30a F1: first write → replay:"none"; duplicate only on true replay.
215
+ ...(args.requestId ? { requestId: args.requestId, idempotency: idempotencyDeclaration(tool.name, "none") } : {}),
216
+ ...(productionConfirmed ? { productionConfirmed: true } : {}),
217
+ ...(rollbackRef ? { rollback: rollbackRef } : {}),
218
+ summary: writeSummary(tool.name, args, { performed, readback }, "done"),
219
+ detailFile: artifactRef.detailFile,
220
+ detailSha256: artifactRef.detailSha256,
221
+ artifactFingerprint: artifactRef.detailSha256,
222
+ };
186
223
  });
187
224
  }
188
225
  /**
@@ -200,29 +237,105 @@ export function deliveryStateFor(readback) {
200
237
  }
201
238
  : { deliveryState: "DELIVERED" };
202
239
  }
203
- function idempotencyDeclaration(tool) {
240
+ /**
241
+ * Single source of truth for requestId replay enum strings (r30a F1).
242
+ * Help / skills must quote these constants — do not invent parallel spellings.
243
+ */
244
+ export const IDEMPOTENCY_REPLAY = Object.freeze({
245
+ none: "none",
246
+ alreadyCompleted: "already-completed",
247
+ });
248
+ export const IDEMPOTENCY_DUPLICATE = Object.freeze({
249
+ sameRequestReplayNoOp: "same-request-replay-no-op",
250
+ alreadyAbsentNoOp: "already-absent-no-op",
251
+ alreadyPublishedNoOp: "already-published-no-op",
252
+ });
253
+ function idempotencyDeclaration(tool, replay) {
254
+ if (replay === IDEMPOTENCY_REPLAY.none)
255
+ return { key: "requestId", replay: IDEMPOTENCY_REPLAY.none };
204
256
  if (tool.includes("delete"))
205
- return { key: "requestId", replay: "completed", duplicate: "already-absent-no-op" };
257
+ return { key: "requestId", replay: IDEMPOTENCY_REPLAY.alreadyCompleted, duplicate: IDEMPOTENCY_DUPLICATE.alreadyAbsentNoOp };
206
258
  if (tool.includes("publish"))
207
- return { key: "requestId", replay: "completed", duplicate: "already-published-no-op" };
208
- if (tool.includes("import"))
209
- return { key: "requestId", replay: "completed", duplicate: "same-request-replay-no-op" };
210
- return { key: "requestId", replay: "completed", duplicate: "same-request-replay-no-op" };
259
+ return { key: "requestId", replay: IDEMPOTENCY_REPLAY.alreadyCompleted, duplicate: IDEMPOTENCY_DUPLICATE.alreadyPublishedNoOp };
260
+ return { key: "requestId", replay: IDEMPOTENCY_REPLAY.alreadyCompleted, duplicate: IDEMPOTENCY_DUPLICATE.sameRequestReplayNoOp };
211
261
  }
212
- function findCompleted(root, requestId, tool, environment, requestFingerprint) {
262
+ /** Walk local write artifacts for a requestId (optionally tool/env scoped). */
263
+ export function listRequestArtifacts(root, requestId, options = {}) {
213
264
  if (!existsSync(root))
214
- return undefined;
265
+ return [];
266
+ const hits = [];
215
267
  for (const name of readdirSync(root)) {
216
268
  if (!name.endsWith(".json"))
217
269
  continue;
218
270
  try {
219
271
  const artifact = JSON.parse(readFileSync(join(root, name), "utf8"));
220
- if (artifact.deliveryState === "DELIVERED" && artifact.requestId === requestId && artifact.tool === tool && artifact.environment === environment && artifact.requestFingerprint === requestFingerprint)
221
- return { status: "ok", deliveryState: "DELIVERED", replay: "already-completed", requestId, idempotency: idempotencyDeclaration(tool), performed: artifact.performed, readback: artifact.readback };
272
+ if (artifact.requestId !== requestId)
273
+ continue;
274
+ if (options.tool !== undefined && artifact.tool !== options.tool)
275
+ continue;
276
+ if (options.environment !== undefined && artifact.environment !== options.environment)
277
+ continue;
278
+ hits.push({ file: name, artifact });
222
279
  }
223
- catch { }
280
+ catch { /* torn/corrupt artifact: skip */ }
281
+ }
282
+ return hits;
283
+ }
284
+ /**
285
+ * Idempotent replay lookup (r30a F1/F2):
286
+ * - same requestId + same fingerprint → already-completed (true no-op replay)
287
+ * - same requestId + different fingerprint → refuse request-id-args-mismatch
288
+ * - no prior hit → undefined (proceed with first write)
289
+ */
290
+ function findCompleted(root, requestId, tool, environment, requestFingerprint) {
291
+ const hits = listRequestArtifacts(root, requestId, { tool, environment });
292
+ const exact = hits.find((hit) => hit.artifact.deliveryState === "DELIVERED" && hit.artifact.requestFingerprint === requestFingerprint);
293
+ if (exact) {
294
+ const artifact = exact.artifact;
295
+ return {
296
+ status: "ok",
297
+ deliveryState: "DELIVERED",
298
+ replay: IDEMPOTENCY_REPLAY.alreadyCompleted,
299
+ requestId,
300
+ idempotency: idempotencyDeclaration(tool, IDEMPOTENCY_REPLAY.alreadyCompleted),
301
+ performed: artifact.performed,
302
+ readback: artifact.readback,
303
+ };
224
304
  }
225
- return undefined;
305
+ const prior = hits.find((hit) => hit.artifact.deliveryState === "DELIVERED" || hit.artifact.deliveryState === "WRITE_UNVERIFIED" || hit.artifact.deliveryState === "STOPPED");
306
+ if (!prior)
307
+ return undefined;
308
+ const priorArtifact = prior.artifact;
309
+ const performed = (priorArtifact.performed ?? {});
310
+ const existingBizObjectId = typeof performed.bizObjectId === "string"
311
+ ? performed.bizObjectId
312
+ : typeof priorArtifact.readback?.bizObjectId === "string"
313
+ ? String(priorArtifact.readback.bizObjectId)
314
+ : undefined;
315
+ const envFlag = `--env ${environment}`;
316
+ return {
317
+ status: "refused",
318
+ blocker: "request-id-args-mismatch",
319
+ requestId,
320
+ argsDigestChanged: true,
321
+ existingRequestFingerprint: priorArtifact.requestFingerprint ?? null,
322
+ ...(existingBizObjectId !== undefined ? { existingBizObjectId } : {}),
323
+ prior: {
324
+ tool: priorArtifact.tool,
325
+ environment: priorArtifact.environment,
326
+ deliveryState: priorArtifact.deliveryState,
327
+ recordedAt: priorArtifact.recordedAt,
328
+ artifactFile: prior.file,
329
+ },
330
+ message: `同一个 requestId「${requestId}」已经用过不同参数写过(argsDigestChanged:true)。` +
331
+ `requestId 是 (id, 参数指纹) 组合键——换参数必须换 requestId,否则拒绝,避免悄悄写出第二条。`,
332
+ nextCommand: `omc call request.status ${envFlag} --args '{"requestId":"${requestId}"}'`,
333
+ recovery: {
334
+ whatWentWrong: "request-id-args-mismatch",
335
+ fixFirst: `换一个未用过的 --request-id 再发;或先 omc call request.status ${envFlag} --args '{"requestId":"${requestId}"}' 查已有结果`,
336
+ doNot: "不要用同一 requestId 换参数期望覆盖/重放——那会拒绝,不会更新原写",
337
+ },
338
+ };
226
339
  }
227
340
  /** Low-level write registration factory: shared by every lane adapter. */
228
341
  export function writeTool(definition) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stalfh233/omc-cli",
3
- "version": "0.6.12",
3
+ "version": "0.6.13",
4
4
  "description": "CloudPivot 8.6 configuration capability CLI — governed, fail-closed, coverage-manifest bounded.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "commander": "14.0.3",
58
- "omc-core": "0.6.12",
58
+ "omc-core": "0.6.13",
59
59
  "proper-lockfile": "4.1.2",
60
60
  "write-file-atomic": "6.0.0",
61
61
  "zod": "^3.23.8",
@@ -73,6 +73,6 @@
73
73
  "directory": "packages/omc-cli"
74
74
  },
75
75
  "optionalDependencies": {
76
- "@stalfh233/omc-fake-platform": "0.6.12"
76
+ "@stalfh233/omc-fake-platform": "0.6.13"
77
77
  }
78
78
  }
@@ -152,7 +152,7 @@ rules:
152
152
 
153
153
  - **两条路**:模型+表单+流程一揽子交付走**意图**(`intent` 的 `workflow` 能力,声明式一次 apply);只动流程或想逐步控制走**低层链** `workflow.create → workflow.update(权限/节点)→ workflow.publish`,标准链配方见 `omc describe workflow.update`(含多级审批/participant 三形态/自动回填说明)。流程权限细节见下节。
154
154
  - 一个模型只能有一个流程;已有流程的模型直接 `workflow.update`,不要另建。
155
- - **清理测试数据用批量删除**:`data.delete --args '{"schemaCode":"<code>","bizObjectIds":["id1","id2"]}'` 一次往返删多条(真机 r6:207 次逐条 `data.delete` 占 13.5% 调用)。
155
+ - **清理测试数据用逐条删除**:1.1.x 上 `bizObjectIds:[多个]` 是假成功(`data-delete-batch-fake-success`)。用 `--args-list` 一次跑多条单删:`omc call data.delete --args-list '[{"schemaCode":"<code>","bizObjectId":"id1"},{"schemaCode":"<code>","bizObjectId":"id2"}]'`,再逐条 `data.load` 确认 `exists:false`(见 `describe data.delete --example sweep-all-readback`)。
156
156
  - **「聚合计算」是字段能力,不是规则节点**:FORMULA(计算公式)字段的 `options.formulaConfig.formulaType="AGGREGATION"` 就是 UI 里的「配置方式 → 聚合计算」。**只能汇总另一个模型**(跨模型),**同模型/自己的子表会 305003**(`field.publish` 时 `publishFormula` 把相对码改写成子表父模型 = 本模型,`checkCycleOfAggregation:1032` 判成环;工具已写前拒绝 `aggregation-self-schema-cycle`)。可直接抄 `omc describe field.create --example formula-aggregation-cross-model`。形状:
157
157
  `options = {persistent:true, formulaConfig: JSON字符串({resultType:2, formulaType:"AGGREGATION", aggregation: JSON字符串({schemaCode:"<来源模型>", dataItemCode:"<来源列>", calculateType:"SUM", formula:"SUM({<来源模型>.<列>})", queryConditions:[[{propertyCode:"<来源模型字段>", currentPropertyCode:"<目标模型字段>", operatorType:1, valueType:"DYNAMIC"}]]})})}`。
158
158
  - **`queryConditions` 不能为空**:它是反向过滤器的唯一输入,空数组时回算日志照打但目标字段恒 0(r17 实测)。
@@ -67,9 +67,9 @@ only the section for the capability you need —
67
67
  ## 数据字典 `dictionary`
68
68
 
69
69
  - **能做哪些事**:字典与字典记录全生命周期(键值型/树型、启停);作为选择字段的选项来源;字典分类。
70
- - **工具**:`dictionary.list`、`dictionary.type`、`dictionary.names`、`dictionary.get`、`dictionary.records`、`dictionary.save`、`dictionary.status`、`dictionary.usage`、`dictionary.parent`、`dictionary.classes`、`dictionary.class.save`、`dictionary.class.delete`
70
+ - **工具**:`dictionary.list`、`dictionary.type`、`dictionary.names`、`dictionary.get`、`dictionary.records`、`dictionary.save`、`dictionary.status`、`dictionary.usage`、`dictionary.references`、`dictionary.parent`、`dictionary.classes`、`dictionary.class.save`、`dictionary.class.delete`
71
71
  - **recipe**:`omc describe dictionary.save --example dictionary-key-value`
72
- - **边界 / 陷阱**:选择字段有三种选项来源——`custom` / `dictionary` / `businessModel`;关联单选/多选支持 `searchFormula` 与 `conditions` JSON 筛选。**字典没有删除**——平台只有停用/启用(`dictionary.status`,target= dictionary|record);不要找 dictionary.delete(分类有 `class.delete`,字典本体没有),清理测试残留用停用即可。
72
+ - **边界 / 陷阱**:选项来源 `custom`/`dictionary`/`businessModel`;关联可用 `searchFormula`/`conditions`。**无字典删除**——只能 `dictionary.status` 停用。`dictionary.usage`=写 initialUsed(非引用查询);引用扫描用 `dictionary.references`。
73
73
 
74
74
  ## 业务模型 `model`
75
75
 
@@ -152,8 +152,8 @@ only the section for the capability you need —
152
152
 
153
153
  - **能做哪些事**:流程模板 get_draft/get_published、create、update、publish、bindRule(事件处理器规则绑定)、delete、workitem.list、workflow.instanceActivity(实例节点状态诊断);参与人用组织对象。
154
154
  - **工具**:`workflow.get_draft`、`workflow.setButtons`(节点「表单操作」按钮勾选)、`workflow.get_published`、`workflow.validate`、`workflow.create`、`workflow.update`、`workflow.publish`、`workflow.bindRule`、`workflow.delete`、`workflow.permissions`、`workflow.addPermission`、`workflow.removePermission`(发起权限:发布流程不会自动建这一行,缺它一律 40009)、`configure_workflow`、`workitem.list`、`workflow.instanceActivity`、`org.role.list`、`org.department.list`、`org.user.search`、`org.role.users`、`org.role.userScope`、`org.roleGroup.create`、`org.role.create`、`org.role.update`、`org.role.addUsers`、`org.role.removeUsers`、`org.role.setUserScope`、`org.department.create`、`org.department.update`、`org.department.delete`、`org.department.users`、`org.user.create`、`org.user.update`、`org.user.delete`、`org.user.info`、`org.user.departments`、`org.role.delete`、`org.roleGroup.delete`、`org.user.transferDepartment`
155
- - **recipe**:`omc describe workflow.bindRule --example workflow-handler-binding`
156
- - **边界 / 陷阱**:greenfield 首版;`publish`/`bindRule` 不得在 `ValidateMessage[]` 拒绝时报告成功。运行时 workitem(提交/审批)走 `data.submit`;想知道实例停在哪/是否错派,用 `workflow.instanceActivity`(无 status=1=真停,参与人全是发起人=解析为空退回)。
155
+ - **recipe**:`omc describe workflow.bindRule --example workflow-handler-binding`;条件两级/一级:`omc describe workflow.update --example workflow-conditional-two-level-approval`
156
+ - **边界 / 陷阱**:greenfield 首版;`publish`/`bindRule` 不得在 `ValidateMessage[]` 拒绝时报告成功。运行时 workitem(提交/审批)走 `data.submit`;想知道实例停在哪/是否错派,用 `workflow.instanceActivity`(无 status=1=真停,参与人全是发起人=解析为空退回)。**发起权限**(`workflow.permissions` / `addPermission`)只有部门/角色/用户三种白名单,**没有「全员」选项**——缺权限行时非管理员通常不能发起(40009),想放开只能授一个够宽的部门/角色,不是「不建行=全员」。**行级数据权限 / 可见范围 / 申请人只看自己的单据不在工具覆盖内**(没有按登录人动态过滤的读面/写面);`org.role.setUserScope` 是角色成员的部门管理范围,不是单据行级可见范围。
157
157
 
158
158
  ## 表单在线 JS `onlinejs`
159
159
 
@@ -207,6 +207,7 @@ only the section for the capability you need —
207
207
  | 面 | 状态 | 说明 |
208
208
  | --- | --- | --- |
209
209
  | 权限 / 授权矩阵 | blocked | 授权矩阵不在配置面 |
210
+ | **行级数据权限 / 可见范围 / 本人单据** | unsupported | 没有按登录人动态过滤的读面/写面;`workflow.permissions` 只管谁能**发起**;`org.role.setUserScope` 是角色成员部门管理范围。搜「数据权限」「可见范围」「行级权限」「本人」应命中本行——不是词写错,是能力缺口 |
210
211
  | 嵌套子表(子表套子表) | 平台不存在 | 平台本身就是一级子表:主表可多子表,子表不能再套子表;不是 CLI 缺口,不要按此设计 |
211
212
  | OCR / 电子签章 / 发票识别 | unsupported | 不在平台配置覆盖内 |
212
213
  | 应用发布 / 环境上线 / 生产发布 | unsupported | CLI 只做配置,不做环境发布 |
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: omc-playbook
3
+ description: 一页纸业务场景步骤清单(采购申请 / 请假审批 / 基础档案)。多面需求先看这里,再按步执行。
4
+ ---
5
+
6
+ # Playbook(业务场景一页纸)
7
+
8
+ 多面需求不要靠自己拼 3–4 个 1KB+ 方法论节。这里每步一条命令。
9
+
10
+ 入口:`omc next "<目标>"`(多面 → `mode:whole-package`)→ 本页 → 逐步执行。
11
+
12
+ ## 采购申请(主子表 + 合计 + 条件审批 + 列表 + 按钮)
13
+
14
+ 1. `omc next "从零做采购申请:主子表、合计、条件审批、列表、按钮"`
15
+ 2. `omc intent schema`(或直接按下方拼装器链)
16
+ 3. `omc model-scaffold --app <app> --code <code> --name 采购申请 --fields '[{"code":"title","name":"标题","propertyType":"SHORT_TEXT"},{"code":"amount","name":"合计","propertyType":"NUMERICAL","options":{"scale":2}},{"code":"details","name":"明细","propertyType":"CHILD_TABLE","subFields":[{"code":"item","name":"品名","propertyType":"SHORT_TEXT"},{"code":"qty","name":"数量","propertyType":"NUMERICAL","options":{"scale":0}},{"code":"price","name":"单价","propertyType":"NUMERICAL","options":{"scale":2}},{"code":"lineAmount","name":"金额","propertyType":"NUMERICAL","options":{"scale":2}}]}]' --apply`
17
+ 4. `omc compute --schema <code> --target amount --formula 'SUM({details.lineAmount})' --apply`(⚠️ 只在门户算,REST 不回填)
18
+ 5. 条件审批(金额>50000 两级):**不要** `approval-flow --condition`(无此参数)。线性先:`omc approval-flow --schema <code> --steps '经理@role:<mgr>' --steps '总监@role:<dir>' --apply`;条件分流改 `omc describe workflow.update --example workflow-conditional-branch` 后 `workflow.update`。
19
+ 6. 发起权限:`omc call workflow.addPermission --args '{"workflowCode":"<code>_wf","unitId":"<deptOrRoleId>","unitType":1}'`(这是**谁能发起**,不是行级可见范围)
20
+ 7. 列表:`omc call listview.configure --args '{"schemaCode":"<code>","fieldCodes":["title","amount"]}'`
21
+ 8. 表单按钮:`omc call listview.configure --args '{"schemaCode":"<code>","buttons":[{"name":"提交审批","useLocation":"FORM","bindAction":"FLOW"}]}'`(形状以 describe 为准)
22
+ 9. ⚠️ **行级「申请人只看自己」当前工具面不支持**(`workflow.permissions` / `org.role.setUserScope` 都不是行级数据权限)
23
+
24
+ ## 请假审批(线性流)
25
+
26
+ 1. `omc model-scaffold --code leave --name 请假 --fields '[{"code":"days","name":"天数","propertyType":"NUMERICAL","options":{"scale":1}},{"code":"reason","name":"事由","propertyType":"LONG_TEXT"}]' --apply`
27
+ 2. `omc approval-flow --schema leave --steps '主管@role:<code>' --apply`
28
+ 3. `omc call workflow.addPermission --args '{"workflowCode":"leave_wf","unitId":"<id>","unitType":2}'`
29
+ 4. 造单验:`omc describe data.submit --example workflow-backed-data-submit`
30
+
31
+ ## 基础档案(无流程)
32
+
33
+ 1. `omc model-scaffold --code vendor --name 供应商 --fields '[{"code":"name","name":"名称","propertyType":"SHORT_TEXT"},{"code":"code","name":"编码","propertyType":"SHORT_TEXT"}]' --apply`
34
+ 2. `omc call listview.configure --args '{"schemaCode":"vendor","fieldCodes":["name","code"]}'`
35
+ 3. 造数:`omc describe data.submit --example plain-data-submit`
36
+
37
+ ## 读法
38
+
39
+ - 默认索引:`omc help omc-playbook`(看节摘要)
40
+ - 按节:`omc help omc-playbook --section "采购申请"`
41
+ - 下次可按节读:`omc help <topic> --section <标题>`
@@ -70,6 +70,23 @@ bisect by deleting/disabling the suspect formula field, which is reversible).
70
70
  `omc` now answers `runtime-write-db-exception` and lists the model's formula
71
71
  fields; the fix is to aggregate a MAIN-table scalar column instead.
72
72
 
73
+ ### 3c. Workflow validate `401013` — duplicate / conflicting edge
74
+
75
+ `workflow.validate` returns `errCode:401013` with `errContent:"节点名A&节点名B"`
76
+ and often `errMsg:null` (r31c F21). Meaning: **the same pre→post pair has more
77
+ than one edge**, typically an unconditional edge plus a condition edge, or two
78
+ condition edges that the platform treats as a duplicate. Fix:
79
+
80
+ 1. Condition edge and else edge must target **different** `postNode`s.
81
+ 2. Else uses `utilizeElse:true` (not a second unconditional edge on the same pair).
82
+ 3. Copy `omc describe workflow.update --example workflow-conditional-two-level-approval`
83
+ for the fork-then-join shape (金额>阈值两级 / 否则一级).
84
+
85
+ Related: `workflow.validate` currently does **not** warn about dead-end graphs
86
+ (a node with only conditional outbounds and no else — r31c F23). That check
87
+ belongs in `src/lanes/workflow.ts` (forbidden for this change set); rely on the
88
+ recipe's else edge until it lands.
89
+
73
90
  ## 4. Readback mismatch — the write landed but does not match
74
91
 
75
92
  `*-readback-failed`, `*-readback-mismatch`, `cleanup-absence-readback`,
@@ -90,8 +107,17 @@ approved draft, and decide with the human. A second write may duplicate.
90
107
 
91
108
  ## 6. Idempotency & replay
92
109
 
93
- - A repeated `requestId` with identical args returns `replay:"already-completed"`
94
- — the first write stands; treat as success, do not re-execute.
110
+ - First write with a new `requestId` returns `idempotency.replay:"none"` (no
111
+ `duplicate` key). Constants live in code as `IDEMPOTENCY_REPLAY` /
112
+ `IDEMPOTENCY_DUPLICATE` — do not invent parallel spellings.
113
+ - A repeated `requestId` with **identical** args returns
114
+ `replay:"already-completed"` + `duplicate:"same-request-replay-no-op"` (or
115
+ `already-absent-no-op` / `already-published-no-op` for delete/publish) — the
116
+ first write stands; treat as success, do not re-execute.
117
+ - Same `requestId` with **different** args is refused:
118
+ `blocker:"request-id-args-mismatch"` + `argsDigestChanged:true` + pasteable
119
+ `nextCommand` (`request.status` or a fresh `--request-id`). It does **not**
120
+ silently write a second row.
95
121
  - `intent-plan-tampered` — the plan file changed after planning. Re-run
96
122
  `intent plan`.
97
123
  - `intent-baseline-drifted` — the environment moved after planning. Re-run
@@ -101,11 +127,15 @@ approved draft, and decide with the human. A second write may duplicate.
101
127
  ## 7. Transport uncertainty — outcome unknown
102
128
 
103
129
  `status:"uncertain"` with `frozen:true` (`cli-timeout`, `batch-timeout-budget`,
104
- `wire-transport:*`). The write **may** be in flight.
130
+ `wire-transport:*`). The write **may** be in flight. On `cli-timeout` the
131
+ envelope carries `retryable:false`, `lockHeldBy` / `retryAfter`, and a pasteable
132
+ `nextCommand` for `omc call request.status --env <env> --args '{"requestId":"..."}'`.
105
133
 
106
- 1. Read back the affected resource (or `artifact get <id>` / the `requestId`).
107
- 2. Only after the outcome is established, decide whether to retry.
108
- 3. Never blind-retry — that risks a second write.
134
+ 1. Run that `request.status` (or read back the affected resource by business key).
135
+ 2. Only after the outcome is established, decide whether to retry — and only
136
+ after the lock holder exits (`retryAfter`).
137
+ 3. Never blind-retry while `retryable:false` / lock held — that risks
138
+ `write-lock-held` or a second write.
109
139
 
110
140
  `write-lock-held` — another write holds the environment lock (`owner`/`recovery`
111
141
  included). Retry with `--wait <ms>` on `omc call` / `omc intent apply`: queueing is
@@ -127,6 +157,28 @@ concurrency, not approval.
127
157
  - `dictionary-target-required`, `intent-plan-invalid` — a required selector or
128
158
  shape is missing; see `describe <tool>`.
129
159
 
160
+ ## 9b. 权限拒绝(40009 / 发起白名单 / 管理员绕过)
161
+
162
+ - `40009` / `workflow-start-permission-denied` — 「流程发起权限不足」。三个同码原因:
163
+ ① 发起人没命中 `h_workflow_permission`(部门/祖先/角色/本人);② 流程未发布;
164
+ ③ 发起人部门记录丢了(`getRootDeptId` 空 → 白名单短路)。先
165
+ `omc call workflow.permissions` 看配置行,再 `workflow.get_published` 看是否已发布。
166
+ - **管理员绕过**:平台 `h_perm_admin` 的 ADMIN/SYS_MNG(= `login().administrator`,
167
+ **不是**登录 API 的 `isAdmin`)会绕过发起白名单。`data.submit` 成功时若
168
+ `actor.isAdmin=true` 且带 `bypassReason:"platform-admin bypasses start-permission whitelist"`,
169
+ **配置成功 ≠ 对普通用户生效**。
170
+ - **怎么验证真生效**:用**非管理员**账号另建环境(`omc env add`)再
171
+ `data.submit`;或看 `workflow.permissions` 的 `effectiveFor.reason`。不要用管理员
172
+ 发起成功当作白名单证据。
173
+ - `workflow-start-permission-empty` — 权限表 0 行:除平台管理员外无人能发起;
174
+ 用 `workflow.addPermission` 授给部门/角色/用户。
175
+ - `workflow-bound-to-listview`(303024)— 列表视图还绑着该流程。回执带
176
+ `boundListviews[{code,name,actionCode,associationCode,fieldCodes}]` 与可粘贴
177
+ `nextCommand`(`listview.configure`:`actions:[]` + `replaceActions:true` + 现有
178
+ `fieldCodes`,无需再挖 `listview.get`)。先解绑再 `workflow.delete`;整体清理用
179
+ `omc model-purge --code <schema>`。注意:默认 `listview.get` 摘要面不露
180
+ `queryActions`,绑定细节以本拒绝回执为准。
181
+
130
182
  ## 10. 投影路径(--jq / --extract)
131
183
 
132
184
  收**路径选择器**,不是 jq 表达式(错误回执的 `hint` 与本节同源)。
@@ -49,6 +49,8 @@ description: Choose the right OMC CLI tool for a goal. Use when unsure whether a
49
49
 
50
50
  **纯读不必先发现**:已知读哪一面就直接 `omc call <读工具>`(上表),省掉 `next`/`discover_environment`;只有选不出工具时才用 `next`。发现仪式(`next`/`capabilities`/`discover_environment`)是写前与选不出工具时的路径。
51
51
 
52
+ **行级数据权限 / 可见范围 / 申请人只看自己**:不在工具覆盖内(没有按登录人动态过滤的读/写面)。`workflow.permissions` 只设谁能发起;`listview.configure` 的 conditions 是全局过滤,不是按登录人。详见 `omc help omc-capabilities --section "边界"`。
53
+
52
54
  ## High layer — orchestrated CLI calls
53
55
 
54
56
  | Goal | Tool |
@@ -41,7 +41,7 @@ omc help omc-write-hazards
41
41
  - 有映射时传非空 JSON 数组字符串,并在 draft/published 两面读回。
42
42
  - **人员字段形状(issue#10,8.6.41+1.1.25 双验)**:save/update 只认对象数组 `[{id,type,name}]`(type:用户 3/部门 1)——字符串/字符串数组 50000;提交面两种形状都 50000。可靠链路=save 对象数组落库 → 提交**省略人员字段**。OMC 双闸已修正(提交带 allowStaffOnSubmit 放行)。单选(9)=id 串;多选(11)=分号串。
43
43
  - **`mappings` 由表单运行时在用户选择时套用**,服务端 `data.save` **不会**自动回填映射目标字段。API 侧要自己写被映射字段,否则读回为空——别把它误判成“映射配置失败”。
44
- - **关联映射的事实源在字段行 `options.mappings`,不在表单控件属性**(真机 2026-10-14 实证:改表单 attributes 里的映射会被平台按字段重算覆盖、回读不生效)。改映射走:`field.update` 按完整 options 提交(只改 mappings,其余键原值保留)→ `field.publish` → `form.draft`(**rebuild:false**,保留现有布局与在线JS)重存 → `form.publish` → 回读核对。**别用 cascadeRepublish 刷带布局/在线JS 的表单**:rebuild 会清空设计器公式(已做写前拒绝)。
44
+ - **关联映射的事实源在字段行 `options.mappings`,不在表单控件属性**(真机 2026-10-14 实证:改表单 attributes 里的映射会被平台按字段重算覆盖、回读不生效)。改映射走:`field.update`→`field.publish`→`form.draft(rebuild:false)`→`form.publish`。**别用 cascadeRepublish 刷带布局/在线JS 的表单**。字典-backed 的 `data.save` 不校验取值(非法码也会落库;要拦请配数据规则)。
45
45
  - **运行时写值的最小形状**(组合出错只会得到一个不透明的 `50000`/NPE,逐一最小化再合):单选关联(9) → id 字符串 `"<id>"`;**多选关联(11) → 分号串 `"<id1>;<id2>"`**;**人员/组织(50/51/60/61) → `[{"id":"<id>","type":3,"name":"<名>"}]`,`type` 必填且为整数**(缺/非整数 → `BizField.parseFromClient` 拆箱 NPE→50000;用户=3、部门=1);日期(3) → 字符串 `"2026-09-30 10:00:00"`;子表(8) → 行对象数组 `[{"<子列code>":<值>, ...}]`。先各写一条最小行通过,再组合。
46
46
  其中三类已有**写前拒绝**(r17b 2026-10-26 起):日期收到毫秒时间戳(平台接受但落库 NULL)、日期字符串不匹配字段声明 format(50000)、单值关联收到数组(50000)——blocker `submit-date-shape` / `submit-relation-shape`。
47
47
  - **DATE(3) 字段的 `options.format` 必须是平台大写枚举**(`YYYY-MM-DD`、`YYYY-MM-DD HH:mm:ss`、`YYYY年MM月DD日`…),**不是 Java 的 `yyyy-MM-dd`**:平台 `DateBizField` 按字面比较,Java 写法能创建字段但**首次运行时 save 稳定 `50000`(`不支持的format`)**(2026-09-30 R5 实测)。`field.create`/`intent` 会就地拒绝非枚举值。
@@ -109,7 +109,7 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
109
109
 
110
110
  ## 写调用纪律
111
111
 
112
- - **同一 `<env>` 的写调用必须串行发起**:`write-lock-held` 不只在"并行重放失败请求"时出现,也会在**并行发起两个无关写**时触发(实测:并行两条 `dictionary.save`、并行 `field.update`+`field.publish` 各失败一次)。读可并行,写不要。确实要并发进程时用 `--wait <ms>`(`omc call` / `omc intent apply` 都支持)让 CLI 内部排队,别把重试循环写在 shell 里。
112
+ - **同一 `<env>` 的写调用必须串行发起**:`write-lock-held` 不只在"并行重放失败请求"时出现,也会在**并行发起两个无关写**时触发(实测:并行两条 `dictionary.save`、并行 `field.update`+`field.publish` 各失败一次)。读可并行,写不要。确实要并发进程时用 `--wait <ms>`(`omc call` / `omc intent apply` 都支持)让 CLI 内部排队,别把重试循环写在 shell 里。并发语义总表(写锁 / 同记录 patch / `version` 非乐观锁 / 翻页无快照 / 删除幂等)见 `omc help concurrency`。
113
113
  - **`data.delete` 批量形态 1.1.25 假成功**(r30 2026-10-31 真机):`bizObjectIds` 数组回 `DELIVERED`+`absent:true`+`modelResidue.total` 递减但行仍在——OMC 已写前拒绝 `data-delete-batch-fake-success`。纪律不变:删除后用 `data.load exists:false`(或重查 `total`)复核,`absent:true` 不是删除成功的证明。
114
114
 
115
115
  ## 审批提交的真实形状(r18b 2026-10-26 真机,1.1.25)
@@ -216,6 +216,7 @@ omc call field.publish --env local --args '{"schemaCode":"expense"}'
216
216
 
217
217
  - **`btnConfigList` 局部行=整行替换**:只传 `{code, remark}` 会把该按钮的 `enabled` 静默置 `false`、`commentPermission` 置 `null`("驳回意见必填"丢失),同提交未提及的其他属性也被重置。改前先 `workflow.get_published`/`get_draft` 取整行,改目标属性后**整条回写**;`propertyPermissions` 同粒度。
218
218
  - **`SINGLE_PARTICIPANT` 多角色只取第一个解析成员**(MULTI 则全员会签)——"配置没生效"常是它,回读确认解析人。
219
+ - **条件流小节点(r31c)**:同一对 `pre→post` 不能既有无条件边又有条件边(平台 `401013`,errContent 常是「节点名A&节点名B」)。条件边与 else(`utilizeElse:true`)必须指向**不同** postNode。两级/一级分叉再汇合抄 `omc describe workflow.update --example workflow-conditional-two-level-approval`。`workflow.validate` 目前不查断头图(只有条件出边、无 else 的节点)——靠配方自带 else。
219
220
 
220
221
  ## 跨表回写的真实语义(1.1.25 源码 + 真机 2026-10-20)
221
222