Skip to content

Step Functions

Protocol: AWS JSON 1.0 (X-Amz-Target: AWSStepFunctions.*) Endpoint: POST http://localhost:4566/

Supported Actions

Action Description
CreateStateMachine Create a state machine (Standard or Express)
UpdateStateMachine Update definition, role, logging, tracing, or encryption settings and optionally publish a version
DescribeStateMachine Get state machine definition and metadata
ListStateMachines List all state machines
DeleteStateMachine Delete a state machine
PublishStateMachineVersion -
ListStateMachineVersions -
DeleteStateMachineVersion -
CreateStateMachineAlias -
DescribeStateMachineAlias -
ListStateMachineAliases -
UpdateStateMachineAlias -
DeleteStateMachineAlias -
ValidateStateMachineDefinition Validate an ASL definition without creating a state machine
StartExecution Start a new execution
StartSyncExecution -
DescribeExecution Get execution status and output
ListExecutions List executions for a state machine
StopExecution Stop a running execution
GetExecutionHistory Get the full event history of an execution
DescribeMapRun Get the item and execution counters of a distributed Map run
SendTaskSuccess Report task success (for .waitForTaskToken tasks)
SendTaskFailure Report task failure
SendTaskHeartbeat Send a heartbeat for long-running tasks
CreateActivity -
DeleteActivity -
DescribeActivity -
ListActivities -
GetActivityTask -
ListTagsForResource -
TagResource -
UntagResource -

UpdateStateMachine returns the new revision ID and update timestamp. CloudFormation updates definition, role, logging, tracing, encryption, and tags without replacing the state machine; changes to StateMachineName or StateMachineType use replacement semantics.

Execution history events

GetExecutionHistory emits an event family around a Task state's TaskStateEntered and TaskStateExited pair. Which family depends on the resource type.

  • A service integration ARN (arn:aws:states:::...) emits TaskScheduled, TaskStarted, and then TaskSucceeded or TaskFailed. taskScheduledEventDetails carries resourceType, resource, region, and parameters. parameters is the resolved Parameters or Arguments payload, serialized as a JSON string. timeoutInSeconds and heartbeatInSeconds appear only when the state sets TimeoutSeconds or HeartbeatSeconds as a literal number. resourceType is the integrated service (states, ecs, aws-sdk:sqs) and resource the API with its suffix (startExecution.sync:2, runTask.sync), as AWS names them. A .sync Task (states:startExecution.sync and .sync:2, ecs:runTask.sync) also records TaskSubmitted after TaskStarted, once the call that started its job has returned and before it waits on the job. taskSubmittedEventDetails carries resourceType, resource, outputDetails, and that call's response as output, in PascalCase: {"ExecutionArn", "StartDate"} with StartDate in epoch milliseconds for a nested execution, {"Failures", "Tasks"} for an ECS task. AWS folds the SDK's SdkHttpMetadata and SdkResponseMetadata into that response as well; Floci records the response fields alone.
  • A direct Lambda function ARN emits LambdaFunctionScheduled, LambdaFunctionStarted, and then LambdaFunctionSucceeded or LambdaFunctionFailed. LambdaFunctionScheduled carries resource and input. resource holds the full function ARN. LambdaFunctionStarted carries no details at all. This matches AWS.
  • An activity ARN emits the equivalent Activity* family.

A Retry re-entry emits its own Scheduled, Started, and Failed triple for each attempt. A mocked Task (SFN_MOCK_CONFIG) emits the same events as a real one, because Step Functions Local does the same.

Every event's previousEventId points to the id of the event right before it on the same chain of states. The one exception is the first state's *StateEntered event. Its previousEventId is 0. That matches ExecutionStarted, which is always id: 1, previousEventId: 0.

Parallel branches and Map iterations

The states inside a Parallel branch or an inline Map iteration publish their events into the parent execution's history, as on AWS. A Parallel records ParallelStateStarted, ParallelStateSucceeded and ParallelStateFailed. An inline Map records MapStateStarted, MapIterationStarted, MapIterationSucceeded, MapIterationFailed, MapStateSucceeded and MapStateFailed. A Distributed Map records MapRunStarted, MapRunSucceeded and MapRunFailed instead. Its items are child executions and publish nothing into the parent history. A Task whose failure ends its branch also records TaskStateAborted. When a failure ends a Parallel, each other branch that is still inside a Task or a Wait records TaskStateAborted or WaitStateAborted, chained to the failing branch's last event and recorded before ParallelStateFailed. The Parallel fails the moment any branch fails, whichever branch is listed first, and cuts the others then; the branch outputs still come back in declaration order. When a failure ends an inline Map, each other iteration that is still running records MapIterationAborted, and one still inside a Task or a Wait records TaskStateAborted or WaitStateAborted right after it, all chained to the failing iteration's last event and recorded before MapIterationFailed and MapStateFailed.

Branches and iterations run concurrently, so the order in which their events interleave differs from run to run. Each branch chains its own events through previousEventId, and that chain is the same every time.

The cause of a failure

The cause of a failure the interpreter raises starts with An error occurred while executing the state '<name>' (entered at the event id #<n>)., as on AWS. The prefix is added once, at the innermost state. A Fail state's Cause and a cause a task's resource answered with pass through unchanged. A Choice that matches no rule and has no Default, and a payload template path that matches nothing, fail with States.Runtime. A JSONata expression that fails also records an EvaluationFailed event with error, cause, location and state, once per attempt.

inputDetails appears on ExecutionStarted, on stateEnteredEventDetails, and on LambdaFunctionScheduled/ActivityScheduled. outputDetails appears on stateExitedEventDetails, on executionSucceededEventDetails, and on taskSucceededEventDetails/lambdaFunctionSucceededEventDetails. Both are always {"truncated": false}. Floci never truncates a payload, so the value never changes. When the request sets includeExecutionData to false, the details objects stay but lose input, inputDetails, output, and outputDetails. Every other field stays, including taskScheduledEventDetails.parameters. This matches AWS.

A few gaps remain. TaskStarted, LambdaFunctionStarted, and ActivityStarted fire at scheduling time, not when a worker actually picks up the task. TaskSubmitted is recorded for the three .sync integrations above and not yet for a .waitForTaskToken one, which AWS also records it for. A Distributed Map that declares no tolerance reports a failed item's own error rather than AWS's States.ExceedToleratedFailureThreshold, and emits MapRunFailed with that error. A Map that declares one reports States.ExceedToleratedFailureThreshold, as AWS does.

Map concurrency

Map states honor MaxConcurrency and, for JSONPath state machines, MaxConcurrencyPath. JSONata state machines may supply MaxConcurrency as an expression. A value of 0, or an omitted value, uses the AWS service ceiling: 40 concurrent iterations for Inline Map states and 10,000 for Distributed Map states. MaxConcurrency: 1 runs iterations sequentially.

Results remain in input order even when iterations finish out of order. If an iteration fails, the Map state fails promptly, cancels its active sibling iterations, and does not start queued iterations. A cancelled iteration whose Task was waiting on a .sync job stops that job, the way a cut Parallel branch does.

Distributed Map ItemReader

ItemReader reads a dataset from S3. The resource decides how the dataset is found, and ReaderConfig.InputType decides how it is read.

arn:aws:states:::s3:getObject reads a single object:

  • JSON is either an array, or an object whose entries become Key and Value items. ReaderConfig.ItemsPointer selects a node inside it.
  • JSONL is one item per line. Blank lines are skipped, and ItemsPointer does not apply, matching AWS.
  • CSV takes its field names from the first row, or from ReaderConfig.CSVHeaders when CSVHeaderLocation is GIVEN. Every value is a string: a row shorter than the headers pads with empty strings, and a longer one drops the surplus. ReaderConfig.CSVDelimiter selects COMMA, PIPE, SEMICOLON, SPACE or TAB, and a quoted field may contain the delimiter or a line break without ending the record. A doubled quote stands for a single quote, in an unquoted field as well as a quoted one, and a backslash escapes another backslash, a quote or the delimiter. A backslash before anything else is dropped, as AWS documents.
  • PARQUET and MANIFEST are accepted by CreateStateMachine and fail the execution with States.ItemReaderFailed.

arn:aws:states:::s3:listObjectsV2 reads every page under Prefix. Each item carries the AWS fields Etag, Key, LastModified (epoch seconds), Size and StorageClass. An empty prefix gives zero iterations and the Map succeeds.

ReaderConfig.MaxItems applies to every reader. Literal values cannot exceed 100,000,000; a larger value resolved by MaxItemsPath or a JSONata expression is capped at that reader limit. A dynamic limit resolving to zero reads the entire dataset. MaxItemsPath accepts integers and integer strings within the signed 64-bit range. Values outside that range or decimal values fail with States.Runtime; negative integers fail with States.ItemReaderFailed. JSONata expressions must return integers, not strings. JSONPath state machines may resolve the limit from the Map input with MaxItemsPath, while JSONata state machines may use a {% %} expression in MaxItems. MaxItems and MaxItemsPath are mutually exclusive, and MaxItemsPath is rejected for JSONata state machines.

Distributed Map ItemBatcher

ItemBatcher hands each child execution a batch of items instead of a single item. The child input is {"BatchInput": ..., "Items": [...]}, with BatchInput present only when the state declares it. ItemSelector still runs per item, before the items are grouped.

A batch closes on MaxItemsPerBatch, on MaxInputBytesPerBatch, or on the 256 KiB child-input ceiling AWS applies whether or not a byte limit is declared. Either limit may be given as a ...Path field, or as an expression in a JSONata state machine. With neither declared, items fill one batch up to that ceiling. The size measured is the serialized child payload, envelope and BatchInput included, not the items alone. An item that would exceed the ceiling on its own can never start a child execution, so the state fails with States.DataLimitExceeded rather than building a batch AWS would reject: reduce the item with ItemSelector first.

MaxConcurrency then bounds concurrent batches, and the Map result has one entry per batch rather than per item. DescribeMapRun reports items under itemCounts and batches under executionCounts.

Tolerated failures

ToleratedFailureCount and ToleratedFailurePercentage let a Distributed Map absorb failed items instead of failing on the first one. Both accept a ...Path field, or an expression in a JSONata state machine, and the percentage is taken over the item count. Declaring both applies the stricter of the two.

An absorbed failure contributes no result, so the Map output carries one entry per successful child execution. A ResultWriter still exports it: successful children go to SUCCEEDED_0.json and absorbed failures to FAILED_0.json, each listed under the matching key of the manifest's ResultFiles. A failed record carries Error and Cause in place of an output. Once the budget is spent, the state fails with States.ExceedToleratedFailureThreshold and the run emits MapRunFailed.

DescribeMapRun reports the declared values under toleratedFailureCount and toleratedFailurePercentage. A Map that declares neither keeps the earlier behaviour: the first failed item fails the state, carrying that item's own error.

Retry policies

Task, Parallel, and Map states honor their Retry field. ErrorEquals matching follows AWS semantics, including the States.ALL and States.TaskFailed wildcards. States.Runtime is never retried. AWS defaults apply when fields are omitted (MaxAttempts 3, IntervalSeconds 1, BackoffRate 2.0), MaxDelaySeconds is honored, and each retrier keeps its own attempt counter. Retry is evaluated before Catch, and $$.State.RetryCount increments per attempt. Attempt counts, defaults, and backoff timing were verified against real AWS Step Functions.

A state with no Retry array runs exactly once. This is what makes CDK-generated provider-framework workflows converge: the framework-isComplete-task throws on every not-yet-complete poll and relies on Retry to poll again until the custom resource reports done — see CloudFormation custom resources.

JitterStrategy supports NONE (the default) and FULL. FULL draws the delay uniformly between zero and the computed delay, as on AWS. One deviation. The delay between attempts is capped at floci.services.stepfunctions.max-wait-seconds (default 30), the same ceiling Floci applies to Wait states, so emulated runs stay fast.

Wait states

A Wait state honors Seconds, SecondsPath, Timestamp, and TimestampPath. The two Seconds forms pause for the given number of seconds. The two Timestamp forms parse an ISO-8601 instant and pause until it, or return promptly when it has already passed. An unparseable timestamp fails the execution with States.Runtime. In a JSONata state machine, Seconds and Timestamp each accept a literal or a JSONata expression that produces the value.

One deviation. Every pause is capped at floci.services.stepfunctions.max-wait-seconds (default 30) so emulated runs stay fast, where AWS sleeps the full duration.

Timeouts

ASL carries two TimeoutSeconds fields and Floci enforces both, in the two terminal shapes AWS uses. The state machine's own field bounds every state; a Task's own field bounds one that waits: for a task token (an activity, or a .waitForTaskToken integration) or for a job to end (ecs:runTask.sync, states:startExecution.sync and .sync:2). A Lambda or other SDK task that returns directly is not bound by it.

The state machine's own TimeoutSeconds is the whole execution's budget. It is checked before every state and inside a Wait, so a Wait longer than what is left is cut rather than slept out. The execution ends TIMED_OUT with stopDate set and no error and no cause at all; States.Timeout is named only in the single ExecutionTimedOut event, whose previousEventId is 0. The state that was cut gets no *StateExited event. A Parallel or Map branch runs on its own thread and is not cut mid-state: the budget is enforced again as soon as the branch returns.

A Task that waits for a task token — an activity, or a .waitForTaskToken integration — has two independent bounds. TimeoutSeconds is the whole wait; HeartbeatSeconds is the longest gap allowed between two SendTaskHeartbeat calls, and every heartbeat pushes that gap forward, so a worker that reports as often as the definition asks runs until TimeoutSeconds runs out. Either clock ends the state the same way: an ActivityTimedOut event — TaskTimedOut for a .waitForTaskToken integration — carrying States.Timeout and no cause, then an execution that reads FAILED with that same error. A Catch on a heartbeat expiry matches under States.HeartbeatTimeout and under States.Timeout alike. Neither timeout carries a cause: DescribeExecution and the ExecutionFailed event both leave the key out, where every other failure reports one. A Task that declares no TimeoutSeconds waits 300 seconds, where AWS waits a year.

A Task that waits for a .sync job runs under the same two clocks and no other: the execution's budget ends it TIMED_OUT, its own TimeoutSeconds ends it FAILED with a TaskTimedOut event carrying States.Timeout and no cause, and a job that takes longer than either simply runs until the earlier clock fires. The default of 300 seconds applies here too when the state declares none. When the Task's own clock fires, the job it was waiting on is stopped the way AWS stops it: the ECS task reads stopCode: UserInitiated, the child execution reads ABORTED with no error, and both carry the cause The Task state in AWS Step Functions execution [<arn>] which was managing this resource was aborted. A StopExecution that lands while the state waits ends the wait and stops the job with the same cause, and so does the execution's budget, and so does a failure in another branch of the Parallel, or in another iteration of the Map, that the Task is in.

One deviation. AWS starts the TimeoutSeconds clock when a worker picks the task up, the instant it emits ActivityStarted. Floci emits ActivityStarted at schedule time, so both clocks start when the task is scheduled.

Intrinsic arguments

A $. reference passed to a States.* intrinsic must find something. An argument that matches nothing fails the execution with States.Runtime, as on AWS.

One deviation. Indexing something that is not an array makes AWS leak its JSONPath library and write Filter: [0] can only be applied to arrays. Current context is: 1. Floci writes its own The JsonPath argument for the field '$.other[0]' could not be found in the input ... there.

JSONata nulls

An expression that evaluates to JSON null produces a value, not a missing one. It keeps its key in Output, in Assign and in a Task's Arguments, at any nesting depth, and it stays in place as an array element:

"Output": {"v": "{% $states.input.bar %}"}   on input {"bar": null}   ->   {"v": null}

The same null is a value inside the expression too: $exists() on it is true and $type() on it is "null".

One deviation. $count() on a JSON null answers 0; AWS answers 1.

An expression that returns nothing fails the state

An expression that returns nothing, which is what $states.input.absent and the functions listed below that evaluate to undefined do, is not a missing value the state carries on without. It fails the state with States.QueryEvaluationError, naming the field it was written in, and a Catch on that error fires:

"Output": {"v": "{% $states.input.absent %}"}
  ->  States.QueryEvaluationError
      The JSONata expression '$states.input.absent' specified for the field 'Output/v'
      returned nothing (undefined).

The field is named relative to the state, with / before each object key and [i] for each array index: Output, Output/a/b[0], Assign/x, Arguments/MessageGroupId, Choices[1]/Condition, Seconds, Error, Cause, Items, MaxConcurrency. A matched Choice rule and a matching Catch clause carry their own Assign and Output, which are named under the rule or clause: Choices[1]/Output/v, Choices[0]/Assign/x, Catch[1]/Output/v. A Choice stops at the first rule that matches, so an undefined condition in a later rule is never evaluated.

The cause of a real execution carries the state prefix described under The cause of a failure.

JSONata functions

State machines with "QueryLanguage": "JSONata" reach the six functions Step Functions adds on top of the JSONata language, alongside every function JSONata itself provides.

Function Returns
$parse(jsonString) the deserialized value; the replacement for $eval, which AWS disables and so does Floci, answering T1006 to a call
$partition(array, chunkSize) array split into chunks of chunkSize, the last one holding the remainder
$range(start, end, step) the values from start to end, inclusive when step lands on end
$hash(str, algorithm) the hex digest of str; algorithm is MD5, SHA-1, SHA-256, SHA-384 or SHA-512, case-sensitive
$random(seed) a number in [0, 1), reproducible under the optional integer seed
$uuid() a v4 UUID

Three behaviours are worth knowing before reading an unexpected result, and all three are AWS's:

  • A non-integer argument is rounded towards zero, so $range(-1.7, 2, 1) starts at -1 and $partition(items, 2.9) chunks by 2.
  • Several arguments evaluate to undefined rather than failing: a chunk size of zero, an empty array, a $range with no step or with a step whose sign disagrees with the direction, and a $hash with no algorithm. The argument itself does not fail; the field the expression was written in does, under States.QueryEvaluationError, as the section above records.
  • $range collapses a single-element range to the bare number, not a one-element array.

JSONata's own $string follows AWS's number notation: a whole number is written out in full below 1e21 and in exponent notation from there, on both signs, so $string(1e20) is 100000000000000000000 and $string(1e21) is 1e+21.

The execution input reaching $states.input follows the same number model as $parse: an integer stays exact while it fits in a long and switches to a double past that boundary, matching AWS.

JSONata's own $formatNumber checks its picture string against the fourteen rules of XPath F&O 4.7.3, as AWS does, so $formatNumber(1, "x") fails with D3086 rather than answering x1: a picture with no digit in it describes no number.

Evaluation is bounded on three axes, as it is on AWS, and past any of them the state fails with States.QueryEvaluationError.

  • Depth: one expression may nest 100 levels, which is AWS's own ceiling: AWS accepts 1+1+…+1 at 100 terms, 99 parentheses, 99 brackets, 99 ~> stages, and a non-tail-recursive $f(31), which nests 3n+5, and refuses one level more of each. The refusal names the depth reached, as in Stack overflow error: … Depth=101 max=100.
  • Memory: one value an expression builds may hold 6,990,256 bytes, counting a number as eight bytes and a character as one. That is AWS's own bound: AWS accepts [1..873782] and refuses one element more, and it accepts a string doubled 22 times, 2^22 characters, refusing the 23rd. The refusal is AWS's own Expression evaluation memory limit exceeded, which is what [1..900000] ~> $count() and $sum([1..900000]) now answer. $range holds a tighter bound of its own, checked before it allocates rather than on the array it would have built: AWS accepts $range(1, 360145, 1), all 360,145 elements, and refuses one element more with the same refusal. A lazy literal range such as [1..873782] is exempt from both bounds on AWS, and stays exempt here.
  • Time: five seconds, which is the library's own default and roughly fifty times the slowest evaluation of a payload AWS itself accepts. A recursive expression with no base case is a tail call, so it loops rather than nesting and only the clock ends it.

JSON has no literal for a non-finite number, so AWS writes each one as the string JavaScript names it by, wherever it lands: 1/0 is "Infinity", 1e308 * 10 is "Infinity", 0/0 is "NaN" and $parseInteger("abc", "0") is "NaN", and nested, [1/0] is ["Infinity"] and {"k": 1/0} is {"k": "Infinity"}.

One deviation, on the arithmetic half of that. A non-finite number a function answers is a value like any other here, so $parseInteger("abc", "0") is "NaN" on its own, inside an array and inside an object, as on AWS. One the arithmetic produces is not: the JSONata library refuses to carry it and raises instead, so Floci sees the value only in that refusal, after it has unwound whatever was being built around it. 1/0, -1/0 and 1e308 * 10 are answered because the expression's own result is what was refused; [1/0], {"k": 1/0} and $string(1/0) fail the state with States.QueryEvaluationError where AWS answers a string, and 0/0 fails the field that holds it, because NaN is dropped as not-a-number without even a refusal to read it from. A state that fails is one a Catch fires on, which is the half of the divergence worth keeping.

Execution names

A Standard execution name is unique per account, Region and state machine. Starting a Standard execution with a name and input that match one still running returns that original execution, so a retried call is idempotent; any other reuse of the name, whether a different input or a name whose execution has already closed, fails with ExecutionAlreadyExists.

One deviation. AWS frees a Standard name 90 days after the execution closes; Floci keeps it taken for the life of the emulator, so a closed name never becomes reusable on its own.

An Express execution name is not unique. Every StartExecution on an Express state machine is its own execution that runs alongside any others of the same name, and its ARN carries a per-start id after the name (express:<stateMachine>:<name>:<id>). Reusing an Express name never returns an earlier execution and never fails with ExecutionAlreadyExists.

One more. AWS keeps no record of an Express execution once it ends, only its logs, and does not serve DescribeExecution, GetExecutionHistory or ListExecutions for one. Floci keeps every Express execution, running or finished, in its execution store (and, in persistent mode, in sfn-executions.json), and nothing evicts them while the emulator runs. A workload that starts the same Express name in a tight loop therefore grows that store without bound.

Nested workflows

A parent workflow calls a child workflow through one of several integrations, and they differ in more than syntax:

Resource Child type A child that fails Result
arn:aws:states:::states:startExecution Standard not awaited {ExecutionArn, StartDate}, StartDate in epoch milliseconds
arn:aws:states:::states:startExecution.sync Standard fails the calling task execution envelope, Input and Output as JSON strings
arn:aws:states:::states:startExecution.sync:2 Standard fails the calling task execution envelope, Input and Output as JSON values
arn:aws:states:::aws-sdk:sfn:startExecution Standard not awaited {ExecutionArn, StartDate}, StartDate as an ISO-8601 string
arn:aws:states:::aws-sdk:sfn:startSyncExecution Express reported through Status PascalCase envelope, Output as a JSON string

states:startExecution and aws-sdk:sfn:startExecution are the same API through two different integrations, and only the format of StartDate tells their results apart. AWS also adds SdkHttpMetadata and SdkResponseMetadata to the states:startExecution result, which Floci omits.

The execution envelope of the two .sync modes is the child's DescribeExecution response, PascalCase with its keys in alphabetical order and dates in epoch milliseconds: ExecutionArn, Input, InputDetails, Name, Output, OutputDetails, RedriveCount, RedriveStatus, RedriveStatusReason, StartDate, StateMachineArn, Status and StopDate. So the child's output is at $.Output in both modes: a JSON string through .sync, to read with States.StringToJson, and a JSON value through .sync:2, to read directly, for example with a ResultSelector or OutputPath of $.Output.

startSyncExecution is the only one that does not fail the calling task when the child fails: the SDK call itself succeeded, so the task result carries Status, Error and Cause and the parent decides what to do next.

The two .sync modes fail the calling task with States.TaskFailed however the child ended (FAILED, TIMED_OUT or ABORTED) and whatever its own error was, States.Timeout included, so a Catch on the child's own error name never takes it. The cause is the child's DescribeExecution response as JSON, PascalCase with its keys in alphabetical order: Cause and Error when the child has them, then ExecutionArn, Input, InputDetails, Name, RedriveCount, RedriveStatus, StartDate, StateMachineArn, Status and StopDate, with dates in epoch milliseconds and no Output. A child started through an alias also carries StateMachineAliasArn and StateMachineVersionArn, and one started through a version carries StateMachineVersionArn, each in its alphabetical place. A parent that needs the child's error reads it from there, for example with States.StringToJson($.Cause) after a Catch.

A Name a Standard child already used fails the calling task with the child's collision error, named for the integration that raised it: StepFunctions.ExecutionAlreadyExistsException through states:startExecution in any of its modes, and Sfn.ExecutionAlreadyExistsException through aws-sdk:sfn:startExecution. An Express child starts a new execution instead, so the same Name never fails it.

AWS SDK task integrations

A resource of the form arn:aws:states:::aws-sdk:<service>:<action> calls the service's API and returns its response. Two conventions separate that result from the same API's wire response, and both are AWS's:

  • Field names are the SDK's, so a startDate on the wire is a StartDate in the task result.
  • A timestamp is an ISO-8601 string such as 2026-08-28T20:34:59.712Z, where the wire response carries epoch seconds.

A failure names the SDK exception class, which always ends in Exception: StartExecution answers a missing state machine with the error code StateMachineDoesNotExist on the wire and the task fails with Sfn.StateMachineDoesNotExistException.

Resource Result Notable failure
arn:aws:states:::aws-sdk:sfn:startExecution {ExecutionArn, StartDate} Sfn.ExecutionAlreadyExistsException when Name is reused on a Standard child
arn:aws:states:::aws-sdk:sfn:startSyncExecution execution envelope Sfn.StateMachineTypeNotSupportedException for a Standard child
arn:aws:states:::aws-sdk:sfn:sendTaskSuccess {} Sfn.InvalidTokenException when no task is waiting on the token
arn:aws:states:::aws-sdk:sfn:sendTaskFailure {} Sfn.InvalidTokenException
arn:aws:states:::aws-sdk:rdsdata:executeStatement RDS Data statement result RdsData.BadRequestException for an invalid request
arn:aws:states:::aws-sdk:scheduler:createSchedule {ScheduleArn} Scheduler.ConflictException when the name is taken
arn:aws:states:::aws-sdk:scheduler:updateSchedule {ScheduleArn} Scheduler.ResourceNotFoundException
arn:aws:states:::aws-sdk:scheduler:deleteSchedule {} Scheduler.ResourceNotFoundException
arn:aws:states:::aws-sdk:sns:publish {MessageId} Sns.NotFoundException when the topic does not exist

Scheduler create and update tasks accept StartDate and EndDate as RFC 3339 strings, including offsets and fractional seconds. The direct Scheduler API continues to use numeric epoch seconds. Structured JSON values supplied as Target.Input are serialized once to the Scheduler API's string field. Textual JSON remains unchanged, and malformed text reaches the existing Scheduler validation.

sendTaskSuccess and sendTaskFailure resolve a token a .waitForTaskToken task is parked on. A token nobody is waiting for fails the calling task rather than reporting a delivery that never happened.

rdsdata:executeStatement uses the existing RDS Data API implementation. Task arguments use SDK PascalCase names such as ResourceArn, SecretArn, Sql and Parameters; the adapter translates them to the direct API shape and returns a recursively PascalCase result. Other RDS Data actions are not routed through Step Functions yet.

Publishing to SNS

arn:aws:states:::sns:publish calls the SNS Publish API with the task's parameters and returns the Publish response, {MessageId}. TopicArn, TargetArn, PhoneNumber, Message, Subject, MessageStructure, MessageAttributes, MessageGroupId and MessageDeduplicationId are the API's own fields, so a FIFO topic needs a MessageGroupId here just as it does from the SDK. A Message given as an object rather than a string is published as its JSON text, which is how a .waitForTaskToken task hands its token to the subscriber:

{
  "Type": "Task",
  "Resource": "arn:aws:states:::sns:publish.waitForTaskToken",
  "Parameters": {
    "TopicArn": "arn:aws:sns:us-east-1:000000000000:myTopic",
    "Message": {
      "Input.$": "$.message",
      "TaskToken.$": "$$.Task.Token"
    }
  },
  "End": true
}

A failure names the SDK exception class under the SNS. prefix, so a topic that does not exist fails the task with SNS.NotFoundException and a missing Message with SNS.InvalidParameterException. arn:aws:states:::aws-sdk:sns:publish is the same call under the Sns. prefix, as the AWS SDK integration table above shows.

Publishing events

arn:aws:states:::events:putEvents returns the PutEvents response itself, Entries and FailedEntryCount, and one rejected entry fails the whole task with EventBridge.FailedEntry. The cause is the response serialized as a string, so a Catch can read which entry was rejected:

{"FailedEntryCount":1,"Entries":[{"EventId":"08cbdc46-…"},{"ErrorCode":"InvalidArgument","ErrorMessage":"EventBus not found: no-such-bus"}]}

The optimized integration accepts Detail as a JSON object in JSONPath and JSONata workflows. It serializes that object once for the EventBridge request, preserving nested values and escaped text. The direct EventBridge API continues to accept its native string-valued Detail field.

One deviation, and it belongs to EventBridge rather than to the integration: Floci rejects an entry addressed to an event bus that does not exist, while AWS accepts it and returns an EventId.

JSONata expressions are validated when the state machine is created

A JSONata expression runs with no context item: the execution input arrives as $states.input and a variable written by Assign as $name. A path that starts from neither reads a context item that does not exist, and AWS refuses the whole definition:

An error occurred (InvalidDefinition) when calling the CreateStateMachine operation:
Invalid State Machine Definition: 'UNSUPPORTED_JSONATA_EXPRESSION: Reference to 'phone' at the
top level is not supported. at /States/E/Output/v'

Floci refuses it too, from CreateStateMachine, UpdateStateMachine and ValidateStateMachineDefinition, with that message and that location. Write $states.input.phone to read the input and $phone to read a variable: an earlier Assign of phone does not put a bare phone in scope on AWS either.

Only the first step of a path is read against the top-level context, so a name in a later step, in a predicate, in a sort term or in an object grouping stays legal and $states.input.items[value > 3] is accepted. A lambda body keeps the context of the expression that defines it, so $map($states.input.a, function($x){ b }) does name b at the top level.

A JSONata expression that fails to parse, such as {% a[1,2) %}, is refused the same way, with INVALID_JSONATA_EXPRESSION and the parser's own message at the field's location. $$, the reference to the top-level context, is refused under UNSUPPORTED_JSONATA_EXPRESSION with the message Reference to '$$' is not supported.; so is $states.errorOutput outside the one place it resolves, a catcher's own Output or Assign.

A definition is refused outright, with no state machine created, for a graph that never reaches a terminal state (MISSING_END_STATE), for a StartAt, Next, Default or Catch[].Next naming a state absent from its container or a state nothing transitions to (MISSING_TRANSITION_TARGET), and for a field the state type does not carry: TimeoutSeconds only on Task, and Catch/Retry only on Task, Parallel and Map.

A state's QueryLanguage, and the fields it may carry

A state's query language is JSONPath when its QueryLanguage field is exactly the string "JSONPath", the state machine's when the field is absent, and JSONata for any other value: the wrong case, an unknown string and a non-string alike. A state inside a Map's ItemProcessor or Iterator, or inside one of a Parallel's Branches, falls back to the state machine's language and not to the enclosing Map's or Parallel's, which the Amazon States Language calls independent of it. The enclosing state's own fields, Items and MaxConcurrency among them, do use its own language.

Three more refusals, each measured against ValidateStateMachineDefinition on real AWS:

  • A field that belongs to the other query language, at the state's path with no field suffix: The QueryLanguage is set to 'JSONPath', but field 'Output' is only supported for the 'JSONata' QueryLanguage, and the mirror of it for InputPath, OutputPath, ResultPath, ResultSelector, Parameters, Result, ItemsPath and MaxConcurrencyPath on a JSONata state. Assign belongs to neither list: AWS accepts it on both.
  • A state declaring "QueryLanguage": "JSONPath" under a JSONata state machine: 'QueryLanguage' can not be 'JSONPath' if set to 'JSONata' for whole state machine, again at the state's path. A JSONata machine cannot be reverted one state at a time; the upgrade in the other direction is allowed. That diagnostic is the whole answer for that state, so the fields its refused language forbids are not reported on top of it, while every other check still runs.
  • QueryLanguage on the ItemProcessor, Iterator or branch object itself, which is not a state: Field 'QueryLanguage' is not supported at /States/M/ItemProcessor.

A QueryLanguage value outside the enum is reported at the field, /States/X/QueryLanguage or /QueryLanguage, with Value should be one of the following: [JSONPath, JSONata], and a non-string value with Expected value of type [STRING]. That diagnostic is independent of the resolution above, so "jsonpath" is a JSONata state that also carries it, and it is not a downgrade: only the exact string "JSONPath" under a JSONata machine is.

Locations follow AWS: the two messages above point at the state, /States/X, while every other schema error points at the offending field, /States/X/MaxConcurrency.

Mocked service integrations

Floci supports the Step Functions Local mock configuration format (MockConfigFile.json). This lets a Task state return a predefined result or error instead of calling the integrated service. It is the standard way to unit test Catch and Retry branches, and it also lets you execute state machines whose integrations Floci does not implement yet.

Point SFN_MOCK_CONFIG at the mock configuration file and start an execution against <stateMachineArn>#<testCaseName>:

# docker run -e SFN_MOCK_CONFIG=/tmp/mock.json -v ./MockConfigFile.json:/tmp/mock.json ...
aws stepfunctions start-execution \
  --state-machine-arn "$SM_ARN#Throw422" \
  --input '{}' \
  --endpoint-url $AWS_ENDPOINT_URL
{
  "StateMachines": {
    "Test": { "TestCases": { "Throw422": { "Call API": "ApiFailure" } } }
  },
  "MockedResponses": {
    "ApiFailure": {
      "0": { "Throw": { "Error": "ApiGateway.422", "Cause": "Unprocessable" } }
    },
    "ApiSuccess": {
      "0-1": { "Return": { "StatusCode": 200, "ResponseBody": { "id": 1 } } }
    }
  }
}

Each test case maps a state name to a MockedResponses entry. Each mocked response is keyed by retry attempt ("0", "1", or a range like "1-2"), so a state can fail on the first attempt and succeed on a retry. Return supplies the task result. Throw fails the task with the given Error and Cause, which flow through Retry and Catch unchanged. States not named in the test case run their real integration, so mocked and real service calls can be combined in one execution. The file is re-read when it changes, so it can be edited without restarting Floci.

Behavior was verified against Step Functions Local 2.0.0. As there, StartSyncExecution rejects a test case suffix with UnsupportedOperation and does not strip a bare trailing #, a bare trailing # on StartExecution runs the execution unmocked, and a retry attempt with no mocked entry fails the execution with States.Runtime. One intentional deviation: Floci reports an unknown test case and any invalid mock configuration (unparseable file, bad attempt key, missing MockedResponses entry, Return and Throw together, Throw without Error) as a structured 400 error at StartExecution. Step Functions Local instead returns a plain HTTP 500 for most of these and starts the execution only to fail it with States.Runtime for the last two.

A mocked response with no attempt entries ({}) is not rejected. As in Step Functions Local, the execution starts and fails with States.Runtime only if the state that names it is entered. This keeps a generated mock file usable when the collection it was built from is empty and the state is never reached.

Executions abandoned by a restart

An execution runs in the Floci process. When Floci restarts while an execution is RUNNING, no worker survives it, so at startup every execution still stored as RUNNING is aborted with no error and no cause, the shape AWS returns for StopExecution called without them: DescribeExecution reports status ABORTED and a stopDate, and leaves the error and cause keys out. How many executions the sweep retired is reported once, as a WARN log line. Executions of every account are swept, each written back under its own account. Executions that already reached a terminal status are left untouched, and so is the status and stopDate of one this sweep aborted on an earlier boot.

Execution history is stored with the execution. While an execution is running, the current history is checkpointed every 100 events and when the execution reaches a terminal state. A graceful shutdown flushes the current execution state before the emulator stops. On restart, persisted history is retained, and a previously running execution is marked ABORTED with one ExecutionAborted event appended. After a further restart, the execution is already terminal, so no additional event is written.

Configuration

Variable Default Description
FLOCI_SERVICES_STEPFUNCTIONS_ENABLED true Enable or disable the service
FLOCI_SERVICES_STEPFUNCTIONS_MAX_WAIT_SECONDS 30 Ceiling in seconds on a Wait state pause and a Retry backoff
SFN_MOCK_CONFIG unset Path to a Step Functions Local compatible mock configuration file (alias: FLOCI_SERVICES_STEPFUNCTIONS_MOCK_CONFIG_FILE)

Examples

export AWS_ENDPOINT_URL=http://localhost:4566

# Create a state machine
SM_ARN=$(aws stepfunctions create-state-machine \
  --name my-workflow \
  --definition '{
    "Comment": "Simple workflow",
    "StartAt": "HelloWorld",
    "States": {
      "HelloWorld": {
        "Type": "Pass",
        "Result": {"message": "Hello, World!"},
        "End": true
      }
    }
  }' \
  --role-arn arn:aws:iam::000000000000:role/step-functions-role \
  --query stateMachineArn --output text \
  --endpoint-url $AWS_ENDPOINT_URL)

# Start an execution
EXEC_ARN=$(aws stepfunctions start-execution \
  --state-machine-arn $SM_ARN \
  --input '{"key":"value"}' \
  --query executionArn --output text \
  --endpoint-url $AWS_ENDPOINT_URL)

# Check status
aws stepfunctions describe-execution \
  --execution-arn $EXEC_ARN \
  --endpoint-url $AWS_ENDPOINT_URL

# Get event history
aws stepfunctions get-execution-history \
  --execution-arn $EXEC_ARN \
  --endpoint-url $AWS_ENDPOINT_URL