Skip to content

feat(workflow-executor): evaluate the operators a list view filter offers, relative dates included - #1880

Open
Scra3 wants to merge 10 commits into
mainfrom
feature/prd-1147-list-filter-operators
Open

feat(workflow-executor): evaluate the operators a list view filter offers, relative dates included#1880
Scra3 wants to merge 10 commits into
mainfrom
feature/prd-1147-list-filter-operators

Conversation

@Scra3

@Scra3 Scra3 commented Sep 4, 2026

Copy link
Copy Markdown
Member

fixes PRD-1147, fixes PRD-1130

Why

A deterministic Decision on a Date field could only say is, is not, is present, is blank. Forest exposes before/after and a relative family for dates, none of which were in the contract, so the first condition anyone writes on a date could not be written. Meanwhile the contract carried three operators (>=, <=, not_in) that the editor can never build, since the front registry does not define them.

What

Operator set = what a list view filter offers. CONDITION_OPERATORS goes from 12 to 23: the three orphans leave, fourteen arrive.

Family Operators
Absolute dates before, after
Relative dates past, future, today, yesterday, previous_x_days, previous_x_days_to_date, before_x_hours_ago, after_x_hours_ago
Strings starts_with, ends_with, i_contains
Arrays includes_all

Relative dates read an injected Clock, { now, timezone }, never the machine's. The evaluator stays pure. The step executor builds one clock per step so every row of a Decision sees the same instant.

Timezone is the project's, carried on the run. AvailableStepExecution.timezone is new; the mapper falls back to UTC when the project has none or the orchestrator predates the field. The machine's zone is never used: the fleet runs several executor instances and the same run must route the same on each.

Semantics mirror datasource-toolkit's time transforms, which is what the list filter runs on: day windows exclude their end and, like the toolkit's GreaterThan / GreaterThanOrEqual split, include their start for a calendar date only, previous_x_days excludes today, previous_x_days_to_date includes it up to the instant. A calendar date (Dateonly) is read at midnight in the project zone, otherwise a Honolulu record's own today is counted as yesterday. i_contains folds case and keeps accents, measured on Postgres: 'É' ILIKE '%é%' holds, 'é' ILIKE '%e%' does not.

The trace records evaluatedAt and timezone. A relative condition makes routing depend on the clock; without the instant, a check on "previous 7 days" cannot be explained a day later.

Decisions taken with product (on PRD-1147)

  • Dates are evaluated at the instant the condition is evaluated.
  • Timezone: project's, fallback UTC. Not the user's (automated runs have none), not the host's.
  • A retry may route differently. Accepted.
  • All operators ship together.

Compatibility

