Skip to content

feat(workflow-executor): run steps once per record of a Multiple related-records step - #2026

Open
hercemer42 wants to merge 12 commits into
mainfrom
feature/prd-1693-phase-1-multiple-related-records-load-several-records-and
Open

hercemer42 wants to merge 12 commits into
mainfrom
feature/prd-1693-phase-1-multiple-related-records-load-several-records-and

Conversation

@hercemer42

@hercemer42 hercemer42 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Phase 1 of multiple related records, Forest Runtime side. Part of PRD-1693 (phase 1 of PRD-24). Nothing reaches customers until the go-live ticket PRD-1695 sets the front's version floors: before that the editor never offers Multiple, so no definition carries multiple or quantifier.

What changes

  1. A Load Related Record step loads several records. Before: one record per step. After: with preRecordedArgs.multiple, the step lists up to 50 candidates (hasMoreRecords when the list is cut), the AI pre-checks its pick in suggestedRecords, told which collection the candidates come from, the confirmation's selectedRecordIds loads every record picked, each among those offered, and the step saves executionResult: { relation, records }. A Multiple step's relation cannot be switched at run time, and a record picked twice is refused. Full AI loads its pick only when it saw every candidate, picked some and is sure, and pauses with the pick pre-checked otherwise.
  2. Steps sourced from it run once per record. Before: Get Data, Update Data, Trigger Action and Load Related Record ran once on one record. After: sourced from a Multiple step, they run once per record, in order, each through the unchanged single-record executor over a run store narrowed to that record, and save iterations[] on their row. Every record is attempted, the step errors when any failed, and { failedRecords: "retry" | "skip", sourceRecordId? } re-runs or skips the failed ones. { unfinishedRecords: "handle-manually", sourceRecordId } on the paused record hands every record not finished over at once, as a decline hands over one, so the step still errors over a record that failed. The step timeout applies per record, and a timed-out record's late result is never saved.
  3. A Decision branches on any, every or no record. Before: a deterministic condition read one Get Data value. After: a condition on per-record Get Data carries quantifier and records { matched, evaluated, unchecked? } in its trace. A record without a value leaves it undecided unless the records read settle it (one match for any and none, one non-match for every), conditions and options combine three-valued, and only when an undecided condition could change the branch does the Decision decide nothing: it reports awaiting-input with executionParams.unchecked, and the operator's { selectedOption } completes it with selectedBy: "operator".
  4. The run lock lasts the whole loop. Before: a runtime kept a run only by reporting a step, so 50 records could outlast the 7-minute lock. After: the runner renews it through POST /renew-run-lock before each record, stops the step without reporting when the orchestrator refuses (aborted or lost), and hands the run back when stopping. A step dispatched while carrying an error sends that error cleared when it next succeeds or pauses; every other report is unchanged.

Release plan

Merges and deploys after ForestAdmin/forestadmin-server#8634, which adds the renewal route. Against an orchestrator without it the renewal fails as unavailable and the step carries on under the lock it holds, so an early release degrades to today's lock behaviour rather than breaking.

Decisions

  • PRD-1693: 1 functional, 1 architectural · 2 unticked

Tests

Red before the code (Tests: 18 failed, 112 passed, 130 total, 11 suites failing on the missing members), green after across the 19 files covering the changed sources (Test Suites: 19 passed, 19 total, Tests: 1081 passed, 1081 total). New: per-record-step-executor.test.ts, through StepExecutorFactory.create with the in-memory store. Later commits added 12 tests, each seen red before its fix: the relation-switch refusal and the review findings. None reaches shared state.

Definition of Done

General

  • Write an explicit title for the Pull Request, following Conventional Commits specification
  • Test manually the implemented changes
  • Validate the code quality (indentation, syntax, style, simplicity, readability)

Security

  • Consider the security impact of the changes made: step outcomes still carry no client data. Per-record results, the AI's pick and the unchecked record ids stay in the run store, on the client's side, and the renewal sends only the run id and its lock.

Squash message

feat(workflow-executor): run steps once per record of a Multiple related-records step

A Load Related Record step loaded one record, so a case with several UBOs
needed one workflow per UBO. A Multiple step now loads up to 50 records the
operator picks, each at most once, and every Get Data, Update Data, Trigger
Action and single Load Related Record sourced from it runs once per record,
in order and independently. Failed records are retried or skipped on their
own, handing the rest over by hand keeps a failed record's error, a timed-out record never writes its late result or starts its action,
and a Decision on per-record data branches on any, every or no record,
pausing for the operator when a record has no value. The runner renews the
run lock before each record and hands the run back when stopping, and a step
re-run after an error reports that error cleared.

🤖 Generated with Claude Code

Note

Add per-record execution for steps sourced from a Multiple related-records step

  • Adds a multiple flag to Load Related Record step definitions. Steps pinned to such a source (ReadRecord, UpdateRecord, TriggerAction, LoadRelatedRecord) now run once per selected record via a new PerRecordStepExecutor with iteration-level storage, summaries, and wire serialization (step-executor-factory.ts, step-execution-data.ts)
  • Adds an AI-assisted related-record picker and condition quantifiers (any, every, none) with new typed errors for invalid configurations (related-record-picker.ts, errors.ts)
  • The runner renews the run lock with the orchestrator before each record; a refused renewal stops the loop, an unavailable one continues, per the accepted ADR
  • HTTP confirmation payloads accept up to 50 selected records, deserialized from pipe-separated IDs (pending-data-validators.ts)
  • Behavioral Change: Runner.executeStep and dispatchDetached now take the dispatch object instead of separate step/token arguments; stale errors are cleared only for steps marked carriesError: true. Sub-workflow calls pinned to a multi-record source now fail with SourceLoadsSeveralRecordsError

Macroscope summarized e29023f.

@linear-code

linear-code Bot commented Oct 20, 2026

Copy link
Copy Markdown

PRD-1693

@qltysh

qltysh Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

30 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 6): doExecute 18
qlty Structure Function with many parameters (count = 4): evaluateCondition 6
qlty Structure Function with high complexity (count = 12): doExecute 5
qlty Structure High total complexity (count = 70) 1

@qltysh

qltysh Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (19)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/runner.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/executors/step-executor-factory.ts100.0%
Coverage rating: A Coverage rating: A
...ow-executor/src/executors/load-related-record-step-executor.ts97.2%604, 613
Coverage rating: A Coverage rating: A
...orkflow-executor/src/executors/summary/step-summary-builder.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/executors/record-step-executor.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/http/pending-data-validators.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/executors/base-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
.../workflow-executor/src/adapters/forest-server-workflow-port.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/errors.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/types/validated/step-definition.ts100.0%
Coverage rating: A Coverage rating: A
...ow-executor/src/adapters/step-outcome-to-update-step-mapper.ts100.0%
Coverage rating: A Coverage rating: A
...ow-executor/src/executors/summary/step-execution-formatters.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/http/step-serializer.ts100.0%
New file Coverage rating: A
packages/workflow-executor/src/executors/iteration-run-store.ts100.0%
New file Coverage rating: A
packages/workflow-executor/src/executors/related-record-picker.ts100.0%
New file Coverage rating: A
packages/workflow-executor/src/types/step-execution-data.ts100.0%
New file Coverage rating: A
...es/workflow-executor/src/executors/per-record-step-executor.ts100.0%
Total99.6%
🤖 Increase coverage with AI coding...
In the `feature/prd-1693-phase-1-multiple-related-records-load-several-records-and` branch, add test coverage for this new code:

- `packages/workflow-executor/src/executors/load-related-record-step-executor.ts` -- Lines 604 and 613

🚦 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.

@hercemer42
hercemer42 marked this pull request as ready for review October 20, 2026 17:30

@hercemer42 hercemer42 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Spec (PRD-1693): conforms. PRD-1693 has 5 unvalidated decisions: not final.

Applies to: the PR as a whole
Claude Opus 5.5 (claude-opus-5-5): Preferential: the run lock renewal before each record has no ADR, so the next reader cannot learn why a step calls the orchestrator mid-step.

Code

Released runtimes call renew-run-lock on another repository, a reader asks why a step skips handing back, and two alternatives were turned down.

A way out: record it with /adr.

@matthv matthv self-assigned this Oct 21, 2026
@hercemer42

Copy link
Copy Markdown
Contributor Author

Claude Opus 5.5 (claude-opus-5-5): The missing ADR is now docs/adr/2026-10-21-a-step-running-once-per-record-renews-its-run-lock-with-the-orchestrat.md, recording the run lock renewal.

c8586d1

@hercemer42

Copy link
Copy Markdown
Contributor Author

Closed, the run lock renewal is now an ADR in docs/adr/, with the two alternatives it turned down.

How it was checked

The predicate is a hard-to-reverse decision no ADR records.
The ADR names the route released runtimes call, its refusal and unanswered cases, and the hand-back and one-record dispatch it rejected.

@hercemer42
hercemer42 marked this pull request as draft November 3, 2026 14:27
@hercemer42
hercemer42 force-pushed the feature/prd-1693-phase-1-multiple-related-records-load-several-records-and branch from 1b131d7 to 03c3f0c Compare November 3, 2026 15:34
hercemer42 and others added 12 commits November 5, 2026 14:48
…ted-records step

A Load Related Record step could load one record, so a KYC case with several
UBOs needed one workflow per UBO. A Multiple step now loads up to 50 records
the operator picks (the AI pre-checks its pick; Full AI loads it only when it
saw every candidate and is sure), and every Get Data, Update Data, Trigger
Action and single Load Related Record sourced from it runs once per record, in
order, each record independently through the unchanged single-record executor.
Failed records are retried or skipped on their own, and a Decision reading
per-record data branches on any, every or no record, pausing for the operator
when a record has no value.

A record loop can outlast the run lock, so the runner renews it before each
record and yields the run when stopping. A step re-run after an error reports
the error cleared, since the orchestrator merges each report into the context
the error left.

Refs: PRD-1693

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t run time

The run view never switches the relation of a step loading several records,
and a request to would load another relation's records than the step was
built for. A relation preview or a confirmation naming another relation now
fails the step instead.

Refs: PRD-1693

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- "none" on a single-record source negates its one value, as it does over records.
- A record picked twice is refused, so no step runs twice on it.
- Each mode refuses the other's pick, and a relation override needs one record.
- An answered record runs before the run is handed back, so the answer is not lost.
- A record's schema read for its AI label no longer fails the whole step.
- Lint formatting on the files this change touches.

Refs: PRD-1693

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rator's branch

- A record run abandoned at its timeout now has its saves refused, so a
  mutation it had not started stops at its write-ahead marker instead of
  running after the record was marked failed.
- A Decision completed by the operator replays that branch when dispatched
  again, instead of evaluating the same unchecked records and pausing anew.
- Lint formatting on the tests added in the previous round.

Refs: PRD-1693

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… gate found untested

The iteration store's pass-through saves, deletes and rows, a Multiple step
on a to-one relation, a Multiple confirmation that picks nothing or whose
relation no longer resolves, an AI pick outside the list, a Load Related
Record and a Trigger Action running once per record, and an unexpected
failure of a step running once per record.

Refs: PRD-1693

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… steps

A record whose change was cut off by a restart or the step timeout read as a plain failure, and
Retry ran the change again with nothing telling the operator it may already have landed. A
record timing out left no log line. A Sub-workflow pinned to a Multiple step failed saying that
step loaded no record. The step error dropped the kind every failed record shared, and the
runner logged an abort as a lost lock.

The cut-off record now says its change may already be applied, the timeout logs the record, a
Sub-workflow on a Multiple step fails as a call-site error, the step carries the shared kind,
and the factory and the record executor find a pinned step through one lookup. The AI
candidate ranking moves to related-record-picker.ts and the two persist methods merge, taking
the Load Related Record executor back under 1,000 lines. The per-record tests now assert which
records ran and as whom, and cover the call frame and a per-record Load Related Record offered
as candidates. The run lock renewal is recorded as an ADR.

Refs: PRD-1693

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…requires

Refs: PRD-1693

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fter a Multiple step

Only the routing in StepExecutorFactory keeps an MCP or Guidance step after a
Multiple step from running once per record, and no test covered it: a change
that sent every step after a Multiple step through PerRecordStepExecutor would
have passed the suite. The new cases fail on exactly that change.

Refs: PRD-1693

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… several records

The AI picking a Multiple step's records was told which collection they come
from only in the field selection, not in the record selection. It then judged
whether each candidate was what the request named: on "the dvds priced above
$8" over titles reading like bicycles, 3 of 8 runs marked a correct pick as
unsure ("I'm not confident they are DVDs"), which makes Full AI pause instead
of loading, and one picked none. With the collection named, 20 of 20 picks
were sure and correct.

Refs: PRD-1693

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… over through the runtime

Handle the rest manually completed the step at the orchestrator, so a record
that had failed before it was dropped with no retry or skip, unlike handling
each record by hand. The runtime now takes { unfinishedRecords:
"handle-manually", sourceRecordId } on the paused record: every record not
finished is handed over at once, as a decline would hand over one, and the
step then ends as usual, in error when a record failed.

Refs: PRD-1693

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a value that cannot change its branch

A quantified condition paused the Decision whenever one record had no value,
even when the records read already answered it: "any UBO is a PEP" with one
PEP found waited for the operator over a UBO that could only add another.
The records read now settle a condition when they can (one match for any and
none, one non-match for every), conditions and options combine three-valued,
and the Decision pauses only when an unchecked record could change the
branch. Each quantified condition lists its unchecked records in its trace.

Refs: PRD-1693

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt requires

Refs: PRD-1693

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hercemer42
hercemer42 force-pushed the feature/prd-1693-phase-1-multiple-related-records-load-several-records-and branch from 655947a to e29023f Compare November 5, 2026 13:49
@hercemer42
hercemer42 marked this pull request as ready for review November 5, 2026 15:47

This branch has not been deployed

No deployments
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