Skip to content

Commit 0283cb9

Browse files
feat(automation)!: edge-branched decision is exclusive; mode: 'inclusive' takes every branch (#15429) (#20344)
Fixes #15429 Clause-②: yes Carries the maintainer's ruling `5793803317` (「跟主流对齐」, 2026-09-23) as ONE change, routed to `domain:spec` by `5860007474` and dispatched by the `domain:spec` seat 4 PM (`session_01CiCTczDo7tGhafXjf61dUJ`, claim `5860277199`). Branch base `10ea9eb2e`; `origin/main` (`a78f731ad`) merged through `os-regen-merge.sh`; every reading below is at head `e92edee5b` unless it says otherwise; the patch commit `27a1a4598` (the seat's answer A in `5861311629`) and the ruling C round (`5863827385`, claim `5864486967`, head `18cbf4e2`) carry their own readings where stated. _(Body revised by the seat at 2026-09-28T01:34Z from the patch-round report `5861745226`.)_ Coordination card: **objectstack-ai/objectui#10750** (the designer offers `mode`). Nothing is written in objectui here. ## What changes 1. **Exclusive by default (ruling item 1).** `AutomationEngine.traverseNext`: on a `decision` node 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; its later siblings are not evaluated and record the same `skipped` step a closed gate does (`skippedBy` names the gate and the edge). `isDefault` is unchanged: it runs when no conditioned sibling did. Scoped to `decision`: conditioned out-edges of any other node type keep the every-true-edge traversal (the census below found none). 2. **Explicit inclusive (item 2).** `config.mode: 'inclusive'` takes every out-edge whose condition holds, one successor at a time (never `Promise.all`; the pin's positive control proves the instrument can see interleaving). 3. **Registration door (acceptance `5857171841`).** `registerFlow` parses every decision's config through the spec's `DecisionConfigSchema` and refuses the flow on any issue rooted at `mode` — the refinement (`mode` beside a non-empty `conditions` list, either member) and the value refusal — with the schema's own sentence at `node 'x' (decision) at config.mode`, inside ADR-0031 regions too. Judged on `mode` alone, deliberately: the same parse also refuses an undeclared key, but that strictness binds at authoring by the standing decision in the module header of `schemaless-node-config.zod.ts`, and refusing an inert extra key at boot would be a second behaviour change riding a ruling that ordered one. Measured reach of the alternative: zero decision nodes in either corpus carry a key other than `conditions`, so promoting it later is cheap. 4. **`os validate` door.** `@objectstack/lint` gains `flow-decision-mode-invalid` (gating; the finding IS the schema's issue message, so both doors say one sentence) and `flow-decision-inclusive-overlap` (advisory, ruling item 4: `mode: 'inclusive'` with two or more conditioned out-edges; beside `flow-decision-unconditional-branch`, which is about an out-edge nothing gates). 5. **Migration (item 3).** ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit` (protocol 18): a decision with no `conditions` list, no `mode`, and two or more conditioned out-edges (a `fault` edge is error routing; a blank condition is none; regions walked through the shared slot table) gets `mode: 'inclusive'` written, with a notice naming the count. One conditioned edge plus a default is left alone; an authored `mode` is left alone (idempotent by construction). No inference over the conditions, per the ruling. Paired D3 entry `flow-decision-edge-branching-first-match` names the D2 id as a whole word and carries the three-way judgment the diff asks for. `MIGRATIONS_BY_MAJOR[18]` wires the id and its rationale grows a sentence. 6. **Rewrites (item 5).** `decision-overlapping-edge-conditions.pin.test.ts` is the contract pin now, per its own header. `flows.mdx`, the `DecisionConfigSchema` docblock and `mode` describe, the module header (the declared-ahead sites of `5852576993` item 2), `logic-nodes.ts`, and `engine.ts`'s traversal comment all describe both modes; the reference mdx is regenerated. ## Ruling C (`5863827385`): stored rows take the new meaning — listed, not rewritten The ruling, verbatim: > **Ruled: C.** Ruling `5793803317` item 3 (「保证已上线的流程行为不变」) is narrowed to the surfaces that can carry it: authored sources (`os migrate meta --from 17`) and built artifacts. Stored rows (`sys_metadata`) take the new meaning on upgrade — a decision node with no `config.conditions`, no `mode` and two or more conditioned out-edges evaluates first-match — and the changeset and the upgrade guide state that as BREAKING, naming the shape and the one-line fix (`mode: 'inclusive'`) for a node that meant every branch. No stored-row rewrite, no cutoff, no read-path completion. A (write-explicit on save plus read-path completion of a missing `mode` to `inclusive`) and B (an operator-supplied cutoff) are not taken. > > Execution parameters (ruled in the same stroke): > - PR #20344 (draft, `6bc84ba59`, CI green) lands after the at-tier contract review (Clause-② yes), with these edits in its next round: the D3 entry's and the changeset's 「judgment owed」 sentence replaced by this ruling; a BREAKING paragraph naming the stored-row shape and the `mode: 'inclusive'` fix; `os migrate meta --stored` lists — report only, 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. ADR-0087 needs no addendum: the artifact-door section's premise holds for sources and artifacts, and the stored seam is stated in the changeset. > - objectui#10750 (the designer writes `mode` explicitly on save) proceeds on its own card; it is the same question's other end and is wanted under this letter too. > - Pins: a stored decision node lacking `mode` with overlapping conditions evaluates first-match after upgrade; the `--stored` report lists it and changes nothing; a source flow migrated with `--from 17` carries explicit `mode: 'inclusive'` and still takes every branch. What this round did: (a) prose — the D3 entry `flow-decision-edge-branching-first-match` (reason tail and acceptance sentence), the D2 docblock, step18's rationale and the changeset now state BREAKING for a stored decision with no `config.conditions`, no `mode` and two or more conditioned out-edges, the one-line fix `mode: 'inclusive'`, and the listing; the upgrade guide renders the D3 entry (reason = Why not automatic, acceptanceCriteria = Done when) when protocol 18 is cut; `DecisionConfigSchema.mode`'s describe/docblock and the traversal comment say 'authored sources'; flows.mdx gains a stored-flow callout and cli.mdx a --stored paragraph; (b) listing — `StoredMigrationReport.decisionModeReview` (`StoredDecisionModeReview`: row id, flow, org, package, state, node id, label, path) in `@objectstack/metadata-protocol`, filled for every flow row on preview and apply alike by `collectDecisionModeReview`, which runs the D2 entry's own `apply` over the stored body and discards the result (one predicate; no engine needed; throws if the entry leaves the registry); it moves no outcome, count, exit code or write; `formatStoredMigrationReport` prints it with the fix, and the CLI and `POST /meta/_migrate-stored` carry it unchanged, so `meta.ts` is not edited; (c) the ruling's three pins, named below. ## PM mechanism assumptions, measured **1. Where the traversal lives — held.** Both sites relocated by content: the conditional loop under the old 「evaluate sequentially (mutually exclusive)」 comment in `engine.ts` (`traverseNext`), and the `config.conditions` first-match in `builtin/logic-nodes.ts` (untouched, still label-narrowing). Declaration order is `flow.edges` array order: `traverseNext` filters that array in place; `FlowSchema.parse` (region transform included) and every conversion walker are copy-on-write maps that never reorder; `normalizeStackInput` normalizes map-form collections at the stack level and never touches a flow's `edges`; `canonicalizeStoredFlow` runs those same two. Grep for any edge re-sort across `packages/*/src` and `packages/*/*/src` (`edges.sort`, `sortEdges`, `.edges.slice().sort`, `localeCompare` on edges): 0 hits. NOT MEASURED: the Studio designer's own serialization order at save time — objectui is not checked out in this container; server side, `saveMetaItem` canonicalizes without reordering. **2. The migration predicate — census.** Corpus: this repository's examples at `10ea9eb2e` and `objectstack-ai/hotcrm` at `2f7b2326` (read-only), every flow module loaded and every graph walked including regions; platform packages ship no decision node (grep over `packages/platform-objects`, `plugins`, `services`: only the executor and the README). | corpus | flows | decisions | rewritten (no list, ≥ 2 conditioned, no `mode`) | left alone | non-decision nodes with a conditioned out-edge | |:--|--:|--:|--:|--:|--:| | examples (app-crm, app-todo, app-showcase) | 35 | 5 | 4 | 1 (`crm_convert_lead_wizard.check_converted`: 1 conditioned + `isDefault`) | 0 | | hotcrm | 13 | 25 | 13 (incl. `lead_conversion.decision_duplicate`, the #1555 node, now three conditioned edges) | 12 (single-conditioned guards, no default) | 0 | Every one of the 17 positives is a hand-written partition by inspection (a predicate beside its negation, `>` beside `<=`, `has()` beside `!has()`, hotcrm's `CASE_HAS_OWNER` beside its exact complement, `memberSource != "contacts"` beside `== "contacts"`), so first-match changes none of their runs; the conversion still writes the key onto all 17, as ruled, and the D3 entry tells the author to delete it there. No decision in either corpus carries a config key other than `conditions`. `examples/**` is outside this claim's surface and is not edited: the four in-tree positives partition, so nothing changes at boot. **3. Stored flows — FAILED, and adapted.** The assumption was that the conversion replays at rehydration like the other step-18 entries. It cannot: this is a DEFAULT FLIP (the old shape still parses and now means exclusive), and the flow rehydration seam serves post-flip authored bodies too — `canonicalizeStoredFlow` is reached by the boot pull for code-shipped flows, by `POST /automation`, by `saveMetaItem` (every Studio save) and by `duplicatePackage`, all through one two-argument signature, none dated. Replaying there would rewrite every NEW exclusive decision into an inclusive one at registration and persist it at save, and the ruled default would be unobservable. The registry's own doctrine for this class (`excludeConversionIds`, the artifact door's `DEFAULT_FLIPS_NOT_REPLAYED_HERE` for `app-hidden-to-unpublished` on #17885, the WITHDRAWN `field-required-notnull-explicit` note) says a seam that cannot state 「this body predates the flip」 refuses the entry by id. So: - the entry is `retiredFromLoadPath: true` (no authoring window — 「不留过渡窗口」) and replays where the operator asserts the source's age: `os migrate meta --from 17` (the D3 chain), pinned both ways; - `canonicalizeStoredFlow` refuses it by id (`CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION`, reason at the call site), pinned on `parsed`, `storable` and notices, with the chain as the firing control; - **Stored `sys_metadata` flows take the new meaning — ruled C (`5863827385`).** A decision saved before this release with two or more conditioned out-edges and no `mode` runs first-match after the upgrade; no pass rewrites it (no stored-row migration, no cutoff, no read-path completion). BREAKING, stated in the changeset and the D3 entry with the one-line fix `mode: 'inclusive'`; `os migrate meta --stored` lists every such node, report only. - **The artifact-ingestion door refuses it too (patch commit `27a1a4598`, the seat's answer A in `5861311629`).** `packages/metadata-core/src/artifact-forward-conversion.ts` lists `flow-decision-mode-inclusive-explicit` in `DEFAULT_FLIPS_NOT_REPLAYED_HERE` beside the `app-hidden-to-unpublished` precedent, with its reason: the door's trigger is the artifact's declared `engines.protocol` floor, `^17.0.0` is what `create-objectstack` stamps, so an app scaffolded today against the exclusive contract lands inside the window and would otherwise be handed an inclusive gateway it never asked for. The door pin has four legs (subject, the strict parse the door feeds, negative, firing control), and the engine seam's and the entry's docblocks now cite the door precisely. **4. Serial state — moved, merged.** `origin/main` gained #20286 (`view-overlay-owner-hidden-removed`) on the same registry lines; `os-regen-merge.sh` merged it (both entries kept in landing order in `conversions/registry.ts` and in `MIGRATIONS_BY_MAJOR[18]`, rationale concatenated), `gen:migration-registry` regenerated to an identical file, `check:generated` found every artifact current, and every sibling symbol was asserted present on both sides by exact-name grep (`viewOverlayOwnerHiddenRemoved` 3/3, `view-overlay-owner-hidden-removed` 9/9, `view.zod.ts` `retiredKey` 21/21). **5. Ruling C round (dispatch assumptions).** (1) The report type is metadata-protocol's, not a spec contract type (`api/protocol.zod.ts`, ruling 2C note), so the widening is `StoredMigrationReport` + `StoredDecisionModeReview` there, covered by `Clause-②: yes`; the renderer is also metadata-protocol's, so `meta.ts` needed no edit. (2) The listing reads the stored body directly, with no engine, through the D2 entry's `apply` by id; a flow row skipped for want of an engine still lists. (3) `origin/main` `15bf186f5` (32 commits) merged through `os-regen-merge.sh` as `df3f6a00`: two hand-written conflicts (both registries; main's `form-layout-inline-grid-to-vertical`, `currency-config-precision-removed`, `permission-rls-tags-removed` kept ahead of ours), the reference mdx regenerated (`62771d8e`), `gen:migration-registry` a no-op on the merged entries, sibling ids and symbols asserted present 2/2 and 2/2. (4) #20316: branch `claude/issue-20316-flow-node-config-build-doors` exists, no PR; overlap with this PR is `conversions.test.ts` and `migrations/registry.ts` only; nothing here touches `registerFlow`. ## Surface 22 files, +2157 / −205 (2362 changed lines against merge base `15bf186f5`, under the 5000 human-merge threshold). Two files entered by the claim's surface amendment (`5861311629`): `packages/metadata-core/src/artifact-forward-conversion.ts` (only the `DEFAULT_FLIPS_NOT_REPLAYED_HERE` array and its reason docblock) and `packages/metadata-core/src/artifact-forward-conversion.test.ts` (the door pin). Two files the claim did not spell are recorded there as covered: `packages/spec/src/conversions/registry.ts` (where every D2 conversion lives) and `packages/lint/src/index.ts` (the two rule-id exports, required by `rule-id-barrel-exports.test.ts`). ⛔ Not touched: `flow-node-expression-paths.ts`, `examples/**`, `packages/cli/**`, `packages/runtime/**`, `packages/rest/**`, `packages/spec/src/contracts/**`, objectui. The ruling C round adds `packages/metadata-protocol/src/{stored-migration,protocol,index}.ts`, `protocol.stored-migration.test.ts` and `content/docs/deployment/cli.mdx`. ⛔ Still not touched: `packages/cli/**`, `packages/runtime/**`, `packages/rest/**`, `packages/spec/src/contracts/**`, `examples/**`, `docs/adr/**`, objectui. ## Pins that carry weight, and the ablations `decision-overlapping-edge-conditions.pin.test.ts` (21 tests): two overlapping true edges → exactly one runs, the first declared, the sibling records `skipped`; declaration order decides (the same predicates reversed take the other branch); `mode: 'inclusive'` → both run nested, no skipped step; none true → `isDefault` runs in both modes; a true edge beside a default passes the default over in both modes; a `conditions` list still narrows by label; registration refuses the pair (either member) and a bad value with the spec sentence, inside a loop body too, and the flow is never armed; the four controls register; the rehydration seam leaves the two-branch shape unrewritten while `applyMetaMigrations(stack, 17, 18)` rewrites it; a non-decision node keeps every-true-edge. `conversions.test.ts`: the fixture pair (2 notices) plus the predicate's edges, region reach, idempotence, the authoring funnel's silence, and the seam refusal with its firing control. `lint-flow-patterns.test.ts`: both rules, gating vs advisory, controls, regions, no double report through rule (2). `migrations.test.ts`'s census pin sees the D3 entry naming the D2 id. `artifact-forward-conversion.test.ts` (`27a1a4598`): a `^17.0.0`-floor artifact carrying a two-branch decision passes the door with no `mode` written, no notice for the id and the same reference back; the strict parse the door feeds receives no `mode`; `^99.0.0` shuts the window; and the same fixture through `applyConversions` with `includeRetired: true` and no refusal comes back `{ mode: inclusive }` with the entry's notice (firing control). Ruling C (`5863827385`): `decision-overlapping-edge-conditions.pin.test.ts` — a decision as a pre-18 row stores it (JSON text, no `mode`, the hotcrm#1555 pair) passes `canonicalizeStoredFlow` with no `mode` written, registers and runs FIRST-MATCH (second branch `skipped` on its edge); the same body through `applyMetaMigrations(stack, 17, 18)` carries `mode: inclusive` and runs EVERY branch, nested, no skipped step (22 tests). `protocol.stored-migration.test.ts` — the stored node is listed with row, flow, node, label and path while the row stays canonical and clean; `--apply` changes no byte and writes no history; a row rewritten for another conversion persists no `mode`; no engine still lists; region path; controls (either `mode`, one edge plus default, `conditions` list, `fault` edge); the list equals the `--from 17` chain's write paths; the renderer prints it beside the on-protocol verdict. Both ablations ran from committed state through `scripts/ablation-replace.mjs` (anchor hit 1→0, marker 0→1, blob hashes printed), the reading was taken, and restore was `git checkout HEAD -- ABS_PATH` under a trap, proven by `git diff HEAD` clean and `git hash-object` equal to the HEAD blob. No dist leg was owed: both suites resolve their subject through `src` (`../engine.js` inside service-automation; `./registry.js` inside spec). - traversal: `if (exclusive && anyConditionMet)` → `if (false && …)`. Blob `a60861d3985717a743cb32c16d9e3ba925dee3c7` → `f0e25d39262ae22b38ef67b5affbba494c0023bf`. Ablated run: **6 failed** (exactly the exclusivity, skipped-step, declaration-order, written-exclusive, default-passed-over and seam-runs-exclusive pins), 15 passed (the controls, inclusive, default and registration pins). Restored: `a60861d3…` on disk and at HEAD. - predicate: `MIN_CONDITIONED_EDGES = 2` → `3`. Blob `fd1a7902d480b791e7f53116eb38c97ad268fb78` → `a03f5cbdf9f742aabf42e8400a1fc5df50b50d1b`. Ablated run: **5 failed** (the fixture pair, the wiring pin, the two-edge rewrite, the left-alone pin, the seam-refusal firing control), 216 passed. Restored: `fd1a7902…` on disk and at HEAD. - artifact door (`27a1a4598`): the id removed from `DEFAULT_FLIPS_NOT_REPLAYED_HERE` (anchor 1→0, marker 0→1). Blob `16742f49e72eaa98214eca097b9b14cef03e8809` → `26220cf59c92d7b4daf75a74b17e5a076156503c`. Ablated run: **2 failed** (the subject leg — `mode` written — and the strict-parse leg), 27 passed (the firing control and the negative stayed green). Restored: `16742f49…` on disk and at HEAD. - stored listing (`18cbf4e2`): `protocol.ts` `for (const node of collectDecisionModeReview(body)) {` emptied (`.slice(0, 0)`), blob `711fded5ddea7d37b4f6d2a52f7b7a6a80db8081` → `ce0c296d5134527c0634c1932ec88b59c6285dbc`; ablated run **5 failed** | 34 passed (the five listing pins; region, controls, parity and the nothing-to-review control green); restored `711fded5…` on disk and at HEAD. - stored-row seam (`18cbf4e2`): `engine.ts` `excludeConversionIds: CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION,` removed (a read-path completion), blob `daac6de07304ae4051f1681ab4311c447a8ad3a9` → `a3555237ccca29ff4ad888bd009cc97be4007ae8`; ablated run **7 failed** | 15 passed (the ruling C stored-row pin, both seam pins, four exclusive-traversal pins; the `--from 17` source pin, inclusive, default, registration and boundary green); restored `daac6de0…` on disk and at HEAD. ## Tests, at `e92edee5b`, every exit captured after a redirect - `pnpm --filter @objectstack/spec test` → exit 0: `Test Files 554 passed (554) · Tests 16366 passed | 1 todo`. - `pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2` → exit 0: `Test Files 147 passed (147) · Tests 1782 passed (1782)`. - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` → exit 0: `Test Files 111 passed (111) · Tests 4313 passed (4313)`. - `typecheck` for the same three packages → exit 0 each (`check:test-typecheck` OK on each test layer). - Importers of `DecisionConfigSchema` outside these packages: `metadata-protocol`'s JSON-projection walk (a refinement projects byte-identically) and `config-expression-ledger.test.ts` (in the service-automation run above); no other importer of the traversal exists (`registerFlow` callers in `runtime` and `plugin.ts` are unchanged call sites). - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 114 commands on this branch; all 114 ran on `e92edee5b` with exit 0 (`check:dual-build-cjs-loads` and `check:type-check-debt` first answered exit 3, PREREQUISITE NOT MET, until the whole `packages/*` closure was built — 71/71 — then 0); `--ran` reconciliation: `114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN`. `check:generated` on the merged tree: every artifact current after `gen:docs`. `spec-changes.json` and the upgrade guide render no protocol-18 id yet (control: `report-joined-chart-removed` 0 hits too), so their green is genuine, not a missed regeneration. - Patch commit `27a1a4598`, readings at that head: `pnpm --filter @objectstack/metadata-core exec vitest run --maxWorkers=2` → exit 0: `Test Files 16 passed (16) · Tests 289 passed (289)`; metadata-core `typecheck` → exit 0. Re-run because the diff reaches them (docblock edits in `engine.ts` and `conversions/registry.ts`): service-automation `Test Files 147 passed (147) · Tests 1782 passed (1782)`, spec `Test Files 554 passed (554) · Tests 16366 passed | 1 todo`, both typechecks exit 0; lint is not reached and was not re-run. Gates: the same 114 derived commands (0 added, 0 dropped), all exit 0 on `27a1a4598`; `--ran`: `114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN`. `check:type-check-debt` first refused (exit 3) because metadata-core's cache-restored `dist/` was older than its source after the ablation's restore rewrote the file; a direct `pnpm --filter @objectstack/metadata-core build`, as the gate prescribes, and a re-run gave 0. - Ruling C round, at `18cbf4e2`: spec `test` `557 passed (557) · 16508 passed | 1 todo`; spec `test:repo` `35 passed (35) · 634 passed (634)`; metadata-protocol `189 passed | 3 skipped (192) · 2745 passed | 19 skipped (2764)`; service-automation `147 passed (147) · 1783 passed (1783)`; metadata-core `16 passed (16) · 289 passed (289)`; lint `113 passed (113) · 4713 passed (4713)`; cli unit `230 passed` plus the two published-subpath pins `2 passed (2) · 29 passed (29)` after a post-merge `pnpm install` (prerequisite, not a red); typecheck exit 0 for all six. Gates: 117 derived, 117 run, `--ran`: `117 derived, 117 run, 0 NOT-MEASURED, 0 UNRUN` (three first answered exit 3 PREREQUISITE NOT MET until the full `./packages/*` closure and `client-react` / `metadata-protocol` were built). Narrowed eslint over the PR's 18 changed `.ts` files: 0 errors, 0 warnings (no type-aware linting in `eslint.config.mjs`). CI on `18cbf4e2`: 33 success, 2 skipped, all seven required contexts success. ## Changeset grade, measured at landing npm `latest` `@objectstack/spec` is `17.4.0` (`npm view`, re-measured 2026-09-28), whose published `DecisionConfig.json` declares `conditions` only; `.changeset/19867-decision-config-mode.md` and `.changeset/20168-…md` are still unconsumed, so `mode` is unreleased and reaches its first release with the traversal that reads it and the conversion that writes it. `Clause-②: yes` per the ruling's item 2 (the D2/D3 entries and the two lint rules widen the published surface; nothing published narrows). `minor` for `@objectstack/spec`, `@objectstack/service-automation` and `@objectstack/lint`, with the BREAKING banner, the FROM → TO block and the disposition marker `registered flow-decision-mode-inclusive-explicit` (the changeset file carries it in the gate's own form). `@objectstack/metadata-protocol` `minor` (the report widens) and `@objectstack/metadata-core` `patch` (the artifact door's refusal) join the bump list; the changeset carries the ruling C BREAKING section. ## Acceptance notes - **Landed on this PR (`27a1a4598`)**: the artifact door's refusal of `flow-decision-mode-inclusive-explicit`, per the seat's answer A (`5861311629`). - **The stored-row half is ruled C (`5863827385`) and landed in this round**: stored rows take the first-match meaning (BREAKING, stated with the fix), `os migrate meta --stored` lists them report-only, and the three pins hold it. objectui#10750 (the designer writes `mode` on save) proceeds on its own card and is not this PR. - `origin/main` moved two commits after this round's merge (`0d7ed5a3` regenerates `packages/spec/src/migrations/registry.ts`); the landing lap merges it through `os-regen-merge.sh`. - `skills/objectstack-automation/SKILL.md` line 65 (「routed by edge `condition` predicates」) stays true and does not mention `mode`; governed surface, not touched. - The REST door answers a registration refusal as `VALIDATION_ERROR` 400 through `flowDefinitionRefusal` (unchanged code path); not pinned here, the runtime package is outside this surface. --- _Generated by [Claude Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 63e320a commit 0283cb9

22 files changed

Lines changed: 2157 additions & 205 deletions
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/lint": minor
5+
"@objectstack/metadata-protocol": minor
6+
"@objectstack/metadata-core": patch
7+
---
8+
9+
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)
10+
11+
<!-- adr-0087: registered flow-decision-mode-inclusive-explicit -->
12+
13+
Clause-②: yes
14+
15+
**BREAKING** — the run-time semantics of a shipped node type change. A `decision` node that
16+
declares no `config.conditions` and branches on its out-edges used to take EVERY out-edge whose
17+
condition held, one after another, while its schema, the docs and the engine's own comment all
18+
called it an exclusive gateway; hotcrm#1555 rendered a refusal screen AND ran the conversion in
19+
one execution. Maintainer ruling on #15429 (2026-09-23, 「跟主流对齐」): the gateway follows
20+
BPMN's exclusive gateway, Salesforce Flow's Decision and n8n's Switch default, and taking every
21+
true branch is a declaration the author writes down.
22+
23+
| | before | after |
24+
|:--|:--|:--|
25+
| two conditioned out-edges, both hold | both successors run, sequentially, nothing reported | the FIRST declared one runs; the second records a `skipped` step |
26+
| `config: { mode: 'inclusive' }` | accepted, never read | every out-edge whose condition holds runs, sequentially |
27+
| none holds | the `isDefault` edge runs | unchanged |
28+
| `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 |
29+
30+
## Migration: FROM → TO
31+
32+
`os migrate meta --from 17` lists the mechanical edits for existing sources and applies them
33+
to the migrated stack: the ADR-0087 D2 conversion `flow-decision-mode-inclusive-explicit`
34+
writes `mode: 'inclusive'` onto every decision that has no `conditions` list and two or more
35+
conditioned out-edges, inside ADR-0031 regions included, so a migrated flow runs exactly as it
36+
did.
37+
38+
```ts
39+
// FROM — every true out-edge ran
40+
{ id: 'verdict', type: 'decision', label: 'Verdict?' }
41+
// TO — what the conversion writes; delete the key where the conditions partition
42+
{ id: 'verdict', type: 'decision', label: 'Verdict?', config: { mode: 'inclusive' } }
43+
```
44+
45+
Then review each written key (the paired D3 entry `flow-decision-edge-branching-first-match`
46+
carries the acceptance criteria): delete it where the conditions partition (`== 'a'` beside
47+
`!= 'a'`, `>` beside `<=`, a guard beside `isDefault: true`), keep it where the flow relies on
48+
more than one branch running for one record, and where the overlap was accidental narrow the
49+
conditions into a partition and delete the key. `os validate` reports
50+
`flow-decision-inclusive-overlap` on every decision that keeps the key with two or more
51+
conditioned out-edges, so the review list is the lint output.
52+
53+
## BREAKING for flows stored in `sys_metadata` — maintainer ruling letter C on #15429
54+
55+
A `decision` node stored in `sys_metadata` (a flow built or edited in the Studio designer) with
56+
**no `config.conditions`, no `mode`, and two or more out-edges carrying a `condition`** evaluates
57+
**first-match** after this upgrade: where it took every out-edge whose condition held, it now takes
58+
only the first one that holds, in the order the flow declares its edges. Nothing rewrites that row
59+
— no stored-row migration, no cutoff, no read-path completion — because nothing about a stored row
60+
says it was saved before the flip. The one-line fix, for a node that meant every branch:
61+
62+
```ts
63+
{ id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } }
64+
```
65+
66+
`os migrate meta --stored` (and `POST /api/v1/meta/_migrate-stored`) lists every such node under
67+
`decisionModeReview` — flow row, node id, label and path — on a preview and an `--apply` run
68+
alike, and writes nothing for it: the list moves no row outcome, no count and no exit code, so an
69+
operator can review the candidates before and after the upgrade. A node leaves the list once it
70+
declares `mode`, either member. Every such node in the measured corpus below is a partition, where
71+
the new meaning runs exactly what the old one did.
72+
73+
Authored sources and built artifacts keep the old behaviour instead, where the source's age is a
74+
fact: `os migrate meta --from 17` writes `mode: 'inclusive'` (above), while the authoring funnel,
75+
the automation engine's flow rehydration seam and the artifact-ingestion door all refuse the
76+
conversion by id — a default flip replayed there would turn a decision written today against this
77+
contract, where an omitted `mode` means exclusive, into an inclusive gateway.
78+
79+
## Reach, measured at landing
80+
81+
- Release state: the npm registry's `latest` `@objectstack/spec` is `17.4.0` (`npm view`,
82+
2026-09-27), whose `json-schema/automation/DecisionConfig.json` declares `conditions` only —
83+
`mode` has not shipped; `.changeset/19867-decision-config-mode.md` and
84+
`.changeset/20168-decision-mode-beside-conditions-refused.md` are still unconsumed in this
85+
tree. So `mode` reaches its first release together with the traversal that reads it and the
86+
conversion that writes it; no published accept set narrows, and the registration and
87+
`os validate` refusals narrow nothing that shipped.
88+
- Corpus census (this repository at the branch base and `objectstack-ai/hotcrm` at `2f7b2326`,
89+
read-only): 30 decision nodes across 48 flows; 17 have two or more conditioned out-edges and
90+
no `mode` (the conversion's positives — every one a hand-written partition, including
91+
hotcrm's `lead_conversion.decision_duplicate`, the #1555 node), 13 have one conditioned
92+
out-edge (left alone), and no node of any other type carries a conditioned out-edge, so the
93+
exclusive traversal is scoped to `decision` with nothing else to migrate.
94+
- What the published surface gains: the D2 conversion and its D3 entry in the protocol-18
95+
chain (`spec-changes.json`, the upgrade guide), `DecisionConfigSchema.mode`'s describe and
96+
docblock now state the run-time semantics, and `@objectstack/lint` gains
97+
`flow-decision-mode-invalid` (gating) and `flow-decision-inclusive-overlap` (advisory).
98+
99+
The traversal change is scoped to `decision` nodes: conditioned out-edges of any other node
100+
type keep the every-true-edge traversal they had (none was measured to exist).

‎content/docs/automation/flows.mdx‎

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1370,7 +1370,7 @@ Edges connect nodes and define the execution path:
13701370
A node has exactly two ways to split its path, and mixing them is what makes a
13711371
guard stop guarding (#4414).
13721372

1373-
**Branch on the edges** (BPMN exclusive gateway — the default choice):
1373+
**Branch on the edges** (BPMN gateway — the default choice):
13741374

13751375
```typescript
13761376
{ id: 'check', type: 'decision', label: 'Already converted?' }, // no config
@@ -1388,6 +1388,61 @@ in parallel with `abort` — so an already-converted lead sees the abort screen
13881388
condition by hand is the only other correct spelling; `isDefault` is the one
13891389
that stays correct when a third branch is added.
13901390

1391+
**The gateway is exclusive.** When more than one out-edge carries a
1392+
`condition`, the engine evaluates them **in the order the `edges` array
1393+
declares them** and takes the **first** one whose condition holds — the BPMN
1394+
exclusive gateway, Salesforce Flow's Decision element, n8n's Switch default.
1395+
The siblings after it are not evaluated, and each records a `skipped` step in
1396+
the run log, so a run says which branch won and which were passed over. When
1397+
none holds, the `isDefault` edge runs. Two conditions that both hold for one
1398+
record therefore run **one** branch, the earlier one; put the branch you want
1399+
to win first.
1400+
1401+
To take **every** out-edge whose condition holds — the BPMN inclusive gateway,
1402+
n8n's "send to all matching outputs" — declare it on the node:
1403+
1404+
```typescript
1405+
{ id: 'route', type: 'decision', label: 'Route', config: { mode: 'inclusive' } },
1406+
edges: [
1407+
{ id: 'e_vip', source: 'route', target: 'notify_account_manager', condition: 'lead.tier == "vip"' },
1408+
{ id: 'e_big', source: 'route', target: 'notify_finance', condition: 'lead.amount > 100000' },
1409+
]
1410+
```
1411+
1412+
A VIP lead over the threshold takes both, one after another (never in
1413+
parallel). `mode` has two members, `'exclusive'` (what an omitted key means)
1414+
and `'inclusive'`; anything else, and a `mode` written beside a `config.conditions`
1415+
list (which is first-match on its own), is refused when the flow registers and
1416+
by `os validate` (`flow-decision-mode-invalid`), with the same sentence at both
1417+
doors. `os validate` also reports `flow-decision-inclusive-overlap` on an
1418+
inclusive decision with two or more conditioned out-edges, because that is the
1419+
shape in which more than one branch can run for one record.
1420+
1421+
<Callout type="info">
1422+
**Upgrading a flow written before protocol 18.** An edge-branched decision used
1423+
to take every out-edge whose condition held. The ADR-0087 conversion
1424+
`flow-decision-mode-inclusive-explicit` writes `mode: 'inclusive'` onto every
1425+
decision with two or more conditioned out-edges and no `mode` when the chain is
1426+
replayed, so the migrated flow runs exactly as before; delete the key where the
1427+
two conditions partition (`== 'a'` beside `!= 'a'`, `>` beside `<=`), which is
1428+
the common case and the honest declaration. Nothing rewrites a flow at load: an
1429+
author who writes two branches today gets the exclusive gateway the contract
1430+
describes. Run `os migrate meta --from 17` to list the mechanical edits for
1431+
existing sources; apply them by hand.
1432+
</Callout>
1433+
1434+
<Callout type="warn">
1435+
**A flow stored from the Studio takes the new meaning (breaking).** A decision
1436+
saved in `sys_metadata` before protocol 18 — no `config.conditions`, no `mode`,
1437+
two or more out-edges with a `condition` — runs **first-match** after the
1438+
upgrade, and nothing rewrites the stored row: nothing about a row says it was
1439+
saved before the change. `os migrate meta --stored` lists every such node (flow,
1440+
node id, label and path) on a preview and an `--apply` run alike and writes
1441+
nothing for it, so you can review them before and after upgrading. Where a node
1442+
meant every branch, declare `config: { mode: 'inclusive' }` on it; declaring
1443+
`mode` either way takes it off the list.
1444+
</Callout>
1445+
13911446
**Branch on the node** (Salesforce-style decision outcomes): the node declares
13921447
`config.conditions[]` and traversal restricts itself to the out-edge whose
13931448
`label` matches the first matching entry.
@@ -1410,11 +1465,14 @@ fallback used to be silent, and a decision declaring `'Yes — already converted
14101465
against an out-edge labelled `'Yes'` is how #4414 shipped. `os validate` reports
14111466
the shape as `flow-branch-label-unmatched` at build time, along with
14121467
`flow-decision-unconditional-branch` (a guarded decision with an unconditional
1413-
sibling), `flow-default-edge-with-condition` and `flow-multiple-default-edges`.
1468+
sibling), `flow-default-edge-with-condition`, `flow-multiple-default-edges`,
1469+
`flow-decision-mode-invalid` and `flow-decision-inclusive-overlap`.
14141470

14151471
<Callout type="warn">
14161472
A decision node that declares **no** `conditions` reports no branch at all — it
1417-
is a plain gateway and its out-edges do the routing.
1473+
is a plain gateway and its out-edges do the routing: the first out-edge whose
1474+
condition holds, in declaration order, unless the node declares
1475+
`config: { mode: 'inclusive' }`.
14181476

14191477
Declaring **both** — `config.conditions` *and* per-edge `condition`s — is
14201478
redundant but not wrong: the node picks a branch, and then that branch's edge

‎content/docs/deployment/cli.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,6 +1203,18 @@ can produce, because both supply a live one.
12031203
| 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 |
12041204
| 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 |
12051205
1206+
**One thing it lists and never writes: decision nodes that changed meaning at
1207+
protocol 18.** A stored `decision` with no `config.conditions`, no `mode` and two
1208+
or more out-edges carrying a `condition` took every out-edge whose condition held
1209+
before protocol 18, and takes only the first one now. A stored row keeps that new
1210+
meaning — the conversion that writes `mode: 'inclusive'` replays over authored
1211+
sources only, where you assert the source's age, and nothing asserts a row's — so
1212+
the report lists each such node under `decisionModeReview` (flow row, node id,
1213+
label and path) for you to review before and after the upgrade, on a preview and
1214+
an `--apply` run alike. The list changes no row, no count and no exit code. Where
1215+
a node meant every branch, declare `mode: 'inclusive'` on it; declaring `mode`
1216+
either way takes it off the list.
1217+
12061218
**Flows are covered, and cost one extra plugin.** Flow-node conversions carry an
12071219
open-namespace conflict guard that has to consult the *live* executor registry
12081220
to tell a rename from a clobber, so this run boots the automation engine — in an

‎content/docs/references/automation/schemaless-node-config.mdx‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,18 @@ The two halves reach different audiences, which is why they shipped together:
6666
nothing read — and then refuses, naming the `function` it does not have,
6767
instead of logging a line and reporting success as it used to.
6868

69-
`decision` stays export-only: nothing parses it at run time. It may carry no
70-
`conditions` at all when it branches purely on edge predicates, its executor
71-
reads `conditions` and nothing else, and its one other key — `mode` — is
72-
declared AHEAD of the engine change that reads it (#15429; see
73-
`DecisionConfigSchema`). Its enforcement remains the objectui
74-
reconciliation test, which is what #4278 was actually about (a form
75-
authoring keys nothing reads).
69+
`decision` is parsed at **registration**, for one key (#15429): the
70+
automation engine's `registerFlow` runs every decision node's config through
71+
`DecisionConfigSchema` and refuses the flow on any issue rooted at
72+
`mode` — a value outside the closed pair, or a `mode` beside a non-empty
73+
`conditions` list — with this schema's own sentence, and `os validate`
74+
reports the same issues as `flow-decision-mode-invalid`, so the two doors
75+
cannot disagree. A decision may carry no `conditions` at all when it
76+
branches purely on edge predicates; its executor reads `conditions` and
77+
nothing else, and the engine's traversal reads `mode`. Its strictness
78+
(unknown keys) still binds at authoring, in the published JSON Schema and in
79+
the objectui reconciliation test, which is what #4278 was actually about (a
80+
form authoring keys nothing reads).
7681

7782
Undeclared aliases are NOT part of these contracts: `subflow`'s historical
7883
`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
96101
precisely the class with no second door. Closing these shapes is therefore
97102
not a duplicate check for `script` and `subflow`; it is their first one.
98103

99-
`decision` is still export-only, so its strictness binds at authoring
100-
(`tsc`), in the published JSON Schema, and in objectui's reconciliation —
101-
not at run time. It is closed anyway, because the campaign's whole finding
104+
`decision`'s strictness binds at authoring (`tsc`), in the published JSON
105+
Schema, and in objectui's reconciliation — not at run time, where the
106+
registration reader judges `mode` alone (#15429). It is closed anyway,
107+
because the campaign's whole finding
102108
is that a shape left open accretes a test, a form and a fixture that assert
103109
the openness, and then closing it is a migration instead of an edit.
104110

@@ -137,7 +143,7 @@ const result = DecisionConditionSchema.parse(data);
137143
| Property | Type | Required | Description |
138144
| :--- | :--- | :--- | :--- |
139145
| **conditions** | `{ label: string; expression: string }[]` | optional | Ordered decision branches (first true expression wins; omit to branch purely on edge conditions) |
140-
| **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. |
146+
| **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). |
141147

142148
### Nested Shape: `DecisionConfig.conditions[number]`
143149

‎packages/lint/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,8 @@ export {
841841
FLOW_MULTI_WRITE_UNFILTERED,
842842
FLOW_LOOP_BODY_UNCONTAINED,
843843
FLOW_TRY_CATCH_WITHOUT_CATCH,
844+
FLOW_DECISION_MODE_INVALID,
845+
FLOW_DECISION_INCLUSIVE_OVERLAP,
844846
} from './lint-flow-patterns.js';
845847

846848
export { lintLivenessProperties } from './lint-liveness-properties.js';

0 commit comments

Comments
 (0)