Dropping greater_than_or_equal, less_than_or_equal and not_in makes a published workflow that still carries one of them fail at parse on this executor (and be refused by the orchestrator's parser). The editor never offered them, and the deterministic Decision has only been open on executor 1.27.0 since 2 September, so no such workflow should exist; a hand-edited BPMN is the only way to have one. The orchestrator and the front move their gate back to the sentinel until this ships, since 1.27.0 refuses the fourteen new operators.

Paired with

  • forestadmin-server: the contract list, the value rules per operator, and sending project.timezone on the run.
  • forestadmin: the editor's allowed list and the run view.

Until the orchestrator sends the timezone, relative dates resolve in UTC.

Tests

1724 passing. Every new operator is pinned with a fixed clock in Europe/Paris, the calendar-date case in Pacific/Honolulu, and the condition executor proves the context timezone reaches the evaluator with fake timers straddling midnight.

Note

Add list-view filter operators and timezone-aware date evaluation to conditions

  • Removes greater_than_or_equal, less_than_or_equal, and not_in from CONDITION_OPERATORS in step-definition.ts; adds includes_all, not_contains, starts_with, ends_with, i_contains, before, after, past, future, today, yesterday, previous_x_days, previous_x_days_to_date, before_x_hours_ago, and after_x_hours_ago
  • Introduces an injected Clock in deterministic-condition-evaluator.ts so relative date and time operators evaluate against a captured instant and project timezone; ConditionStepExecutor persists the evaluation timestamp and timezone in execution params
  • Adds a required timezone field to ExecutionContext and AvailableStepExecutionSchema, validated as an IANA zone via Luxon; toAvailableStepExecution in run-to-available-step-mapper.ts forwards the run timezone and falls back to UTC for missing or invalid values
  • Adds luxon as a runtime dependency in package.json for IANA zone validation and timezone-aware date math
  • Behavioral Change: ConditionStepDefinitionSchema rejects the three removed operators at the schema boundary; AvailableStepExecutionSchema rejects available-step values that omit timezone or contain an invalid IANA zone; ExecutionContext.timezone is now required, so all callers and test fixtures must supply it; greater_than and less_than now use strict comparison instead of inclusive

Changes since #1880 opened

  • Added support for 8 new calendar-period operators to the workflow-executor package's deterministic condition evaluation system [0148a2a]
  • Updated documentation to reflect the expanded operator set and new calendar-period functionality [0148a2a]
  • Refactored memberOf utility function in deterministic-condition-evaluator to return tri-state results (true/false/null) instead of boolean, added not_in operator to the EVALUATORS map that evaluates to true only when candidate is definitively not a member of the expected list, and updated in operator to check for definite membership using tri-state result [fe1ff08]
  • Extended the CONDITION_OPERATORS constant in step-definition types to include not_in operator, increasing the total count from 31 to 32 operators [fe1ff08]
  • Added test coverage for not_in operator behavior and updated operator validation tests to accept not_in as a valid operator [fe1ff08]
  • Updated documentation in CLAUDE.md to reflect the addition of not_in operator and clarify tri-state membership semantics [fe1ff08]
  • Implemented time-of-day parsing infrastructure converting time strings to milliseconds since midnight [ceaaae3]
  • Updated time-of-day comparison operations in condition evaluator to use numeric millisecond values instead of string comparison [ceaaae3]
  • Added test coverage for fractional second handling and invalid time behavior in time-of-day comparisons [ceaaae3]

Macroscope summarized 3486a64.

@linear-code

linear-code Bot commented Sep 4, 2026

Copy link
Copy Markdown

PRD-1147

PRD-1130

@qltysh

qltysh Bot commented Sep 4, 2026

Copy link
Copy Markdown

7 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 4): evaluateDeterministically 5
qlty Structure High total complexity (count = 74) 1
qlty Structure Function with many parameters (count = 4): evaluateOperator 1

Comment thread packages/workflow-executor/src/types/validated/execution.ts Outdated
@qltysh

qltysh Bot commented Sep 4, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (5)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/types/validated/execution.ts100.0%
Coverage rating: A Coverage rating: A
...ow-executor/src/executors/deterministic-condition-evaluator.ts100.0%
Coverage rating: A Coverage rating: A
...ges/workflow-executor/src/executors/condition-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
...workflow-executor/src/adapters/run-to-available-step-mapper.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/types/validated/step-definition.ts100.0%
Total100.0%
🚦 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.

alban bertolini and others added 4 commits September 7, 2026 09:54
…fers, relative dates included

The deterministic Decision accepted 12 operators, three of which the editor could
never build, and none of the date family beyond equality. A Decision on a date
could not say "before", "after" or "in the previous 7 days", which is the first
thing anyone writes on a date.

The operator set is now the one a list view filter offers, 23 names: the three
orphans (>=, <=, not_in) go, and 14 arrive — before/after, past/future,
today/yesterday, previous_x_days(_to_date), before/after_x_hours_ago,
starts_with/ends_with/i_contains, includes_all.

Relative dates read an injected Clock, never the machine's. The orchestrator
sends the project's timezone on the run (mapper falls back to UTC when unset or
when the orchestrator predates it); the step executor builds one clock per step
so every row sees the same instant. Day windows are half open in that zone,
mirroring datasource-toolkit's time transforms, and a calendar date is read at
midnight in that zone rather than UTC, otherwise Honolulu's own today would count
as yesterday. i_contains folds case and keeps accents, measured against Postgres
ILIKE. The trace persists evaluatedAt and timezone, without which a check on
"previous 7 days" cannot be explained a day later.

Product decisions recorded on PRD-1147: evaluation instant, project timezone,
Postgres semantics for text, all operators in one delivery, and a retry that may
route differently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…idate the project zone

The start of a day window is included for a calendar date and excluded for a datetime, as
datasource-toolkit's time transforms do. An unknown IANA zone falls back to UTC. includes_all
is never met on an empty list. The SQL datetime form with a space is read as UTC too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… parse Postgres datetime forms

A mid-day bound (past, before_x_hours_ago, the end of previous_x_days_to_date) is read at the
start of its day when the value is a calendar date, as the toolkit formats the bound with
toISODate for a Dateonly column. The SQL datetime form with a bare-hour or detached offset
parses again. Schema tests cover every operator of the contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Scra3
Scra3 force-pushed the feature/prd-1147-list-filter-operators branch from 45b4bf8 to 82e89d6 Compare September 7, 2026 07:54
alban bertolini added 2 commits September 7, 2026 10:18
…compare

