Skip to content

feat(workflow-executor): evaluate deterministic condition steps without AI - #1837

Open
Scra3 wants to merge 2 commits into
feature/prd-472-deterministic-decision-stepfrom
feature/prd-472-condition-evaluator
Open

feat(workflow-executor): evaluate deterministic condition steps without AI#1837
Scra3 wants to merge 2 commits into
feature/prd-472-deterministic-decision-stepfrom
feature/prd-472-condition-evaluator

Conversation

@Scra3

@Scra3 Scra3 commented Aug 19, 2026

Copy link
Copy Markdown
Member

Why

PRD-472: Decision steps can now be configured with explicit conditions instead of a natural-language question. The runtime must evaluate them itself — no LLM call, no human wait.

Stacked on #1836 (the version gating).

What

  • StepExecutionMode.Deterministic; ConditionStepDefinitionSchema accepts it with preRecordedArgs { optionConditions, fallbackOption } per the cross-repo contract. superRefine makes preRecordedArgs mandatory in that mode; operators and aggregators are enum-validated, so an unknown operator fails loud instead of evaluating to something arbitrary.
  • New deterministic branch at the top of doExecute: resolves each condition's value from the run history (sourceStepId + fieldName against the Get Data step's persisted fields, most-recent occurrence for loops), evaluates top-to-bottom with first-match-wins and and/or aggregation.
  • Pure evaluator (deterministic-condition-evaluator.ts): null/unresolvable value → met: null (not evaluable, never an error), type mismatch → false including for negated operators, ISO dates compared as timestamps.
  • No option matches → the Fallback option is selected. The step can never end undefined or in error because of data, per spec.
  • executionParams persisted as { evaluations[{ option, outcome, conditions[{ index, met }] }], selectedOption, usedFallback } — the data the run view needs to show why an option won.

Tests

Full package suite: 1575 passed / 0 failed. 24 evaluator unit tests (happy / null / type-mismatch per operator) + 10 deterministic-branch executor tests.

Part of PRD-472

🤖 Generated with Claude Code

Note

Add deterministic evaluation mode for condition steps without AI

  • Introduces a Deterministic execution type for condition steps in the workflow executor that evaluates conditions against prior Get Data step outputs, bypassing AI and user input entirely.
  • Adds deterministic-condition-evaluator.ts implementing operator semantics (equal, numeric/date ordering, in/not_in, contains, present/blank) with coercion for numeric strings and ISO dates.
  • The ConditionStepExecutor evaluates ordered optionConditions with and/or aggregation, selects the first matching option or uses fallbackOption, and persists a detailed evaluation trace to runStore.
  • Validation via ConditionStepDefinitionSchema now requires preRecordedArgs when executionType is Deterministic and rejects unknown operators or aggregators at parse time.
  • Risk: if the selected or fallback option is not present in step.options, the executor throws InvalidStepDefinitionError rather than silently failing.

Macroscope summarized 8d4ec64.

…ut AI

Decision steps in the new Automatic mode carry their branching logic as
build-time preRecordedArgs (optionConditions + fallbackOption, wire-final
operator names from the PRD-472 contract). The executor now resolves each
condition's value from the run's Get Data outputs and evaluates
top-to-bottom, first-match-wins — never calling the AI and never awaiting
input, because the builder chose this mode precisely to remove AI
judgement from the branch. A null/missing/unresolvable value is "not met"
(met: null), never an error, and no match selects the fallback, so the
step can never end undefined. The evaluation trace is persisted in
executionParams for the run view; unknown operators are rejected at the
schema boundary so a run never reaches evaluation with a comparison it
cannot honor.

Part of PRD-472

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 19, 2026

Copy link
Copy Markdown

PRD-472

@qltysh

qltysh Bot commented Aug 19, 2026

Copy link
Copy Markdown

5 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 4): doExecute 5

Comment on lines +7 to +12
function toTimestamp(value: unknown): number | null {
if (typeof value !== 'string' || !ISO_DATE_PREFIX.test(value)) return null;

const parsed = Date.parse(value);

return Number.isNaN(parsed) ? null : parsed;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High executors/deterministic-condition-evaluator.ts:7

toTimestamp accepts invalid calendar dates such as 2026-02-30 and converts them to normalized timestamps, so malformed values compare equal to 2026-03-02 and can satisfy equality, membership, or ordering conditions. Validate that the parsed date round-trips to the original calendar date before returning its timestamp.

-function toTimestamp(value: unknown): number | null {
-  if (typeof value !== 'string' || !ISO_DATE_PREFIX.test(value)) return null;
-
-  const parsed = Date.parse(value);
-
-  return Number.isNaN(parsed) ? null : parsed;
-}
+function toTimestamp(value: unknown): number | null {
+  if (typeof value !== 'string' || !ISO_DATE_PREFIX.test(value)) return null;
+
+  const datePart = value.slice(0, 10);
+  const normalized = new Date(`${datePart}T00:00:00.000Z`);
+  if (Number.isNaN(normalized.getTime()) || normalized.toISOString().slice(0, 10) !== datePart) {
+    return null;
+  }
+
+  const parsed = Date.parse(value);
+
+  return Number.isNaN(parsed) ? null : parsed;
+}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/executors/deterministic-condition-evaluator.ts around lines 7-12:

`toTimestamp` accepts invalid calendar dates such as `2026-02-30` and converts them to normalized timestamps, so malformed values compare equal to `2026-03-02` and can satisfy equality, membership, or ordering conditions. Validate that the parsed date round-trips to the original calendar date before returning its timestamp.

operator: z.enum(CONDITION_OPERATORS),
/** Absent for `present`/`blank`. */
value: z.unknown().optional(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High validated/step-definition.ts:69

DeterministicConditionSchema accepts { operator: 'equal' }, so the evaluator compares against undefined and silently falls through to the fallback option instead of rejecting the invalid workflow configuration. Add a refinement requiring value for every operator except present and blank.

Suggested change
});
}).superRefine((condition, ctx) => {
if (!['present', 'blank'].includes(condition.operator) && condition.value === undefined) {
ctx.addIssue({
code: 'custom',
path: ['value'],
message: 'value is required for this operator',
});
}
});
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/types/validated/step-definition.ts around line 69:

`DeterministicConditionSchema` accepts `{ operator: 'equal' }`, so the evaluator compares against `undefined` and silently falls through to the fallback option instead of rejecting the invalid workflow configuration. Add a refinement requiring `value` for every operator except `present` and `blank`.

Comment thread packages/workflow-executor/src/executors/deterministic-condition-evaluator.ts Outdated
.optional(),
})
// No silent fallback to manual/AI: a deterministic step without its conditions must fail loud.
.superRefine((step, ctx) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High validated/step-definition.ts:97

A deterministic condition definition with a stale fallbackOption or optionConditions[].option passes this schema and later returns status: 'success' with a selectedOption absent from step.options, so the gateway cannot select an outgoing branch and the workflow cannot advance. The superRefine check only requires preRecordedArgs for deterministic steps; it must also reject every configured option that is not present in step.options, matching the manual path's membership validation.

Also found in 1 other location(s)

packages/workflow-executor/src/executors/condition-step-executor.ts:137

evaluateDeterministically accepts matchedOption/fallbackOption without checking that the selected string exists in step.options, unlike readUserChoice. If preRecordedArgs is stale or malformed (for example, its fallback names a removed outgoing transition), the executor persists and returns success with a selectedOption that cannot identify any outgoing branch, so the workflow cannot advance correctly. Validate every configured option against step.options while mapping, or validate selectedOption before returning success.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/types/validated/step-definition.ts around line 97:

A deterministic condition definition with a stale `fallbackOption` or `optionConditions[].option` passes this schema and later returns `status: 'success'` with a `selectedOption` absent from `step.options`, so the gateway cannot select an outgoing branch and the workflow cannot advance. The `superRefine` check only requires `preRecordedArgs` for deterministic steps; it must also reject every configured option that is not present in `step.options`, matching the manual path's membership validation.

Also found in 1 other location(s):
- packages/workflow-executor/src/executors/condition-step-executor.ts:137 -- `evaluateDeterministically` accepts `matchedOption`/`fallbackOption` without checking that the selected string exists in `step.options`, unlike `readUserChoice`. If `preRecordedArgs` is stale or malformed (for example, its fallback names a removed outgoing transition), the executor persists and returns `success` with a `selectedOption` that cannot identify any outgoing branch, so the workflow cannot advance correctly. Validate every configured option against `step.options` while mapping, or validate `selectedOption` before returning success.

@qltysh

qltysh Bot commented Aug 19, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (4)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/adapters/server-types.ts100.0%
Coverage rating: A Coverage rating: A
...ges/workflow-executor/src/executors/condition-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/types/validated/step-definition.ts100.0%
New Coverage rating: A
...ow-executor/src/executors/deterministic-condition-evaluator.ts98.0%125
Total98.9%
🤖 Increase coverage with AI coding...
In the `feature/prd-472-condition-evaluator` branch, add test coverage for this new code:

- `packages/workflow-executor/src/executors/deterministic-condition-evaluator.ts` -- Line 125

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

…safe and routable

The deterministic evaluator could turn a data or config mismatch into a silent
misroute — a `status: 'success'` step carrying the wrong branch.

- Numeric strings: Sequelize returns Postgres/MySQL `numeric`/`decimal`/`bigint`
  columns as strings while datasource-sequelize maps those types to the `Number`
  primitive, so the builder's `value: 100` met `"150.00"` at runtime and every
  comparison bailed → no option matched → silent fallback. A strictly numeric
  string is now coerced against a real number (both sides stay uncoerced when
  neither is a number, since ordering operators are Number/Date-only per the
  contract); `'abc'` vs `100` is still not evaluable.
- Selected option: the deterministic path emitted `matchedOption ?? fallbackOption`
  without checking `step.options`, unlike the manual path. `optionConditions` and
  `options` are two different server-side derivations, so drift produced a success
  outcome the orchestrator cannot route and the run died far from the cause. It now
  throws `InvalidStepDefinitionError` before persisting anything.
- `not_equal` contradicted the file's own documented policy ("a type mismatch can
  never satisfy a negated operator") by returning true on mismatch; equality is now
  tri-state, so a mismatch satisfies neither `equal` nor `not_equal`.
- Timezone: an offset-less ISO datetime was parsed host-local, so "deterministic"
  evaluation varied per machine. Offset-less datetimes are read as UTC, pinned by a
  test that runs under a non-UTC TZ.
- `contains`/`not_contains` were extended to array membership beyond the contract
  (§1: String only). Restricted back to strings — dead flexibility the builder never
  emits, and the "mismatch is never satisfied" invariant keeps it from misrouting.
- Removed the unreachable `default` branch by replacing the operator switch with an
  exhaustive lookup keyed by `ConditionOperator`, so a new operator fails to compile
  instead of silently returning null (lint's `default-case` forbids a bare switch).

Also asserts three spec behaviors that were unasserted: deterministic mode ignores
`incomingPendingData`, `or` matches on a mix of not-evaluable and true, and an
unresolvable reference satisfies neither `blank` nor `present`.

Part of PRD-472

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Scra3
Scra3 marked this pull request as ready for review August 19, 2026 06:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant