test(service-automation): pin the measured semantics of overlapping decision out-edge conditions - #16408
Merged
Conversation
…ecision out-edge conditions A status-quo pin, not a contract. It records what the engine does today when two out-edges of one `decision` node carry conditions that both hold, so the pending semantic ruling has a baseline that cannot drift. Measured against the reported shape: - Take-every-match is real: both successors run in one execution. - "In parallel" is not: matching conditional edges are traversed one at a time, each successor fully executed before the next is evaluated. Only the unconditional bucket fans out through `Promise.all`, kept here as a positive control so the interleaving instrument is shown to detect concurrency where it exists. - A disjoint-condition control proves the counting method reads "exactly one" when the conditions do partition. - Nothing reports the multi-take: no warning, and no `skipped` step, so the run log is indistinguishable from a flow authored to run both branches. - Declared `config.conditions` is a distinct mode: first match wins, and the losing edge is narrowed away rather than gated (no step at all). No engine, spec or lint change: the card rules that changing evaluation semantics on a shipped node type is a behaviour change with its own ruling. Part of #15429 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-warren
marked this pull request as ready for review
September 6, 2026 19:45
os-warren
enabled auto-merge
September 6, 2026 19:46
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #15429
A status-quo pin only. No engine, spec or lint change: the card rules that changing evaluation semantics on a shipped node type is a behaviour change with its own ruling, and this PR is the step that ruling needs first — a measured baseline that cannot drift while it is pending.
One file added:
packages/services/service-automation/src/builtin/decision-overlapping-edge-conditions.pin.test.ts.The three questions, and what was measured
1. Is "no
config.conditions" a distinct mode? Yes, and deliberately so.builtin/logic-nodes.tsreturns{ success: true }with nobranchLabelwhen the node declares no conditions; with conditions declared it returns on the first matching entry's label andtraverseNextnarrows the edge set to the edge carrying it. The split is documented in that executor as the #4414 repair. Both halves are measured here, not just read: with the same two overlapping predicates moved onto the node, only the first branch runs — and the losing edge leaves no step at all, not even theskippedone a closed gate writes.2. How many successors does the engine take when several conditions hold? Every one of them — the reported hazard, confirmed by driving. Two out-edges spelled as in the reproduction (
status != 'suspected'andstatus == 'confirmed'), one confirmed record: both successors execute in one run.Promise.all. The pin keeps that fan-out as a positive control, so the interleaving instrument is shown to detect concurrency where concurrency exists (enter,enter,exit,exit) and to report nesting where it does not (enter,exit,enter,exit). The hazard is multi-branch execution, not a race — a distinction the ruling starts from, since two branches writing the same record do so in a defined order.3. Intended or incidental? ⛔ No record of a decision. The take-every-match loop predates the buckets it lives in: before
cc8484224(2026-02-21) traversal was one loop thatcontinued past a closed gate and executed everything else. That commit split conditional from unconditional edges, recorded a decision about the unconditional half in its own message ("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, and it is an assumption: nothing in the engine, the schema or the linter makes sibling conditions exclusive. Searchedgit log -Soverengine.tsfor the loop's symbols (conditionalEdges,anyConditionMet, the comment text) on a non-shallow clone, andcontent/docs/automation/flows.mdxdescribes the mode as a "BPMN exclusive gateway" without ever saying what happens when two conditions hold at once.Also measured: the multi-take is reported nowhere. No warning, and no
skippedstep, so the run log of a two-branch execution is indistinguishable from a flow authored to run both.The instrument was proven to discriminate first
skippedstep for the closed gate.breakinserted intotraverseNext, mutation confirmed on disk (marker count 0 before / 1 after, blob hashf7a18c3cto43429b93), turns the three take-every-match assertions red and leaves both controls and the declared-conditions test green. Restored viagit checkout HEAD -- (absolute path)under a trap; restoration proven by an emptygit diff HEAD, a cleangit status, a blob hash equal to the HEAD blob, and a marker count back to 0. No rebuild leg was owed: the test imports../engine.jsinside its own package, which vitest resolves tosrc, so nodistsits between the mutation and the reading.Adjacent work, not duplicated
flow-decision-unconditional-branch(landed via #16382) reports the fully inert decision — no edge gated, noconditions[]declared. This pin is about the opposite shape: gates that are present and overlap. Neither rule is touched or extended here. #14945 is an expressiveness gap and stays separate; it is not addressed by this PR.Verification
Everything below ran on the final head
74ff87419, exit codes captured immediately after a single redirect, never through a pipe.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands— 46 commands, all 46 exit 0. Two first returned exit 3PREREQUISITE NOT MET(check:dual-build-cjs-loads,check:type-check-debt) — neither green nor red; the closure was built (turbo run build, 71/71 tasks) and both re-run to a real green, the debt gate under an 8192 MB heap.pnpm --filter @objectstack/service-automation exec vitest run— 124 files, 1443 tests, all passing.pnpm --filter @objectstack/service-automation typecheck— clean, and the new file is genuinely in that program:tsc --listFiles -p tsconfig.test.jsonnames it among 578 files (atypecheckthat excluded tests would have been a green over nothing).pnpm lint(repo-wideeslint . --no-inline-config) — exit 0. Run in full, so no narrowing is claimed.Changeset
skip-changeset, and the reason rather than the habit: the only file added is a*.pin.test.tsunderpackages/services/service-automation/src, and that package publishesfiles: ["dist", "README.md", "CHANGELOG.md"]from asrc/index.tsentry. Nothing in this PR reaches any published artifact, and no behaviour changes, so there is no release surface for a changeset to describe.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code