Relationships
#279 Resolve self.* expressions in modelIdOrName during forEach expansion
Opened by webframp · 5/7/2026· Shipped 5/7/2026
Problem Statement
When using forEach to iterate over a list and target different model instances per iteration, self.* expressions in modelIdOrName are never resolved. This prevents a common pattern where a workflow needs to fan out across multiple model instances named by convention.
Example that doesn't work today:
steps:
- name: summary-${{ self.region }}
forEach:
item: region
in: ${{ inputs.regions }}
task:
type: model_method
modelIdOrName: aws-alarms-${{ self.region }}
methodName: get_summary
inputs:
historyHours: 24The step name resolves correctly, task.inputs resolves correctly, but modelIdOrName arrives at findDefinitionByIdOrName() as the literal string aws-alarms-${{ self.region }} and fails with "Model not found".
Root Cause
There are two expansion paths, and neither resolves self.* in modelIdOrName:
Pre-evaluation (
src/libswamp/workflows/evaluate.ts):resolveForEachTaskExpressions()only resolves expressions intask.inputsandtask.args. Its docstring explicitly says: "Resolves forEach self.* expressions in a task's inputs and args."Runtime (
src/domain/workflows/execution_service.ts):evaluateWorkflow()skipsself.*expressions (correctly — self doesn't exist yet).expandForEachSteps()creates theforEachVarcontext but passes the original step with unresolvedmodelIdOrNameto execution. ThestepExprContextwithselfpopulated is available inStepExecutionContext, butexecuteModelMethod()usestask.modelIdOrNamedirectly at line 224 without resolving it first.
Note: ${{ inputs.* }} in modelIdOrName works fine (confirmed by PR #543 fix) because those are resolved during evaluateWorkflow() before forEach expansion.
Proposed Solution
Resolve self.* expressions in modelIdOrName (and methodName) during forEach expansion. Two possible approaches:
Option A — Evaluate-time (in resolveForEachTaskExpressions):
// Also resolve modelIdOrName and methodName
if (expandedTask.modelIdOrName && typeof expandedTask.modelIdOrName === 'string') {
const match = expandedTask.modelIdOrName.match(/\$\{\{\s*(.+?)\s*\}\}/);
if (match && !containsRuntimeExpression(match[1])) {
try {
expandedTask.modelIdOrName = String(deps.evaluateCel(match[1], stepContext));
} catch { /* leave as-is */ }
}
}Option B — Runtime (in DefaultStepExecutor.execute):
Resolve any remaining ${{ self.* }} in task.modelIdOrName using ctx.expressionContext before calling executeModelMethod().
Option A is preferred since it also makes --last-evaluated output show the resolved model names.
Alternatives Considered
- Nested workflow workaround: Pass region as input to a child workflow where
${{ inputs.region }}resolves inmodelIdOrName. Works but adds unnecessary complexity. - Fan-out model method: A single model that accepts a list of targets internally. Loses the parallelism and composability of forEach.
Use Case
Multi-region/multi-account AWS monitoring workflows where model instances follow a naming convention (e.g., aws-alarms-us-east-1, aws-alarms-eu-west-1) and the workflow should dynamically target them based on user-provided region list.
Discovery context: https://github.com/webframp/swamp-extensions/issues/58
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.