From 395cc45844a9da2fc3ccf463bb149ec782ebb2c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:54:42 +0000 Subject: [PATCH 01/11] feat(automation): edge-branched decision is exclusive; `mode: 'inclusive'` takes every branch A `decision` with no `config.conditions` now takes the FIRST conditioned out-edge whose condition holds, in declaration order (BPMN exclusive gateway); the passed-over siblings record a `skipped` step. `mode: 'inclusive'` takes every one, sequentially. `isDefault` is unchanged. - registration parses `DecisionConfigSchema` and refuses an invalid `mode` (value outside the pair, or beside a non-empty `conditions` list) with the schema's sentence; `os validate` reports the same as `flow-decision-mode-invalid`, plus the advisory `flow-decision-inclusive-overlap`. - ADR-0087 D2 `flow-decision-mode-inclusive-explicit` (retired from the load path, refused by the flow rehydration seam by id) writes `mode: 'inclusive'` onto decisions with >= 2 conditioned out-edges for `os migrate meta --from 17`; D3 entry `flow-decision-edge-branching-first-match` carries the judgment. - the status-quo pin is rewritten as the contract pin; docs describe both modes. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- ...429-decision-edge-branching-first-match.md | 81 ++++ content/docs/automation/flows.mdx | 51 ++- packages/lint/src/lint-flow-patterns.test.ts | 146 ++++++ packages/lint/src/lint-flow-patterns.ts | 88 ++++ ...on-overlapping-edge-conditions.pin.test.ts | 418 +++++++++++++----- .../src/builtin/logic-nodes.ts | 11 + .../services/service-automation/src/engine.ts | 207 +++++++-- .../automation/schemaless-node-config.test.ts | 15 +- .../automation/schemaless-node-config.zod.ts | 90 ++-- .../spec/src/conversions/conversions.test.ts | 126 ++++++ packages/spec/src/conversions/registry.ts | 285 ++++++++++++ ...low-decision-edge-branching-first-match.ts | 75 ++++ packages/spec/src/migrations/registry.ts | 68 ++- 13 files changed, 1471 insertions(+), 190 deletions(-) create mode 100644 .changeset/15429-decision-edge-branching-first-match.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts diff --git a/.changeset/15429-decision-edge-branching-first-match.md b/.changeset/15429-decision-edge-branching-first-match.md new file mode 100644 index 00000000000..3ef2b190d53 --- /dev/null +++ b/.changeset/15429-decision-edge-branching-first-match.md @@ -0,0 +1,81 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-automation": minor +"@objectstack/lint": minor +--- + +feat(automation)!: an edge-branched `decision` is exclusive — the first out-edge whose condition holds, in declaration order, wins; `mode: 'inclusive'` takes every one (#15429) + + + +Clause-②: yes + +**BREAKING** — the run-time semantics of a shipped node type change. A `decision` node that +declares no `config.conditions` and branches on its out-edges used to take EVERY out-edge whose +condition held, one after another, while its schema, the docs and the engine's own comment all +called it an exclusive gateway; hotcrm#1555 rendered a refusal screen AND ran the conversion in +one execution. Maintainer ruling on #15429 (2026-09-23, 「跟主流对齐」): the gateway follows +BPMN's exclusive gateway, Salesforce Flow's Decision and n8n's Switch default, and taking every +true branch is a declaration the author writes down. + +| | before | after | +|:--|:--|:--| +| two conditioned out-edges, both hold | both successors run, sequentially, nothing reported | the FIRST declared one runs; the second records a `skipped` step | +| `config: { mode: 'inclusive' }` | accepted, never read | every out-edge whose condition holds runs, sequentially | +| none holds | the `isDefault` edge runs | unchanged | +| `mode` beside a non-empty `conditions` list, or outside `'exclusive' \| 'inclusive'` | refused by a direct parse only | refused at `registerFlow` and by `os validate`, with the schema's own sentence | + +## Migration: FROM → TO + +`os migrate meta --from 17` lists the mechanical edits for existing sources and applies them +to the migrated stack: the ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit` +writes `mode: 'inclusive'` onto every decision that has no `conditions` list and two or more +conditioned out-edges, inside ADR-0031 regions included, so a migrated flow runs exactly as it +did. + +```ts +// FROM — every true out-edge ran +{ id: 'verdict', type: 'decision', label: 'Verdict?' } +// TO — what the conversion writes; delete the key where the conditions partition +{ id: 'verdict', type: 'decision', label: 'Verdict?', config: { mode: 'inclusive' } } +``` + +Then review each written key (the paired D3 entry `flow-decision-edge-branching-first-match` +carries the acceptance criteria): delete it where the conditions partition (`== 'a'` beside +`!= 'a'`, `>` beside `<=`, a guard beside `isDefault: true`), keep it where the flow relies on +more than one branch running for one record, and where the overlap was accidental narrow the +conditions into a partition and delete the key. `os validate` reports +`flow-decision-inclusive-overlap` on every decision that keeps the key with two or more +conditioned out-edges, so the review list is the lint output. + +⚠️ **The conversion replays only where the operator asserts the source's age.** It is a default +flip — the old shape still parses and now means exclusive — so the authoring funnel never +rewrites a source written against this contract, the automation engine's flow rehydration seam +refuses it by id (a code-shipped flow, a REST body and a Studio save all arrive there undated), +and `os migrate meta --stored` canonicalizes through that same seam. A flow stored in +`sys_metadata` from the Studio before this release, with two or more conditioned out-edges and +no `mode`, now runs first-match and is rewritten by nothing: list those rows and declare `mode` +on each in the designer. + +## Reach, measured at landing + +- Release state: the npm registry's `latest` `@objectstack/spec` is `17.4.0` (`npm view`, + 2026-09-27), whose `json-schema/automation/DecisionConfig.json` declares `conditions` only — + `mode` has not shipped; `.changeset/19867-decision-config-mode.md` and + `.changeset/20168-decision-mode-beside-conditions-refused.md` are still unconsumed in this + tree. So `mode` reaches its first release together with the traversal that reads it and the + conversion that writes it; no published accept set narrows, and the registration and + `os validate` refusals narrow nothing that shipped. +- Corpus census (this repository at the branch base and `objectstack-ai/hotcrm` at `2f7b2326`, + read-only): 30 decision nodes across 48 flows; 17 have two or more conditioned out-edges and + no `mode` (the conversion's positives — every one a hand-written partition, including + hotcrm's `lead_conversion.decision_duplicate`, the #1555 node), 13 have one conditioned + out-edge (left alone), and no node of any other type carries a conditioned out-edge, so the + exclusive traversal is scoped to `decision` with nothing else to migrate. +- What the published surface gains: the D2 conversion and its D3 entry in the protocol-18 + chain (`spec-changes.json`, the upgrade guide), `DecisionConfigSchema.mode`'s describe and + docblock now state the run-time semantics, and `@objectstack/lint` gains + `flow-decision-mode-invalid` (gating) and `flow-decision-inclusive-overlap` (advisory). + +The traversal change is scoped to `decision` nodes: conditioned out-edges of any other node +type keep the every-true-edge traversal they had (none was measured to exist). diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 074b4657f05..d99a9f38d46 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -1357,7 +1357,7 @@ Edges connect nodes and define the execution path: A node has exactly two ways to split its path, and mixing them is what makes a guard stop guarding (#4414). -**Branch on the edges** (BPMN exclusive gateway — the default choice): +**Branch on the edges** (BPMN gateway — the default choice): ```typescript { id: 'check', type: 'decision', label: 'Already converted?' }, // no config @@ -1375,6 +1375,48 @@ in parallel with `abort` — so an already-converted lead sees the abort screen condition by hand is the only other correct spelling; `isDefault` is the one that stays correct when a third branch is added. +**The gateway is exclusive.** When more than one out-edge carries a +`condition`, the engine evaluates them **in the order the `edges` array +declares them** and takes the **first** one whose condition holds — the BPMN +exclusive gateway, Salesforce Flow's Decision element, n8n's Switch default. +The siblings after it are not evaluated, and each records a `skipped` step in +the run log, so a run says which branch won and which were passed over. When +none holds, the `isDefault` edge runs. Two conditions that both hold for one +record therefore run **one** branch, the earlier one; put the branch you want +to win first. + +To take **every** out-edge whose condition holds — the BPMN inclusive gateway, +n8n's "send to all matching outputs" — declare it on the node: + +```typescript +{ id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } }, +edges: [ + { id: 'e_vip', source: 'route', target: 'notify_account_manager', condition: 'lead.tier == "vip"' }, + { id: 'e_big', source: 'route', target: 'notify_finance', condition: 'lead.amount > 100000' }, +] +``` + +A VIP lead over the threshold takes both, one after another (never in +parallel). `mode` has two members, `'exclusive'` (what an omitted key means) +and `'inclusive'`; anything else, and a `mode` written beside a `config.conditions` +list (which is first-match on its own), is refused when the flow registers and +by `os validate` (`flow-decision-mode-invalid`), with the same sentence at both +doors. `os validate` also reports `flow-decision-inclusive-overlap` on an +inclusive decision with two or more conditioned out-edges, because that is the +shape in which more than one branch can run for one record. + + +**Upgrading a flow written before protocol 18.** An edge-branched decision used +to take every out-edge whose condition held. `os migrate meta --from 17` writes +`mode: 'inclusive'` onto every decision with two or more conditioned out-edges +and no `mode` (the ADR-0087 conversion `flow-decision-mode-inclusive-explicit`), +so the migrated flow runs exactly as before; delete the key where the two +conditions partition (`== 'a'` beside `!= 'a'`, `>` beside `<=`), which is the +common case and the honest declaration. Nothing rewrites a flow at load: an +author who writes two branches today gets the exclusive gateway the contract +describes. + + **Branch on the node** (Salesforce-style decision outcomes): the node declares `config.conditions[]` and traversal restricts itself to the out-edge whose `label` matches the first matching entry. @@ -1397,11 +1439,14 @@ fallback used to be silent, and a decision declaring `'Yes — already converted against an out-edge labelled `'Yes'` is how #4414 shipped. `os validate` reports the shape as `flow-branch-label-unmatched` at build time, along with `flow-decision-unconditional-branch` (a guarded decision with an unconditional -sibling), `flow-default-edge-with-condition` and `flow-multiple-default-edges`. +sibling), `flow-default-edge-with-condition`, `flow-multiple-default-edges`, +`flow-decision-mode-invalid` and `flow-decision-inclusive-overlap`. A decision node that declares **no** `conditions` reports no branch at all — it -is a plain gateway and its out-edges do the routing. +is a plain gateway and its out-edges do the routing: the first out-edge whose +condition holds, in declaration order, unless the node declares +`config: { mode: 'inclusive' }`. Declaring **both** — `config.conditions` *and* per-edge `condition`s — is redundant but not wrong: the node picks a branch, and then that branch's edge diff --git a/packages/lint/src/lint-flow-patterns.test.ts b/packages/lint/src/lint-flow-patterns.test.ts index 2b947c69065..5a9cf751841 100644 --- a/packages/lint/src/lint-flow-patterns.test.ts +++ b/packages/lint/src/lint-flow-patterns.test.ts @@ -31,6 +31,8 @@ import { FLOW_MULTI_WRITE_UNFILTERED, FLOW_LOOP_BODY_UNCONTAINED, FLOW_TRY_CATCH_WITHOUT_CATCH, + FLOW_DECISION_MODE_INVALID, + FLOW_DECISION_INCLUSIVE_OVERLAP, } from './lint-flow-patterns.js'; // [#17495] Cross-site pin only — the re-judged `flow-inert-node-condition` // case below. This family's own coverage is unaffected by it; @@ -2605,3 +2607,147 @@ describe('a non-record member of a flow `edges` list (#16910)', () => { } }); }); + +/** + * #15429 — the decision's `mode`, at the `os validate` door. + * + * Two rules, two severities. `flow-decision-mode-invalid` GATES: the finding is + * the spec's own `DecisionConfigSchema` issue message, and `registerFlow` + * refuses the same declaration with the same sentence, so the flow can never + * arm. `flow-decision-inclusive-overlap` (ruling item 4) is advisory: an + * inclusive gateway with two or more conditioned out-edges is legal, and the + * rule says what it does rather than proving the conditions overlap. + */ +describe('decision `mode` (#15429)', () => { + /** One edge-branched decision with two conditioned out-edges, plus whatever config the case declares. */ + const modeFlow = (config: Record | undefined, edges?: Record[]) => ({ + flows: [{ + name: 'verdict', + nodes: [ + { id: 'start', type: 'start', config: {} }, + { id: 'check', type: 'decision', ...(config ? { config } : {}) }, + { id: 'refuse', type: 'screen', config: {} }, + { id: 'convert', type: 'screen', config: {} }, + ], + edges: edges ?? [ + { id: 'e1', source: 'start', target: 'check' }, + { id: 'e2', source: 'check', target: 'refuse', condition: "lead.status != 'suspected'" }, + { id: 'e3', source: 'check', target: 'convert', condition: "lead.status == 'confirmed'" }, + ], + }], + }); + const ofRule = (stack: unknown, rule: string) => lintFlowPatterns(stack as AnyRec).filter((f) => f.rule === rule); + type AnyRec = Record; + + /** The same decision inside a loop body — the region walk must reach it. */ + const nestedModeFlow = (config: Record) => ({ + flows: [{ + name: 'sweep', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 9 * * *' } }, + { + id: 'each', type: 'loop', label: 'Each', + config: { + collection: '{vars.rows}', + iteratorVariable: 'row', + body: { + nodes: [ + { id: 'check', type: 'decision', config }, + { id: 'refuse', type: 'screen', config: {} }, + { id: 'convert', type: 'screen', config: {} }, + ], + edges: [ + { id: 'e2', source: 'check', target: 'refuse', condition: "row.status != 'suspected'" }, + { id: 'e3', source: 'check', target: 'convert', condition: "row.status == 'confirmed'" }, + ], + }, + }, + }, + ], + edges: [{ id: 'e1', source: 'start', target: 'each' }], + }], + }); + + describe('flow-decision-mode-invalid — gating, with the schema sentence', () => { + it('gates `mode` beside a non-empty `conditions` list, either member, at config.mode', () => { + for (const mode of ['inclusive', 'exclusive']) { + const fnds = ofRule(modeFlow({ mode, conditions: [{ label: 'Refuse', expression: "lead.status != 'suspected'" }] }), FLOW_DECISION_MODE_INVALID); + expect(fnds).toHaveLength(1); + expect(fnds[0].severity).toBe('error'); + expect(fnds[0].where).toBe("flow 'verdict' · decision 'check' · config.mode"); + expect(fnds[0].message).toContain(`\`mode: '${mode}'\` is not valid on a decision that declares a \`conditions\` list`); + expect(fnds[0].message).toContain('Either delete `mode` and keep the list'); + expect(fnds[0].hint).toContain('`registerFlow` refuses this flow with the same sentence'); + } + }); + + it('gates a `mode` outside the closed pair with the value prescription', () => { + const fnds = ofRule(modeFlow({ mode: 'all' }), FLOW_DECISION_MODE_INVALID); + expect(fnds).toHaveLength(1); + expect(fnds[0].severity).toBe('error'); + expect(fnds[0].message).toContain("`mode: 'all'` is not a decision mode"); + expect(fnds[0].message).toContain("'exclusive' declares that only the FIRST out-edge"); + }); + + it('CONTROLS — an omitted `mode`, `mode` alone, `mode` on an empty list, and a list without `mode` are clean', () => { + for (const config of [undefined, { mode: 'inclusive' }, { mode: 'exclusive' }, { mode: 'inclusive', conditions: [] }]) { + expect(ofRule(modeFlow(config), FLOW_DECISION_MODE_INVALID)).toHaveLength(0); + } + expect(ofRule(modeFlow( + { conditions: [{ label: 'Refuse', expression: "lead.status != 'suspected'" }] }, + [ + { id: 'e1', source: 'start', target: 'check' }, + { id: 'e2', source: 'check', target: 'refuse', label: 'Refuse' }, + { id: 'e3', source: 'check', target: 'convert', isDefault: true }, + ], + ), FLOW_DECISION_MODE_INVALID)).toHaveLength(0); + }); + + it('reaches a decision inside a loop body and names the region', () => { + const fnds = ofRule(nestedModeFlow({ mode: 'parallel' }), FLOW_DECISION_MODE_INVALID); + expect(fnds).toHaveLength(1); + expect(fnds[0].where).toContain("loop 'each'"); + expect(fnds[0].where).toContain("decision 'check' · config.mode"); + }); + }); + + describe('flow-decision-inclusive-overlap — advisory (ruling item 4)', () => { + it("advises an inclusive decision with two conditioned out-edges, naming them and what the mode does", () => { + const fnds = ofRule(modeFlow({ mode: 'inclusive' }), FLOW_DECISION_INCLUSIVE_OVERLAP); + expect(fnds).toHaveLength(1); + // Advisory: the declaration is legal and the rule cannot prove overlap. + expect(fnds[0].severity).toBeUndefined(); + expect(fnds[0].where).toBe("flow 'verdict' · decision 'check'"); + expect(fnds[0].message).toContain('2 conditioned out-edge(s)'); + expect(fnds[0].message).toContain("'refuse', 'convert'"); + expect(fnds[0].message).toContain('EVERY one whose condition holds runs'); + expect(fnds[0].hint).toContain('delete `mode`'); + expect(fnds[0].hint).toContain('os migrate meta --from 17'); + }); + + it('does NOT advise an exclusive decision — omitted or written — with the same two edges', () => { + expect(ofRule(modeFlow(undefined), FLOW_DECISION_INCLUSIVE_OVERLAP)).toHaveLength(0); + expect(ofRule(modeFlow({ mode: 'exclusive' }), FLOW_DECISION_INCLUSIVE_OVERLAP)).toHaveLength(0); + }); + + it('does NOT advise one conditioned out-edge plus a default: inclusive and exclusive cannot differ there', () => { + const stack = modeFlow({ mode: 'inclusive' }, [ + { id: 'e1', source: 'start', target: 'check' }, + { id: 'e2', source: 'check', target: 'refuse', condition: "lead.status == 'suspected'" }, + { id: 'e3', source: 'check', target: 'convert', isDefault: true }, + ]); + expect(ofRule(stack, FLOW_DECISION_INCLUSIVE_OVERLAP)).toHaveLength(0); + }); + + it('does not double-report through flow-decision-unconditional-branch — both edges are gated', () => { + expect(ofRule(modeFlow({ mode: 'inclusive' }), FLOW_DECISION_UNCONDITIONAL_BRANCH)).toHaveLength(0); + }); + + it('reaches a decision inside a loop body', () => { + const fnds = ofRule(nestedModeFlow({ mode: 'inclusive' }), FLOW_DECISION_INCLUSIVE_OVERLAP); + expect(fnds).toHaveLength(1); + expect(fnds[0].where).toContain("loop 'each'"); + }); + }); +}); diff --git a/packages/lint/src/lint-flow-patterns.ts b/packages/lint/src/lint-flow-patterns.ts index 228a408b478..17fdb186d9f 100644 --- a/packages/lint/src/lint-flow-patterns.ts +++ b/packages/lint/src/lint-flow-patterns.ts @@ -159,6 +159,9 @@ import { collectFlowGraphs, } from '@objectstack/spec/automation'; import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automation'; +// [#15429] The decision's `mode` contract, parsed here so `os validate` and +// `registerFlow` refuse the same declaration with the same sentence. +import { DecisionConfigSchema } from '@objectstack/spec/automation'; // [#5659] The Filter Protocol's boolean identity reduction — the same predicate // driver-sql, driver-mongodb and driver-memory execute. This linter asks it // rather than hand-writing a fourth copy; see {@link filterCarriesNoCondition}. @@ -230,6 +233,21 @@ export const FLOW_DEFAULT_EDGE_WITH_CONDITION = 'flow-default-edge-with-conditio export const FLOW_MULTIPLE_DEFAULT_EDGES = 'flow-multiple-default-edges'; /** #4414 — `config.condition` on a node whose executor never reads it. */ export const FLOW_INERT_NODE_CONDITION = 'flow-inert-node-condition'; +/** + * #15429 — a `decision` `config.mode` the spec refuses: a value outside + * `'exclusive' | 'inclusive'`, or a legal `mode` beside a non-empty + * `conditions` list (ruling A on #20168). `error`: `registerFlow` refuses the + * same declaration with the same sentence, so the flow can never arm. + */ +export const FLOW_DECISION_MODE_INVALID = 'flow-decision-mode-invalid'; +/** + * #15429 (ruling item 4) — a `decision` declaring `mode: 'inclusive'` with two + * or more conditioned out-edges: every edge whose condition holds runs, so + * where the conditions overlap more than one branch runs for one record. + * Advisory — that is exactly what the declaration asks for, and the rule + * cannot prove the conditions disjoint over CEL; it says what the shape does. + */ +export const FLOW_DECISION_INCLUSIVE_OVERLAP = 'flow-decision-inclusive-overlap'; /** * #5482 — a `delete_record` / `update_record` node that declares `multi: true` * and bounds it with NOTHING: the whole-object write, by declaration. @@ -752,6 +770,23 @@ function scanErrorLabelledEdges( * (5) `flow-inert-node-condition` — `config.condition` on a node that never * reads it. The key is the trigger gate on `start` and dead on every other * builtin, so the predicate reads like a guard and gates nothing. + * (6) `flow-decision-mode-invalid` (#15429) — a `config.mode` the spec's + * `DecisionConfigSchema` refuses: a value outside the closed pair, or a + * legal `mode` beside a non-empty `conditions` list, which is first-match + * on its own so the key would be accepted and never read (ruling A on + * #20168). The finding IS the schema's issue message, and `registerFlow` + * refuses the same shape with the same sentence. + * (7) `flow-decision-inclusive-overlap` (#15429, ruling item 4) — a decision + * declaring `mode: 'inclusive'` with two or more conditioned out-edges. + * An edge-branched decision is exclusive by default (the first true edge + * in declaration order wins); `inclusive` takes every one that holds, and + * where the conditions overlap that is more than one branch for one + * record. Beside (2), not a repeat of it: (2) is about an out-edge nothing + * gates, this is about gates that can all open. + * + * (6) GATES — the engine refuses the flow at registration with the same + * sentence, so a warning would just be a slower way of finding out. (7) stays + * advisory: it names what the declaration does, and the declaration is legal. * * (1) and (3) GATE — neither has a reading under which the author's metadata * routes what it says, on any run, so a warning would just be a slower way of @@ -869,6 +904,59 @@ function scanBranchRouting( ); const edgeLabels = new Set(outs.map(edgeLabelOf).filter(Boolean)); + // (6) #15429 — the decision's `mode`, judged by the spec's own contract. + // Only issues rooted at `mode` are reported here: the same parse also + // refuses an undeclared key, but that strictness binds at authoring by + // the standing decision in `schemaless-node-config.zod.ts`, and (5) + // above already owns the one such key an author reaches for. Read + // before (1)/(2): a decision whose `mode` is refused never registers, + // so what its branches would route is moot until the key is fixed — + // but the other findings still print, so the author fixes it once. + const modeVerdict = DecisionConfigSchema.safeParse(cfg); + if (!modeVerdict.success) { + for (const issue of modeVerdict.error.issues) { + if (issue.path[0] !== 'mode') continue; + findings.push({ + where: `${at} · decision '${nid}' · config.mode`, + message: issue.message, + hint: + `\`registerFlow\` refuses this flow with the same sentence, so it can never arm. Fix the node ` + + `in the flow definition: an omitted \`mode\` is exclusive (the first true out-edge wins), ` + + `\`mode: 'inclusive'\` takes every true out-edge, and a \`conditions\` list is first-match ` + + `on its own and takes no \`mode\`.`, + rule: FLOW_DECISION_MODE_INVALID, + // Gating: the engine refuses the same declaration at registration. + severity: 'error', + }); + } + } + + // (7) #15429 ruling item 4 — an inclusive gateway whose gates can all + // open. Counted over conditioned out-edges only (a `fault` edge is error + // routing and was dropped above; an `isDefault` edge opens only when + // nothing else did), so one conditioned edge plus a default is not + // this shape: there, inclusive and exclusive cannot differ. + if (cfg.mode === 'inclusive') { + const conditioned = outs.filter((e) => e.condition && conditionSource(e.condition).trim() !== ''); + if (conditioned.length >= 2) { + findings.push({ + where: `${at} · decision '${nid}'`, + message: + `declares \`mode: 'inclusive'\` with ${conditioned.length} conditioned out-edge(s) ` + + `(${conditioned.map((e) => `'${String(e.target)}'`).join(', ')}) — EVERY one whose condition ` + + `holds runs, one after another, so where the conditions overlap more than one branch runs ` + + `for one record. Nothing checks that they partition.`, + hint: + `If exactly one branch was meant, delete \`mode\`: an omitted \`mode\` is exclusive, and the ` + + `first out-edge whose condition holds, in declaration order, wins (mark the fallback ` + + `\`isDefault: true\`). Keep \`mode: 'inclusive'\` only where running every matching branch is ` + + `the intent. A flow migrated by \`os migrate meta --from 17\` carries this key wherever two ` + + `or more conditioned out-edges left a decision — delete it where the branches partition.`, + rule: FLOW_DECISION_INCLUSIVE_OVERLAP, + }); + } + } + // (1) a declared branch label nothing claims. `default` is the engine's own // sentinel for "no declared condition matched" and is additionally // claimed by the BPMN default edge, so it is never counted as unclaimed. diff --git a/packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts b/packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts index 21310b3508f..3a720f633e0 100644 --- a/packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts +++ b/packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts @@ -1,65 +1,51 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeEach } from 'vitest'; +import { ALL_CONVERSIONS, applyMetaMigrations } from '@objectstack/spec'; import { AutomationEngine } from '../engine.js'; import { registerLogicNodes } from './logic-nodes.js'; /** - * ⭐ A STATUS-QUO PIN, NOT A CONTRACT (#15429). + * ⭐ THE CONTRACT PIN for an edge-branched `decision` (#15429). * - * Every assertion below records what the engine **does today** when two - * out-edges of one `decision` node carry conditions that both hold. None of it - * says the behaviour is right, and nothing here blesses it. The card that asked - * for this file is explicit that changing evaluation semantics on a shipped node - * type is a behaviour change with its own ruling, ⛔ not a bug fix — so the - * first step is a baseline that cannot drift while that ruling is pending. + * This file began life as a STATUS-QUO pin: it recorded that a decision with + * no declared `config.conditions` took EVERY out-edge whose condition held, + * one after another, and reported nothing — the hotcrm#1555 hazard — under a + * header that said "when the ruling lands, this file is rewritten with it". + * The ruling landed (maintainer, 2026-09-23, 「跟主流对齐」), and this is that + * rewrite. Every assertion below is the contract now, not a measurement of + * drift: * - * ⇒ When the ruling lands, **this file is rewritten with it**. A failure here - * after a deliberate semantic change is the pin doing its job, not a regression: - * update the assertions and the prose together. A failure here after a change - * that did NOT intend to touch branch selection is the drift it exists to catch. + * 1. **Exclusive by default.** The conditioned out-edges are evaluated in the + * order the flow's `edges` array declares them and the FIRST one whose + * condition holds is the branch — the BPMN exclusive gateway, Salesforce + * Flow's Decision, n8n's Switch default. Its later siblings are passed over + * unevaluated and record the same `skipped` step a closed gate does. + * 2. **Inclusive by declaration.** `config.mode: 'inclusive'` takes every + * out-edge whose condition holds, sequentially — never `Promise.all`; that + * fan-out belongs to the unconditional bucket, kept below as the positive + * control for the interleaving instrument. + * 3. **`isDefault` is unchanged**: it runs when no conditioned sibling did, + * in either mode. + * 4. **`mode` is judged at registration** through the spec's own + * `DecisionConfigSchema`: a value outside the closed pair, or a `mode` + * beside a non-empty `conditions` list, refuses the flow with the schema's + * sentence — the same one `os validate` prints. + * 5. **The rehydration seam does not rewrite the flip.** The ADR-0087 + * conversion `flow-decision-mode-inclusive-explicit` writes `mode: + * 'inclusive'` onto a two-branch decision so an OLD flow keeps its + * behaviour, but only where the operator asserts the source's age + * (`os migrate meta --from 17`); `registerFlow` cannot date a body and + * refuses the entry by id, or every NEW exclusive decision would register + * as an inclusive one. + * 6. **Scoped to `decision`.** Conditioned out-edges of any other node type + * keep the every-true-edge traversal they had. * - * What the card reported, and what this file measured against it: - * - * • REPORTED (from one hotcrm deployment, reasoned backwards from a - * reproduction): a decision with no declared `config.conditions` takes every - * out-edge whose condition holds, **in parallel**. - * • MEASURED here: the take-every-match half is real. The **in parallel** half - * is not — matching conditional edges are traversed one at a time, each - * successor fully executed before the next is evaluated (`traverseNext` - * awaits inside the loop). Only the *unconditional* bucket fans out through - * `Promise.all`, and this file keeps that fan-out as a positive control so - * the interleaving instrument is shown to detect parallelism where it exists. - * - * The distinction matters for whoever writes the ruling: the hazard is - * multi-branch execution, not concurrency. Two branches that both write the same - * record run in a defined order, which is a different (and easier) starting - * point than a genuine race. - * - * The two modes are separate on purpose (`logic-nodes.ts`, #4414): a decision - * that DECLARES `config.conditions` reports the first matching entry's label and - * traversal narrows to the edge carrying it; a decision that declares none - * reports no branch at all and is a plain gateway whose out-edges route. The - * last two tests pin both halves of that split, because "is undeclared a - * distinct mode?" is the other question the ruling has to start from. - * - * On provenance, stated because the answer is "none": the take-every-match loop - * predates the buckets it lives in. Before `cc8484224` (2026-02-21) traversal - * was one loop that `continue`d past a closed gate and executed everything else; - * that commit split conditional from unconditional edges, recorded a decision - * about the unconditional half ("parallel branch execution (Promise.all for - * unconditional edges)") and carried the conditional half over unchanged, under - * a new comment reading `// Conditional edges: evaluate sequentially (mutually - * exclusive)`. That comment is the only written trace of the assumption, and it - * is an assumption: nothing in the engine, the schema or the linter makes - * sibling conditions exclusive. ⛔ No commit message, ADR or doc records the - * multi-take as a decision — searched with `git log -S` over `engine.ts` for the - * loop's symbols, and `content/docs/automation/flows.mdx` describes the mode - * ("BPMN exclusive gateway") without ever saying what happens when two - * conditions hold at once. + * A failure here after a change that did NOT intend to touch branch selection + * is the drift this file exists to catch. */ -/** One captured `warn` call, so "nothing reports this" is measured, not assumed. */ +/** One captured `warn` call, so "nothing reports this" stays measured, not assumed. */ const warnings: Array<{ msg: string; meta?: Record }> = []; function createTestLogger(): any { @@ -77,7 +63,11 @@ function createCtx(): any { return { logger: createTestLogger(), getService: () => undefined }; } -describe('decision with overlapping out-edge conditions — status-quo pin (#15429)', () => { +/** The refusal sentences, as the spec spells them — pinned by prefix, never re-derived. */ +const MODE_BESIDE_LIST = /is not valid on a decision that declares a `conditions` list/; +const MODE_NOT_A_MODE = /is not a decision mode/; + +describe('decision edge branching — exclusive by default, inclusive by declaration (#15429)', () => { let engine: AutomationEngine; /** `enter:` / `exit:` per visited successor — order AND nesting. */ let trace: string[]; @@ -101,18 +91,28 @@ describe('decision with overlapping out-edge conditions — status-quo pin (#154 }, }); // This harness is an embedded host, so it owes the ADR-0018 host half: - // the vocabulary it contributes is complete (#4771). Without the seal the - // first `execute()` warns about it and the zero-warning assertion below — - // which is about branch reporting, not node types — would count that line. + // the vocabulary it contributes is complete (#4771). engine.sealNodeTypeVocabulary(); }); /** - * The hotcrm shape, reduced: one `decision`, two out-edges, no - * `config.conditions` on the node. `a` and `b` are the two edge predicates; - * an empty string means the edge carries no condition at all. + * The hotcrm shape, reduced: one `decision`, two conditioned out-edges + * (`a` on `refuse`, `b` on `convert`; an empty string means no condition), + * optionally a third `isDefault` edge to `fallback`, optionally a node + * config. `reversed` declares `convert` before `refuse`, so declaration + * order — not edge id, not target name — is what the pin reads. */ - function gatewayFlow(opts: { a: string; b: string; conditions?: Array<{ label: string; expression: string }> }) { + function gatewayFlow(opts: { + a: string; + b: string; + config?: Record; + withDefault?: boolean; + reversed?: boolean; + gatewayType?: string; + }) { + const gatewayType = opts.gatewayType ?? 'decision'; + const refuseEdge = { id: 'e_refuse', source: 'check', target: 'refuse', label: 'Refuse', ...(opts.a ? { condition: opts.a } : {}) }; + const convertEdge = { id: 'e_convert', source: 'check', target: 'convert', label: 'Convert', ...(opts.b ? { condition: opts.b } : {}) }; return { name: 'gateway', label: 'Gateway', @@ -120,21 +120,22 @@ describe('decision with overlapping out-edge conditions — status-quo pin (#154 variables: [{ name: 'lead', type: 'object', isInput: true }], nodes: [ { id: 'start', type: 'start' as const, label: 'Start' }, - { - id: 'check', type: 'decision' as const, label: 'Check', - ...(opts.conditions ? { config: { conditions: opts.conditions } } : {}), - }, + { id: 'check', type: gatewayType, label: 'Check', ...(opts.config ? { config: opts.config } : {}) }, { id: 'refuse', type: 'mark' as const, label: 'Refuse' }, { id: 'convert', type: 'mark' as const, label: 'Convert' }, + ...(opts.withDefault ? [{ id: 'fallback', type: 'mark' as const, label: 'Fallback' }] : []), ], edges: [ { id: 'e1', source: 'start', target: 'check' }, - { id: 'e_refuse', source: 'check', target: 'refuse', label: 'Refuse', ...(opts.a ? { condition: opts.a } : {}) }, - { id: 'e_convert', source: 'check', target: 'convert', label: 'Convert', ...(opts.b ? { condition: opts.b } : {}) }, + ...(opts.reversed ? [convertEdge, refuseEdge] : [refuseEdge, convertEdge]), + ...(opts.withDefault ? [{ id: 'e_fallback', source: 'check', target: 'fallback', label: 'Otherwise', isDefault: true }] : []), ], }; } + /** The hotcrm#1555 pair: both predicates hold for a confirmed record. */ + const OVERLAP = { a: "lead.status != 'suspected'", b: "lead.status == 'confirmed'" }; + const run = (lead: Record) => engine.execute('gateway', { params: { lead } } as any); const stepsOfLastRun = async () => { @@ -144,9 +145,7 @@ describe('decision with overlapping out-edge conditions — status-quo pin (#154 // ── The instrument, before any reading taken with it ────────────────── - it('CONTROL — disjoint edge conditions take exactly one successor', async () => { - // Same node, same two edges, same record: only the predicates differ. - // If this read "both" the counting method would prove nothing below. + it('CONTROL — disjoint edge conditions take exactly one successor, and the closed gate leaves a skipped step', async () => { engine.registerFlow('gateway', gatewayFlow({ a: "lead.status == 'suspected'", b: "lead.status == 'confirmed'", @@ -155,95 +154,282 @@ describe('decision with overlapping out-edge conditions — status-quo pin (#154 await run({ status: 'confirmed' }); expect(trace).toEqual(['enter:convert', 'exit:convert']); - // …and the branch not taken leaves a trace: a closed gate records a - // `skipped` step naming the edge that closed it (#4354). expect(await stepsOfLastRun()).toContainEqual({ nodeId: 'refuse', status: 'skipped', edgeId: 'e_refuse' }); }); - // ── The reading ─────────────────────────────────────────────────────── + it('POSITIVE CONTROL — the same instrument reads interleaving on the unconditional fan-out', async () => { + // Drop both conditions and the identical pair of edges lands in the + // unconditional bucket, which really is `Promise.all`. This is what + // proves the sequential readings below measured sequencing rather than + // an instrument that cannot see concurrency at all. + engine.registerFlow('gateway', gatewayFlow({ a: '', b: '' })); - it('takes EVERY out-edge whose condition holds — the reported hazard, confirmed', async () => { - // The hotcrm pair verbatim in shape: a `Clean` guard spelled as a - // negation, and a later `== confirmed` branch added beside it. For a - // confirmed record both predicates are true, and the author's reading of - // the node — "one of these" — is nowhere written down. - engine.registerFlow('gateway', gatewayFlow({ - a: "lead.status != 'suspected'", - b: "lead.status == 'confirmed'", - })); + await run({ status: 'confirmed' }); + + expect(trace).toEqual(['enter:refuse', 'enter:convert', 'exit:refuse', 'exit:convert']); + }); + + // ── 1. Exclusive by default ─────────────────────────────────────────── + + it('two overlapping true edges → exactly ONE successor runs: the first declared', async () => { + engine.registerFlow('gateway', gatewayFlow(OVERLAP)); + + const result = await run({ status: 'confirmed' }); + + expect(trace).toEqual(['enter:refuse', 'exit:refuse']); + expect(result.success).toBe(true); + expect(warnings).toEqual([]); + }); + + it('the passed-over sibling records a `skipped` step naming the gate and its edge', async () => { + engine.registerFlow('gateway', gatewayFlow(OVERLAP)); + + await run({ status: 'confirmed' }); + + const steps = await stepsOfLastRun(); + expect(steps).toContainEqual({ nodeId: 'convert', status: 'skipped', edgeId: 'e_convert' }); + expect(steps.filter(s => s.status === 'success').map(s => s.nodeId)).toEqual(['start', 'check', 'refuse']); + }); + + it('DECLARATION ORDER decides — the same two predicates, declared the other way round, take the other branch', async () => { + engine.registerFlow('gateway', gatewayFlow({ ...OVERLAP, reversed: true })); + + await run({ status: 'confirmed' }); + + expect(trace).toEqual(['enter:convert', 'exit:convert']); + expect(await stepsOfLastRun()).toContainEqual({ nodeId: 'refuse', status: 'skipped', edgeId: 'e_refuse' }); + }); + + it("`mode: 'exclusive'` written out says the same thing as an omitted `mode`", async () => { + engine.registerFlow('gateway', gatewayFlow({ ...OVERLAP, config: { mode: 'exclusive' } })); + + await run({ status: 'confirmed' }); + + expect(trace).toEqual(['enter:refuse', 'exit:refuse']); + }); + + // ── 2. Inclusive by declaration ─────────────────────────────────────── + + it("`mode: 'inclusive'` → BOTH run, one after another (nested, never interleaved), with no skipped step", async () => { + engine.registerFlow('gateway', gatewayFlow({ ...OVERLAP, config: { mode: 'inclusive' } })); const result = await run({ status: 'confirmed' }); - // Both successors run. The refusal screen renders AND the conversion - // runs, in one execution — pinned as today's behaviour, ⛔ not endorsed. - expect(trace.filter(t => t.startsWith('enter:'))).toEqual(['enter:refuse', 'enter:convert']); + expect(trace).toEqual(['enter:refuse', 'exit:refuse', 'enter:convert', 'exit:convert']); expect(result.success).toBe(true); + const steps = await stepsOfLastRun(); + expect(steps.filter(s => s.status === 'skipped')).toEqual([]); + expect(steps.filter(s => s.status === 'success').map(s => s.nodeId)).toEqual(['start', 'check', 'refuse', 'convert']); }); - it('takes them one at a time, NOT in parallel — the card says parallel; the engine does not', async () => { + it("`mode: 'inclusive'` with one true edge takes that one and records the closed gate", async () => { engine.registerFlow('gateway', gatewayFlow({ - a: "lead.status != 'suspected'", + a: "lead.status == 'suspected'", b: "lead.status == 'confirmed'", + config: { mode: 'inclusive' }, })); await run({ status: 'confirmed' }); - // Nested, not interleaved: `refuse` finishes before `convert` starts. - expect(trace).toEqual(['enter:refuse', 'exit:refuse', 'enter:convert', 'exit:convert']); + expect(trace).toEqual(['enter:convert', 'exit:convert']); + expect(await stepsOfLastRun()).toContainEqual({ nodeId: 'refuse', status: 'skipped', edgeId: 'e_refuse' }); }); - it('POSITIVE CONTROL — the same instrument reads interleaving on the unconditional fan-out', async () => { - // Drop both conditions and the identical pair of edges lands in the - // unconditional bucket, which really is `Promise.all`. This is what - // proves the previous test measured sequencing rather than an instrument - // that cannot see concurrency at all. - engine.registerFlow('gateway', gatewayFlow({ a: '', b: '' })); + // ── 3. `isDefault` is unchanged in both modes ───────────────────────── - await run({ status: 'confirmed' }); + it('none true → the `isDefault` edge runs (exclusive), and every closed gate is recorded', async () => { + engine.registerFlow('gateway', gatewayFlow({ + a: "lead.status == 'suspected'", + b: "lead.status == 'confirmed'", + withDefault: true, + })); - expect(trace).toEqual(['enter:refuse', 'enter:convert', 'exit:refuse', 'exit:convert']); + await run({ status: 'new' }); + + expect(trace).toEqual(['enter:fallback', 'exit:fallback']); + const steps = await stepsOfLastRun(); + expect(steps).toContainEqual({ nodeId: 'refuse', status: 'skipped', edgeId: 'e_refuse' }); + expect(steps).toContainEqual({ nodeId: 'convert', status: 'skipped', edgeId: 'e_convert' }); }); - it('reports the multi-take NOWHERE — no warning, and no `skipped` step to notice it by', async () => { + it('none true → the `isDefault` edge runs (inclusive too)', async () => { engine.registerFlow('gateway', gatewayFlow({ - a: "lead.status != 'suspected'", + a: "lead.status == 'suspected'", b: "lead.status == 'confirmed'", + withDefault: true, + config: { mode: 'inclusive' }, })); + await run({ status: 'new' }); + + expect(trace).toEqual(['enter:fallback', 'exit:fallback']); + }); + + it('a true edge beside a default → the branch runs and the default is passed over, in both modes', async () => { + engine.registerFlow('gateway', gatewayFlow({ ...OVERLAP, withDefault: true })); await run({ status: 'confirmed' }); + expect(trace).toEqual(['enter:refuse', 'exit:refuse']); + expect(await stepsOfLastRun()).toContainEqual({ nodeId: 'fallback', status: 'skipped', edgeId: 'e_fallback' }); - // Both edges opened, so neither records the `skipped` step that makes a - // closed gate visible in the CONTROL above: the run log of a - // two-branch execution is indistinguishable from a flow that was - // *authored* to run both. - const steps = await stepsOfLastRun(); - expect(steps.filter(s => s.status === 'skipped')).toEqual([]); - expect(steps.filter(s => s.status === 'success').map(s => s.nodeId)).toEqual(['start', 'check', 'refuse', 'convert']); - expect(warnings).toEqual([]); + trace = []; + engine.registerFlow('gateway', gatewayFlow({ ...OVERLAP, withDefault: true, config: { mode: 'inclusive' } })); + await run({ status: 'confirmed' }); + expect(trace).toEqual(['enter:refuse', 'exit:refuse', 'enter:convert', 'exit:convert']); + expect(await stepsOfLastRun()).toContainEqual({ nodeId: 'fallback', status: 'skipped', edgeId: 'e_fallback' }); }); - // ── The other half of the question: is undeclared a distinct mode? ──── + // ── A `conditions` list is a different mechanism, and it is unchanged ── - it('declared `config.conditions` is a DIFFERENT mode — first match wins, even when two match', async () => { - // The same two overlapping predicates, moved onto the node. The executor - // returns on the first match (`logic-nodes.ts`), traversal narrows to the - // edge carrying that label, and the second branch is never reached. + it('declared `config.conditions` stays first-match by label narrowing — the losing edge leaves no step at all', async () => { engine.registerFlow('gateway', gatewayFlow({ a: '', b: '', - conditions: [ - { label: 'Refuse', expression: "lead.status != 'suspected'" }, - { label: 'Convert', expression: "lead.status == 'confirmed'" }, - ], + config: { + conditions: [ + { label: 'Refuse', expression: "lead.status != 'suspected'" }, + { label: 'Convert', expression: "lead.status == 'confirmed'" }, + ], + }, })); await run({ status: 'confirmed' }); expect(trace).toEqual(['enter:refuse', 'exit:refuse']); - // Narrowed away, not gated: the losing edge leaves no step at all — not - // even the `skipped` one a closed gate writes. The two modes differ in - // what they record as well as in what they run. const steps = await stepsOfLastRun(); expect(steps.map(s => s.nodeId)).toEqual(['start', 'check', 'refuse']); expect(warnings).toEqual([]); }); + + // ── 4. `mode` is judged at registration, with the spec's own sentence ── + + describe('registration parses `DecisionConfigSchema` and refuses an invalid `mode`', () => { + it('refuses `mode` beside a non-empty `conditions` list — either member — at config.mode, with the refinement sentence', () => { + for (const mode of ['inclusive', 'exclusive']) { + const definition = gatewayFlow({ + a: '', b: '', + config: { mode, conditions: [{ label: 'Refuse', expression: "lead.status != 'suspected'" }] }, + }); + expect(() => engine.registerFlow('gateway', definition)).toThrow(MODE_BESIDE_LIST); + try { + engine.registerFlow('gateway', definition); + } catch (e) { + const message = (e as Error).message; + expect(message).toContain("Flow 'gateway' rejected"); + expect(message).toContain("node 'check' (decision) at config.mode"); + expect(message).toContain(`\`mode: '${mode}'\``); + // Both ruled ways out ride along, verbatim from the schema. + expect(message).toContain('Either delete `mode` and keep the list'); + expect(message).toContain('move the branches onto the out-edges'); + } + // Refused means never armed. + expect(engine.getFlow('gateway')).toBeUndefined(); + } + }); + + it('refuses a `mode` outside the closed pair with the value prescription', () => { + const definition = gatewayFlow({ ...OVERLAP, config: { mode: 'all' } }); + expect(() => engine.registerFlow('gateway', definition)).toThrow(MODE_NOT_A_MODE); + try { + engine.registerFlow('gateway', definition); + } catch (e) { + const message = (e as Error).message; + expect(message).toContain("`mode: 'all'` is not a decision mode"); + expect(message).toContain("at config.mode"); + } + expect(engine.getFlow('gateway')).toBeUndefined(); + }); + + it('refuses the same shape inside a loop body, naming the region', () => { + const definition = { + name: 'gateway', + label: 'Gateway', + type: 'autolaunched' as const, + variables: [{ name: 'leads', type: 'object', isInput: true }], + nodes: [ + { id: 'start', type: 'start' as const, label: 'Start' }, + { + id: 'sweep', type: 'loop' as const, label: 'Sweep', + config: { + collection: '{leads}', + iteratorVariable: 'lead', + body: { + nodes: [ + { id: 'gate', type: 'decision', label: 'Gate', config: { mode: 'parallel' } }, + { id: 'x', type: 'mark', label: 'X' }, + ], + edges: [{ id: 'g1', source: 'gate', target: 'x', condition: 'true' }], + }, + }, + }, + ], + edges: [{ id: 'e1', source: 'start', target: 'sweep' }], + }; + expect(() => engine.registerFlow('gateway', definition)).toThrow(MODE_NOT_A_MODE); + try { + engine.registerFlow('gateway', definition); + } catch (e) { + expect((e as Error).message).toMatch(/loop 'sweep'.*node 'gate' \(decision\) at config\.mode/); + } + }); + + it('CONTROLS — `mode` on an empty list, `mode` alone, and a list without `mode` all register', () => { + const shapes: Record[] = [ + { mode: 'inclusive', conditions: [] }, + { mode: 'exclusive', conditions: [] }, + { mode: 'inclusive' }, + { conditions: [{ label: 'Refuse', expression: "lead.status != 'suspected'" }] }, + ]; + for (const config of shapes) { + const registered = engine.registerFlow('gateway', gatewayFlow({ ...OVERLAP, config })); + expect(registered.name).toBe('gateway'); + engine.unregisterFlow('gateway'); + } + }); + }); + + // ── 5. The rehydration seam does not rewrite the flip ───────────────── + + describe('the flow rehydration seam refuses `flow-decision-mode-inclusive-explicit` by id', () => { + const twoBranches = () => gatewayFlow(OVERLAP); + + it('the refused id is a real registry entry, retired from the load path (anti-vacuity for the seam pin)', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === 'flow-decision-mode-inclusive-explicit'); + expect(entry).toBeDefined(); + expect(entry!.retiredFromLoadPath).toBe(true); + expect(entry!.toMajor).toBe(18); + }); + + it('`canonicalizeStoredFlow` leaves a two-branch decision without `mode` — parsed AND storable — and emits no notice for it', () => { + const { parsed, storable, notices } = engine.canonicalizeStoredFlow('gateway', twoBranches()); + const parsedCheck = parsed.nodes.find((n) => n.id === 'check')!; + expect(parsedCheck.config).toBeUndefined(); + const storableCheck = ((storable as { nodes: Array<{ id: string; config?: unknown }> }).nodes).find((n) => n.id === 'check')!; + expect(storableCheck.config).toBeUndefined(); + expect(notices.map((n) => n.conversionId)).not.toContain('flow-decision-mode-inclusive-explicit'); + }); + + it('…and the registered flow therefore runs EXCLUSIVE, which is the ruled default for a body this seam cannot date', async () => { + engine.registerFlow('gateway', twoBranches()); + await run({ status: 'confirmed' }); + expect(trace).toEqual(['enter:refuse', 'exit:refuse']); + }); + + it('FIRING CONTROL — the same body through the D3 chain (`os migrate meta --from 17`) DOES get `mode: inclusive`', () => { + const result = applyMetaMigrations({ flows: [twoBranches()] }, 17, 18); + const migrated = (result.stack.flows as Array<{ nodes: Array<{ id: string; config?: Record }> }>)[0]!; + expect(migrated.nodes.find((n) => n.id === 'check')!.config).toEqual({ mode: 'inclusive' }); + expect(result.applied.map((a) => a.conversionId)).toContain('flow-decision-mode-inclusive-explicit'); + }); + }); + + // ── 6. Scoped to `decision` ─────────────────────────────────────────── + + it('a NON-decision node with two true conditioned out-edges keeps the every-true-edge traversal (the boundary)', async () => { + // `mark` returns success with no branch label, so its out-edges route + // through the same conditional loop — and `mode` is not its key. + engine.registerFlow('gateway', gatewayFlow({ ...OVERLAP, gatewayType: 'mark' })); + + await run({ status: 'confirmed' }); + + expect(trace).toEqual(['enter:check', 'exit:check', 'enter:refuse', 'exit:refuse', 'enter:convert', 'exit:convert']); + }); }); diff --git a/packages/services/service-automation/src/builtin/logic-nodes.ts b/packages/services/service-automation/src/builtin/logic-nodes.ts index 1fb75432337..6042787d92e 100644 --- a/packages/services/service-automation/src/builtin/logic-nodes.ts +++ b/packages/services/service-automation/src/builtin/logic-nodes.ts @@ -42,6 +42,17 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) * node silently fell back to "consider every out-edge", which is * the state #4414 measured (0 label matches across all example * apps) and, on an unconditional sibling, ran both branches. + * + * On that second path the gateway is EXCLUSIVE (#15429): the + * engine's traversal takes the first conditioned out-edge that + * holds, in declaration order, and passes its siblings over; a + * decision declaring `config.mode: 'inclusive'` takes every one + * that holds. Both live in `AutomationEngine.traverseNext`, not + * here — this executor reads `conditions` and nothing else. The + * `mode` key itself is judged at registration: `registerFlow` parses + * every decision's config through the spec's `DecisionConfigSchema` + * and refuses a value outside the closed pair, or a `mode` beside a + * non-empty `conditions` list, with the schema's own sentence. */ async execute(node, variables, _context) { const config = node.config as Record | undefined; diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 95c1722169d..a60861d3985 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -19,7 +19,7 @@ import { type ScreenFieldVisibility, } from './screen-input-contract.js'; import type { Logger } from '@objectstack/spec/contracts'; -import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor } from '@objectstack/spec/automation'; +import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor, DecisionConfigSchema } from '@objectstack/spec/automation'; // [#14328] The ONE answer to "which trigger kind does this flow ask for?" — // shared with `defineStack`'s trigger-capability refusal and `@objectstack/lint`'s // `validate-flow-trigger-readiness`, so the runtime cannot drift from what @@ -229,6 +229,18 @@ import { describeThrownForLog } from './thrown-cause-diagnostics.js'; // reaches back here. import { interpolateText } from './builtin/template.js'; +/** + * Does this `decision` take EVERY out-edge whose condition holds (#15429)? + * Only an explicit `config.mode: 'inclusive'` says so; an omitted `mode` and + * `'exclusive'` both mean the first true edge in declaration order wins. Any + * other value never reaches here — {@link AutomationEngine.registerFlow} + * refuses it with the spec's prescription. + */ +function decisionTakesEveryBranch(node: FlowNodeParsed): boolean { + const config = node.config as { mode?: unknown } | undefined; + return config?.mode === 'inclusive'; +} + // ─── Node Executor Interface (Plugin Extension Point) ─────────────── /** @@ -2112,6 +2124,33 @@ export interface FlowActivationStore { */ export const IN_PROCESS_DISPATCH_CLAIM_TTL_MS = 48 * 60 * 60 * 1000; +/** + * ADR-0087 conversions the flow rehydration seam refuses to replay, by id — + * the DEFAULT-FLIP class, on the artifact door's precedent + * (`DEFAULT_FLIPS_NOT_REPLAYED_HERE` in `@objectstack/metadata-core`). The + * registry stays the single authority on what converts; this is only this + * seam saying which entries its own evidence cannot carry, and each id owes + * its reason beside it. + * + * - `flow-decision-mode-inclusive-explicit` (#15429) writes `mode: 'inclusive'` + * onto an edge-branched `decision` with two or more conditioned out-edges, + * so a flow written while every true branch ran keeps that behaviour under + * the exclusive traversal. The rewrite is sound only where "this body + * predates the flip" is a FACT, and here it never is: `canonicalizeStoredFlow` + * sees every body alike — a code-shipped flow at the boot pull, a REST + * `POST /automation` definition, a Studio save and a package duplication + * (both resolve this same method) — and a decision written yesterday against + * the contract that says an omitted `mode` is exclusive is byte-identical to + * a row written before the contract said so. Replaying it would rewrite every + * new exclusive decision into an inclusive one at registration and persist + * that at save, and the ruled default would be unobservable. The entry + * replays where the age IS asserted: `os migrate meta --from 17`, by the + * operator, over authored sources — never at a load seam. + */ +const CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION: readonly string[] = [ + 'flow-decision-mode-inclusive-explicit', +]; + /** * Lift the `{ dialect, source }` envelopes the flow schema derives for edge * `condition`s back onto the conversion output — and take nothing else with @@ -4062,6 +4101,13 @@ export class AutomationEngine implements IAutomationService { // exact hazard this seam exists to prevent. Authored sources keep // window semantics at their own seam (`normalizeStackInput` applies // live-window entries only; the schema tombstones the retired shape). + // + // `excludeConversionIds`: the retired window is opened for a CLASS of + // caller, and one kind of entry inside it is not a rescue but a + // reinterpretation — a DEFAULT FLIP, whose old shape still parses and + // now means something else. This seam cannot say a body predates such + // a flip, so it refuses those entries by id, each with its reason on + // `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION`. const reservedNodeTypes = new Set([ ...FLOW_STRUCTURAL_NODE_TYPES, ...this.nodeExecutors.keys(), @@ -4072,6 +4118,7 @@ export class AutomationEngine implements IAutomationService { const converted = applyConversionsToFlow(definition, { reservedNodeTypes, includeRetired: true, + excludeConversionIds: CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION, onNotice: (n) => { notices.push(n); this.logger.warn(`[flow '${name}'] ${n.code}: ${n.message}`); @@ -4129,6 +4176,11 @@ export class AutomationEngine implements IAutomationService { // types, keyValue maps). this.validateNodeConfigKeys(name, parsed); + // #15429 — parse every `decision` node's config against the spec's + // `DecisionConfigSchema` and refuse the flow on an invalid `mode`, with + // the schema's own sentence — the same answer `os validate` gives. + this.validateDecisionModes(name, parsed); + // ADR-0032 §Decision 1a — parse-validate every predicate at registration, // so a malformed condition (e.g. the #1491 `{record.x}` template-brace-in- // CEL mistake) is a LOUD registration error with the offending source, @@ -9240,6 +9292,60 @@ export class AutomationEngine implements IAutomationService { } } + /** + * [#15429] The registration-time reader of a `decision` node's `mode`. + * + * `decision` publishes no descriptor `configSchema` (its Target column is + * derived from the out-edges), so {@link validateNodeConfigKeys}' + * schemaless exemption skips it and, until this pass, nothing at run time + * parsed its config at all — `mode` was export-only, enforced by `tsc`, the + * published JSON Schema and the objectui reconciliation, and a stored + * `mode: 'bogus'` registered clean and ran as whatever the traversal made + * of it. Now every decision's config goes through the spec's + * `DecisionConfigSchema` here and the flow is refused on any issue rooted + * at `mode`: a value outside `'exclusive' | 'inclusive'`, or a legal `mode` + * beside a non-empty `conditions` list (a list is first-match on its own, + * so a `mode` beside it would be accepted and never read — ruling A on + * #20168). The refusal is the schema's own sentence, shared with + * `os validate`'s `flow-decision-mode-invalid`, so build time and + * registration cannot disagree about the key. + * + * Judged on `mode` alone, deliberately. The same parse also refuses an + * undeclared key on a decision (the shape is `strictObject`), but that + * strictness binds at authoring by a standing decision (the module header + * of `schemaless-node-config.zod.ts`): a flow carrying an inert extra key + * registered and ran before this pass, and refusing it at boot would be a + * second behaviour change riding a ruling that ordered one. `os validate` + * keeps reporting that shape through its own advisory rule. + * + * Hard-fail, like {@link validateNodeConfigKeys}: a flow whose gateway + * declares a mode the engine does not have is wrong metadata, and the + * caller's per-flow try/catch skips it loudly at boot rather than arming a + * gateway that would run as something the author did not write. + */ + private validateDecisionModes(flowName: string, flow: FlowParsed): void { + const failures: string[] = []; + for (const graph of collectFlowGraphs(flow)) { + const at = graph.scope ? `${graph.scope}: ` : ''; + for (const node of graph.nodes) { + if (node.type !== 'decision') continue; + const verdict = DecisionConfigSchema.safeParse(node.config ?? {}); + if (verdict.success) continue; + for (const issue of verdict.error.issues) { + if (issue.path[0] !== 'mode') continue; + failures.push(` • ${at}node '${node.id}' (decision) at config.mode: ${issue.message}`); + } + } + } + if (failures.length > 0) { + throw new Error( + `Flow '${flowName}' rejected: ${failures.length} invalid decision \`mode\` ` + + `declaration${failures.length > 1 ? 's' : ''}. The config is metadata, so re-registering ` + + `changes nothing; fix the node in the flow definition:\n${failures.join('\n')}`, + ); + } + } + /** * Walk `value` against `schema` in lockstep, collecting keys the schema does * not declare into `violations`. @@ -10119,7 +10225,12 @@ export class AutomationEngine implements IAutomationService { * computed a branch and nothing routed it, which is how app-crm's * convert-lead guard ran its abort screen AND its wizard. * 2. **`edge.condition`** — evaluated per edge; a closed gate records a - * `skipped` step (#4354). + * `skipped` step (#4354). On a `decision` node the conditioned + * out-edges are EXCLUSIVE (#15429): evaluated in declaration order, + * the first one that holds is the branch and its later siblings are + * passed over unevaluated, recording the same `skipped` step. A + * decision declaring `config.mode: 'inclusive'` takes every one that + * holds instead. * 3. **`edge.isDefault`** — BPMN default flow. Traversed **only** when no * conditional sibling in the selected set matched. Before #4414 this key * had zero readers: it parsed, it was documented as "the default path @@ -10203,42 +10314,80 @@ export class AutomationEngine implements IAutomationService { } } - // Conditional edges: evaluate sequentially (mutually exclusive) + // #4354 — a gate that did not open leaves a trace. Record it: this is + // THE event that had no trace anywhere, and the reason #4347 shipped + // three inert production flows. A closed gate inside a loop body is + // logged once per iteration (region tagging in `runRegion` attaches the + // container + iteration), so the run summary can say "selected 30, + // acted 0, skipped 30 by " instead of reporting a green run that + // did nothing. + // + // The step is `skipped`, never a run: the re-entrancy guard, per-node + // `runs` counts and node status all exclude it, so recording a + // non-event stays a non-event to execution. + const recordSkipped = (nextNode: FlowNodeParsed, edge: FlowEdgeParsed): void => { + const at = new Date().toISOString(); + steps.push({ + nodeId: nextNode.id, + nodeType: nextNode.type, + ...(nextNode.label ? { nodeLabel: nextNode.label } : {}), + status: 'skipped', + startedAt: at, + completedAt: at, + durationMs: 0, + skippedBy: { + nodeId: node.id, + ...(edge.id ? { edgeId: edge.id } : {}), + ...(edge.label ? { label: edge.label } : {}), + }, + }); + }; + + // Conditional edges: evaluated sequentially, in the order the flow's + // `edges` array declares them — `FlowSchema.parse` and every conversion + // are copy-on-write maps that never reorder, so declaration order here + // IS the author's. + // + // On a `decision` node they are the gateway's branches, and the gateway + // is EXCLUSIVE unless it declares `config.mode: 'inclusive'` (#15429, + // maintainer ruling 「跟主流对齐」): the FIRST edge whose condition holds + // is the branch — the BPMN exclusive gateway, Salesforce Flow's + // Decision, n8n's Switch default — and its later siblings are not + // evaluated at all; they record the same `skipped` step a closed gate + // does, so the run log says which branch won and which were passed + // over. Until this change every true edge ran, one after another, under + // a comment calling that "mutually exclusive": hotcrm#1555 rendered a + // refusal screen AND ran the conversion in one execution. Flows written + // against that behaviour are carried across by the ADR-0087 conversion + // `flow-decision-mode-inclusive-explicit` (`os migrate meta --from 17`), + // which writes the inclusive declaration onto them — see + // `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` for why this seam does not. + // + // `mode: 'inclusive'` is the BPMN inclusive gateway: every edge whose + // condition holds runs, one successor at a time (never `Promise.all` — + // that fan-out belongs to the UNCONDITIONAL bucket below alone). + // + // Scoped to `decision` on purpose: `mode` is a decision-config key, and + // conditioned out-edges of any other node type keep the traversal they + // had — every one whose condition holds, sequentially. The ruling and + // its migration cover the gateway, and nothing else was measured to + // carry two conditioned out-edges (the corpus census on #15429). + const exclusive = node.type === 'decision' && !decisionTakesEveryBranch(node); let anyConditionMet = false; for (const edge of conditionalEdges) { const nextNode = flow.nodes.find(n => n.id === edge.target); + if (exclusive && anyConditionMet) { + // A sibling already won: passed over, not evaluated. + if (nextNode) recordSkipped(nextNode, edge); + continue; + } if (this.evaluateCondition(edge.condition!, variables)) { anyConditionMet = true; if (nextNode) { await this.executeNode(nextNode, flow, variables, context, steps); } } else if (nextNode) { - // #4354 — the gate closed. Record it: this is THE event that had - // no trace anywhere, and the reason #4347 shipped three inert - // production flows. A closed gate inside a loop body is logged - // once per iteration (region tagging in `runRegion` attaches the - // container + iteration), so the run summary can say - // "selected 30, acted 0, skipped 30 by " instead of - // reporting a green run that did nothing. - // - // The step is `skipped`, never a run: the re-entrancy guard, - // per-node `runs` counts and node status all exclude it, so - // recording a non-event stays a non-event to execution. - const at = new Date().toISOString(); - steps.push({ - nodeId: nextNode.id, - nodeType: nextNode.type, - ...(nextNode.label ? { nodeLabel: nextNode.label } : {}), - status: 'skipped', - startedAt: at, - completedAt: at, - durationMs: 0, - skippedBy: { - nodeId: node.id, - ...(edge.id ? { edgeId: edge.id } : {}), - ...(edge.label ? { label: edge.label } : {}), - }, - }); + recordSkipped(nextNode, edge); } } diff --git a/packages/spec/src/automation/schemaless-node-config.test.ts b/packages/spec/src/automation/schemaless-node-config.test.ts index 256796925b3..90f1893e3ae 100644 --- a/packages/spec/src/automation/schemaless-node-config.test.ts +++ b/packages/spec/src/automation/schemaless-node-config.test.ts @@ -6,12 +6,15 @@ * `script` and `subflow` run through `service-automation`'s `parseNodeConfig()` * before their executors do anything, so what this file pins is not decoration: * a shape accepted here runs, and a shape rejected here refuses the node as a - * guard. `decision` is the exception — it stays export-only (nothing parses it - * at run time), so its pins below bind the authoring doors only: `tsc`, the - * published JSON Schema and a direct parse. Its `mode` key is declared ahead of - * the engine change that reads it (#15429), and is refused beside a non-empty - * `conditions` list (ruling 5856786357 on #20168) — a refinement, so of those - * doors it binds the direct parse and is declared dropped in the JSON Schema. + * guard. `decision` is different — no execute-time parse; its strictness binds + * at the authoring doors only (`tsc`, the published JSON Schema and a direct + * parse), while its `mode` key is ALSO judged at registration (#15429): the + * automation engine's `registerFlow` parses every decision's config through + * this schema and refuses the flow on any issue rooted at `mode`, and + * `os validate` reports the same as `flow-decision-mode-invalid`. `mode` is + * refused beside a non-empty `conditions` list (ruling 5856786357 on #20168) — + * a refinement, so of the authoring doors it binds the direct parse and is + * declared dropped in the JSON Schema; the two run-time doors carry it too. * * The structural assertions at the bottom guard the downstream walkers that a * union-shaped contract would have broken, which is why #4343 converged the diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts index 70f2ae5d5b0..e0be028d4f2 100644 --- a/packages/spec/src/automation/schemaless-node-config.zod.ts +++ b/packages/spec/src/automation/schemaless-node-config.zod.ts @@ -64,13 +64,18 @@ * nothing read — and then refuses, naming the `function` it does not have, * instead of logging a line and reporting success as it used to. * - * `decision` stays export-only: nothing parses it at run time. It may carry no - * `conditions` at all when it branches purely on edge predicates, its executor - * reads `conditions` and nothing else, and its one other key — `mode` — is - * declared AHEAD of the engine change that reads it (#15429; see - * {@link DecisionConfigSchema}). Its enforcement remains the objectui - * reconciliation test, which is what #4278 was actually about (a form - * authoring keys nothing reads). + * `decision` is parsed at **registration**, for one key (#15429): the + * automation engine's `registerFlow` runs every decision node's config through + * {@link DecisionConfigSchema} and refuses the flow on any issue rooted at + * `mode` — a value outside the closed pair, or a `mode` beside a non-empty + * `conditions` list — with this schema's own sentence, and `os validate` + * reports the same issues as `flow-decision-mode-invalid`, so the two doors + * cannot disagree. A decision may carry no `conditions` at all when it + * branches purely on edge predicates; its executor reads `conditions` and + * nothing else, and the engine's traversal reads `mode`. Its strictness + * (unknown keys) still binds at authoring, in the published JSON Schema and in + * the objectui reconciliation test, which is what #4278 was actually about (a + * form authoring keys nothing reads). * * Undeclared aliases are NOT part of these contracts: `subflow`'s historical * `flow` spelling graduated into the ADR-0087 D2 conversion @@ -94,9 +99,10 @@ * precisely the class with no second door. Closing these shapes is therefore * not a duplicate check for `script` and `subflow`; it is their first one. * - * `decision` is still export-only, so its strictness binds at authoring - * (`tsc`), in the published JSON Schema, and in objectui's reconciliation — - * not at run time. It is closed anyway, because the campaign's whole finding + * `decision`'s strictness binds at authoring (`tsc`), in the published JSON + * Schema, and in objectui's reconciliation — not at run time, where the + * registration reader judges `mode` alone (#15429). It is closed anyway, + * because the campaign's whole finding * is that a shape left open accretes a test, a form and a fixture that assert * the openness, and then closing it is a migration instead of an edit. */ @@ -497,20 +503,31 @@ export type DecisionCondition = z.input; * forbids that one when the list is non-empty" — and the site is declared in * `dropped-refinements.baseline.json` and on the artifact as * `x-dropped-refinements`. Like the rest of this contract it binds wherever - * `DecisionConfigSchema` is parsed: a direct parse, and the registration-time - * reader #15429 adds. - * - * ⚠️ **Declared ahead of its enforcement, and the status quo does NOT match - * the default above.** The ruling's split order lands this key first, then - * the engine semantics together with the `os migrate meta` conversion in one - * change, then the docs. Until that second step lands, nothing reads `mode`, - * and an edge-branched decision takes EVERY out-edge whose condition holds, - * one after another, whatever `mode` says — the behaviour - * `decision-overlapping-edge-conditions.pin.test.ts` (service-automation) - * pins as the status quo. The engine change and the conversion that writes - * `mode: 'inclusive'` onto every decision relying on that behaviour land - * together, so no shipped flow changes behaviour silently; this paragraph is - * rewritten with them (#15429). + * `DecisionConfigSchema` is parsed: a direct parse, the automation engine's + * registration reader (`registerFlow` refuses the flow with this sentence), + * and `os validate` (`flow-decision-mode-invalid`, a gating finding). + * + * ## Where `mode` is honoured — the traversal, and the migration that keeps old flows whole + * + * The engine's traversal reads it (#15429): on a `decision` with no + * `conditions` list, the conditioned out-edges are evaluated in the order the + * flow's `edges` array declares them and the FIRST one whose condition holds + * is the branch; the siblings after it are not evaluated and record the same + * `skipped` step a closed gate does. With `mode: 'inclusive'` every out-edge + * whose condition holds runs, one after another. When none holds, the + * `isDefault` edge runs either way. `os validate` reports + * `flow-decision-inclusive-overlap` on an inclusive decision with two or more + * conditioned out-edges, because that is the shape in which more than one + * branch can run for one record. + * + * Flows written while every true branch ran keep their behaviour through the + * ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit`: `os migrate + * meta --from 17` writes `mode: 'inclusive'` onto every edge-branched decision + * with two or more conditioned out-edges, and the author deletes it where the + * branches partition. It is a default flip, so no load seam replays it — the + * authoring funnel is where a source written against THIS contract arrives, + * and the flow rehydration seam cannot date a body — which is why the key has + * to be written into the source, once, by the operator's own command. * * The legacy singular `config.condition` is a structural surface the engine * parse-validates on every node at registration but the decision executor never @@ -526,23 +543,26 @@ export const DecisionConfigSchema = lazySchema(() => strictObject({ .describe('Ordered decision branches (first true expression wins; omit to branch purely on edge conditions)'), /** * How many out-edges an edge-branched decision takes when more than one - * condition holds — `'exclusive'` (the first; what an omitted key means) or - * `'inclusive'` (every one). Not read by the engine yet: see the - * "Declared ahead of its enforcement" note above. Any other value is refused - * with {@link decisionModePrescription}; either member beside a non-empty - * `conditions` list is refused by the `.superRefine` below, with - * {@link decisionModeWithConditionsRefusal}. + * condition holds — `'exclusive'` (the first, in declaration order; what an + * omitted key means) or `'inclusive'` (every one). Read by the engine's + * traversal: see "Where `mode` is honoured" above. Any other value is + * refused with {@link decisionModePrescription}; either member beside a + * non-empty `conditions` list is refused by the `.superRefine` below, with + * {@link decisionModeWithConditionsRefusal} — at a direct parse, at + * registration and by `os validate` alike. */ mode: z.enum(['exclusive', 'inclusive'], { error: (issue) => (issue.code === 'invalid_value' ? decisionModePrescription(issue.input) : undefined), }).optional() .describe( 'Declares how many out-edges an edge-branched decision takes when more than one out-edge condition holds: ' - + "'exclusive' = only the first, in the order the edges are declared (what an omitted mode means); " - + "'inclusive' = every one that holds. Declared ahead of the engine change that reads it: until that lands, " - + 'an edge-branched decision takes every out-edge whose condition holds, whatever this says. Refused beside a ' - + 'non-empty conditions list, which is first-match on its own: delete mode there, or move the branches onto ' - + 'the out-edges, delete conditions, and keep mode.', + + "'exclusive' = only the first, in the order the edges are declared (what an omitted mode means; the " + + "siblings after it are not evaluated and record a skipped step); 'inclusive' = every one that holds, one " + + 'after another. When none holds the isDefault edge runs either way. Refused beside a non-empty conditions ' + + 'list, which is first-match on its own: delete mode there, or move the branches onto the out-edges, delete ' + + 'conditions, and keep mode. Flows written while every true branch ran keep that behaviour through the ' + + 'os migrate meta --from 17 conversion, which writes mode: inclusive onto every edge-branched decision with ' + + 'two or more conditioned out-edges.', ), }).superRefine((config, ctx) => { // Ruling 5856786357 on #20168 (letter A): `mode` belongs to the diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index 2a284e4731d..1281ab11ebd 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -14,6 +14,7 @@ import { import { normalizeStackInput } from '../shared/metadata-collection.zod.js'; import { ElementButtonPropsSchema, PageHeaderProps, PageTabsProps } from '../ui/component.zod.js'; import { PageSchema } from '../ui/page.zod.js'; +import { applyMetaMigrations } from '../migrations/chain.js'; import { applyConversions, collectConversionNotices } from './apply.js'; import { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from './registry.js'; import { applyConversionsToStoredItem } from './stored.js'; @@ -345,6 +346,131 @@ describe('conversion layer (ADR-0087 D2)', () => { }); }); + /** + * `flow-decision-mode-inclusive-explicit` (#15429) — the DEFAULT FLIP that + * carries an edge-branched decision across the exclusive-gateway ruling. + * + * The fixture pair above pins the rewrite; what needs its own cover is the + * PREDICATE's edges (the count, the two shapes it must leave alone, regions) + * and its JURISDICTION: the chain replays it, the authoring funnel does not, + * and a second replay is a no-op. The flow rehydration seam's refusal by id + * is pinned where that seam lives (`service-automation`). + */ + describe('flow-decision-mode-inclusive-explicit (#15429)', () => { + const ID = 'flow-decision-mode-inclusive-explicit'; + const entry = () => ALL_CONVERSIONS.find((c) => c.id === ID)!; + const decisionFlow = (edges: Record[], config?: Record) => ({ + flows: [{ + name: 'gateway', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'check', type: 'decision', label: 'Check', ...(config ? { config } : {}) }, + { id: 'a', type: 'end', label: 'A' }, + { id: 'b', type: 'end', label: 'B' }, + { id: 'c', type: 'end', label: 'C' }, + ], + edges: [{ id: 'e0', source: 'start', target: 'check' }, ...edges], + }], + }); + const chain = (stack: Record) => applyMetaMigrations(structuredClone(stack), 17, 18); + const checkConfigAfter = (result: ReturnType) => + ((result.stack.flows as any[])[0].nodes as any[]).find((n) => n.id === 'check').config; + const two = [ + { id: 'e1', source: 'check', target: 'a', condition: "x != 'a'" }, + { id: 'e2', source: 'check', target: 'b', condition: "x == 'b'" }, + ]; + + it('is registered at protocol 18, retired from the load path, and wired into the step-18 chain', () => { + expect(entry().toMajor).toBe(18); + expect(entry().retiredFromLoadPath).toBe(true); + const result = chain(decisionFlow(two)); + expect(result.applied.map((a) => a.conversionId)).toContain(ID); + }); + + it('writes `mode: inclusive` on two conditioned out-edges, and says what the site relied on', () => { + const result = chain(decisionFlow(two)); + expect(checkConfigAfter(result)).toEqual({ mode: 'inclusive' }); + const mine = result.applied.filter((a) => a.conversionId === ID); + expect(mine).toHaveLength(1); + expect(mine[0]!.path).toBe('flows[0].nodes[1].config.mode'); + expect(mine[0]!.from).toContain('2 conditioned out-edges'); + expect(mine[0]!.to).toBe('inclusive'); + }); + + it('counts an envelope condition, and a third conditioned edge, but never a fault edge or a blank one', () => { + const three = [ + ...two, + { id: 'e3', source: 'check', target: 'c', condition: { dialect: 'cel', source: "x == 'c'" } }, + ]; + expect(checkConfigAfter(chain(decisionFlow(three)))).toEqual({ mode: 'inclusive' }); + // A `fault` edge is error routing; a blank condition is no condition. + const notBranches = [ + { id: 'e1', source: 'check', target: 'a', condition: "x != 'a'" }, + { id: 'e2', source: 'check', target: 'b', condition: "x == 'b'", type: 'fault' }, + { id: 'e3', source: 'check', target: 'c', condition: ' ' }, + ]; + expect(checkConfigAfter(chain(decisionFlow(notBranches)))).toBeUndefined(); + }); + + it('leaves ONE conditioned out-edge plus a default alone — first-match and every-true-edge cannot differ', () => { + const guarded = [ + { id: 'e1', source: 'check', target: 'a', condition: "x == 'a'" }, + { id: 'e2', source: 'check', target: 'b', isDefault: true }, + ]; + const result = chain(decisionFlow(guarded)); + expect(checkConfigAfter(result)).toBeUndefined(); + expect(result.applied.filter((a) => a.conversionId === ID)).toEqual([]); + }); + + it('leaves a decision that already declares `mode` alone — either member — and a `conditions` list alone', () => { + expect(checkConfigAfter(chain(decisionFlow(two, { mode: 'exclusive' })))).toEqual({ mode: 'exclusive' }); + expect(checkConfigAfter(chain(decisionFlow(two, { mode: 'inclusive' })))).toEqual({ mode: 'inclusive' }); + const listed = { conditions: [{ label: 'A', expression: "x == 'a'" }] }; + expect(checkConfigAfter(chain(decisionFlow(two, listed)))).toEqual(listed); + // An EMPTY list declares no branch: the node routes on its edges, so it is rewritten. + expect(checkConfigAfter(chain(decisionFlow(two, { conditions: [] })))).toEqual({ conditions: [], mode: 'inclusive' }); + }); + + it('never touches a non-decision node with the same two conditioned out-edges', () => { + const stack = decisionFlow(two); + (stack.flows[0]!.nodes[1] as Record).type = 'screen'; + const result = chain(stack); + expect(checkConfigAfter(result)).toBeUndefined(); + expect(result.applied.filter((a) => a.conversionId === ID)).toEqual([]); + }); + + it('is idempotent: the migrated stack replays to itself with nothing applied', () => { + const first = chain(decisionFlow(two)); + const again = applyMetaMigrations(structuredClone(first.stack), 17, 18); + expect(again.stack).toEqual(first.stack); + expect(again.applied.filter((a) => a.conversionId === ID)).toEqual([]); + }); + + it('⛔ never replays on the authoring funnel — a decision written against the new contract stays exclusive', () => { + const authored = decisionFlow(two); + const notices: ConversionNotice[] = []; + const out = normalizeStackInput(structuredClone(authored), { onConversionNotice: (n) => notices.push(n) }); + expect(out).toEqual(authored); + expect(notices.map((n) => n.conversionId)).not.toContain(ID); + }); + + it('a seam that opens the retired window can still refuse it by id — the flow rehydration seam does', () => { + // The primitive behind `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` in the + // automation engine: same bytes, window open, entry refused by name. + const notices: ConversionNotice[] = []; + const out = applyConversions(decisionFlow(two), { + includeRetired: true, + excludeConversionIds: [ID], + onNotice: (n) => notices.push(n), + }); + expect(((out.flows as any[])[0].nodes as any[])[1].config).toBeUndefined(); + expect(notices.map((n) => n.conversionId)).not.toContain(ID); + // FIRING CONTROL: with the window open and no refusal, it does fire. + const fired = applyConversions(decisionFlow(two), { includeRetired: true }); + expect(((fired.flows as any[])[0].nodes as any[])[1].config).toEqual({ mode: 'inclusive' }); + }); + }); + describe('registry invariants', () => { it('every conversion carries a fixture pair and a positive retirement window', () => { for (const c of ALL_CONVERSIONS) { diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 4e76af6ca83..11b2a5209a4 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -44,6 +44,7 @@ import { type ViewFilterOperator, } from '../ui/view.zod.js'; import { ASSEMBLED_VIEW_ITEMS_KEY } from '../ui/assembled-views.zod.js'; +import { FLOW_REGION_SLOTS_BY_TYPE } from '../automation/region-slots.js'; /** * Flow callout node type rename (protocol 11.0). @@ -11205,6 +11206,289 @@ const reportJoinedChartRemoved: MetadataConversion = { }, }; +/** + * `decision` edge branching became EXCLUSIVE — first match in declaration + * order — and taking every true branch is now the declared `mode: 'inclusive'` + * (protocol 18, #15429; maintainer ruling 「跟主流对齐」, 2026-09-23). + * + * Until this change an edge-branched decision (no `config.conditions`) took + * EVERY out-edge whose condition held, one after another, while its schema, + * its docs and the engine's own comment all called it an exclusive gateway. The + * traversal now takes the FIRST conditioned out-edge that holds, in the order + * the flow's `edges` array declares them — the BPMN exclusive gateway, + * Salesforce Flow's Decision, n8n's Switch default — and `mode: 'inclusive'` + * is what an author writes to take every one (the BPMN inclusive gateway). + * + * ## What this rewrites, and the one thing it does not infer + * + * A decision with no `conditions` list (absent or empty), no `mode` of its + * own, and TWO OR MORE out-edges carrying a `condition` (a `fault` edge is + * error routing, not a branch) gets `mode: 'inclusive'` written explicitly, so + * a flow written while every true branch ran keeps that behaviour under the + * exclusive traversal. One conditioned edge plus a default is left alone: + * first-match and every-true-edge cannot differ there, so there is nothing to + * preserve. A decision whose author already wrote `mode` — either member — has + * spoken and is left alone, which is also what makes a second replay a no-op. + * + * ⛔ No smarter inference: a pair of conditions that PROVABLY partition + * (`x == 'a'` beside `x != 'a'`) is rewritten too. First-match equals + * every-true-edge only when the conditions are exclusive, and the ruling's + * predicate is the count, not a decision procedure over CEL — the author then + * deletes the key where the branches partition, and the `os migrate meta` diff + * is where that judgment is made (the paired D3 entry + * `flow-decision-edge-branching-first-match` says how). + * + * Reaches the nodes inside ADR-0031 regions (`loop.config.body`, + * `parallel.config.branches[]`, `try_catch.config.try` / `.catch`) through the + * shared slot table, judging each region's decisions against ITS OWN edges — + * a nested gate's out-edges live in the region, not in `flow.edges`. + * + * ## Where it replays — `os migrate meta --from 17`, and no load seam + * + * This is a DEFAULT FLIP, not a rename or a delete: the old shape (no `mode`) + * still parses and now MEANS exclusive, and the rewrite changes what it means. + * Such an entry is sound only where "this source predates the flip" is a fact, + * and that is the D3 chain alone — the operator asserts the source's age with + * `--from`. `retiredFromLoadPath: true` keeps it off the authoring funnel + * (`normalizeStackInput`), where an author who wrote two branches today, against + * the contract that says an omitted `mode` is exclusive, must not be rewritten + * into an inclusive gateway. The flag's jurisdiction ends there (#16864), so + * every data-at-rest seam that opens the retired window has to refuse this id + * by name, on the artifact door's precedent for `app-hidden-to-unpublished` + * (#17885, `DEFAULT_FLIPS_NOT_REPLAYED_HERE` in `@objectstack/metadata-core`): + * the automation engine's flow rehydration seam does (`registerFlow` serves + * code-shipped flows, REST bodies and Studio saves alike, none of them dated), + * and the artifact-ingestion door must (a scaffolded `^17.0.0` floor is a + * dependency range, not an age). ⛔ Not replayed over stored `sys_metadata` + * flows by `os migrate meta --stored` either — that pass canonicalizes through + * the same engine seam, and an operator-asserted rewrite of stored rows is a + * separate plumbing, named by the D3 entry as the judgment still owed. + */ +const flowDecisionModeInclusiveExplicit: MetadataConversion = { + id: 'flow-decision-mode-inclusive-explicit', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'flow.nodes[].config.mode (decision)', + summary: + "edge-branched decision with two or more conditioned out-edges and no `mode`: `mode: 'inclusive'` written " + + 'explicitly (#15429 — the traversal became exclusive, first match in declaration order; the key keeps the ' + + 'every-true-edge behaviour those nodes had, and the author deletes it where the branches partition)', + apply(stack, emit) { + return mapCollection(stack, 'flows', (flow, path) => rewriteDecisionModesInGraph(flow, path, emit, 0)); + }, + fixture: { + // DISJOINT from every other flow fixture: only `start` / `decision` / `end` + // / `loop` nodes, no key another entry rewrites, so the whole table hits + // only this one. + before: { + flows: [ + { + name: 'lead_verdict', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + // The hotcrm#1555 shape: two conditioned out-edges that can BOTH + // hold for a confirmed record. Rewritten — every true branch ran. + { id: 'verdict', type: 'decision', label: 'Verdict?' }, + { id: 'refuse', type: 'end', label: 'Refuse' }, + { id: 'convert', type: 'end', label: 'Convert' }, + // One guarded branch plus the default: first-match and + // every-true-edge agree, so there is nothing to preserve. Left alone. + { id: 'converted', type: 'decision', label: 'Already converted?' }, + { id: 'abort', type: 'end', label: 'Abort' }, + { id: 'proceed', type: 'end', label: 'Proceed' }, + // The author already spoke. Left alone (and a second replay is a no-op). + { id: 'spoken', type: 'decision', label: 'Spoken', config: { mode: 'exclusive' } }, + { id: 'a', type: 'end', label: 'A' }, + { id: 'b', type: 'end', label: 'B' }, + // A `conditions` list is first-match on its own and refuses `mode`. Left alone. + { + id: 'listed', type: 'decision', label: 'Listed', + config: { conditions: [{ label: 'Hot', expression: 'lead.score > 80' }] }, + }, + { id: 'hot', type: 'end', label: 'Hot' }, + // The same two-branch shape inside a loop body, judged against the + // region's own edges. Rewritten. + { + id: 'sweep', type: 'loop', label: 'Sweep', + config: { + collection: '{leads}', + iteratorVariable: 'lead', + body: { + nodes: [ + { id: 'gate', type: 'decision', label: 'Gate' }, + { id: 'x', type: 'end', label: 'X' }, + { id: 'y', type: 'end', label: 'Y' }, + ], + edges: [ + { id: 'g1', source: 'gate', target: 'x', condition: "lead.status != 'suspected'" }, + { id: 'g2', source: 'gate', target: 'y', condition: { dialect: 'cel', source: "lead.status == 'confirmed'" } }, + ], + }, + }, + }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'verdict' }, + { id: 'e2', source: 'verdict', target: 'refuse', condition: "lead.status != 'suspected'", label: 'Refuse' }, + { id: 'e3', source: 'verdict', target: 'convert', condition: "lead.status == 'confirmed'", label: 'Convert' }, + { id: 'e4', source: 'converted', target: 'abort', condition: "lead.status == 'converted'" }, + { id: 'e5', source: 'converted', target: 'proceed', isDefault: true }, + { id: 'e6', source: 'spoken', target: 'a', condition: 'x > 1' }, + { id: 'e7', source: 'spoken', target: 'b', condition: 'x > 2' }, + { id: 'e8', source: 'listed', target: 'hot', label: 'Hot' }, + ], + }, + ], + }, + after: { + flows: [ + { + name: 'lead_verdict', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'verdict', type: 'decision', label: 'Verdict?', config: { mode: 'inclusive' } }, + { id: 'refuse', type: 'end', label: 'Refuse' }, + { id: 'convert', type: 'end', label: 'Convert' }, + { id: 'converted', type: 'decision', label: 'Already converted?' }, + { id: 'abort', type: 'end', label: 'Abort' }, + { id: 'proceed', type: 'end', label: 'Proceed' }, + { id: 'spoken', type: 'decision', label: 'Spoken', config: { mode: 'exclusive' } }, + { id: 'a', type: 'end', label: 'A' }, + { id: 'b', type: 'end', label: 'B' }, + { + id: 'listed', type: 'decision', label: 'Listed', + config: { conditions: [{ label: 'Hot', expression: 'lead.score > 80' }] }, + }, + { id: 'hot', type: 'end', label: 'Hot' }, + { + id: 'sweep', type: 'loop', label: 'Sweep', + config: { + collection: '{leads}', + iteratorVariable: 'lead', + body: { + nodes: [ + { id: 'gate', type: 'decision', label: 'Gate', config: { mode: 'inclusive' } }, + { id: 'x', type: 'end', label: 'X' }, + { id: 'y', type: 'end', label: 'Y' }, + ], + edges: [ + { id: 'g1', source: 'gate', target: 'x', condition: "lead.status != 'suspected'" }, + { id: 'g2', source: 'gate', target: 'y', condition: { dialect: 'cel', source: "lead.status == 'confirmed'" } }, + ], + }, + }, + }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'verdict' }, + { id: 'e2', source: 'verdict', target: 'refuse', condition: "lead.status != 'suspected'", label: 'Refuse' }, + { id: 'e3', source: 'verdict', target: 'convert', condition: "lead.status == 'confirmed'", label: 'Convert' }, + { id: 'e4', source: 'converted', target: 'abort', condition: "lead.status == 'converted'" }, + { id: 'e5', source: 'converted', target: 'proceed', isDefault: true }, + { id: 'e6', source: 'spoken', target: 'a', condition: 'x > 1' }, + { id: 'e7', source: 'spoken', target: 'b', condition: 'x > 2' }, + { id: 'e8', source: 'listed', target: 'hot', label: 'Hot' }, + ], + }, + ], + }, + expectedNotices: 2, + }, +}; + +/** + * The ruling's predicate, as a number: an edge-branched decision is rewritten + * when at least this many of its out-edges carry a `condition`. Below it, + * first-match and every-true-edge cannot differ. + */ +const DECISION_MODE_INCLUSIVE_MIN_CONDITIONED_EDGES = 2; + +/** + * Depth ceiling for the region recursion — mirrors the walkers' ceiling + * (`walk.ts`, `control-flow.zod.ts`): a self-referencing region in a + * hand-built stack must not recurse without bound. + */ +const DECISION_MODE_REGION_DEPTH_CEILING = 32; + +/** An edge whose `condition` carries a predicate — bare text, or the parsed `{ dialect, source }` envelope. */ +function edgeCarriesCondition(edge: Record): boolean { + const c = edge.condition; + if (typeof c === 'string') return c.trim() !== ''; + if (isDict(c)) return typeof c.source === 'string' && c.source.trim() !== ''; + return false; +} + +/** + * One graph — a flow, or one ADR-0031 region — for + * {@link flowDecisionModeInclusiveExplicit}: rewrite its own decisions against + * its own `edges`, then descend into each node's region slots. Copy-on-write: + * the same reference comes back when nothing under it changed. + */ +function rewriteDecisionModesInGraph( + graph: Record, + path: string, + emit: (detail: ConversionApplication) => void, + depth: number, +): Record { + const nodes = graph.nodes; + if (!Array.isArray(nodes)) return graph; + const edges = Array.isArray(graph.edges) ? graph.edges.filter(isDict) : []; + let changed = false; + const nextNodes = nodes.map((node, i) => { + if (!isDict(node)) return node; + const nodePath = `${path}.nodes[${i}]`; + let next = node; + + if (node.type === 'decision') { + const cfg = isDict(node.config) ? node.config : {}; + const declaresBranches = Array.isArray(cfg.conditions) && cfg.conditions.length > 0; + if (!declaresBranches && !('mode' in cfg)) { + const conditioned = edges.filter( + (e) => e.source === node.id && e.type !== 'fault' && edgeCarriesCondition(e), + ).length; + if (conditioned >= DECISION_MODE_INCLUSIVE_MIN_CONDITIONED_EDGES) { + next = { ...node, config: { ...cfg, mode: 'inclusive' } }; + emit({ + from: `mode unset (${conditioned} conditioned out-edges; every one whose condition held was taken)`, + to: 'inclusive', + path: `${nodePath}.config.mode`, + }); + } + } + } + + if (depth < DECISION_MODE_REGION_DEPTH_CEILING) { + const slots = typeof next.type === 'string' ? FLOW_REGION_SLOTS_BY_TYPE.get(next.type) : undefined; + if (slots && isDict(next.config)) { + let nextConfig = next.config; + for (const slot of slots) { + const raw = nextConfig[slot.key]; + if (slot.arity === 'many') { + if (!Array.isArray(raw)) continue; + let branchesChanged = false; + const nextBranches = raw.map((branch, bi) => { + if (!isDict(branch)) return branch; + const mapped = rewriteDecisionModesInGraph(branch, `${nodePath}.config.${slot.key}[${bi}]`, emit, depth + 1); + if (mapped !== branch) branchesChanged = true; + return mapped; + }); + if (branchesChanged) nextConfig = { ...nextConfig, [slot.key]: nextBranches }; + } else { + if (!isDict(raw)) continue; + const mapped = rewriteDecisionModesInGraph(raw, `${nodePath}.config.${slot.key}`, emit, depth + 1); + if (mapped !== raw) nextConfig = { ...nextConfig, [slot.key]: mapped }; + } + } + if (nextConfig !== next.config) next = { ...next, config: nextConfig }; + } + } + + if (next !== node) changed = true; + return next; + }); + return changed ? { ...graph, nodes: nextNodes } : graph; +} + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -11314,6 +11598,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly` beside `<=`, or a ' + + 'guard beside `isDefault: true` — delete the written `mode`; the run is unchanged either ' + + 'way and the exclusive default is the honest declaration; (2) where the flow relies on ' + + 'more than one branch running for one record, keep `mode: \'inclusive\'`; (3) where the ' + + 'conditions overlap by accident, narrow them into a partition and delete the key, then ' + + 're-run the flow on a record that satisfied both and confirm exactly one successor ' + + 'ran — the passed-over branch now leaves a `skipped` step in the run log. `os validate` ' + + 'reports `flow-decision-inclusive-overlap` on every decision that keeps the key with ' + + 'two or more conditioned out-edges, so the review list is the lint output. Then the ' + + 'half no command reaches: list the `sys_metadata` flow rows of each deployment whose ' + + 'decision nodes carry two or more conditioned out-edges and no `mode` — the stored ' + + 'pass reports these rows canonical and rewrites nothing — and declare `mode` on each in ' + + 'the Studio designer by the same three-way judgment. A decision registering with ' + + '`mode` beside a non-empty `conditions` list, or with a `mode` outside ' + + '`\'exclusive\' | \'inclusive\'`, is refused at registration and by `os validate` with the ' + + 'schema\'s own sentence; nothing else about `conditions`-list decisions changes.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3b0c986d768..703e303b7c2 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5336,7 +5336,15 @@ const step18: MigrationStep = { + '`report-joined-chart-removed` strips both as a pure lossless delete — neither ever had ' + 'an effect to lose — because a stored report row CAN carry them (the Studio report form ' + 'offered a block `chart` input until this change); it is retired from the load path, so ' - + 'authors are refused at parse rather than rewritten.', + + 'authors are refused at parse rather than rewritten. ' + + 'Finally it makes edge-branched `decision` nodes EXCLUSIVE (#15429, maintainer ruling ' + + '「跟主流对齐」): the first conditioned out-edge that holds, in declaration order, is the ' + + 'branch, and taking every true branch is the declared `mode: \'inclusive\'`. The D2 ' + + 'conversion `flow-decision-mode-inclusive-explicit` writes that key onto every decision ' + + 'with two or more conditioned out-edges and no `conditions` list, so a flow written while ' + + 'every true branch ran keeps its behaviour; it is a default flip, so it is retired from ' + + 'the load path AND refused by the flow rehydration seam, and replays only here — the ' + + 'paired semantic entry carries the judgment the diff then asks for.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5375,6 +5383,7 @@ const step18: MigrationStep = { 'page-component-filter-record-to-rule-array', 'view-item-owner-hidden-removed', 'report-joined-chart-removed', + 'flow-decision-mode-inclusive-explicit', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -10211,6 +10220,63 @@ const step18: MigrationStep = { + 'predicate parses and registers byte-identically to before, a decision with no `conditions` ' + 'still routes by its out-edges, and an absent screen field `visibleWhen` is still legal.', }, + { + id: 'flow-decision-edge-branching-first-match', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: + 'flow.nodes[].config.mode (decision) — an OMITTED mode on a decision that branches ' + + 'on its out-edges and carries two or more conditioned ones', + replacement: + 'nothing, where the out-edge conditions partition (exactly one can hold for any ' + + 'record): an omitted `mode` now means exclusive, the first true edge in declaration ' + + 'order wins, and the run is what it always was. `mode: \'inclusive\'` where the flow ' + + 'RELIES on more than one branch running for one record — the value the D2 conversion ' + + '`flow-decision-mode-inclusive-explicit` writes onto every such decision so nothing ' + + 'changes silently. Where the conditions overlap by accident (a `!=` guard beside a ' + + 'later `==` branch), neither: narrow them into a partition, or mark the fallback ' + + '`isDefault: true`, and delete the written key.', + reason: + 'A DEFAULT FLIP of a shipped node type, ruled rather than patched: the schema, the docs ' + + 'and the engine\'s own comment all called an edge-branched decision an exclusive gateway ' + + 'while the traversal took EVERY out-edge whose condition held, one after another, and ' + + 'reported nothing — hotcrm#1555 rendered a refusal screen AND ran the conversion in one ' + + 'execution. The traversal now matches the declaration (BPMN exclusive gateway, ' + + 'Salesforce Flow Decision, n8n Switch default), and the every-true-edge behaviour is the ' + + 'BPMN inclusive gateway an author must write down. The KEY converts mechanically and ' + + 'does: `flow-decision-mode-inclusive-explicit` writes `mode: \'inclusive\'` wherever two ' + + 'or more conditioned out-edges leave a decision that declares no `conditions` list, so ' + + 'the migrated source runs exactly as before. What does NOT convert is the INTENT: the ' + + 'count cannot tell a partition (where the key is redundant) from a reliance on ' + + 'multi-branch runs (where it is load-bearing) from an accidental overlap (where the ' + + 'old behaviour was the bug), so the diff `os migrate meta --from 17` prints is where ' + + 'that judgment is made, node by node. And the conversion replays ONLY there: it is a ' + + 'default flip, so the authoring funnel never rewrites a source written against the ' + + 'new contract, the automation engine\'s flow rehydration seam refuses it by id (a ' + + 'code-shipped flow, a REST body and a Studio save all arrive there undated), and the ' + + 'stored-row pass (`os migrate meta --stored`) canonicalizes through that same seam — so ' + + 'a decision saved from the Studio BEFORE this release, with two or more conditioned ' + + 'out-edges and no `mode`, now runs first-match and is rewritten by nothing.', + acceptanceCriteria: + 'Run `os migrate meta --from 17` over each authored stack and review every ' + + '`flow-decision-mode-inclusive-explicit` line in its diff: (1) where the two (or more) ' + + 'out-edge conditions partition — a predicate and its negation, `>` beside `<=`, or a ' + + 'guard beside `isDefault: true` — delete the written `mode`; the run is unchanged either ' + + 'way and the exclusive default is the honest declaration; (2) where the flow relies on ' + + 'more than one branch running for one record, keep `mode: \'inclusive\'`; (3) where the ' + + 'conditions overlap by accident, narrow them into a partition and delete the key, then ' + + 're-run the flow on a record that satisfied both and confirm exactly one successor ' + + 'ran — the passed-over branch now leaves a `skipped` step in the run log. `os validate` ' + + 'reports `flow-decision-inclusive-overlap` on every decision that keeps the key with ' + + 'two or more conditioned out-edges, so the review list is the lint output. Then the ' + + 'half no command reaches: list the `sys_metadata` flow rows of each deployment whose ' + + 'decision nodes carry two or more conditioned out-edges and no `mode` — the stored ' + + 'pass reports these rows canonical and rewrites nothing — and declare `mode` on each in ' + + 'the Studio designer by the same three-way judgment. A decision registering with ' + + '`mode` beside a non-empty `conditions` list, or with a `mode` outside ' + + '`\'exclusive\' | \'inclusive\'`, is refused at registration and by `os validate` with the ' + + 'schema\'s own sentence; nothing else about `conditions`-list decisions changes.', + }, // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code // span already, and a nested backtick would close it. { From 48e8d11db9712e5797096205b7e16c1929bfc133 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:01:59 +0000 Subject: [PATCH 02/11] chore(spec): regenerate schemaless-node-config reference (gen:docs) Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../automation/schemaless-node-config.mdx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/content/docs/references/automation/schemaless-node-config.mdx b/content/docs/references/automation/schemaless-node-config.mdx index 42128f245cd..2940819fbb7 100644 --- a/content/docs/references/automation/schemaless-node-config.mdx +++ b/content/docs/references/automation/schemaless-node-config.mdx @@ -66,13 +66,18 @@ The two halves reach different audiences, which is why they shipped together: nothing read — and then refuses, naming the `function` it does not have, instead of logging a line and reporting success as it used to. -`decision` stays export-only: nothing parses it at run time. It may carry no -`conditions` at all when it branches purely on edge predicates, its executor -reads `conditions` and nothing else, and its one other key — `mode` — is -declared AHEAD of the engine change that reads it (#15429; see -`DecisionConfigSchema`). Its enforcement remains the objectui -reconciliation test, which is what #4278 was actually about (a form -authoring keys nothing reads). +`decision` is parsed at **registration**, for one key (#15429): the +automation engine's `registerFlow` runs every decision node's config through +`DecisionConfigSchema` and refuses the flow on any issue rooted at +`mode` — a value outside the closed pair, or a `mode` beside a non-empty +`conditions` list — with this schema's own sentence, and `os validate` +reports the same issues as `flow-decision-mode-invalid`, so the two doors +cannot disagree. A decision may carry no `conditions` at all when it +branches purely on edge predicates; its executor reads `conditions` and +nothing else, and the engine's traversal reads `mode`. Its strictness +(unknown keys) still binds at authoring, in the published JSON Schema and in +the objectui reconciliation test, which is what #4278 was actually about (a +form authoring keys nothing reads). Undeclared aliases are NOT part of these contracts: `subflow`'s historical `flow` spelling graduated into the ADR-0087 D2 conversion @@ -96,9 +101,10 @@ door in front of its author, and the class it structurally could not cover is precisely the class with no second door. Closing these shapes is therefore not a duplicate check for `script` and `subflow`; it is their first one. -`decision` is still export-only, so its strictness binds at authoring -(`tsc`), in the published JSON Schema, and in objectui's reconciliation — -not at run time. It is closed anyway, because the campaign's whole finding +`decision`'s strictness binds at authoring (`tsc`), in the published JSON +Schema, and in objectui's reconciliation — not at run time, where the +registration reader judges `mode` alone (#15429). It is closed anyway, +because the campaign's whole finding is that a shape left open accretes a test, a form and a fixture that assert the openness, and then closing it is a migration instead of an edit. @@ -137,7 +143,7 @@ const result = DecisionConditionSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **conditions** | `{ label: string; expression: string }[]` | optional | Ordered decision branches (first true expression wins; omit to branch purely on edge conditions) | -| **mode** | `Enum<'exclusive' \| 'inclusive'>` | optional | Declares how many out-edges an edge-branched decision takes when more than one out-edge condition holds: 'exclusive' = only the first, in the order the edges are declared (what an omitted mode means); 'inclusive' = every one that holds. Declared ahead of the engine change that reads it: until that lands, an edge-branched decision takes every out-edge whose condition holds, whatever this says. Refused beside a non-empty conditions list, which is first-match on its own: delete mode there, or move the branches onto the out-edges, delete conditions, and keep mode. | +| **mode** | `Enum<'exclusive' \| 'inclusive'>` | optional | Declares how many out-edges an edge-branched decision takes when more than one out-edge condition holds: 'exclusive' = only the first, in the order the edges are declared (what an omitted mode means; the siblings after it are not evaluated and record a skipped step); 'inclusive' = every one that holds, one after another. When none holds the isDefault edge runs either way. Refused beside a non-empty conditions list, which is first-match on its own: delete mode there, or move the branches onto the out-edges, delete conditions, and keep mode. Flows written while every true branch ran keep that behaviour through the os migrate meta --from 17 conversion, which writes mode: inclusive onto every edge-branched decision with two or more conditioned out-edges. | ### Nested Shape: `DecisionConfig.conditions[number]` From d613ec217de4337ddb1118355240204a454425eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:07:08 +0000 Subject: [PATCH 03/11] test(service-automation): the refused-flow pin reads getFlow's null answer Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../decision-overlapping-edge-conditions.pin.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts b/packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts index 3a720f633e0..b73bc00c3d3 100644 --- a/packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts +++ b/packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts @@ -302,7 +302,7 @@ describe('decision edge branching — exclusive by default, inclusive by declara // ── 4. `mode` is judged at registration, with the spec's own sentence ── describe('registration parses `DecisionConfigSchema` and refuses an invalid `mode`', () => { - it('refuses `mode` beside a non-empty `conditions` list — either member — at config.mode, with the refinement sentence', () => { + it('refuses `mode` beside a non-empty `conditions` list — either member — at config.mode, with the refinement sentence', async () => { for (const mode of ['inclusive', 'exclusive']) { const definition = gatewayFlow({ a: '', b: '', @@ -320,12 +320,12 @@ describe('decision edge branching — exclusive by default, inclusive by declara expect(message).toContain('Either delete `mode` and keep the list'); expect(message).toContain('move the branches onto the out-edges'); } - // Refused means never armed. - expect(engine.getFlow('gateway')).toBeUndefined(); + // Refused means never armed (`getFlow` answers `null` for a name it does not hold). + expect(await engine.getFlow('gateway')).toBeNull(); } }); - it('refuses a `mode` outside the closed pair with the value prescription', () => { + it('refuses a `mode` outside the closed pair with the value prescription', async () => { const definition = gatewayFlow({ ...OVERLAP, config: { mode: 'all' } }); expect(() => engine.registerFlow('gateway', definition)).toThrow(MODE_NOT_A_MODE); try { @@ -335,7 +335,7 @@ describe('decision edge branching — exclusive by default, inclusive by declara expect(message).toContain("`mode: 'all'` is not a decision mode"); expect(message).toContain("at config.mode"); } - expect(engine.getFlow('gateway')).toBeUndefined(); + expect(await engine.getFlow('gateway')).toBeNull(); }); it('refuses the same shape inside a loop body, naming the region', () => { From e92edee5b2260fd53332d0ecc86d9008cbda77ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:43:54 +0000 Subject: [PATCH 04/11] fix(lint,spec): export the two new rule ids from the lint barrel; re-anchor the retirement-jurisdiction citation to the docblock that decided it - packages/lint/src/index.ts: FLOW_DECISION_MODE_INVALID and FLOW_DECISION_INCLUSIVE_OVERLAP join the flow-pattern export block (rule-id-barrel-exports pin). - conversions/registry.ts: the retiredFromLoadPath jurisdiction is cited from MetadataConversion's docblock and ADR-0087's 2026-07-31 addendum, not from a tracker number that no longer resolves. - schemaless-node-config.zod.ts: the mode JSDoc no longer spells an omitted-means-every sentence the empty-state gate has to classify. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/lint/src/index.ts | 2 ++ .../spec/src/automation/schemaless-node-config.zod.ts | 5 +++-- packages/spec/src/conversions/registry.ts | 8 +++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 137d4c4a109..c32eda832bf 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -841,6 +841,8 @@ export { FLOW_MULTI_WRITE_UNFILTERED, FLOW_LOOP_BODY_UNCONTAINED, FLOW_TRY_CATCH_WITHOUT_CATCH, + FLOW_DECISION_MODE_INVALID, + FLOW_DECISION_INCLUSIVE_OVERLAP, } from './lint-flow-patterns.js'; export { lintLivenessProperties } from './lint-liveness-properties.js'; diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts index e0be028d4f2..ea2981505eb 100644 --- a/packages/spec/src/automation/schemaless-node-config.zod.ts +++ b/packages/spec/src/automation/schemaless-node-config.zod.ts @@ -543,8 +543,9 @@ export const DecisionConfigSchema = lazySchema(() => strictObject({ .describe('Ordered decision branches (first true expression wins; omit to branch purely on edge conditions)'), /** * How many out-edges an edge-branched decision takes when more than one - * condition holds — `'exclusive'` (the first, in declaration order; what an - * omitted key means) or `'inclusive'` (every one). Read by the engine's + * condition holds: `'exclusive'` is the first, in declaration order, and is + * the reading an absent key gets — the narrower one, a single branch; + * `'inclusive'` is all of them. Read by the engine's * traversal: see "Where `mode` is honoured" above. Any other value is * refused with {@link decisionModePrescription}; either member beside a * non-empty `conditions` list is refused by the `.superRefine` below, with diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index fd1a7902d48..24682e76978 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -11383,9 +11383,11 @@ const reportJoinedChartRemoved: MetadataConversion = { * `--from`. `retiredFromLoadPath: true` keeps it off the authoring funnel * (`normalizeStackInput`), where an author who wrote two branches today, against * the contract that says an omitted `mode` is exclusive, must not be rewritten - * into an inclusive gateway. The flag's jurisdiction ends there (#16864), so - * every data-at-rest seam that opens the retired window has to refuse this id - * by name, on the artifact door's precedent for `app-hidden-to-unpublished` + * into an inclusive gateway. The flag's jurisdiction ends there — its own + * docblock on `MetadataConversion` says so, and ADR-0087's 2026-07-31 addendum + * is why: data-at-rest seams replay retired entries on purpose — so every + * data-at-rest seam that opens the retired window has to refuse this id by + * name, on the artifact door's precedent for `app-hidden-to-unpublished` * (#17885, `DEFAULT_FLIPS_NOT_REPLAYED_HERE` in `@objectstack/metadata-core`): * the automation engine's flow rehydration seam does (`registerFlow` serves * code-shipped flows, REST bodies and Studio saves alike, none of them dated), From 27a1a45985d8ff877c6da75920eebb9206466936 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:46:08 +0000 Subject: [PATCH 05/11] fix(metadata-core): the artifact door refuses `flow-decision-mode-inclusive-explicit` by id (#15429 patch round) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry is a DEFAULT FLIP: an omitted `mode` IS the exclusive gateway by the contract on `DecisionConfigSchema`, so writing `mode: 'inclusive'` is a reinterpretation that is sound only where the source's age is a fact — `os migrate meta --from 17`. The artifact-ingestion door's trigger is the declared `engines.protocol` floor, and `^17.0.0` is what `create-objectstack` stamps, so an app scaffolded today against the exclusive contract lands inside the window and would be handed an inclusive gateway it never asked for. The id joins `DEFAULT_FLIPS_NOT_REPLAYED_HERE` beside the `app-hidden-to-unpublished` precedent, with its reason; the door pin has four legs (subject, strict parse, negative, firing control through the primitive). The engine seam's `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` docblock and the conversion entry's docblock now cite the door precisely (「must」 became 「does」). Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../src/artifact-forward-conversion.test.ts | 104 +++++++++++++++++- .../src/artifact-forward-conversion.ts | 24 +++- .../services/service-automation/src/engine.ts | 15 ++- packages/spec/src/conversions/registry.ts | 10 +- 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index 207575e6717..0a748a28e54 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -17,7 +17,7 @@ */ import { describe, it, expect } from 'vitest'; -import { ObjectStackDefinitionSchema, applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; +import { ObjectStackDefinitionSchema, applyConversions, applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; import { applyArtifactForwardConversions, parseRangeFloor, @@ -401,6 +401,108 @@ describe('the artifact door never turns an authored `hidden: true` into an unpub }); }); +/** + * #15429 — the second member of the DEFAULT-FLIP class this door refuses. + * + * `flow-decision-mode-inclusive-explicit` writes `mode: 'inclusive'` onto an + * edge-branched `decision` with two or more conditioned out-edges, so a flow + * written while every true branch ran keeps that behaviour now that the + * traversal is exclusive. An omitted `mode` IS the exclusive gateway by the + * contract on `DecisionConfigSchema`, so the rewrite reinterprets a legal + * shape — sound only where the source's age is a fact (`os migrate meta + * --from 17`, the operator's assertion). At this door the trigger is the + * artifact's declared `engines.protocol` floor, and `^17.0.0` is what + * `create-objectstack` stamps: an app scaffolded today, authored against the + * exclusive contract, lands inside the window. Replaying the entry here would + * hand it an inclusive gateway it never asked for — the #17885 shape, on a + * key whose omission is the ruled default. + * + * Same four legs as the block above: SUBJECT (window open, no `mode` + * written) · the strict parse the door feeds · NEGATIVE (window shut) · + * FIRING CONTROL (the entry WOULD rewrite this very fixture with the window + * open and no refusal, so the subject leg cannot pass vacuously). + */ +describe('the artifact door never writes `mode: inclusive` onto an authored exclusive decision (#15429)', () => { + /** One edge-branched decision with two conditioned out-edges and no `mode` — the shape the entry rewrites. */ + const twoBranchDecisionDefinition = (protocolRange: string) => ({ + manifest: { + id: 'app.example.leads', name: 'leads', version: '1.0.0', type: 'app', + engines: { protocol: protocolRange }, + }, + flows: [{ + name: 'lead_verdict', + label: 'Lead verdict', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'verdict', type: 'decision', label: 'Verdict?' }, + { id: 'refuse', type: 'end', label: 'Refuse' }, + { id: 'convert', type: 'end', label: 'Convert' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'verdict' }, + { id: 'e2', source: 'verdict', target: 'refuse', condition: "lead.status != 'suspected'", label: 'Refuse' }, + { id: 'e3', source: 'verdict', target: 'convert', condition: "lead.status == 'confirmed'", label: 'Convert' }, + ], + }], + }); + const ID = 'flow-decision-mode-inclusive-explicit'; + const verdictNodeOf = (definition: unknown) => + (definition as { flows: { nodes: { id: string; config?: Record }[] }[] }) + .flows[0]!.nodes.find((n) => n.id === 'verdict')!; + + it('leaves the decision without `mode` on an artifact the retired window IS open for', () => { + const def = twoBranchDecisionDefinition('^17.0.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); + + // ⭐ ANTI-VACUITY: the window really is open on this input. + expect(result.verdict).toBe('converted-forward'); + expect(result.authoredFloor).toBe('17.0.0'); + + const verdict = verdictNodeOf(result.definition); + expect(verdict.config, 'the authored exclusive gateway is untouched').toBeUndefined(); + expect(result.notices.map((n) => n.conversionId)).not.toContain(ID); + // Copy-on-write: nothing was recognized, so the same reference comes back. + expect(result.definition).toBe(def); + }); + + it('registers the decision without `mode` — asserted AFTER the strict parse the door feeds', () => { + const def = twoBranchDecisionDefinition('^17.0.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); + expect(result.verdict).toBe('converted-forward'); + + const parsed = ObjectStackDefinitionSchema.parse(result.definition); + const registered = verdictNodeOf(parsed); + expect(Object.keys(registered.config ?? {}), 'what registration receives').not.toContain('mode'); + }); + + it('floor ^99.0.0 — the window is shut and nothing is replayed at all', () => { + const def = twoBranchDecisionDefinition('^99.0.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' }); + expect(result.verdict).toBe('authored-current'); + expect(result.notices).toEqual([]); + expect(verdictNodeOf(result.definition).config).toBeUndefined(); + }); + + /** + * ⭐ FIRING CONTROL — the entry WOULD rewrite this exact fixture: the same + * bytes through the primitive with the retired window open and no refusal + * come back inclusive, with the entry's own notice. A door that had merely + * stopped recognizing the shape, or a fixture the entry never matched, would + * go green above and prove nothing; this leg is what makes the subject a + * reading of the refusal. + */ + it('FIRING CONTROL — without the refusal, the same fixture IS rewritten to `mode: inclusive`', () => { + const notices: ConversionNotice[] = []; + const rewritten = applyConversions(twoBranchDecisionDefinition('^17.0.0'), { + includeRetired: true, + onNotice: (n) => notices.push(n), + }); + expect(verdictNodeOf(rewritten).config).toEqual({ mode: 'inclusive' }); + expect(notices.map((n) => n.conversionId)).toContain(ID); + }); +}); + describe('parseRangeFloor — the range spellings artifacts actually carry', () => { it.each([ ['^17.1.0', [17, 1, 0]], diff --git a/packages/metadata-core/src/artifact-forward-conversion.ts b/packages/metadata-core/src/artifact-forward-conversion.ts index 309b84443aa..16742f49e72 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.ts @@ -287,8 +287,30 @@ export function resolveInstalledSpecVersion(): string | null { * materialization path, and `os migrate meta --from <=16`, where the * operator asserts the source's age) — this door is neither, so it opts out * rather than the entry ceasing to fire. + * - `flow-decision-mode-inclusive-explicit` (#15429, maintainer ruling + * 「跟主流对齐」): writes `mode: 'inclusive'` onto an edge-branched `decision` + * (no `conditions` list) that has two or more conditioned out-edges, so a + * flow written while every true branch ran keeps that behaviour now that the + * traversal is exclusive (first true edge in declaration order). Both shapes + * are legal and mean different things — an omitted `mode` IS the exclusive + * gateway, by the contract on `DecisionConfigSchema` — so the rewrite is a + * reinterpretation, sound only where "this source predates the flip" is a + * fact. Here it is a guess: the trigger is the artifact's declared + * `engines.protocol` floor, and `^17.0.0` is the range `create-objectstack` + * stamps, so an app scaffolded today, whose author wrote two branches + * against the contract that says an omitted `mode` is exclusive, lands + * inside the window and would be handed an inclusive gateway it never + * asked for — the ruled default made unobservable for every artifact-deployed + * app. The entry is sound at exactly one seam: `os migrate meta --from 17`, + * where the operator asserts the source's age and reviews the diff. The + * automation engine's flow rehydration seam refuses it by id for the same + * reason (`CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` in + * `packages/services/service-automation/src/engine.ts`); this door does too. */ -const DEFAULT_FLIPS_NOT_REPLAYED_HERE: readonly string[] = ['app-hidden-to-unpublished']; +const DEFAULT_FLIPS_NOT_REPLAYED_HERE: readonly string[] = [ + 'app-hidden-to-unpublished', + 'flow-decision-mode-inclusive-explicit', +]; /** * Apply the versioned forward conversion to one compiled-artifact definition. diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index a60861d3985..c61d9cf232b 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -2126,11 +2126,12 @@ export const IN_PROCESS_DISPATCH_CLAIM_TTL_MS = 48 * 60 * 60 * 1000; /** * ADR-0087 conversions the flow rehydration seam refuses to replay, by id — - * the DEFAULT-FLIP class, on the artifact door's precedent - * (`DEFAULT_FLIPS_NOT_REPLAYED_HERE` in `@objectstack/metadata-core`). The - * registry stays the single authority on what converts; this is only this - * seam saying which entries its own evidence cannot carry, and each id owes - * its reason beside it. + * the DEFAULT-FLIP class, the same per-door refusal the artifact-ingestion + * door makes (`DEFAULT_FLIPS_NOT_REPLAYED_HERE` in + * `packages/metadata-core/src/artifact-forward-conversion.ts`, which lists this + * seam's one id beside its own precedent). The registry stays the single + * authority on what converts; this is only this seam saying which entries its + * own evidence cannot carry, and each id owes its reason beside it. * * - `flow-decision-mode-inclusive-explicit` (#15429) writes `mode: 'inclusive'` * onto an edge-branched `decision` with two or more conditioned out-edges, @@ -2145,7 +2146,9 @@ export const IN_PROCESS_DISPATCH_CLAIM_TTL_MS = 48 * 60 * 60 * 1000; * new exclusive decision into an inclusive one at registration and persist * that at save, and the ruled default would be unobservable. The entry * replays where the age IS asserted: `os migrate meta --from 17`, by the - * operator, over authored sources — never at a load seam. + * operator, over authored sources — never at a load seam. The artifact + * door refuses it for the same reason (its declared `^17.0.0` floor is a + * dependency range, not an age), in the module named above. */ const CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION: readonly string[] = [ 'flow-decision-mode-inclusive-explicit', diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 24682e76978..c56e8796366 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -11389,10 +11389,12 @@ const reportJoinedChartRemoved: MetadataConversion = { * data-at-rest seam that opens the retired window has to refuse this id by * name, on the artifact door's precedent for `app-hidden-to-unpublished` * (#17885, `DEFAULT_FLIPS_NOT_REPLAYED_HERE` in `@objectstack/metadata-core`): - * the automation engine's flow rehydration seam does (`registerFlow` serves - * code-shipped flows, REST bodies and Studio saves alike, none of them dated), - * and the artifact-ingestion door must (a scaffolded `^17.0.0` floor is a - * dependency range, not an age). ⛔ Not replayed over stored `sys_metadata` + * the automation engine's flow rehydration seam does + * (`CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` in `service-automation/src/engine.ts`: + * `registerFlow` serves code-shipped flows, REST bodies and Studio saves alike, + * none of them dated), and the artifact-ingestion door does + * (`DEFAULT_FLIPS_NOT_REPLAYED_HERE` in `metadata-core/src/artifact-forward-conversion.ts`: + * a scaffolded `^17.0.0` floor is a dependency range, not an age). ⛔ Not replayed over stored `sys_metadata` * flows by `os migrate meta --stored` either — that pass canonicalizes through * the same engine seam, and an operator-asserted rewrite of stored rows is a * separate plumbing, named by the D3 entry as the judgment still owed. From 6bc84ba59199744b671cc6e48e584308fdbdee21 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:39:28 +0000 Subject: [PATCH 06/11] fix(spec): the D3 entry carries the house `os migrate meta --from 17` sentence the repo pin requires (#15429 CI fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI `Test Core (1/6)` ran `packages/spec` `test:repo` and the repo-project pin `src/shared/retired-key-migrate-sentence.test.ts` refused two sites in `migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts`: the `reason` prose quoted the command mid-sentence ("the diff `os migrate meta --from 17` prints is where…") and `acceptanceCriteria` opened with a bespoke "Run `os migrate meta --from 17` over each authored stack…" — neither is the house sentence the pin requires as the LAST sentence of any literal that names the command, and the pin's anti-vacuity case turned red with it. - `reason` no longer names the command (the chain replay's edit list is where the judgment is made); `acceptanceCriteria` now ends with the house sentence "Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand." and opens with the review list instead. - `migrations/registry.ts` regenerated from the entry (`gen:migration-registry`; 298 semantic, 217 retired-key, 199 retired-def — counts unchanged). - `content/docs/automation/flows.mdx` upgrade callout reworded to the same house sentence so the docs and the entry read identically. Stored-row sentences in the entry are untouched. Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- content/docs/automation/flows.mdx | 15 ++++++++------- ...18.flow-decision-edge-branching-first-match.ts | 11 ++++++----- packages/spec/src/migrations/registry.ts | 11 ++++++----- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index d99a9f38d46..618193439dd 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -1407,14 +1407,15 @@ shape in which more than one branch can run for one record. **Upgrading a flow written before protocol 18.** An edge-branched decision used -to take every out-edge whose condition held. `os migrate meta --from 17` writes -`mode: 'inclusive'` onto every decision with two or more conditioned out-edges -and no `mode` (the ADR-0087 conversion `flow-decision-mode-inclusive-explicit`), -so the migrated flow runs exactly as before; delete the key where the two -conditions partition (`== 'a'` beside `!= 'a'`, `>` beside `<=`), which is the -common case and the honest declaration. Nothing rewrites a flow at load: an +to take every out-edge whose condition held. The ADR-0087 conversion +`flow-decision-mode-inclusive-explicit` writes `mode: 'inclusive'` onto every +decision with two or more conditioned out-edges and no `mode` when the chain is +replayed, so the migrated flow runs exactly as before; delete the key where the +two conditions partition (`== 'a'` beside `!= 'a'`, `>` beside `<=`), which is +the common case and the honest declaration. Nothing rewrites a flow at load: an author who writes two branches today gets the exclusive gateway the contract -describes. +describes. Run `os migrate meta --from 17` to list the mechanical edits for +existing sources; apply them by hand. **Branch on the node** (Salesforce-style decision outcomes): the node declares diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts b/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts index 4509bec6b57..f9520007796 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts @@ -45,8 +45,8 @@ export const entry: SemanticMigration = { + 'the migrated source runs exactly as before. What does NOT convert is the INTENT: the ' + 'count cannot tell a partition (where the key is redundant) from a reliance on ' + 'multi-branch runs (where it is load-bearing) from an accidental overlap (where the ' - + 'old behaviour was the bug), so the diff `os migrate meta --from 17` prints is where ' - + 'that judgment is made, node by node. And the conversion replays ONLY there: it is a ' + + 'old behaviour was the bug), so the mechanical edit list the chain replay prints is ' + + 'where that judgment is made, node by node. And the conversion replays ONLY there: it is a ' + 'default flip, so the authoring funnel never rewrites a source written against the ' + 'new contract, the automation engine\'s flow rehydration seam refuses it by id (a ' + 'code-shipped flow, a REST body and a Studio save all arrive there undated), and the ' @@ -54,8 +54,8 @@ export const entry: SemanticMigration = { + 'a decision saved from the Studio BEFORE this release, with two or more conditioned ' + 'out-edges and no `mode`, now runs first-match and is rewritten by nothing.', acceptanceCriteria: - 'Run `os migrate meta --from 17` over each authored stack and review every ' - + '`flow-decision-mode-inclusive-explicit` line in its diff: (1) where the two (or more) ' + 'Review every `flow-decision-mode-inclusive-explicit` line the chain replay lists for ' + + 'each authored stack: (1) where the two (or more) ' + 'out-edge conditions partition — a predicate and its negation, `>` beside `<=`, or a ' + 'guard beside `isDefault: true` — delete the written `mode`; the run is unchanged either ' + 'way and the exclusive default is the honest declaration; (2) where the flow relies on ' @@ -71,5 +71,6 @@ export const entry: SemanticMigration = { + 'the Studio designer by the same three-way judgment. A decision registering with ' + '`mode` beside a non-empty `conditions` list, or with a `mode` outside ' + '`\'exclusive\' | \'inclusive\'`, is refused at registration and by `os validate` with the ' - + 'schema\'s own sentence; nothing else about `conditions`-list decisions changes.', + + 'schema\'s own sentence; nothing else about `conditions`-list decisions changes. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index bdb89857535..6775f0535bc 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10299,8 +10299,8 @@ const step18: MigrationStep = { + 'the migrated source runs exactly as before. What does NOT convert is the INTENT: the ' + 'count cannot tell a partition (where the key is redundant) from a reliance on ' + 'multi-branch runs (where it is load-bearing) from an accidental overlap (where the ' - + 'old behaviour was the bug), so the diff `os migrate meta --from 17` prints is where ' - + 'that judgment is made, node by node. And the conversion replays ONLY there: it is a ' + + 'old behaviour was the bug), so the mechanical edit list the chain replay prints is ' + + 'where that judgment is made, node by node. And the conversion replays ONLY there: it is a ' + 'default flip, so the authoring funnel never rewrites a source written against the ' + 'new contract, the automation engine\'s flow rehydration seam refuses it by id (a ' + 'code-shipped flow, a REST body and a Studio save all arrive there undated), and the ' @@ -10308,8 +10308,8 @@ const step18: MigrationStep = { + 'a decision saved from the Studio BEFORE this release, with two or more conditioned ' + 'out-edges and no `mode`, now runs first-match and is rewritten by nothing.', acceptanceCriteria: - 'Run `os migrate meta --from 17` over each authored stack and review every ' - + '`flow-decision-mode-inclusive-explicit` line in its diff: (1) where the two (or more) ' + 'Review every `flow-decision-mode-inclusive-explicit` line the chain replay lists for ' + + 'each authored stack: (1) where the two (or more) ' + 'out-edge conditions partition — a predicate and its negation, `>` beside `<=`, or a ' + 'guard beside `isDefault: true` — delete the written `mode`; the run is unchanged either ' + 'way and the exclusive default is the honest declaration; (2) where the flow relies on ' @@ -10325,7 +10325,8 @@ const step18: MigrationStep = { + 'the Studio designer by the same three-way judgment. A decision registering with ' + '`mode` beside a non-empty `conditions` list, or with a `mode` outside ' + '`\'exclusive\' | \'inclusive\'`, is refused at registration and by `os validate` with the ' - + 'schema\'s own sentence; nothing else about `conditions`-list decisions changes.', + + 'schema\'s own sentence; nothing else about `conditions`-list decisions changes. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', }, // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code // span already, and a nested backtick would close it. From 62771d8e5bf7ea43f607d1c17ec055c3b4440a09 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:35:02 +0000 Subject: [PATCH 07/11] chore(spec): regenerate the schemaless-node-config reference on the merged tree Discharges the merge's os-regen deferral. The driver kept this branch's side of the generated reference; regenerated from the merged sources (`pnpm --filter @objectstack/spec build && gen:docs`), it carries this branch's decision-mode prose AND main's derived frontmatter description. `gen:migration-registry` over the merged entries was a no-op (303 semantic, 221 retired-key, 199 retired-def), so the textual merge of its generated regions was already exact. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- content/docs/references/automation/schemaless-node-config.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/automation/schemaless-node-config.mdx b/content/docs/references/automation/schemaless-node-config.mdx index 2940819fbb7..32bbef0f8d0 100644 --- a/content/docs/references/automation/schemaless-node-config.mdx +++ b/content/docs/references/automation/schemaless-node-config.mdx @@ -1,6 +1,6 @@ --- title: Schemaless Node Config -description: Schemaless Node Config protocol schemas +description: "Config contracts for the descriptor-schemaless builtins whose designer form lives ONLY in objectui's hand-written FLOW_NODE_CONFIG table — script." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} From a347db3f7b6369ba063c4c7570f9632f23ca6c86 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:37:36 +0000 Subject: [PATCH 08/11] =?UTF-8?q?docs(spec,automation):=20ruling=20C=20?= =?UTF-8?q?=E2=80=94=20a=20stored=20decision=20takes=20first-match=20on=20?= =?UTF-8?q?upgrade=20(BREAKING),=20named=20with=20its=20one-line=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling letter C on #15429 narrows the first ruling's "shipped flows keep their behaviour" to the surfaces that can carry it: authored sources (`os migrate meta --from 17`) and built artifacts. A decision stored in `sys_metadata` with no `config.conditions`, no `mode` and two or more conditioned out-edges evaluates first-match after the upgrade; no stored-row rewrite, no cutoff, no read-path completion. - D3 entry `flow-decision-edge-branching-first-match`: the "rewritten by nothing" reason tail and the "half no command reaches" acceptance sentence are replaced by the ruling — BREAKING for stored rows, the shape, the `mode: 'inclusive'` fix, and the `--stored` review list. The upgrade guide renders this entry (reason = "Why not automatic", acceptanceCriteria = "Done when") once protocol 18 is cut. - step18 rationale (hand-written, same registry file): one BREAKING sentence for stored flows; the artifact door named beside the seam. - D2 docblock: "the judgment still owed" replaced by the ruling, and the review list's reuse of this entry's `apply` stated. - Changeset: the stored-row paragraph becomes a BREAKING section naming the shape, the fix and the listing; `@objectstack/metadata-protocol` (minor, the report widens) and `@objectstack/metadata-core` (patch, the artifact door's refusal from the previous round) join the bump list. - `DecisionConfigSchema.mode` describe + docblock and the engine's traversal comment say "authored sources" where they said "flows", and name the stored-row reading; flows.mdx gains the stored-flow callout. Generated artifacts (registry regions, reference mdx) follow in their own commit. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- ...429-decision-edge-branching-first-match.md | 35 ++++++++++++++----- content/docs/automation/flows.mdx | 12 +++++++ .../services/service-automation/src/engine.ts | 14 +++++--- .../automation/schemaless-node-config.zod.ts | 26 ++++++++------ packages/spec/src/conversions/registry.ts | 11 ++++-- ...low-decision-edge-branching-first-match.ts | 34 +++++++++++------- packages/spec/src/migrations/registry.ts | 8 +++-- 7 files changed, 99 insertions(+), 41 deletions(-) diff --git a/.changeset/15429-decision-edge-branching-first-match.md b/.changeset/15429-decision-edge-branching-first-match.md index 3ef2b190d53..e0f19ecd4e7 100644 --- a/.changeset/15429-decision-edge-branching-first-match.md +++ b/.changeset/15429-decision-edge-branching-first-match.md @@ -2,6 +2,8 @@ "@objectstack/spec": minor "@objectstack/service-automation": minor "@objectstack/lint": minor +"@objectstack/metadata-protocol": minor +"@objectstack/metadata-core": patch --- feat(automation)!: an edge-branched `decision` is exclusive — the first out-edge whose condition holds, in declaration order, wins; `mode: 'inclusive'` takes every one (#15429) @@ -48,14 +50,31 @@ conditions into a partition and delete the key. `os validate` reports `flow-decision-inclusive-overlap` on every decision that keeps the key with two or more conditioned out-edges, so the review list is the lint output. -⚠️ **The conversion replays only where the operator asserts the source's age.** It is a default -flip — the old shape still parses and now means exclusive — so the authoring funnel never -rewrites a source written against this contract, the automation engine's flow rehydration seam -refuses it by id (a code-shipped flow, a REST body and a Studio save all arrive there undated), -and `os migrate meta --stored` canonicalizes through that same seam. A flow stored in -`sys_metadata` from the Studio before this release, with two or more conditioned out-edges and -no `mode`, now runs first-match and is rewritten by nothing: list those rows and declare `mode` -on each in the designer. +## BREAKING for flows stored in `sys_metadata` — maintainer ruling letter C on #15429 + +A `decision` node stored in `sys_metadata` (a flow built or edited in the Studio designer) with +**no `config.conditions`, no `mode`, and two or more out-edges carrying a `condition`** evaluates +**first-match** after this upgrade: where it took every out-edge whose condition held, it now takes +only the first one that holds, in the order the flow declares its edges. Nothing rewrites that row +— no stored-row migration, no cutoff, no read-path completion — because nothing about a stored row +says it was saved before the flip. The one-line fix, for a node that meant every branch: + +```ts +{ id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } } +``` + +`os migrate meta --stored` (and `POST /api/v1/meta/_migrate-stored`) lists every such node under +`decisionModeReview` — flow row, node id, label and path — on a preview and an `--apply` run +alike, and writes nothing for it: the list moves no row outcome, no count and no exit code, so an +operator can review the candidates before and after the upgrade. A node leaves the list once it +declares `mode`, either member. Every such node in the measured corpus below is a partition, where +the new meaning runs exactly what the old one did. + +Authored sources and built artifacts keep the old behaviour instead, where the source's age is a +fact: `os migrate meta --from 17` writes `mode: 'inclusive'` (above), while the authoring funnel, +the automation engine's flow rehydration seam and the artifact-ingestion door all refuse the +conversion by id — a default flip replayed there would turn a decision written today against this +contract, where an omitted `mode` means exclusive, into an inclusive gateway. ## Reach, measured at landing diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index e2fd1ef1dd2..5fa10b1e134 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -1431,6 +1431,18 @@ describes. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + +**A flow stored from the Studio takes the new meaning (breaking).** A decision +saved in `sys_metadata` before protocol 18 — no `config.conditions`, no `mode`, +two or more out-edges with a `condition` — runs **first-match** after the +upgrade, and nothing rewrites the stored row: nothing about a row says it was +saved before the change. `os migrate meta --stored` lists every such node (flow, +node id, label and path) on a preview and an `--apply` run alike and writes +nothing for it, so you can review them before and after upgrading. Where a node +meant every branch, declare `config: { mode: 'inclusive' }` on it; declaring +`mode` either way takes it off the list. + + **Branch on the node** (Salesforce-style decision outcomes): the node declares `config.conditions[]` and traversal restricts itself to the out-edge whose `label` matches the first matching entry. diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index c61d9cf232b..daac6de0730 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -10360,11 +10360,15 @@ export class AutomationEngine implements IAutomationService { // does, so the run log says which branch won and which were passed // over. Until this change every true edge ran, one after another, under // a comment calling that "mutually exclusive": hotcrm#1555 rendered a - // refusal screen AND ran the conversion in one execution. Flows written - // against that behaviour are carried across by the ADR-0087 conversion - // `flow-decision-mode-inclusive-explicit` (`os migrate meta --from 17`), - // which writes the inclusive declaration onto them — see - // `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` for why this seam does not. + // refusal screen AND ran the conversion in one execution. Authored + // sources written against that behaviour are carried across by the + // ADR-0087 conversion `flow-decision-mode-inclusive-explicit` (`os + // migrate meta --from 17`), which writes the inclusive declaration onto + // them — see `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` for why this seam + // does not. A flow stored in `sys_metadata` arrives here as stored and + // takes THIS reading (maintainer ruling letter C on #15429: no + // stored-row rewrite, no read-path completion); `os migrate meta + // --stored` lists such decisions for an operator to review. // // `mode: 'inclusive'` is the BPMN inclusive gateway: every edge whose // condition holds runs, one successor at a time (never `Promise.all` — diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts index ea2981505eb..bbae9357f35 100644 --- a/packages/spec/src/automation/schemaless-node-config.zod.ts +++ b/packages/spec/src/automation/schemaless-node-config.zod.ts @@ -520,14 +520,17 @@ export type DecisionCondition = z.input; * conditioned out-edges, because that is the shape in which more than one * branch can run for one record. * - * Flows written while every true branch ran keep their behaviour through the - * ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit`: `os migrate - * meta --from 17` writes `mode: 'inclusive'` onto every edge-branched decision - * with two or more conditioned out-edges, and the author deletes it where the - * branches partition. It is a default flip, so no load seam replays it — the - * authoring funnel is where a source written against THIS contract arrives, - * and the flow rehydration seam cannot date a body — which is why the key has - * to be written into the source, once, by the operator's own command. + * Authored sources written while every true branch ran keep their behaviour + * through the ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit`: + * `os migrate meta --from 17` writes `mode: 'inclusive'` onto every + * edge-branched decision with two or more conditioned out-edges, and the author + * deletes it where the branches partition. It is a default flip, so no load + * seam replays it — the authoring funnel is where a source written against THIS + * contract arrives, and the flow rehydration seam cannot date a body — which is + * why the key has to be written into the source, once, by the operator's own + * command. A flow STORED in `sys_metadata` is not rewritten at all (maintainer + * ruling letter C on #15429): it takes the first-match meaning on upgrade, and + * `os migrate meta --stored` lists each such decision for review. * * The legacy singular `config.condition` is a structural surface the engine * parse-validates on every node at registration but the decision executor never @@ -561,9 +564,10 @@ export const DecisionConfigSchema = lazySchema(() => strictObject({ + "siblings after it are not evaluated and record a skipped step); 'inclusive' = every one that holds, one " + 'after another. When none holds the isDefault edge runs either way. Refused beside a non-empty conditions ' + 'list, which is first-match on its own: delete mode there, or move the branches onto the out-edges, delete ' - + 'conditions, and keep mode. Flows written while every true branch ran keep that behaviour through the ' - + 'os migrate meta --from 17 conversion, which writes mode: inclusive onto every edge-branched decision with ' - + 'two or more conditioned out-edges.', + + 'conditions, and keep mode. Authored sources written while every true branch ran keep that behaviour ' + + 'through the os migrate meta --from 17 conversion, which writes mode: inclusive onto every edge-branched ' + + 'decision with two or more conditioned out-edges; a flow stored in sys_metadata is not rewritten and takes ' + + 'the first-match reading on upgrade (os migrate meta --stored lists those decisions).', ), }).superRefine((config, ctx) => { // Ruling 5856786357 on #20168 (letter A): `mode` belongs to the diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 0b303c461ae..fc5f6f23ef0 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -11770,9 +11770,14 @@ const permissionRlsTagsRemoved: MetadataConversion = { * none of them dated), and the artifact-ingestion door does * (`DEFAULT_FLIPS_NOT_REPLAYED_HERE` in `metadata-core/src/artifact-forward-conversion.ts`: * a scaffolded `^17.0.0` floor is a dependency range, not an age). ⛔ Not replayed over stored `sys_metadata` - * flows by `os migrate meta --stored` either — that pass canonicalizes through - * the same engine seam, and an operator-asserted rewrite of stored rows is a - * separate plumbing, named by the D3 entry as the judgment still owed. + * flows either, by maintainer ruling (letter C on #15429): a stored row takes + * the first-match meaning on upgrade — BREAKING, stated in the D3 entry and the + * changeset with the one-line fix `mode: 'inclusive'` — with no stored-row + * rewrite, no cutoff and no read-path completion. `os migrate meta --stored` + * runs this entry's `apply` over each stored flow body only to LIST the nodes + * it would write (`collectDecisionModeReview` in + * `@objectstack/metadata-protocol`), discarding the result, so the review list + * and this predicate are one and the same. */ const flowDecisionModeInclusiveExplicit: MetadataConversion = { id: 'flow-decision-mode-inclusive-explicit', diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts b/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts index f9520007796..fe97c792c52 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-decision-edge-branching-first-match.ts @@ -12,8 +12,11 @@ // behaviour — mechanically, by the count, with no inference over the // conditions. This SEMANTIC entry is the judgment that count cannot make: on // most such decisions the branches partition and the written key changes -// nothing, on the hotcrm#1555 shape it preserves a multi-branch run the author -// never meant, and on a stored Studio flow no load seam writes it at all. +// nothing, and on the hotcrm#1555 shape it preserves a multi-branch run the +// author never meant. A flow STORED in `sys_metadata` is outside the +// conversion by maintainer ruling (letter C on #15429): it takes the +// first-match meaning on upgrade, BREAKING, and `os migrate meta --stored` +// lists its candidates for review without writing them. import type { SemanticMigration } from '../../types.js'; export const entry: SemanticMigration = { @@ -48,11 +51,17 @@ export const entry: SemanticMigration = { + 'old behaviour was the bug), so the mechanical edit list the chain replay prints is ' + 'where that judgment is made, node by node. And the conversion replays ONLY there: it is a ' + 'default flip, so the authoring funnel never rewrites a source written against the ' - + 'new contract, the automation engine\'s flow rehydration seam refuses it by id (a ' - + 'code-shipped flow, a REST body and a Studio save all arrive there undated), and the ' - + 'stored-row pass (`os migrate meta --stored`) canonicalizes through that same seam — so ' - + 'a decision saved from the Studio BEFORE this release, with two or more conditioned ' - + 'out-edges and no `mode`, now runs first-match and is rewritten by nothing.', + + 'new contract, and the automation engine\'s flow rehydration seam and the ' + + 'artifact-ingestion door both refuse it by id (a code-shipped flow, a REST body, a Studio ' + + 'save and a scaffolded artifact all arrive undated). BREAKING for stored rows, by ' + + 'maintainer ruling: the promise that a flow keeps its behaviour is kept by authored ' + + 'sources and built artifacts only. A decision stored in `sys_metadata` ' + + 'with no `conditions` list, no `mode` and two or more conditioned out-edges takes the new ' + + 'meaning on upgrade — it evaluates first-match — and nothing rewrites the row: no ' + + 'stored-row migration, no cutoff, no read-path completion, because nothing about a stored ' + + 'row says it was saved before the flip. The one-line fix, for a stored node that meant ' + + 'every branch, is `mode: \'inclusive\'`; `os migrate meta --stored` lists every such node, ' + + 'report only, so an operator can review the candidates before and after the upgrade.', acceptanceCriteria: 'Review every `flow-decision-mode-inclusive-explicit` line the chain replay lists for ' + 'each authored stack: (1) where the two (or more) ' @@ -64,11 +73,12 @@ export const entry: SemanticMigration = { + 're-run the flow on a record that satisfied both and confirm exactly one successor ' + 'ran — the passed-over branch now leaves a `skipped` step in the run log. `os validate` ' + 'reports `flow-decision-inclusive-overlap` on every decision that keeps the key with ' - + 'two or more conditioned out-edges, so the review list is the lint output. Then the ' - + 'half no command reaches: list the `sys_metadata` flow rows of each deployment whose ' - + 'decision nodes carry two or more conditioned out-edges and no `mode` — the stored ' - + 'pass reports these rows canonical and rewrites nothing — and declare `mode` on each in ' - + 'the Studio designer by the same three-way judgment. A decision registering with ' + + 'two or more conditioned out-edges, so the review list is the lint output. Then each ' + + 'deployment: `os migrate meta --stored` lists, under `decisionModeReview`, every stored ' + + 'decision with two or more conditioned out-edges and no `mode` — each one already ' + + 'evaluates first-match, and the pass writes none of them — so where one of those nodes ' + + 'meant every branch, declare `mode: \'inclusive\'` on it in the designer; a node ' + + 'that declares `mode` either way leaves the list. A decision registering with ' + '`mode` beside a non-empty `conditions` list, or with a `mode` outside ' + '`\'exclusive\' | \'inclusive\'`, is refused at registration and by `os validate` with the ' + 'schema\'s own sentence; nothing else about `conditions`-list decisions changes. ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3016716f136..8f5afe9df64 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5421,8 +5421,12 @@ const step18: MigrationStep = { + 'conversion `flow-decision-mode-inclusive-explicit` writes that key onto every decision ' + 'with two or more conditioned out-edges and no `conditions` list, so a flow written while ' + 'every true branch ran keeps its behaviour; it is a default flip, so it is retired from ' - + 'the load path AND refused by the flow rehydration seam, and replays only here — the ' - + 'paired semantic entry carries the judgment the diff then asks for.', + + 'the load path AND refused by the flow rehydration seam and the artifact-ingestion door, ' + + 'and replays only here — the paired semantic entry carries the judgment the diff then ' + + 'asks for. BREAKING for flows stored in `sys_metadata`, by maintainer ruling: such a ' + + 'decision with no `mode` takes the first-match meaning on upgrade and nothing rewrites ' + + 'it; `os migrate meta --stored` lists each one for review, and `mode: \'inclusive\'` is ' + + 'the one-line fix where a node meant every branch.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', From 43d357ab10a231f14193116d028c714c9a13ac2a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:37:53 +0000 Subject: [PATCH 09/11] =?UTF-8?q?feat(metadata-protocol):=20`os=20migrate?= =?UTF-8?q?=20meta=20--stored`=20lists=20every=20stored=20decision=20that?= =?UTF-8?q?=20takes=20first-match=20since=20protocol=2018=20=E2=80=94=20re?= =?UTF-8?q?port=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling C's execution parameter: `--stored` lists, with no `--apply` effect, every stored decision node with two or more conditioned out-edges and no `mode`, so an operator can review candidates before and after the upgrade. - `StoredMigrationReport.decisionModeReview: StoredDecisionModeReview[]` (row id, flow name, org, package, state, node id, label, path). The report type lives in this package, not in `packages/spec` (the spec's own note at `api/protocol.zod.ts` says so), so the widening is this package's exported type, covered by `Clause-②: yes`. - `collectDecisionModeReview(body)` runs the D2 entry `flow-decision-mode-inclusive-explicit`'s own `apply` over the stored body and keeps only the paths it would write, discarding the result: one predicate, so the list is exactly what `--from 17` rewrites in a source, regions included. It reads the STORED body before and apart from the flow canonicalizer, so it needs no engine: a host with no automation service (flow row `skipped`) still lists. It throws if the entry leaves the registry, rather than reporting an empty list. - `migrateStoredMetadata` fills it for every flow row, preview and apply alike; it moves no outcome, count, verdict or write. - `formatStoredMigrationReport` prints the list with the one-line fix, beside the on-protocol verdict. The CLI renders through this function and spreads the report into `--json`, and `POST /meta/_migrate-stored` answers the same object, so no CLI edit is needed. - `cli.mdx`'s `--stored` section documents the list. - Pins in `protocol.stored-migration.test.ts`: listed and canonical; `--apply` changes nothing (bytes, history); a row rewritten for another conversion persists no `mode`; no engine still lists; region path; controls (declared `mode` either way, one edge plus default, `conditions` list, `fault` edge); one-predicate parity with the `--from 17` chain; renderer. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 12 + packages/metadata-protocol/src/index.ts | 1 + .../src/protocol.stored-migration.test.ts | 226 +++++++++++++++++- packages/metadata-protocol/src/protocol.ts | 39 +++ .../metadata-protocol/src/stored-migration.ts | 156 +++++++++++- 5 files changed, 432 insertions(+), 2 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 69d875e7b7d..cc9dd12178b 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1203,6 +1203,18 @@ can produce, because both supply a live one. | A flow whose rename the conflict guard refused | The old node-type token is a live name something else owns here. Rewriting would clobber that owner, so the row fails loudly naming the token — never a silent skip | | A site the conversion chain leaves as stored because no lossless rewrite exists — above all a page filter carrying `$and` / `$or` / `$not` | Flattening a combinator changes which rows the page selects, so it is never done. Each site is printed as a `TODO` line under its row — path, block, and what blocks the rewrite — whatever the row's outcome; a row with nothing but TODOs is reported `skipped`. It does not fail the run, since no run of this pass can clear it: rewrite each site by hand | +**One thing it lists and never writes: decision nodes that changed meaning at +protocol 18.** A stored `decision` with no `config.conditions`, no `mode` and two +or more out-edges carrying a `condition` took every out-edge whose condition held +before protocol 18, and takes only the first one now. A stored row keeps that new +meaning — the conversion that writes `mode: 'inclusive'` replays over authored +sources only, where you assert the source's age, and nothing asserts a row's — so +the report lists each such node under `decisionModeReview` (flow row, node id, +label and path) for you to review before and after the upgrade, on a preview and +an `--apply` run alike. The list changes no row, no count and no exit code. Where +a node meant every branch, declare `mode: 'inclusive'` on it; declaring `mode` +either way takes it off the list. + **Flows are covered, and cost one extra plugin.** Flow-node conversions carry an open-namespace conflict guard that has to consult the *live* executor registry to tell a rename from a clobber, so this run boots the automation engine — in an diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 294b1be9ad5..ca7e60d48cd 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -159,6 +159,7 @@ export type { export { formatStoredMigrationReport, storedMigrationClean } from './stored-migration.js'; export type { + StoredDecisionModeReview, StoredFlowCanonicalization, StoredMigrationNotice, StoredMigrationOutcome, diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index 8329120bb1a..bc12317fcd8 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -26,8 +26,14 @@ import { describe, expect, it } from 'vitest'; // of this package's (file, verb) pairs sat in the gate's DEBT ledger until // #5619 sank the two predicates into a package both sides already depend on. import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { applyMetaMigrations } from '@objectstack/spec'; import { ObjectStackProtocolImplementation } from './protocol.js'; -import { formatStoredMigrationReport, storedMigrationClean } from './stored-migration.js'; +import { + DECISION_MODE_REVIEW_CONVERSION_ID, + collectDecisionModeReview, + formatStoredMigrationReport, + storedMigrationClean, +} from './stored-migration.js'; interface Row { id: string; @@ -803,3 +809,221 @@ describe('migrateStoredMetadata — a site the chain leaves as stored is a TODO, expect(again.canonical).toBe(0); }); }); + +describe('migrateStoredMetadata — the decision review list: stored rows take first-match, and are LISTED, never rewritten (#15429, ruling C)', () => { + // Maintainer ruling letter C on #15429: a stored decision with no + // `conditions` list, no `mode` and two or more conditioned out-edges takes + // the protocol-18 meaning (first match) on upgrade — no stored-row rewrite, + // no cutoff, no read-path completion — and `os migrate meta --stored` lists + // every such node, report only, so an operator can review candidates + // before and after the upgrade. + // + // The hotcrm#1555 shape: both predicates hold for a confirmed lead. + const OVERLAP = { a: "lead.status != 'suspected'", b: "lead.status == 'confirmed'" }; + const gatewayBody = (name: string, opts: { + config?: Record; + b?: string; + bType?: string; + withDefault?: boolean; + withLegacyPurge?: boolean; + } = {}) => ({ + name, + label: 'Lead Verdict', + type: 'autolaunched', + status: 'active', + nodes: [ + // A second, unrelated pre-protocol shape the canonicalizer rewrites, + // so an apply run DOES write this row — and must still write no `mode`. + ...(opts.withLegacyPurge + ? [{ id: 'purge', type: 'delete_record', label: 'Purge', config: { objectName: 'lead', filters: { status: 'stale' } } }] + : []), + { id: 'check', type: 'decision', label: 'Verdict?', ...(opts.config ? { config: opts.config } : {}) }, + { id: 'refuse', type: 'end', label: 'Refuse' }, + { id: 'convert', type: 'end', label: 'Convert' }, + ...(opts.withDefault ? [{ id: 'fallback', type: 'end', label: 'Fallback' }] : []), + ], + edges: [ + { id: 'e_refuse', source: 'check', target: 'refuse', condition: OVERLAP.a }, + { + id: 'e_convert', source: 'check', target: 'convert', + ...(opts.bType ? { type: opts.bType } : {}), + ...(opts.b === '' ? {} : { condition: opts.b ?? OVERLAP.b }), + }, + ...(opts.withDefault ? [{ id: 'e_fallback', source: 'check', target: 'fallback', isDefault: true }] : []), + ], + }); + const flowRow = (name: string, body: unknown, extra: Record = {}) => + ({ type: 'flow', name, metadata: body, ...extra }); + /** + * Stands in for `AutomationEngine.canonicalizeStoredFlow`, and answers what + * the real seam answers for these bodies: it refuses the D2 entry by id, so + * a two-branch decision comes back WITHOUT `mode`. It rewrites only the + * legacy `filters` alias, so a row carrying one is a real rewrite. + */ + const canonicalizeFlow = (_name: string, body: any) => { + const purge = body?.nodes?.find((n: any) => n.id === 'purge'); + if (!purge || !('filters' in (purge.config ?? {}))) return { storable: body, notices: [], conflicts: [] }; + return { + storable: { + ...body, + nodes: body.nodes.map((n: any) => (n === purge + ? { ...n, config: { objectName: 'lead', filter: purge.config.filters } } + : n)), + }, + notices: [{ + conversionId: 'flow-node-crud-filter-alias', + surface: 'flow.node.config.filter', + from: 'filters', + to: 'filter', + path: 'flows[0].nodes[0].config', + message: 'filters → filter', + }], + conflicts: [], + }; + }; + + it('a stored two-branch decision with no `mode` is LISTED — row, flow, node, label and path — and the row is canonical', async () => { + const { engine, tables } = makeStubEngine([flowRow('lead_verdict', gatewayBody('lead_verdict'), { organization_id: 'org_1' })]); + const before = JSON.stringify(metaRows(tables)); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ canonicalizeFlow }); + + expect(report.decisionModeReview).toEqual([{ + id: metaRows(tables)[0]!.id, + name: 'lead_verdict', + organizationId: 'org_1', + packageId: null, + state: 'active', + nodeId: 'check', + nodeLabel: 'Verdict?', + path: 'nodes[0]', + }]); + // ON protocol: listed, not pending — the list moves no count and no verdict. + expect(report).toMatchObject({ scanned: 1, canonical: 1, pending: 0, skipped: 0, failed: 0, rows: [] }); + expect(storedMigrationClean(report)).toBe(true); + expect(JSON.stringify(metaRows(tables))).toBe(before); + }); + + it('`--apply` changes nothing for it: no rewrite, no history row, the stored bytes identical — and the list is the same', async () => { + const { engine, tables } = makeStubEngine([flowRow('lead_verdict', gatewayBody('lead_verdict'))]); + const before = JSON.stringify(metaRows(tables)); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true, canonicalizeFlow }); + + expect(report.decisionModeReview.map((r) => `${r.name}:${r.nodeId}`)).toEqual(['lead_verdict:check']); + expect(report).toMatchObject({ canonical: 1, rewritten: 0, failed: 0 }); + expect(historyRows(tables)).toHaveLength(0); + expect(JSON.stringify(metaRows(tables))).toBe(before); + expect(JSON.parse(metaRows(tables)[0]!.metadata).nodes[0]).not.toHaveProperty('config'); + }); + + it('a row `--apply` DOES rewrite for another conversion persists no `mode` — the list never rides into the write', async () => { + const { engine, tables } = makeStubEngine([flowRow('lead_verdict', gatewayBody('lead_verdict', { withLegacyPurge: true }))]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true, canonicalizeFlow }); + + expect(report.rewritten).toBe(1); + expect(report.rows[0]!.notices.map((n) => n.conversionId)).toEqual(['flow-node-crud-filter-alias']); + const stored = JSON.parse(metaRows(tables)[0]!.metadata); + expect(stored.nodes.find((n: any) => n.id === 'purge').config).toEqual({ objectName: 'lead', filter: { status: 'stale' } }); + expect(stored.nodes.find((n: any) => n.id === 'check')).not.toHaveProperty('config'); + expect(report.decisionModeReview.map((r) => `${r.nodeId}@${r.path}`)).toEqual(['check@nodes[1]']); + }); + + it('needs no engine: with no canonicalizer the flow row is `skipped` and its decision is STILL listed', async () => { + const { engine } = makeStubEngine([flowRow('lead_verdict', gatewayBody('lead_verdict'))]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata(); + + expect(report.skipped).toBe(1); + expect(report.rows[0]!.reason).toMatch(/no automation service/); + expect(report.decisionModeReview.map((r) => r.nodeId)).toEqual(['check']); + }); + + it('lists a decision inside a loop body, judged against the region\'s own edges, at its region path', async () => { + const body = { + name: 'lead_sweep', + label: 'Sweep', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'sweep', type: 'loop', label: 'Sweep', + config: { + collection: '{leads}', + iteratorVariable: 'lead', + body: { + nodes: [ + { id: 'gate', type: 'decision' }, + { id: 'x', type: 'end', label: 'X' }, + { id: 'y', type: 'end', label: 'Y' }, + ], + edges: [ + { id: 'g1', source: 'gate', target: 'x', condition: OVERLAP.a }, + { id: 'g2', source: 'gate', target: 'y', condition: { dialect: 'cel', source: OVERLAP.b } }, + ], + }, + }, + }, + ], + edges: [{ id: 'e1', source: 'start', target: 'sweep' }], + }; + + expect(collectDecisionModeReview(body)).toEqual([{ nodeId: 'gate', path: 'nodes[1].config.body.nodes[0]' }]); + }); + + it('CONTROLS — a declared `mode` (either member), one conditioned edge beside a default, a `conditions` list and a `fault` second edge are NOT listed', () => { + expect(collectDecisionModeReview(gatewayBody('f', { config: { mode: 'exclusive' } }))).toEqual([]); + expect(collectDecisionModeReview(gatewayBody('f', { config: { mode: 'inclusive' } }))).toEqual([]); + expect(collectDecisionModeReview(gatewayBody('f', { b: '', withDefault: true }))).toEqual([]); + expect(collectDecisionModeReview(gatewayBody('f', { + config: { conditions: [{ label: 'Refuse', expression: OVERLAP.a }] }, + }))).toEqual([]); + expect(collectDecisionModeReview(gatewayBody('f', { bType: 'fault' }))).toEqual([]); + // …and the POSITIVE for the same builder, so the controls cannot pass vacuously. + expect(collectDecisionModeReview(gatewayBody('f')).map((r) => r.nodeId)).toEqual(['check']); + }); + + it('ONE PREDICATE — the list is exactly what the `--from 17` chain writes `mode` at, through the same registry entry', () => { + const body = gatewayBody('lead_verdict'); + const bodyBytes = JSON.stringify(body); + + const listed = collectDecisionModeReview(body).map((r) => `flows[0].${r.path}.config.mode`); + const chain = applyMetaMigrations({ flows: [body] }, 17, 18); + const written = chain.applied + .filter((a) => a.conversionId === DECISION_MODE_REVIEW_CONVERSION_ID) + .map((a) => a.path); + + expect(written).toEqual(['flows[0].nodes[0].config.mode']); + expect(listed).toEqual(written); + // The listing ran the entry and discarded its output: the body is untouched. + expect(JSON.stringify(body)).toBe(bodyBytes); + }); + + it('the renderer prints the list with the one-line fix, beside — not instead of — the on-protocol verdict', async () => { + const { engine } = makeStubEngine([flowRow('lead_verdict', gatewayBody('lead_verdict'), { state: 'draft' })]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const text = formatStoredMigrationReport(await protocol.migrateStoredMetadata({ canonicalizeFlow })).join('\n'); + + expect(text).toMatch(/1 decision node\(s\) in 1 flow row\(s\) take the FIRST matching branch since protocol 18/); + expect(text).toContain(`flow/lead_verdict [env-wide, draft] — decision 'check' "Verdict?" at nodes[0]`); + expect(text).toContain("declare `mode: 'inclusive'`"); + expect(text).toMatch(/already on protocol/); + }); + + it('CONTROL — a run with nothing to review prints no review section', async () => { + const { engine } = makeStubEngine([flowRow('lead_verdict', gatewayBody('lead_verdict', { config: { mode: 'exclusive' } }))]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ canonicalizeFlow }); + + expect(report.decisionModeReview).toEqual([]); + expect(formatStoredMigrationReport(report).join('\n')).not.toMatch(/FIRST matching branch/); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 9c5b9aec575..711fded5dde 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -176,6 +176,7 @@ import type { StoredMigrationRow, StoredMigrationTodo, } from './stored-migration.js'; +import { collectDecisionModeReview } from './stored-migration.js'; /** * Canonical Zod schema per metadata type lives in @@ -16682,6 +16683,20 @@ export class ObjectStackProtocolImplementation implements * - `source: 'migrate-stored'` — so a history diff distinguishes a * canonicalization pass from an edit someone made. * + * ## What it lists and never writes + * + * `decisionModeReview` (#15429, maintainer ruling letter C): every stored + * `decision` node with no `conditions` list, no `mode` and two or more + * conditioned out-edges. Such a node took every true branch before + * protocol 18 and takes the first one now, and a stored row keeps that + * new meaning — the D2 entry that writes `mode: 'inclusive'` replays only + * over authored sources (`os migrate meta --from 17`), where the operator + * asserts the source's age; nothing asserts a row's. The list is the + * operator's review of that change, before and after the upgrade: it is + * built from the stored body with the D2 entry's own predicate + * ({@link collectDecisionModeReview}), it is the same on preview and apply, + * and it moves no row outcome, no count and no verdict. + * * ## What it declines to touch, and says so * * This section documents the function's FULL internal surface, which is @@ -16808,6 +16823,7 @@ export class ObjectStackProtocolImplementation implements skipped: 0, failed: 0, rows: [], + decisionModeReview: [], }; // Two scoped queries rather than one unfiltered scan: `state` is an @@ -16914,6 +16930,29 @@ export class ObjectStackProtocolImplementation implements continue; } + // [#15429, ruling C] The decision review list — REPORT ONLY. A stored + // decision with no `conditions` list, no `mode` and two or more + // conditioned out-edges took every true branch before protocol 18 + // and takes the first one now; by ruling the row keeps that new + // meaning (no pass rewrites it — nothing can say the row predates + // the flip), and this names each such node so an operator can find + // the one that MEANT every branch. Read off the stored body BEFORE + // the canonicalizer and with no engine, so the list is the same + // whether the row below converts, is skipped for want of an + // engine, or fails; it touches no count, no outcome and no write. + if (singular === 'flow') { + for (const node of collectDecisionModeReview(body)) { + report.decisionModeReview.push({ + id: base.id, + name: base.name, + organizationId, + packageId, + state, + ...node, + }); + } + } + // Flow rows need the automation engine's live executor registry for // ADR-0078's open-namespace conflict guard — supplied by the caller // (#4454) or resolved from the services registry (#4498), and diff --git a/packages/metadata-protocol/src/stored-migration.ts b/packages/metadata-protocol/src/stored-migration.ts index 91b5d28ab74..38e8687df66 100644 --- a/packages/metadata-protocol/src/stored-migration.ts +++ b/packages/metadata-protocol/src/stored-migration.ts @@ -27,6 +27,8 @@ * that reports every row canonical is the evidence, and it costs one command. */ +import { ALL_CONVERSIONS } from '@objectstack/spec'; + /** * What a caller with a live automation engine hands back for a stored `flow` * body (#4454) — structurally `AutomationEngine.canonicalizeStoredFlow`'s @@ -141,6 +143,46 @@ export interface StoredMigrationRow { reason?: string; } +/** + * One stored `decision` node whose evaluation changed meaning at protocol 18 + * (#15429) — LISTED for an operator to review, never rewritten. + * + * The shape: a decision with no `config.conditions` list, no `mode`, and two or + * more out-edges carrying a `condition`. Before protocol 18 such a node took + * EVERY out-edge whose condition held; it now takes the first one, in the order + * the flow declares its edges. By maintainer ruling (letter C on #15429) a + * stored row takes that new meaning on upgrade: `os migrate meta --from 17` + * writes `mode: 'inclusive'` onto an authored SOURCE of this shape, because the + * operator asserts the source's age there, and no pass writes it onto a stored + * row, whose age nothing can assert. So the row is on protocol as it stands + * — this entry is not residue, it does not make the row `pending`, and it never + * flips {@link storedMigrationClean}; it is the list an operator reads before + * and after the upgrade to find the one node in a hundred that MEANT every + * branch, and declares `mode: 'inclusive'` on it by hand. + * + * The predicate is not restated here: {@link collectDecisionModeReview} runs + * the ADR-0087 D2 entry `flow-decision-mode-inclusive-explicit` itself over the + * stored body and keeps only where it would write — so this list is, by + * construction, the set of nodes the `--from 17` chain rewrites in a source. + */ +export interface StoredDecisionModeReview { + /** `sys_metadata.id` of the flow row. */ + id: string; + /** The flow's name. */ + name: string; + /** `null` = the env-wide overlay bucket. */ + organizationId: string | null; + /** `null` = a package-less (global) overlay row. */ + packageId: string | null; + state: 'active' | 'draft'; + /** The decision node's `id`. */ + nodeId: string; + /** The node's `label`, when it has one — what the flow designer shows. */ + nodeLabel?: string; + /** Where the node sits in the stored body, e.g. `nodes[3]` or `nodes[1].config.body.nodes[0]`. */ + path: string; +} + /** The whole run. `apply: false` is a preview — it writes nothing, by construction. */ export interface StoredMigrationReport { /** False = preview. A preview never writes, not even a row it would leave identical. */ @@ -159,6 +201,14 @@ export interface StoredMigrationReport { failed: number; /** Every row that is not `canonical`, in scan order. */ rows: StoredMigrationRow[]; + /** + * Stored `decision` nodes that take first-match since protocol 18 and were + * written before anyone had to say otherwise (#15429, ruling C) — in scan + * order, whatever each row's outcome, on a preview and an apply run alike. + * REPORT ONLY: nothing in this list is ever written, and it moves no count + * and no verdict. See {@link StoredDecisionModeReview}. + */ + decisionModeReview: StoredDecisionModeReview[]; } /** @@ -191,11 +241,92 @@ export interface StoredMigrationReport { * does not flip this verdict. TODOs never move it in either direction: a row * that also converts something stays `pending` / `rewritten` / `failed` exactly * as it would without them. + * + * The decision review list ({@link StoredMigrationReport.decisionModeReview}) + * is not a skip class and never moves this verdict either: a row it names is ON + * protocol — by ruling it takes the protocol-18 meaning as stored — so there is + * nothing for any run of this pass to do about it. */ export function storedMigrationClean(report: StoredMigrationReport): boolean { return report.pending === 0 && report.failed === 0; } +/** + * The ADR-0087 D2 entry whose predicate {@link collectDecisionModeReview} runs + * (#15429): the conversion `os migrate meta --from 17` replays over authored + * sources to write `mode: 'inclusive'`. + */ +export const DECISION_MODE_REVIEW_CONVERSION_ID = 'flow-decision-mode-inclusive-explicit'; + +/** + * The decision nodes in ONE stored flow body that the review list names — see + * {@link StoredDecisionModeReview} for what they are and why a stored row + * keeps them as they are (#15429, ruling C). + * + * **One predicate, not two.** The D2 entry's own `apply` runs over + * `{ flows: [body] }` and only the paths it WOULD write `mode` at are kept; the + * stack it returns is discarded unread, so nothing here can reach a write, and + * the entry is copy-on-write, so the body is not touched either. The list is + * therefore exactly the set a `--from 17` replay rewrites in a source — regions + * included — with no second statement of "two or more conditioned out-edges" + * to drift from the first. + * + * **Read off the stored body, with no engine.** The entry renames no node type, + * so it needs no executor registry, and it runs before — and independently of — + * the flow canonicalizer: a host with no automation service (where the flow row + * itself is reported `skipped`) and a row that fails to canonicalize still + * list their decisions. No earlier conversion writes a decision's `mode` or + * `conditions` or an edge's `condition`, so the stored body and the canonical + * one give the same answer. + * + * ⛔ Throws when the entry is gone from the registry, or names a path of a + * shape this reader does not know: a list that silently came back empty would + * tell every deployment "no decision to review". The entry leaves the registry + * only when the chain floor passes protocol 17 — the upgrade this list serves + * is then outside the supported window, and the list goes with it. + */ +export function collectDecisionModeReview( + body: unknown, +): Array<{ nodeId: string; nodeLabel?: string; path: string }> { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return []; + const entry = ALL_CONVERSIONS.find((c) => c.id === DECISION_MODE_REVIEW_CONVERSION_ID); + if (!entry) { + throw new Error( + `the decision review list runs the conversion '${DECISION_MODE_REVIEW_CONVERSION_ID}', and the ` + + 'registry no longer carries it. Remove the review list together with the conversion.', + ); + } + const paths: string[] = []; + entry.apply({ flows: [body as Record] }, (detail) => { + paths.push(detail.path); + }); + return paths.map((path) => { + const match = /^flows\[0\]\.(.+)\.config\.mode$/.exec(path); + if (!match) { + throw new Error( + `the conversion '${DECISION_MODE_REVIEW_CONVERSION_ID}' reported a path of an unknown shape ` + + `('${path}'), so the decision review list cannot name the node it is about.`, + ); + } + const nodePath = match[1]!; + const node = readAtPath(body, nodePath) as { id?: unknown; label?: unknown } | undefined; + const label = typeof node?.label === 'string' && node.label.trim() !== '' ? node.label : undefined; + return { nodeId: String(node?.id ?? ''), ...(label ? { nodeLabel: label } : {}), path: nodePath }; + }); +} + +/** Read `nodes[1].config.body.nodes[0]`-style paths — the spelling the conversions emit. */ +function readAtPath(root: unknown, path: string): unknown { + let current: unknown = root; + for (const step of path.matchAll(/([^.[\]]+)|\[(\d+)\]/g)) { + if (current === null || typeof current !== 'object') return undefined; + current = step[2] !== undefined + ? (current as unknown[])[Number(step[2])] + : (current as Record)[step[1]!]; + } + return current; +} + /** * Render a run for a terminal. One line per non-canonical row, its notices and * its TODOs nested under it — wherever the row is listed, since a TODO rides on @@ -259,6 +390,29 @@ export function formatStoredMigrationReport(report: StoredMigrationReport): stri ); } + // Printed on a preview and an apply run alike, and beside the "already on + // protocol" verdict below without contradicting it: these rows ARE on + // protocol — a stored decision takes the protocol-18 meaning as it stands — + // and the list is what an operator reviews, not what this pass owes. + const review = report.decisionModeReview; + if (review.length > 0) { + const flowRows = new Set(review.map((r) => r.id)).size; + lines.push( + `◆ ${review.length} decision node(s) in ${flowRows} flow row(s) take the FIRST matching branch ` + + 'since protocol 18 — listed for review, never rewritten:', + ); + for (const r of review) { + const label = r.nodeLabel ? ` "${r.nodeLabel}"` : ''; + lines.push(` • flow/${r.name} ${describeScope(r)} — decision '${r.nodeId}'${label} at ${r.path}`); + } + lines.push( + ' Each has two or more out-edges with a condition and no `mode`. Before protocol 18 it took ' + + 'EVERY out-edge whose condition held; it now takes only the first one that holds, in the order ' + + "the flow declares its edges. Where a node meant every branch, declare `mode: 'inclusive'` on " + + 'it; declaring `mode` either way takes it off this list. No run of this pass writes it.', + ); + } + if (report.scanned === 0) { // "Nothing to convert" and "nothing was looked at" are different claims, // and only the first is a pass. A run pointed at the wrong project — the @@ -287,7 +441,7 @@ function pushTodos(lines: string[], row: StoredMigrationRow): void { } /** `[org=… package=… draft]` — only the parts that are not the default. */ -function describeScope(row: StoredMigrationRow): string { +function describeScope(row: Pick): string { const parts: string[] = []; parts.push(row.organizationId ? `org=${row.organizationId}` : 'env-wide'); if (row.packageId) parts.push(`package=${row.packageId}`); From 636386bf5b46cf00c37d40ec813cb8011192caac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:37:54 +0000 Subject: [PATCH 10/11] =?UTF-8?q?test(service-automation):=20ruling=20C=20?= =?UTF-8?q?pins=20=E2=80=94=20a=20stored=20decision=20without=20`mode`=20r?= =?UTF-8?q?uns=20first-match;=20a=20`--from=2017`=20source=20keeps=20every?= =?UTF-8?q?=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of ruling C's three pins, in the decision contract pin file: - A decision as a pre-18 `sys_metadata` row carries it (JSON text, no `mode`, the hotcrm#1555 overlapping pair) goes through `canonicalizeStoredFlow` with no `mode` written and no notice for the id, then registers and runs FIRST-MATCH: only the first declared branch runs, the second records a `skipped` step on its edge. - The same body through `applyMetaMigrations(..., 17, 18)` carries explicit `mode: 'inclusive'` and, registered, still takes EVERY branch, nested, with no skipped step (extends the former firing control, which only read the written key). The third pin (the `--stored` report lists it and changes nothing) is in `@objectstack/metadata-protocol`. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- ...on-overlapping-edge-conditions.pin.test.ts | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts b/packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts index b73bc00c3d3..254b845cf2a 100644 --- a/packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts +++ b/packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts @@ -31,13 +31,18 @@ import { registerLogicNodes } from './logic-nodes.js'; * `DecisionConfigSchema`: a value outside the closed pair, or a `mode` * beside a non-empty `conditions` list, refuses the flow with the schema's * sentence — the same one `os validate` prints. - * 5. **The rehydration seam does not rewrite the flip.** The ADR-0087 - * conversion `flow-decision-mode-inclusive-explicit` writes `mode: - * 'inclusive'` onto a two-branch decision so an OLD flow keeps its - * behaviour, but only where the operator asserts the source's age - * (`os migrate meta --from 17`); `registerFlow` cannot date a body and - * refuses the entry by id, or every NEW exclusive decision would register - * as an inclusive one. + * 5. **The rehydration seam does not rewrite the flip, and a stored row takes + * the new meaning.** The ADR-0087 conversion + * `flow-decision-mode-inclusive-explicit` writes `mode: 'inclusive'` onto a + * two-branch decision so an OLD authored source keeps its behaviour, but + * only where the operator asserts the source's age (`os migrate meta + * --from 17`); `registerFlow` cannot date a body and refuses the entry by + * id, or every NEW exclusive decision would register as an inclusive one. + * So a decision stored in `sys_metadata` before protocol 18, with no + * `mode` and overlapping conditions, runs FIRST-MATCH after the upgrade — + * by maintainer ruling letter C on #15429 (no stored-row rewrite, no + * cutoff, no read-path completion); `os migrate meta --stored` lists such + * nodes for review and writes nothing. * 6. **Scoped to `decision`.** Conditioned out-edges of any other node type * keep the every-true-edge traversal they had. * @@ -386,7 +391,7 @@ describe('decision edge branching — exclusive by default, inclusive by declara }); }); - // ── 5. The rehydration seam does not rewrite the flip ───────────────── + // ── 5. The rehydration seam does not rewrite the flip (ruling C) ────── describe('the flow rehydration seam refuses `flow-decision-mode-inclusive-explicit` by id', () => { const twoBranches = () => gatewayFlow(OVERLAP); @@ -413,11 +418,39 @@ describe('decision edge branching — exclusive by default, inclusive by declara expect(trace).toEqual(['enter:refuse', 'exit:refuse']); }); - it('FIRING CONTROL — the same body through the D3 chain (`os migrate meta --from 17`) DOES get `mode: inclusive`', () => { + it('RULING C — a STORED decision lacking `mode`, with overlapping conditions, evaluates FIRST-MATCH after the upgrade: the row registers as stored', async () => { + // The body exactly as a `sys_metadata` row written before protocol 18 + // carries it — JSON text, no `mode` — and every door a stored row + // re-enters by (the boot pull, a Studio save, `os migrate meta + // --stored`) goes through this one seam. + const stored = JSON.parse(JSON.stringify(twoBranches())); + + // What the stored pass would persist: no `mode` written, no notice for it. + const { storable, notices } = engine.canonicalizeStoredFlow('gateway', stored); + const storableCheck = ((storable as { nodes: Array<{ id: string; config?: unknown }> }).nodes).find((n) => n.id === 'check')!; + expect(storableCheck.config).toBeUndefined(); + expect(notices.map((n) => n.conversionId)).not.toContain('flow-decision-mode-inclusive-explicit'); + + engine.registerFlow('gateway', stored); + await run({ status: 'confirmed' }); + + // Both predicates hold for a confirmed lead; only the first declared runs. + expect(trace).toEqual(['enter:refuse', 'exit:refuse']); + expect(await stepsOfLastRun()).toContainEqual({ nodeId: 'convert', status: 'skipped', edgeId: 'e_convert' }); + }); + + it('RULING C, and the FIRING CONTROL for both pins above — a SOURCE migrated with `os migrate meta --from 17` carries explicit `mode: inclusive` and still takes EVERY branch', async () => { const result = applyMetaMigrations({ flows: [twoBranches()] }, 17, 18); const migrated = (result.stack.flows as Array<{ nodes: Array<{ id: string; config?: Record }> }>)[0]!; expect(migrated.nodes.find((n) => n.id === 'check')!.config).toEqual({ mode: 'inclusive' }); expect(result.applied.map((a) => a.conversionId)).toContain('flow-decision-mode-inclusive-explicit'); + + engine.registerFlow('gateway', migrated); + await run({ status: 'confirmed' }); + + // Every true branch, one after another — exactly what the flow did before protocol 18. + expect(trace).toEqual(['enter:refuse', 'exit:refuse', 'enter:convert', 'exit:convert']); + expect((await stepsOfLastRun()).filter((s) => s.status === 'skipped')).toEqual([]); }); }); From 18cbf4e2b923dd530304235a39924fdf6043c395 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:57:31 +0000 Subject: [PATCH 11/11] chore(spec): regenerate the migration registry and the schemaless-node-config reference after the ruling C prose `gen:migration-registry` concatenates the edited D3 entry into `registry.ts`'s semantic:18 region; `build && gen:docs` re-renders the `mode` describe. `check:generated`: all 15 artifacts up to date on the regenerated tree. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude --- .../automation/schemaless-node-config.mdx | 2 +- packages/spec/src/migrations/registry.ts | 27 ++++++++++++------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/content/docs/references/automation/schemaless-node-config.mdx b/content/docs/references/automation/schemaless-node-config.mdx index 32bbef0f8d0..8f0bf19f832 100644 --- a/content/docs/references/automation/schemaless-node-config.mdx +++ b/content/docs/references/automation/schemaless-node-config.mdx @@ -143,7 +143,7 @@ const result = DecisionConditionSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **conditions** | `{ label: string; expression: string }[]` | optional | Ordered decision branches (first true expression wins; omit to branch purely on edge conditions) | -| **mode** | `Enum<'exclusive' \| 'inclusive'>` | optional | Declares how many out-edges an edge-branched decision takes when more than one out-edge condition holds: 'exclusive' = only the first, in the order the edges are declared (what an omitted mode means; the siblings after it are not evaluated and record a skipped step); 'inclusive' = every one that holds, one after another. When none holds the isDefault edge runs either way. Refused beside a non-empty conditions list, which is first-match on its own: delete mode there, or move the branches onto the out-edges, delete conditions, and keep mode. Flows written while every true branch ran keep that behaviour through the os migrate meta --from 17 conversion, which writes mode: inclusive onto every edge-branched decision with two or more conditioned out-edges. | +| **mode** | `Enum<'exclusive' \| 'inclusive'>` | optional | Declares how many out-edges an edge-branched decision takes when more than one out-edge condition holds: 'exclusive' = only the first, in the order the edges are declared (what an omitted mode means; the siblings after it are not evaluated and record a skipped step); 'inclusive' = every one that holds, one after another. When none holds the isDefault edge runs either way. Refused beside a non-empty conditions list, which is first-match on its own: delete mode there, or move the branches onto the out-edges, delete conditions, and keep mode. Authored sources written while every true branch ran keep that behaviour through the os migrate meta --from 17 conversion, which writes mode: inclusive onto every edge-branched decision with two or more conditioned out-edges; a flow stored in sys_metadata is not rewritten and takes the first-match reading on upgrade (os migrate meta --stored lists those decisions). | ### Nested Shape: `DecisionConfig.conditions[number]` diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 8f5afe9df64..d41706d6dd6 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10459,11 +10459,17 @@ const step18: MigrationStep = { + 'old behaviour was the bug), so the mechanical edit list the chain replay prints is ' + 'where that judgment is made, node by node. And the conversion replays ONLY there: it is a ' + 'default flip, so the authoring funnel never rewrites a source written against the ' - + 'new contract, the automation engine\'s flow rehydration seam refuses it by id (a ' - + 'code-shipped flow, a REST body and a Studio save all arrive there undated), and the ' - + 'stored-row pass (`os migrate meta --stored`) canonicalizes through that same seam — so ' - + 'a decision saved from the Studio BEFORE this release, with two or more conditioned ' - + 'out-edges and no `mode`, now runs first-match and is rewritten by nothing.', + + 'new contract, and the automation engine\'s flow rehydration seam and the ' + + 'artifact-ingestion door both refuse it by id (a code-shipped flow, a REST body, a Studio ' + + 'save and a scaffolded artifact all arrive undated). BREAKING for stored rows, by ' + + 'maintainer ruling: the promise that a flow keeps its behaviour is kept by authored ' + + 'sources and built artifacts only. A decision stored in `sys_metadata` ' + + 'with no `conditions` list, no `mode` and two or more conditioned out-edges takes the new ' + + 'meaning on upgrade — it evaluates first-match — and nothing rewrites the row: no ' + + 'stored-row migration, no cutoff, no read-path completion, because nothing about a stored ' + + 'row says it was saved before the flip. The one-line fix, for a stored node that meant ' + + 'every branch, is `mode: \'inclusive\'`; `os migrate meta --stored` lists every such node, ' + + 'report only, so an operator can review the candidates before and after the upgrade.', acceptanceCriteria: 'Review every `flow-decision-mode-inclusive-explicit` line the chain replay lists for ' + 'each authored stack: (1) where the two (or more) ' @@ -10475,11 +10481,12 @@ const step18: MigrationStep = { + 're-run the flow on a record that satisfied both and confirm exactly one successor ' + 'ran — the passed-over branch now leaves a `skipped` step in the run log. `os validate` ' + 'reports `flow-decision-inclusive-overlap` on every decision that keeps the key with ' - + 'two or more conditioned out-edges, so the review list is the lint output. Then the ' - + 'half no command reaches: list the `sys_metadata` flow rows of each deployment whose ' - + 'decision nodes carry two or more conditioned out-edges and no `mode` — the stored ' - + 'pass reports these rows canonical and rewrites nothing — and declare `mode` on each in ' - + 'the Studio designer by the same three-way judgment. A decision registering with ' + + 'two or more conditioned out-edges, so the review list is the lint output. Then each ' + + 'deployment: `os migrate meta --stored` lists, under `decisionModeReview`, every stored ' + + 'decision with two or more conditioned out-edges and no `mode` — each one already ' + + 'evaluates first-match, and the pass writes none of them — so where one of those nodes ' + + 'meant every branch, declare `mode: \'inclusive\'` on it in the designer; a node ' + + 'that declares `mode` either way leaves the list. A decision registering with ' + '`mode` beside a non-empty `conditions` list, or with a `mode` outside ' + '`\'exclusive\' | \'inclusive\'`, is refused at registration and by `os validate` with the ' + 'schema\'s own sentence; nothing else about `conditions`-list decisions changes. '