Description
In declarative workflows, when a Foreach action iterates over a table whose rows are multi-field records, the loop value variable receives only the first field's value of each record instead of the full record.
For example, iterating over [{ name: "Alice", role: "Engineer" }, ...] assigns "Alice" to Local.currentItem rather than the whole { name, role } record, so Local.currentItem.role is unavailable inside the loop body.
This is distinct from #4195 (parsing produced empty records). Here the records are correctly populated, but ForeachExecutor deliberately reduces each one to its first property.
To Reproduce
1. Workflow YAML
kind: Workflow
name: repro-foreach-first-field
trigger:
kind: OnConversationStart
id: trigger
actions:
- kind: SetVariable
id: set_people
variable: Local.people
value: |
=Table(
{ name: "Alice", role: "Engineer" },
{ name: "Bob", role: "Designer" },
{ name: "Carol", role: "PM" }
)
- kind: Foreach
id: iterate_people
items: =Local.people
value: Local.currentItem
index: Local.index
actions:
- kind: SendActivity
id: show_item
activity: "item={Local.currentItem} role={Local.currentItem.role}"
- kind: EndWorkflow
id: end
2. Expected behavior
Local.currentItem should be the full record for each iteration, so show_item prints e.g. item={ name: "Alice", role: "Engineer" } role=Engineer.
3. Actual behavior
Local.currentItem is just the first field's value ("Alice"), and Local.currentItem.role resolves to blank. The whole record is lost.
Root Cause
ForeachExecutor.ExecuteAsync collapses each row to its first property:
// ForeachExecutor.cs, line 47
this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormula())];
tableValue.Values is a sequence of RecordDataValue. Calling .Properties.Values.First() keeps only the first field's value and discards the rest of the record.
The framework already has the correct conversion elsewhere — DataValueExtensions.ToFormula maps each row through value.ToRecordValue(), preserving all fields:
TableDataValue tableValue =>
FormulaValue.NewTable(
tableValue.Values.FirstOrDefault()?.ParseRecordType() ?? RecordType.Empty(),
tableValue.Values.Select(value => value.ToRecordValue())),
...
RecordDataValue recordValue => recordValue.ToRecordValue(),
ForeachExecutor simply does not use that path.
Proposed Fix
In ForeachExecutor.ExecuteAsync, convert each row as a whole value so records keep every field:
- this._values = [.. tableValue.Values.Select(value => value.Properties.Values.First().ToFormula())];
+ this._values = [.. tableValue.Values.Select(value => value.ToFormula())];
Each RecordDataValue then routes through the RecordDataValue recordValue => recordValue.ToRecordValue() branch of ToFormula, producing a full RecordValue. This makes Local.currentItem.<field> work directly and removes the need for the common user-side workaround of declaring an index and using Index(originalCollection, Local.index + 1).FieldName.
Tests Gap
The existing tests in ForeachExecutorTest.cs only build records with a single field (e.g. value/item), so .First() happens to return the correct value and the bug stays hidden. A test that iterates over multi-field records and asserts value is the full record would catch this regression.
Related
Package Versions
Reproduced against origin/main (ForeachExecutor.cs line 47 unchanged as of filing; file last modified 2026-02-12, #3835).
.NET Version
.NET 8.0
Description
In declarative workflows, when a
Foreachaction iterates over a table whose rows are multi-field records, the loopvaluevariable receives only the first field's value of each record instead of the full record.For example, iterating over
[{ name: "Alice", role: "Engineer" }, ...]assigns"Alice"toLocal.currentItemrather than the whole{ name, role }record, soLocal.currentItem.roleis unavailable inside the loop body.This is distinct from #4195 (parsing produced empty records). Here the records are correctly populated, but
ForeachExecutordeliberately reduces each one to its first property.To Reproduce
1. Workflow YAML
2. Expected behavior
Local.currentItemshould be the full record for each iteration, soshow_itemprints e.g.item={ name: "Alice", role: "Engineer" } role=Engineer.3. Actual behavior
Local.currentItemis just the first field's value ("Alice"), andLocal.currentItem.roleresolves to blank. The whole record is lost.Root Cause
ForeachExecutor.ExecuteAsynccollapses each row to its first property:tableValue.Valuesis a sequence ofRecordDataValue. Calling.Properties.Values.First()keeps only the first field's value and discards the rest of the record.The framework already has the correct conversion elsewhere —
DataValueExtensions.ToFormulamaps each row throughvalue.ToRecordValue(), preserving all fields:ForeachExecutorsimply does not use that path.Proposed Fix
In
ForeachExecutor.ExecuteAsync, convert each row as a whole value so records keep every field:Each
RecordDataValuethen routes through theRecordDataValue recordValue => recordValue.ToRecordValue()branch ofToFormula, producing a fullRecordValue. This makesLocal.currentItem.<field>work directly and removes the need for the common user-side workaround of declaring anindexand usingIndex(originalCollection, Local.index + 1).FieldName.Tests Gap
The existing tests in
ForeachExecutorTest.csonly build records with a single field (e.g.value/item), so.First()happens to return the correct value and the bug stays hidden. A test that iterates over multi-field records and assertsvalueis the full record would catch this regression.Related
JsonDocumentExtensions.cs(preventing empty records) and did not changeForeachExecutor. After that fix, records are populated correctly, yetForeachstill discards all but the first field — so this issue remains.Package Versions
Reproduced against
origin/main(ForeachExecutor.csline 47 unchanged as of filing; file last modified 2026-02-12, #3835)..NET Version
.NET 8.0