Skip to content

test(service-automation): pin the measured semantics of overlapping decision out-edge conditions - #16408

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-15429-decision-out-edge-semantics
Sep 6, 2026
Merged

test(service-automation): pin the measured semantics of overlapping decision out-edge conditions#16408
os-warren merged 1 commit into
mainfrom
claude/issue-15429-decision-out-edge-semantics

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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.ts returns { success: true } with no branchLabel when the node declares no conditions; with conditions declared it returns on the first matching entry's label and traverseNext narrows 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 the skipped one 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' and status == 'confirmed'), one confirmed record: both successors execute in one run.

⚠️ But not in parallel, and the card says parallel. Matching conditional edges are traversed one at a time; each successor completes before the next edge is evaluated. Only the unconditional bucket fans out through 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 that continued 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. Searched git log -S over engine.ts for the loop's symbols (conditionalEdges, anyConditionMet, the comment text) on a non-shallow clone, and content/docs/automation/flows.mdx describes 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 skipped step, 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

  • Control — the same node, the same two edges, the same record, disjoint predicates: reads exactly one successor, and records the skipped step for the closed gate.
  • Positive control — the same async instrument on the known-parallel unconditional fan-out: reads interleaving.
  • Ablation — a first-match break inserted into traverseNext, mutation confirmed on disk (marker count 0 before / 1 after, blob hash f7a18c3c to 43429b93), turns the three take-every-match assertions red and leaves both controls and the declared-conditions test green. Restored via git checkout HEAD -- (absolute path) under a trap; restoration proven by an empty git diff HEAD, a clean git 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.js inside its own package, which vitest resolves to src, so no dist sits 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, no conditions[] 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.

  • Gate family derived mechanically on that head: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands — 46 commands, all 46 exit 0. Two first returned exit 3 PREREQUISITE 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.json names it among 578 files (a typecheck that excluded tests would have been a green over nothing).
  • pnpm lint (repo-wide eslint . --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.ts under packages/services/service-automation/src, and that package publishes files: ["dist", "README.md", "CHANGELOG.md"] from a src/index.ts entry. 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

…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
@github-actions github-actions Bot added the size/m label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json de75e407e53af5a54b265261bc886e167626ce98packageMentionDocs.

@os-warren os-warren added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 6, 2026 — with Claude
@github-actions github-actions Bot added the tests label Sep 6, 2026
@os-warren
os-warren marked this pull request as ready for review September 6, 2026 19:45
@os-warren
os-warren enabled auto-merge September 6, 2026 19:46
@os-warren
os-warren added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit d5df816 Sep 6, 2026
38 of 39 checks passed
@os-warren
os-warren deleted the claude/issue-15429-decision-out-edge-semantics branch September 6, 2026 21:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants