luizmachado.dev

PT EN

Session 022 — Step Functions: Standard vs Express, basic states (Task, Choice, Wait) and execution

Estimated duration: 60 minutes
Prerequisites: session-021-lambda-extensions-layers-powertools


Objective

By the end, you will be able to create a state machine with Task, Choice, Wait, Succeed and Fail states, execute it via console and CLI, understand the execution guarantees and cost of Standard vs Express Workflows, and read an execution history to identify where an execution failed.


Context

[FACT] AWS Step Functions is a serverless workflow orchestration service launched in 2016. Unlike messaging solutions (SQS, SNS) that only transport data, Step Functions coordinates sequences of states — including conditional logic, waits, parallelism and error handling — in a declarative, durable and auditable way.

[CONSENSUS] The core value of the service lies in removing from application code the responsibility of orchestrating long-running or multi-step flows. Without Step Functions, this state is often managed via database flags, chained queues or retry logic embedded in Lambda functions — patterns that are difficult to monitor, test and debug. Step Functions externalizes this state to the service itself, making the flow visible, reproducible and auditable in the console.

[FACT] The language used to define state machines is the Amazon States Language (ASL), a JSON format described in an open specification at states-language.net. ASL defines eight state types: Task, Choice, Wait, Succeed, Fail, Pass, Parallel and Map.


Key concepts

1. Standard Workflows vs Express Workflows

There are two radically different modes of operation in Step Functions. The choice between them affects execution guarantees, auditability, maximum duration and pricing model.

[FACT] The table below summarizes the differences documented by AWS:

┌─────────────────────────┬──────────────────────────┬──────────────────────────────────┐
│ Dimensão                │ Standard Workflow         │ Express Workflow                  │
├─────────────────────────┼──────────────────────────┼──────────────────────────────────┤
│ Garantia de execução    │ Exactly-once             │ Async: at-least-once             │
│                         │                          │ Sync:  at-most-once              │
├─────────────────────────┼──────────────────────────┼──────────────────────────────────┤
│ Duração máxima          │ 1 ano                    │ 5 minutos                         │
├─────────────────────────┼──────────────────────────┼──────────────────────────────────┤
│ Throughput              │ > 2.000 exec/s           │ > 100.000 exec/s                  │
├─────────────────────────┼──────────────────────────┼──────────────────────────────────┤
│ Execution history       │ GetExecutionHistory API  │ CloudWatch Logs (obrigatório)     │
├─────────────────────────┼──────────────────────────┼──────────────────────────────────┤
│ Modelo de preço         │ Por state transition      │ Por execução + GB-segundo         │
│                         │ $0.025 / 1.000 trans.    │ $1.00 / 1M exec + $0.00001/GB·s  │
├─────────────────────────┼──────────────────────────┼──────────────────────────────────┤
│ Caso de uso ideal       │ Workflows de negócio,     │ Alta volumetria, event-driven,   │
│                         │ processos duráveis        │ IoT, streaming, microserviços    │
└─────────────────────────┴──────────────────────────┴──────────────────────────────────┘

[FACT] Exactly-once in Standard Workflows means each state is initiated at most once, unless you explicitly configure Retry on the state. The service guarantees this by persisting the state in its backend, even if there are internal failures in AWS infrastructure.

[FACT] At-least-once in asynchronous Express Workflows means the execution may start more than once in internal failure scenarios. This implies that the called services must be idempotent — the same input executed twice cannot produce distinct side effects.

[CONSENSUS] The most commonly used rule of thumb in the community: if the workflow involves financial transactions, human approvals, or any non-idempotent operation with duration > 5 min → Standard. If it involves high-frequency event processing where idempotency is natural → Express.

Comparative cost calculation (example):

Scenario: 10,000 executions/day, 5 states per execution, 30 days.
- Standard: 10,000 × 5 × 30 = 1,500,000 transitions × $0.025/1,000 = $37.50/month
- Express (100ms, 64MB): 300,000 exec × $1.00/1M + 300,000 × 0.1s × 64MB/1024 × $0.00001 = $0.30 + $0.019 ≈ $0.32/month

[UNCERTAIN] The prices above reflect AWS documentation as of May 2026. Always check the updated pricing page, as Express Workflows have had price adjustments in recent years.


2. Amazon States Language (ASL) — state machine structure

[FACT] An ASL definition is a JSON object with the following required fields:

{
  "Comment": "Descrição opcional",
  "StartAt": "NomePrimeiroEstado",
  "States": {
    "NomePrimeiroEstado": { ... },
    "OutroEstado": { ... }
  }
}

StartAt points to the name of a state within States. Each state must have a Type field and, for non-terminal states, a Next field (or End: true for the final state).

Estado inicial                Transição                    Estado terminal
──────────────────────────────────────────────────────────────────────────────
{                                                          {
  "Type": "Task",       ──── "Next": "ProximoEstado" ────   "Type": "Succeed"
  "Resource": "...",                                        (ou "Fail")
  "Next": "ProximoEstado"                                 }
}

[FACT] Transition rules:
- States with "End": true are terminal — execution ends at them.
- Succeed and Fail are always terminal (they have no Next).
- Choice is the only type that does not have Next at the top level — it defines Choices[] with Next in each rule.
- An execution fails if it reaches a Fail state or if an unhandled error occurs.
- An execution succeeds if it reaches Succeed or any state with "End": true.


3. Task State — invoking services and awaiting results

[FACT] The Task state is the heart of any workflow: it represents a unit of work delegated to an external service. The Resource field is an ARN that specifies what will be invoked.

Three distinct integration patterns:

PADRÃO 1 — Request-Response (default)
───────────────────────────────────────────────────────────────────────────────
Step Functions invoca o serviço e avança IMEDIATAMENTE após o aceite da chamada.
Não aguarda o resultado. Adequado quando o resultado não importa ou é assíncrono.

"Resource": "arn:aws:states:::lambda:invoke"


PADRÃO 2 — Synchronous (sufixo .sync:2)
───────────────────────────────────────────────────────────────────────────────
Step Functions aguarda a conclusão do job/operação antes de avançar.
AWS SDK Integrations usam polling interno via CloudWatch Events.

"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken"
             arn:aws:states:::dynamodb:putItem.sync:2
             arn:aws:states:::ecs:runTask.sync:2
             arn:aws:states:::glue:startJobRun.sync

PADRÃO 3 — Wait for Task Token
───────────────────────────────────────────────────────────────────────────────
Step Functions para e aguarda até que um worker externo chame
SendTaskSuccess ou SendTaskFailure com o token recebido.
Adequado para aprovações humanas, processos legados, callbacks.

"Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken"

[FACT] Example of a Task invoking Lambda with .waitForTaskToken:

"WaitForApproval": {
  "Type": "Task",
  "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken",
  "Parameters": {
    "QueueUrl": "https://sqs.us-east-1.amazonaws.com/123/approvals",
    "MessageBody": {
      "TaskToken.$": "$$.Task.Token",
      "Input.$": "$"
    }
  },
  "TimeoutSeconds": 86400,
  "HeartbeatSeconds": 3600,
  "Next": "ProcessarResposta"
}

$$.Task.Token is a special reference to the Context Object — data about the current execution that Step Functions injects. The $$ prefix accesses the context; $ accesses the state input.

[FACT] Important optional fields in the Task State:

Campo Descrição
TimeoutSeconds Tempo máximo de espera antes de lançar States.Timeout
HeartbeatSeconds Intervalo máximo entre heartbeats; se excedido, lança States.HeartbeatTimeout
Retry Array de regras de retry com ErrorEquals, IntervalSeconds, MaxAttempts, BackoffRate
Catch Array de fallbacks: se erro em ErrorEquals, transiciona para estado Next
ResultPath Onde no input gravar o resultado (ex: "$.resultado" para não sobrescrever input)
Parameters Reformata o input antes de passar ao serviço (suporta .$ para referências)
ResultSelector Filtra/reformata a saída do serviço antes de gravar

4. Choice State — conditional logic

[FACT] The Choice state evaluates a list of rules in order and transitions to the first Next whose conditions are true. If no rule matches, it uses Default (required in practice to avoid execution failure).

"VerificarStatus": {
  "Type": "Choice",
  "Choices": [
    {
      "Variable": "$.status",
      "StringEquals": "APROVADO",
      "Next": "ProcessarAprovado"
    },
    {
      "Variable": "$.tentativas",
      "NumericGreaterThanEquals": 3,
      "Next": "EscalarParaHumano"
    },
    {
      "And": [
        { "Variable": "$.valor", "NumericGreaterThan": 1000 },
        { "Variable": "$.categoria", "StringEquals": "VIP" }
      ],
      "Next": "ProcessarVIP"
    }
  ],
  "Default": "ProcessarPadrao"
}

[FACT] Comparison operators available in ASL (selection):

StringEquals / StringEqualsPath
StringLessThan / StringGreaterThan / StringMatches (glob, ex: "foo*")
NumericEquals / NumericLessThan / NumericGreaterThan / ...
BooleanEquals / BooleanEqualsPath
TimestampEquals / TimestampLessThan / TimestampGreaterThan / ...
IsNull / IsPresent / IsString / IsNumeric / IsBoolean / IsTimestamp
And / Or / Not  (combinadores lógicos)

[CONSENSUS] The Path suffix (e.g., StringEqualsPath) allows comparing the Variable value with the value of another path in the input — useful when the comparison value also comes from dynamic input.

IMPORTANTE: Choice não tem "Next" no nível superior.
Cada regra dentro de "Choices[]" tem seu próprio "Next".
Choice também não pode ter "End: true".

5. Wait State — temporal pauses

[FACT] The Wait state pauses execution without consuming compute resources. Standard Workflow billing continues (the state transitioned upon entering Wait), but no Lambda or external service is running.

Four ways to specify the duration:

// 1. Duração fixa em segundos
{
  "Type": "Wait",
  "Seconds": 300,
  "Next": "ProximoEstado"
}

// 2. Timestamp absoluto fixo
{
  "Type": "Wait",
  "Timestamp": "2026-12-31T23:59:59Z",
  "Next": "ProximoEstado"
}

// 3. Duração vinda do input (JsonPath)
{
  "Type": "Wait",
  "SecondsPath": "$.aguardar_segundos",
  "Next": "ProximoEstado"
}

// 4. Timestamp vindo do input (JsonPath)
{
  "Type": "Wait",
  "TimestampPath": "$.data_processamento",
  "Next": "ProximoEstado"
}

[CONSENSUS] TimestampPath is the most powerful pattern: it allows scheduling the execution continuation to a specific instant calculated in previous states. Example: send a reminder 24h after the user creates an order, using the timestamp generated by the creation Lambda.


6. Succeed and Fail States — terminal states

[FACT] Succeed ends the execution with status SUCCEEDED. It has no Next, no required fields beyond Type.

"Finalizado": {
  "Type": "Succeed"
}

[FACT] Fail ends the execution with status FAILED. It accepts Error and Cause to describe the reason:

"ErroNegocio": {
  "Type": "Fail",
  "Error": "PedidoInvalido",
  "Cause": "Valor do pedido excede limite de crédito disponível"
}

[FACT] It is also possible to use ErrorPath and CausePath (JsonPath) to fill these fields dynamically from the state input — useful when the Catch of a Task forwards the error to a descriptive Fail state.


7. Execution History — reading and debugging executions

[FACT] For Standard Workflows, the GetExecutionHistory API returns all events of an execution in chronological order. Each event has type, timestamp, and a type-specific payload.

Most relevant event types for diagnostics:

ExecutionStarted           — input inicial da execução
TaskStateEntered           — quando um Task state foi iniciado
TaskScheduled              — quando o serviço externo foi invocado
TaskStarted                — confirmação que o serviço começou
TaskSucceeded              — resultado bem-sucedido do serviço
TaskFailed                 — erro retornado pelo serviço
TaskTimedOut               — TimeoutSeconds ou HeartbeatSeconds excedido
ChoiceStateEntered         — Choice state sendo avaliado
WaitStateEntered/Exited    — início e fim de espera
ExecutionFailed            — execução encerrou com falha (inclui Error e Cause)
ExecutionSucceeded         — execução encerrou com sucesso

[FACT] For Express Workflows, GetExecutionHistory is not available. Events must be sent to CloudWatch Logs, configuring logging_configuration with level ALL, ERROR, FATAL or OFF. Analysis is done via CloudWatch Log Insights.


Practical example

Scenario: E-commerce order processing workflow. An order comes in, is validated by a Lambda, waits 2 hours for anti-fraud analysis, is approved or rejected via Choice, and finishes.

Complete ASL definition

{
  "Comment": "Workflow de processamento de pedido",
  "StartAt": "ValidarPedido",
  "States": {

    "ValidarPedido": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "arn:aws:lambda:us-east-1:123456789:function:ValidarPedido",
        "Payload.$": "$"
      },
      "ResultSelector": {
        "valido.$": "$.Payload.valido",
        "score_fraude.$": "$.Payload.score_fraude",
        "pedido_id.$": "$.Payload.pedido_id"
      },
      "ResultPath": "$.validacao",
      "TimeoutSeconds": 30,
      "Retry": [
        {
          "ErrorEquals": ["Lambda.ServiceException", "Lambda.TooManyRequestsException"],
          "IntervalSeconds": 2,
          "MaxAttempts": 3,
          "BackoffRate": 2
        }
      ],
      "Catch": [
        {
          "ErrorEquals": ["States.ALL"],
          "ResultPath": "$.erro",
          "Next": "FalhaValidacao"
        }
      ],
      "Next": "AguardarAntifraude"
    },

    "AguardarAntifraude": {
      "Type": "Wait",
      "Seconds": 7200,
      "Next": "VerificarFraude"
    },

    "VerificarFraude": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.validacao.valido",
          "BooleanEquals": false,
          "Next": "PedidoRejeitado"
        },
        {
          "Variable": "$.validacao.score_fraude",
          "NumericGreaterThan": 80,
          "Next": "PedidoSuspeito"
        }
      ],
      "Default": "AprovarPedido"
    },

    "AprovarPedido": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "arn:aws:lambda:us-east-1:123456789:function:AprovarPedido",
        "Payload.$": "$"
      },
      "ResultPath": null,
      "Next": "PedidoAprovado"
    },

    "PedidoAprovado": {
      "Type": "Succeed"
    },

    "PedidoSuspeito": {
      "Type": "Task",
      "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken",
      "Parameters": {
        "QueueUrl": "https://sqs.us-east-1.amazonaws.com/123/revisao-manual",
        "MessageBody": {
          "TaskToken.$": "$$.Task.Token",
          "PedidoId.$": "$.validacao.pedido_id",
          "ScoreFraude.$": "$.validacao.score_fraude"
        }
      },
      "TimeoutSeconds": 86400,
      "Next": "AprovarPedido"
    },

    "PedidoRejeitado": {
      "Type": "Fail",
      "Error": "PedidoInvalido",
      "Cause": "Pedido rejeitado na validação ou análise de fraude"
    },

    "FalhaValidacao": {
      "Type": "Fail",
      "Error": "ErroValidacao",
      "CausePath": "$.erro.Cause"
    }

  }
}

Flow diagram

                   ┌──────────────┐
                   │ ValidarPedido│ ◄─── Lambda invoke + Retry
                   └──────┬───────┘
                          │ ResultPath: $.validacao
                          ▼
               ┌──────────────────────┐
               │ AguardarAntifraude   │ (Wait 2h)
               └──────────┬───────────┘
                          ▼
               ┌──────────────────────┐
               │   VerificarFraude    │ (Choice)
               └──┬──────────┬────────┘
                  │          │           └──────────────────────┐
             valido=false  score>80       (default)             │
                  │          │                                   │
                  ▼          ▼                                   ▼
         ┌──────────┐  ┌───────────────┐              ┌──────────────────┐
         │ Pedido   │  │ PedidoSuspeito│ (waitForToken)│  AprovarPedido  │
         │ Rejeitado│  │ → SQS → Manual│──────────────►│  Lambda invoke  │
         │ (Fail)   │  └───────────────┘              └────────┬─────────┘
         └──────────┘                                          ▼
                                                     ┌──────────────────┐
                                                     │  PedidoAprovado  │
                                                     │   (Succeed)      │
                                                     └──────────────────┘

CDK — creating the state machine

from aws_cdk import (
    Stack,
    aws_stepfunctions as sfn,
    aws_stepfunctions_tasks as tasks,
    aws_lambda as lambda_,
    Duration,
)
import json

class PedidoWorkflowStack(Stack):

    def __init__(self, scope, construct_id, **kwargs):
        super().__init__(scope, construct_id, **kwargs)

        # Funções Lambda (já existentes ou definidas aqui)
        fn_validar = lambda_.Function.from_function_name(
            self, "FnValidar", "ValidarPedido"
        )
        fn_aprovar = lambda_.Function.from_function_name(
            self, "FnAprovar", "AprovarPedido"
        )

        # ── Estados ──────────────────────────────────────────────────
        falha_validacao = sfn.Fail(
            self, "FalhaValidacao",
            error="ErroValidacao",
            cause="Erro ao validar pedido",
        )

        pedido_rejeitado = sfn.Fail(
            self, "PedidoRejeitado",
            error="PedidoInvalido",
            cause="Rejeitado na análise de fraude",
        )

        pedido_aprovado = sfn.Succeed(self, "PedidoAprovado")

        aprovar_pedido = tasks.LambdaInvoke(
            self, "AprovarPedido",
            lambda_function=fn_aprovar,
            result_path=sfn.JsonPath.DISCARD,  # ResultPath: null
        ).next(pedido_aprovado)

        verificar_fraude = sfn.Choice(self, "VerificarFraude") \
            .when(
                sfn.Condition.boolean_equals("$.validacao.valido", False),
                pedido_rejeitado
            ) \
            .when(
                sfn.Condition.number_greater_than("$.validacao.score_fraude", 80),
                # PedidoSuspeito simplificado para o exemplo CDK
                aprovar_pedido
            ) \
            .otherwise(aprovar_pedido)

        aguardar_antifraude = sfn.Wait(
            self, "AguardarAntifraude",
            time=sfn.WaitTime.duration(Duration.hours(2)),
        ).next(verificar_fraude)

        validar_pedido = tasks.LambdaInvoke(
            self, "ValidarPedido",
            lambda_function=fn_validar,
            result_selector={
                "valido": sfn.JsonPath.string_at("$.Payload.valido"),
                "score_fraude": sfn.JsonPath.number_at("$.Payload.score_fraude"),
                "pedido_id": sfn.JsonPath.string_at("$.Payload.pedido_id"),
            },
            result_path="$.validacao",
            timeout=Duration.seconds(30),
        ).add_retry(
            errors=["Lambda.ServiceException", "Lambda.TooManyRequestsException"],
            interval=Duration.seconds(2),
            max_attempts=3,
            backoff_rate=2,
        ).add_catch(
            falha_validacao,
            errors=["States.ALL"],
            result_path="$.erro",
        ).next(aguardar_antifraude)

        # ── State Machine ─────────────────────────────────────────────
        state_machine = sfn.StateMachine(
            self, "ProcessamentoPedido",
            state_machine_name="ProcessamentoPedido",
            definition_body=sfn.DefinitionBody.from_chainable(validar_pedido),
            # Para Standard Workflow (default):
            state_machine_type=sfn.StateMachineType.STANDARD,
        )

Execute and inspect via CLI

# Iniciar execução
aws stepfunctions start-execution \
  --state-machine-arn arn:aws:states:us-east-1:123456789:stateMachine:ProcessamentoPedido \
  --name "exec-pedido-001" \
  --input '{"pedido_id": "P001", "valor": 1500, "cliente_id": "C42"}'

# Listar execuções recentes
aws stepfunctions list-executions \
  --state-machine-arn arn:aws:states:us-east-1:123456789:stateMachine:ProcessamentoPedido \
  --status-filter RUNNING

# Descrever uma execução (status + input/output)
aws stepfunctions describe-execution \
  --execution-arn arn:aws:states:us-east-1:123456789:execution:ProcessamentoPedido:exec-pedido-001

# Ver histórico completo (Standard Workflow apenas)
aws stepfunctions get-execution-history \
  --execution-arn arn:aws:states:us-east-1:123456789:execution:ProcessamentoPedido:exec-pedido-001 \
  --query 'events[?type==`TaskFailed` || type==`ExecutionFailed`]'

Common pitfalls

Pitfall 1 — Confusing ResultPath: null with absence of ResultPath

The error: The developer wants the Task output not to alter the current state and simply omits ResultPath. The result is that the service output completely replaces the state input — all previous fields are lost.

Why it happens: The default behavior of ResultPath when absent is $ — meaning the service result becomes the new input for the next state, overwriting everything.

How to avoid:
- ResultPath: null → discards the result, passes the original input forward.
- ResultPath: "$.resultado" → preserves the input and adds the result in the .resultado field.
- ResultPath: "$" (default) → replaces the entire input with the result.

// Errado: sem ResultPath, o output da Lambda substitui $.pedido_id, $.valor etc.
"AprovarPedido": { "Type": "Task", "Resource": "...", "Next": "..." }

// Correto: descarta resultado, preserva input
"AprovarPedido": { "Type": "Task", "Resource": "...", "ResultPath": null, "Next": "..." }

Pitfall 2 — Using Express Workflow where the Task is not idempotent

The error: The developer chooses Express Workflow (cheaper, higher throughput) for a workflow that includes a Task that creates a unique resource (e.g., inserting a record in a database with auto-increment ID). In case of internal service retry (at-least-once), the same record is created twice.

Why it happens: Asynchronous Express Workflows have at-least-once guarantee, not exactly-once. The developer often does not realize that "at-least-once" means the execution may run again.

How to avoid:
- For workflows with non-idempotent Tasks: use Standard Workflow.
- If you want to use Express for cost savings, ensure all Tasks are idempotent (upsert instead of insert, unique keys via $.input.id, etc.).
- Synchronous Express Workflow has at-most-once guarantee — but this means it may not execute on failures, not that it will execute exactly once.


Pitfall 3 — Forgetting TimeoutSeconds in Tasks with .waitForTaskToken

The error: The WaitForTaskToken state is configured without TimeoutSeconds. The workflow remains blocked indefinitely if the worker that should send the token fails silently (crash, bug, unhandled error).

Why it happens: Step Functions does not impose a timeout by default — a Task without TimeoutSeconds waits until the maximum workflow limit (1 year in Standard). The developer tests the happy path and never notices the failure behavior.

How to avoid:
- Always define TimeoutSeconds in Tasks with waitForTaskToken. Use a realistic value for the process SLA (e.g., 86400 for 24h approvals).
- Also define HeartbeatSeconds if the worker should confirm progress periodically.
- Configure a Catch for States.Timeout and States.HeartbeatTimeout that transitions to a compensation or alert state.

"AguardandoAprovacao": {
  "Type": "Task",
  "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken",
  "TimeoutSeconds": 86400,
  "HeartbeatSeconds": 3600,
  "Catch": [
    {
      "ErrorEquals": ["States.Timeout", "States.HeartbeatTimeout"],
      "Next": "AprovacaoExpirada"
    }
  ]
}

Reflection exercise

You are designing a corporate client onboarding system. The process involves: (1) validating registration data via internal API, (2) sending documents for compliance analysis (which can take up to 5 business days), (3) waiting for manual approval from an analyst, (4) provisioning system access when approved, or (5) notifying the client and closing if rejected.

Question: How would you choose between Standard Workflow and Express Workflow for this case? Which states would you use at each step (Task, Wait, Choice, Succeed, Fail)? In particular, how would you model step 3 (manual approval) so that the analyst can approve or reject via a REST API, without the execution blocking a server? Also describe how you would read the history of an execution that failed at step 4 to identify the root cause.


Resources for further study

  1. AWS Step Functions Developer Guide — Welcome
    URL: https://docs.aws.amazon.com/step-functions/latest/dg/welcome.html
    The entry point of the official documentation. The "Concepts" section covers terminology (state machine, execution, state, transition) with precision. Read especially "How Step Functions Works" to understand the execution model.

  2. Choosing workflow type — Standard vs Express
    URL: https://docs.aws.amazon.com/step-functions/latest/dg/concepts-standard-vs-express.html
    The official comparison table with all parameters: guarantees, duration, pricing, logging, throughput and use cases. It is the primary reference for workflow type decisions.

  3. Amazon States Language specification
    URL: https://states-language.net/spec.html
    The complete and open ASL language specification. It covers each state type, JsonPath in the ASL context (including Context Object with $$), required and optional fields, Choice operators, and error handling behavior. More precise than any tutorial for understanding edge cases.