The builder offers is greater than and is less than on a Time column, and both were never met:
compare had no branch for a time of day, so the step routed to the fallback on every record with
nothing in the trace to explain it.
Comment thread packages/workflow-executor/src/executors/deterministic-condition-evaluator.ts Outdated
The builder's time widget writes the hour and minutes only while a Time column holds the seconds
too, so is, is not and is in on a Time column could never be met from the editor.

@hercemer42 hercemer42 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Spec (PRD-1147, PRD-1130): conforms — the 23-operator set, the injected clock, the project-zone/UTC rule and the evaluatedAt/timezone trace are in place and pinned; "today included up to the instant" for previous_x_days_to_date holds for datetime columns, and a Dateonly column excludes today exactly as the list filter does (deliberate, documented in the package CLAUDE.md).

Applies to: the PR as a whole
Claude Fable 5.1 (claude-fable-5-1): Should fix — The next engineer touching either half of the relative-date rule (the front anchoring the list filter on the browser's zone, the orchestrator sending project.timezone) finds no decision record outside this package's CLAUDE.md and PRD-1147.
The diff fixes two cross-repo decisions that are hard to reverse once workflows are published on them: relative dates evaluate in the project's zone with a UTC fallback, never the viewer's or the host's (run-to-available-step-mapper.ts:182-190), and i_contains follows Postgres ILIKE across dialects (deterministic-condition-evaluator.ts:279-283); no ADR in docs/adr/** covers either (the org-wide search on timezone returns nothing).
Record them with /adr, one per decision.


// Without this, "is greater than" on a Time column can never be met: the builder offers the
// operator, and the step would route to the fallback on every record without saying why.
const actualTime = toTimeOfDay(actual);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Applies to: deterministic-condition-evaluator.ts:111-125 (scalarEqual, not in this diff). This line is where the time-of-day rule is applied, and it is the only place.
Claude Fable 5.1 (claude-fable-5-1): Should fix — A Decision "is 08:30" on a Time column never matches a record stored as 08:30:00, and "is not 08:30" matches it, while "is greater than" and "is less than" both treat the pair as one instant.
compare pads and orders a time of day at :161-168, but scalarEqual (:111-125), which backs equal, not_equal, in and includes_all, still ends on typeof actual === typeof expected ? false : null, so '08:30:00' and '08:30' are two different strings; a Postgres or MySQL Time column arrives as HH:mm:ss and the builder's <input type="time"> writes HH:mm.
Parse both sides to milliseconds since midnight in one helper used by scalarEqual and compare, which also settles the fractional-precision ordering already raised on this PR.

Reproduction
  1. Time column openingTime stored 08:30:00 in Postgres, read by a Get Data step.
  2. Decision: option A openingTime is 08:30 (the time widget writes 08:30), option B openingTime is not 08:30, fallback C.
  3. Run on that record: A is met: false, B is met: true; the list view filter "is 08:30" matches the same row.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Right on both counts, and fixed in ceaaae3 — with the parse you suggest rather than the padding I had.

toMillisOfDay parses a time of day to milliseconds since midnight, and both scalarEqual and compare use it, so equal, not_equal, in, includes_all, greater_than and less_than all read 08:30 and 08:30:00 as one instant. That also settles the fractional-precision ordering raised by the bot on the same lines: 08:30:00.1 and 08:30:00.10 now compare equal instead of ordering by text length. A time nobody can be at (25:00, 08:70) parses to nothing rather than to a number.

Six assertions cover it: the two formats through equality and membership, the fraction, the out-of-range hours and minutes, and a non-time value.

Worth noting the starting point, since your reproduction understates it: before this PR greater_than and less_than on a Time column were never met at all, in any format — compare had no branch for a time of day, so the step took the fallback on every record with nothing in the trace.

alban bertolini added 3 commits September 7, 2026 20:43
previous week, month, quarter and year, and the same four up to the clock, with the toolkit's
bounds: the whole previous period end excluded, or the current one from its start to now.
The Filters registry gained is not in (#9957), so the Decision offers it too. Membership answers
true, false or not-comparable, so a value the members cannot be compared to does not satisfy the
negated operator.
Comparing the strings ordered 08:30:00.10 before 08:30:00.1, the same instant written twice, and
left equality to a padding rule. One parse now backs equality and ordering, and a time nobody can
be at is refused.
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.

2 participants