diff --git a/.autover/autover.json b/.autover/autover.json
index 02f2ad0db..88c3c1795 100644
--- a/.autover/autover.json
+++ b/.autover/autover.json
@@ -49,7 +49,10 @@
},
{
"Name": "Amazon.Lambda.DurableExecution",
- "Path": "Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj",
+ "Paths": [
+ "Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj",
+ "Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj"
+ ],
"PrereleaseLabel": "preview"
},
{
diff --git a/.autover/changes/durable-execution-analyzers.json b/.autover/changes/durable-execution-analyzers.json
new file mode 100644
index 000000000..4689a8839
--- /dev/null
+++ b/.autover/changes/durable-execution-analyzers.json
@@ -0,0 +1,11 @@
+{
+ "Projects": [
+ {
+ "Name": "Amazon.Lambda.DurableExecution",
+ "Type": "Minor",
+ "ChangelogMessages": [
+ "Add Roslyn analyzers (DE001-DE004) that catch common durable-execution authoring mistakes at build time, bundled in the package so they activate automatically for consumers. DE001 (Warning) flags non-deterministic APIs (DateTime.Now, Guid.NewGuid(), Random, Stopwatch, Environment.TickCount, crypto RNG) used in workflow code outside a step. DE002 (Warning) flags a durable operation invoked inside a step body via the captured outer IDurableContext. DE003 (Warning) flags mutation of a captured outer-scope variable inside a durable-operation delegate. DE004 (Info) suggests ParallelAsync/MapAsync over Task.WhenAll/Task.WhenAny for durable tasks. DE001 and DE004 include code fixes. Preview."
+ ]
+ }
+ ]
+}
diff --git a/.github/workflows/auto-update-Dockerfiles.yml b/.github/workflows/auto-update-Dockerfiles.yml
index d2fb2b9d6..64095bb1c 100644
--- a/.github/workflows/auto-update-Dockerfiles.yml
+++ b/.github/workflows/auto-update-Dockerfiles.yml
@@ -1,8 +1,6 @@
name: Auto-Update Lambda Dockerfiles Daily
-permissions:
- contents: write
- pull-requests: write
+permissions: {}
on:
# Run daily at midnight UTC
@@ -11,9 +9,17 @@ on:
# Allows to run this workflow manually from the Actions tab for testing
workflow_dispatch:
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: false
+
jobs:
auto-update:
+ name: Auto-update Dockerfiles and open PR
runs-on: ubuntu-latest
+ permissions:
+ contents: write # to push the daily Dockerfile update branch
+ pull-requests: write # to open the update PR and label it
env:
NET_8_AMD64_Dockerfile: "LambdaRuntimeDockerfiles/Images/net8/amd64/Dockerfile"
NET_8_ARM64_Dockerfile: "LambdaRuntimeDockerfiles/Images/net8/arm64/Dockerfile"
@@ -39,7 +45,7 @@ jobs:
run: |
$version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "8"
if (-not [string]::IsNullOrEmpty($version)) {
- & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version
+ & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version
} else {
Write-Host "Skipping .NET 8 AMD64 update - No version detected"
}
@@ -53,7 +59,7 @@ jobs:
run: |
$version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "8"
if (-not [string]::IsNullOrEmpty($version)) {
- & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version
+ & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version
} else {
Write-Host "Skipping .NET 8 ARM64 update - No version detected"
}
@@ -67,7 +73,7 @@ jobs:
run: |
$version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "9"
if (-not [string]::IsNullOrEmpty($version)) {
- & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version
+ & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version
} else {
Write-Host "Skipping .NET 9 AMD64 update - No version detected"
}
@@ -81,7 +87,7 @@ jobs:
run: |
$version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "9"
if (-not [string]::IsNullOrEmpty($version)) {
- & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version
+ & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version
} else {
Write-Host "Skipping .NET 9 ARM64 update - No version detected"
}
@@ -95,7 +101,7 @@ jobs:
run: |
$version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "10"
if (-not [string]::IsNullOrEmpty($version)) {
- & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version
+ & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version
} else {
Write-Host "Skipping .NET 10 AMD64 update - No version detected"
}
@@ -109,7 +115,7 @@ jobs:
run: |
$version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "10"
if (-not [string]::IsNullOrEmpty($version)) {
- & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version
+ & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version
} else {
Write-Host "Skipping .NET 10 ARM64 update - No version detected"
}
@@ -123,7 +129,7 @@ jobs:
run: |
$version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "11"
if (-not [string]::IsNullOrEmpty($version)) {
- & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version
+ & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version
} else {
Write-Host "Skipping .NET 11 AMD64 update - No version detected"
}
@@ -137,7 +143,7 @@ jobs:
run: |
$version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "11"
if (-not [string]::IsNullOrEmpty($version)) {
- & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version
+ & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version
} else {
Write-Host "Skipping .NET 11 ARM64 update - No version detected"
}
@@ -196,7 +202,7 @@ jobs:
- name: Create Pull Request
id: pull-request
if: ${{ steps.commit-push.outputs.CHANGES_MADE == 'true' }}
- uses: repo-sync/pull-request@v2
+ uses: repo-sync/pull-request@7e79a9f5dc3ad0ce53138f01df2fad14a04831c5 # v2
with:
source_branch: ${{ steps.commit-push.outputs.BRANCH }}
destination_branch: "dev"
@@ -226,13 +232,15 @@ jobs:
# Add "Release Not Needed" label to the PR
- name: Add Release Not Needed label
if: ${{ steps.pull-request.outputs.pr_number }}
- uses: actions/github-script@v8
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ PR_NUMBER: ${{ steps.pull-request.outputs.pr_number }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
- issue_number: ${{ steps.pull-request.outputs.pr_number }},
+ issue_number: Number(process.env.PR_NUMBER),
labels: ['Release Not Needed']
})
diff --git a/.github/workflows/aws-ci.yml b/.github/workflows/aws-ci.yml
index dc8491927..83afe378f 100644
--- a/.github/workflows/aws-ci.yml
+++ b/.github/workflows/aws-ci.yml
@@ -8,12 +8,18 @@ on:
- dev
- "feature/**"
-permissions:
- id-token: write
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
jobs:
run-ci:
+ name: Run CI
runs-on: ubuntu-latest
+ permissions:
+ id-token: write # to assume AWS roles via OIDC
steps:
- name: Configure Load Balancer Credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
@@ -24,8 +30,13 @@ jobs:
- name: Invoke Load Balancer Lambda
id: lambda
shell: pwsh
+ env:
+ LOAD_BALANCER_LAMBDA_NAME: ${{ secrets.CI_TESTING_LOAD_BALANCER_LAMBDA_NAME }}
+ TEST_RUNNER_ACCOUNT_ROLES: ${{ secrets.CI_TEST_RUNNER_ACCOUNT_ROLES }}
+ CODE_BUILD_PROJECT_NAME: ${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }}
+ BRANCH: ${{ github.sha }}
run: |
- aws lambda invoke response.json --function-name "${{ secrets.CI_TESTING_LOAD_BALANCER_LAMBDA_NAME }}" --cli-binary-format raw-in-base64-out --payload '{"Roles": "${{ secrets.CI_TEST_RUNNER_ACCOUNT_ROLES }}", "ProjectName": "${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }}", "Branch": "${{ github.sha }}"}'
+ aws lambda invoke response.json --function-name "$env:LOAD_BALANCER_LAMBDA_NAME" --cli-binary-format raw-in-base64-out --payload "{`"Roles`": `"$env:TEST_RUNNER_ACCOUNT_ROLES`", `"ProjectName`": `"$env:CODE_BUILD_PROJECT_NAME`", `"Branch`": `"$env:BRANCH`"}"
$roleArn=$(cat ./response.json)
"roleArn=$($roleArn -replace '"', '')" >> $env:GITHUB_OUTPUT
- name: Configure Test Runner Credentials
@@ -36,7 +47,7 @@ jobs:
aws-region: us-west-2
- name: Run Tests on AWS
id: codebuild
- uses: aws-actions/aws-codebuild-run-build@v1
+ uses: aws-actions/aws-codebuild-run-build@4d15a47425739ac2296ba5e7eee3bdd4bfbdd767 # v1.0.18
with:
project-name: ${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }}
- name: Configure Test Sweeper Lambda Credentials
@@ -49,10 +60,15 @@ jobs:
- name: Invoke Test Sweeper Lambda
if: always()
shell: pwsh
+ env:
+ TEST_SWEEPER_LAMBDA_NAME: ${{ secrets.CI_TESTING_TEST_SWEEPER_LAMBDA_NAME }}
+ CODE_BUILD_PROJECT_NAME: ${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }}
run: |
- aws lambda invoke response.json --function-name "${{ secrets.CI_TESTING_TEST_SWEEPER_LAMBDA_NAME }}" --cli-binary-format raw-in-base64-out --payload '{"Tags": "aws-repo=${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }}"}'
+ aws lambda invoke response.json --function-name "$env:TEST_SWEEPER_LAMBDA_NAME" --cli-binary-format raw-in-base64-out --payload "{`"Tags`": `"aws-repo=$env:CODE_BUILD_PROJECT_NAME`"}"
- name: CodeBuild Link
shell: pwsh
+ env:
+ BUILD_ID: ${{ steps.codebuild.outputs.aws-build-id }}
run: |
- $buildId = "${{ steps.codebuild.outputs.aws-build-id }}"
+ $buildId = "$env:BUILD_ID"
echo $buildId
diff --git a/.github/workflows/build-lambda-runtime-dockerfiles.yml b/.github/workflows/build-lambda-runtime-dockerfiles.yml
index 425181175..9f30c61ee 100644
--- a/.github/workflows/build-lambda-runtime-dockerfiles.yml
+++ b/.github/workflows/build-lambda-runtime-dockerfiles.yml
@@ -8,13 +8,18 @@ on:
paths:
- "LambdaRuntimeDockerfiles/**"
-permissions:
- contents: read
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
jobs:
build-runtime-images:
name: Build runtime image (${{ matrix.name }})
runs-on: ubuntu-latest
+ permissions:
+ contents: read # to check out the repository and build the Dockerfiles
strategy:
fail-fast: false
matrix:
@@ -45,16 +50,18 @@ jobs:
platform: linux/arm64
steps:
- - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 #v4.2.2
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ persist-credentials: false
- name: Set up QEMU
- uses: docker/setup-qemu-action@v4
+ uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Build ${{ matrix.name }}
- uses: docker/build-push-action@v7
+ uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
file: ${{ matrix.dockerfile }}
diff --git a/.github/workflows/change-file-in-pr.yml b/.github/workflows/change-file-in-pr.yml
index 51a2fb001..7ff26b6a5 100644
--- a/.github/workflows/change-file-in-pr.yml
+++ b/.github/workflows/change-file-in-pr.yml
@@ -4,24 +4,36 @@ on:
pull_request:
types: [opened, synchronize, reopened, labeled]
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
jobs:
check-files-in-directory:
if: ${{ !contains(github.event.pull_request.labels.*.name, 'Release Not Needed') && !contains(github.event.pull_request.labels.*.name, 'Release PR') }}
name: Change File Included in PR
runs-on: ubuntu-latest
+ permissions:
+ contents: read # to check out the repository and list changed files
steps:
- name: Checkout PR code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Get List of Changed Files
id: changed-files
- uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
+ uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
- name: Check for Change File(s) in .autover/changes/
+ env:
+ ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
run: |
DIRECTORY=".autover/changes/"
- if echo "${{ steps.changed-files.outputs.all_changed_files }}" | grep -q "$DIRECTORY"; then
+ if echo "$ALL_CHANGED_FILES" | grep -q "$DIRECTORY"; then
echo "✅ One or more change files in '$DIRECTORY' are included in this PR."
else
echo "❌ No change files in '$DIRECTORY' are included in this PR."
diff --git a/.github/workflows/closed-issue-message.yml b/.github/workflows/closed-issue-message.yml
index 3c394caaa..32c8f8706 100644
--- a/.github/workflows/closed-issue-message.yml
+++ b/.github/workflows/closed-issue-message.yml
@@ -3,18 +3,24 @@ on:
issues:
types: [closed]
-permissions:
- issues: write
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: false
jobs:
auto_comment:
+ name: Comment on closed issue
runs-on: ubuntu-latest
+ permissions:
+ issues: write # to comment on the closed issue
steps:
- - uses: aws-actions/closed-issue-message@v2
+ - uses: aws-actions/closed-issue-message@10aaf6366131b673a7c8b7742f8b3849f1d44f18 # v2
with:
# These inputs are both required
repo-token: "${{ secrets.GITHUB_TOKEN }}"
message: |
- Comments on closed issues are hard for our team to see.
- If you need more assistance, please either tag a team member or open a new issue that references this one.
+ Comments on closed issues are hard for our team to see.
+ If you need more assistance, please either tag a team member or open a new issue that references this one.
If you wish to keep having a conversation with other community members under this issue feel free to do so.
diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml
index 02c7fb641..d47599303 100644
--- a/.github/workflows/create-release-pr.yml
+++ b/.github/workflows/create-release-pr.yml
@@ -11,14 +11,19 @@ on:
type: string
required: false
-permissions:
- id-token: write
- repository-projects: read
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: false
jobs:
release-pr:
name: Release PR
runs-on: ubuntu-latest
+ permissions:
+ id-token: write # to assume AWS roles via OIDC
+ repository-projects: read # to read project metadata when creating the release PR
env:
INPUT_OVERRIDE_VERSION: ${{ github.event.inputs.OVERRIDE_VERSION }}
@@ -97,10 +102,11 @@ jobs:
run: autover changelog
# Push the release branch up as well as the created tag
- name: Push Changes
+ env:
+ BRANCH: ${{ steps.create-release-branch.outputs.BRANCH }}
run: |
- branch=${{ steps.create-release-branch.outputs.BRANCH }}
- git push origin $branch
- git push origin $branch --tags
+ git push origin "$BRANCH"
+ git push origin "$BRANCH" --tags
# Get the release name that will be used to create a PR
- name: Read Release Name
id: read-release-name
@@ -117,7 +123,10 @@ jobs:
- name: Create Pull Request
env:
GITHUB_TOKEN: ${{ env.FG_PAT }}
+ VERSION: ${{ steps.read-release-name.outputs.VERSION }}
+ CHANGELOG: ${{ steps.read-changelog.outputs.CHANGELOG }}
+ BRANCH: ${{ steps.create-release-branch.outputs.BRANCH }}
run: |
gh label create "Release PR" --description "A Release PR that includes versioning and changelog changes" -c "#FF0000" -f
- pr_url="$(gh pr create --title "${{ steps.read-release-name.outputs.VERSION }}" --label "Release PR" --body "${{ steps.read-changelog.outputs.CHANGELOG }}" --base dev --head ${{ steps.create-release-branch.outputs.BRANCH }})"
+ pr_url="$(gh pr create --title "$VERSION" --label "Release PR" --body "$CHANGELOG" --base dev --head "$BRANCH")"
diff --git a/.github/workflows/handle-stale-discussions.yml b/.github/workflows/handle-stale-discussions.yml
index 534dc6584..e8494e64f 100644
--- a/.github/workflows/handle-stale-discussions.yml
+++ b/.github/workflows/handle-stale-discussions.yml
@@ -5,14 +5,20 @@ on:
discussion_comment:
types: [created]
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: false
+
jobs:
handle-stale-discussions:
name: Handle stale discussions
runs-on: ubuntu-latest
permissions:
- discussions: write
+ discussions: write # to mark and close stale discussions
steps:
- name: Stale discussions action
- uses: aws-github-ops/handle-stale-discussions@v1.6.0
+ uses: aws-github-ops/handle-stale-discussions@c0beee451a5d33d9c8f048a6d4e7c856b5422544 # v1.6.0
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
diff --git a/.github/workflows/issue-regression-labeler.yml b/.github/workflows/issue-regression-labeler.yml
index 7c335bcfe..4f8695a2d 100644
--- a/.github/workflows/issue-regression-labeler.yml
+++ b/.github/workflows/issue-regression-labeler.yml
@@ -3,16 +3,21 @@ name: issue-regression-label
on:
issues:
types: [opened, edited]
+permissions: {}
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.issue.number }}
+ cancel-in-progress: true
jobs:
add-regression-label:
+ name: Add potential-regression label
runs-on: ubuntu-latest
permissions:
- issues: write
+ issues: write # to add or remove the potential-regression label
steps:
- name: Fetch template body
id: check_regression
- uses: actions/github-script@v8
- env:
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TEMPLATE_BODY: ${{ github.event.issue.body }}
with:
@@ -24,9 +29,11 @@ jobs:
- name: Manage regression label
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ IS_REGRESSION: ${{ steps.check_regression.outputs.is_regression }}
+ ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
- if [ "${{ steps.check_regression.outputs.is_regression }}" == "true" ]; then
- gh issue edit ${{ github.event.issue.number }} --add-label "potential-regression" -R ${{ github.repository }}
+ if [ "$IS_REGRESSION" == "true" ]; then
+ gh issue edit "$ISSUE_NUMBER" --add-label "potential-regression" -R "$GITHUB_REPOSITORY"
else
- gh issue edit ${{ github.event.issue.number }} --remove-label "potential-regression" -R ${{ github.repository }}
+ gh issue edit "$ISSUE_NUMBER" --remove-label "potential-regression" -R "$GITHUB_REPOSITORY"
fi
diff --git a/.github/workflows/semgrep-analysis.yml b/.github/workflows/semgrep-analysis.yml
index da6e998de..b140b7159 100644
--- a/.github/workflows/semgrep-analysis.yml
+++ b/.github/workflows/semgrep-analysis.yml
@@ -13,19 +13,28 @@ on:
# Manually trigger the workflow
workflow_dispatch:
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
jobs:
semgrep:
name: Scan
permissions:
- security-events: write
+ contents: read # to check out the repository
+ security-events: write # to upload SARIF results to the code scanning dashboard
runs-on: ubuntu-latest
container:
- image: returntocorp/semgrep
+ image: returntocorp/semgrep@sha256:06938c1f365d3f67b8cedd8bc117607ae64253f88a0e768e9da9408548927dd6 # latest
# Skip any PR created by dependabot to avoid permission issues
if: (github.actor != 'dependabot[bot]')
steps:
# Fetch project source
- - uses: actions/checkout@v6
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- run: semgrep ci --sarif > semgrep.sarif
env:
diff --git a/.github/workflows/stale_issues.yml b/.github/workflows/stale_issues.yml
index 90932eea5..2e3866233 100644
--- a/.github/workflows/stale_issues.yml
+++ b/.github/workflows/stale_issues.yml
@@ -5,16 +5,21 @@ on:
schedule:
- cron: "0 0 * * *"
-permissions:
- issues: write
- pull-requests: write
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: false
jobs:
cleanup:
runs-on: ubuntu-latest
name: Stale issue job
+ permissions:
+ issues: write # to mark and close stale issues
+ pull-requests: write # to mark and close stale pull requests
steps:
- - uses: aws-actions/stale-issue-cleanup@v7
+ - uses: aws-actions/stale-issue-cleanup@0604f2edf84a3a66bc0dfb4a30eb07814cbdf440 # v7.1.1
with:
# Setting messages to an empty string will cause the automation to skip
# that category
diff --git a/.github/workflows/sync-master-dev.yml b/.github/workflows/sync-master-dev.yml
index ae1f6e923..d3f91027f 100644
--- a/.github/workflows/sync-master-dev.yml
+++ b/.github/workflows/sync-master-dev.yml
@@ -8,9 +8,11 @@ on:
pull_request:
types: [closed]
-permissions:
- contents: write
- id-token: write
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: false
jobs:
# This job will check if the PR was successfully merged, it's source branch is `releases/next-release` and target branch is `dev`.
@@ -23,6 +25,9 @@ jobs:
github.event.pull_request.head.ref == 'releases/next-release' &&
github.event.pull_request.base.ref == 'dev'
runs-on: ubuntu-latest
+ permissions:
+ contents: write # to merge dev into master and push the release tag
+ id-token: write # to assume AWS roles via OIDC
steps:
# Assume an AWS Role that provides access to the Access Token
- name: Configure AWS Credentials
@@ -96,8 +101,11 @@ jobs:
- name: Create GitHub Release
env:
GITHUB_TOKEN: ${{ env.FG_PAT }}
+ TAG: ${{ steps.read-tag-name.outputs.TAG }}
+ VERSION: ${{ steps.read-release-name.outputs.VERSION }}
+ CHANGELOG: ${{ steps.read-changelog.outputs.CHANGELOG }}
run: |
- gh release create "${{ steps.read-tag-name.outputs.TAG }}" --title "${{ steps.read-release-name.outputs.VERSION }}" --notes "${{ steps.read-changelog.outputs.CHANGELOG }}"
+ gh release create "$TAG" --title "$VERSION" --notes "$CHANGELOG"
# Delete the `releases/next-release` branch
- name: Clean up
run: |
@@ -118,6 +126,8 @@ jobs:
github.event.pull_request.head.ref == 'releases/next-release' &&
github.event.pull_request.base.ref == 'dev'
runs-on: ubuntu-latest
+ permissions:
+ contents: write # to delete the release tag and branch
steps:
# Checkout a full clone of the repo using the deploy key (push runs over SSH)
- name: Checkout code
@@ -156,9 +166,11 @@ jobs:
echo "TAG=$tag" >> $GITHUB_OUTPUT
# Delete the tag created by AutoVer and the release branch
- name: Clean up
+ env:
+ TAG: ${{ steps.read-tag-name.outputs.TAG }}
run: |
git fetch origin
- git push --delete origin ${{ steps.read-tag-name.outputs.TAG }}
+ git push --delete origin "$TAG"
if git ls-remote --exit-code --heads origin releases/next-release > /dev/null; then
echo "Branch 'releases/next-release' exists on origin. Deleting..."
git push origin --delete releases/next-release
diff --git a/.github/workflows/update-Dockerfiles.yml b/.github/workflows/update-Dockerfiles.yml
index 7709115fd..283e51f81 100644
--- a/.github/workflows/update-Dockerfiles.yml
+++ b/.github/workflows/update-Dockerfiles.yml
@@ -1,8 +1,6 @@
name: Update Lambda Dockerfiles
-permissions:
- contents: write
- pull-requests: write
+permissions: {}
on:
# Allows to run this workflow manually from the Actions tab
@@ -65,9 +63,17 @@ on:
type: string
required: true
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: false
+
jobs:
build:
+ name: Update Dockerfiles and open PR
runs-on: ubuntu-latest
+ permissions:
+ contents: write # to push the Dockerfile update branch
+ pull-requests: write # to open the update PR and label it
env:
NET_8_AMD64_Dockerfile: "LambdaRuntimeDockerfiles/Images/net8/amd64/Dockerfile"
NET_8_ARM64_Dockerfile: "LambdaRuntimeDockerfiles/Images/net8/arm64/Dockerfile"
@@ -92,7 +98,7 @@ jobs:
DOCKERFILE_PATH: ${{ env.NET_8_AMD64_Dockerfile }}
NEXT_VERSION: ${{ github.event.inputs.NET_8_NEXT_VERSION }}
run: |
- .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}"
+ .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION"
if: ${{ github.event.inputs.NET_8_AMD64 == 'true' }}
- name: Update .NET 8 ARM64
@@ -102,7 +108,7 @@ jobs:
DOCKERFILE_PATH: ${{ env.NET_8_ARM64_Dockerfile }}
NEXT_VERSION: ${{ github.event.inputs.NET_8_NEXT_VERSION }}
run: |
- .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}"
+ .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION"
if: ${{ github.event.inputs.NET_8_ARM64 == 'true' }}
- name: Update .NET 9 AMD64
@@ -112,7 +118,7 @@ jobs:
DOCKERFILE_PATH: ${{ env.NET_9_AMD64_Dockerfile }}
NEXT_VERSION: ${{ github.event.inputs.NET_9_NEXT_VERSION }}
run: |
- .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}"
+ .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION"
if: ${{ github.event.inputs.NET_9_AMD64 == 'true' }}
- name: Update .NET 9 ARM64
@@ -122,7 +128,7 @@ jobs:
DOCKERFILE_PATH: ${{ env.NET_9_ARM64_Dockerfile }}
NEXT_VERSION: ${{ github.event.inputs.NET_9_NEXT_VERSION }}
run: |
- .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}"
+ .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION"
if: ${{ github.event.inputs.NET_9_ARM64 == 'true' }}
- name: Update .NET 10 AMD64
@@ -132,7 +138,7 @@ jobs:
DOCKERFILE_PATH: ${{ env.NET_10_AMD64_Dockerfile }}
NEXT_VERSION: ${{ github.event.inputs.NET_10_NEXT_VERSION }}
run: |
- .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}"
+ .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION"
if: ${{ github.event.inputs.NET_10_AMD64 == 'true' }}
- name: Update .NET 10 ARM64
@@ -142,7 +148,7 @@ jobs:
DOCKERFILE_PATH: ${{ env.NET_10_ARM64_Dockerfile }}
NEXT_VERSION: ${{ github.event.inputs.NET_10_NEXT_VERSION }}
run: |
- .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}"
+ .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION"
if: ${{ github.event.inputs.NET_10_ARM64 == 'true' }}
- name: Update .NET 11 AMD64
@@ -152,7 +158,7 @@ jobs:
DOCKERFILE_PATH: ${{ env.NET_11_AMD64_Dockerfile }}
NEXT_VERSION: ${{ github.event.inputs.NET_11_NEXT_VERSION }}
run: |
- .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}"
+ .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION"
if: ${{ github.event.inputs.NET_11_AMD64 == 'true' }}
- name: Update .NET 11 ARM64
@@ -162,7 +168,7 @@ jobs:
DOCKERFILE_PATH: ${{ env.NET_11_ARM64_Dockerfile }}
NEXT_VERSION: ${{ github.event.inputs.NET_11_NEXT_VERSION }}
run: |
- .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}"
+ .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION"
if: ${{ github.event.inputs.NET_11_ARM64 == 'true' }}
# Update Dockerfiles if newer version of ASP.NET Core is available
@@ -184,7 +190,7 @@ jobs:
- name: Pull Request
id: pull-request
if: ${{ steps.commit-push.outputs.BRANCH }}
- uses: repo-sync/pull-request@v2
+ uses: repo-sync/pull-request@7e79a9f5dc3ad0ce53138f01df2fad14a04831c5 # v2
with:
source_branch: ${{ steps.commit-push.outputs.BRANCH }}
destination_branch: "dev"
@@ -211,13 +217,15 @@ jobs:
# Add "Release Not Needed" label to the PR
- name: Add Release Not Needed label
if: ${{ steps.pull-request.outputs.pr_number }}
- uses: actions/github-script@v8
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ env:
+ PR_NUMBER: ${{ steps.pull-request.outputs.pr_number }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
- issue_number: ${{ steps.pull-request.outputs.pr_number }},
+ issue_number: Number(process.env.PR_NUMBER),
labels: ['Release Not Needed']
})
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab6d6a3b2..a95470214 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+## Release 2026-06-26
+
+### Amazon.Lambda.DurableExecution (0.1.2-preview)
+* Add internal IDurableServiceClient abstraction over the durable execution service RPCs so the testing package can inject an in-memory implementation; route DurableFunction.WrapAsync overloads through it.
+
## Release 2026-06-25
### Amazon.Lambda.Annotations (2.1.0)
diff --git a/Libraries/Libraries.sln b/Libraries/Libraries.sln
index 1b2bedcd5..9f66a0031 100644
--- a/Libraries/Libraries.sln
+++ b/Libraries/Libraries.sln
@@ -165,6 +165,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecut
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnnotationsClassLibraryFunction", "test\Amazon.Lambda.DurableExecution.IntegrationTests\TestFunctions\AnnotationsClassLibraryFunction\AnnotationsClassLibraryFunction.csproj", "{D55E2D57-8374-4573-999B-6E64E109C25F}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecution.Analyzers", "src\Amazon.Lambda.DurableExecution.Analyzers\Amazon.Lambda.DurableExecution.Analyzers.csproj", "{6702F1BE-3A11-4DFB-93C3-50065789D814}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecution.Analyzers.Tests", "test\Amazon.Lambda.DurableExecution.Analyzers.Tests\Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj", "{592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -1039,6 +1043,30 @@ Global
{D55E2D57-8374-4573-999B-6E64E109C25F}.Release|x64.Build.0 = Release|Any CPU
{D55E2D57-8374-4573-999B-6E64E109C25F}.Release|x86.ActiveCfg = Release|Any CPU
{D55E2D57-8374-4573-999B-6E64E109C25F}.Release|x86.Build.0 = Release|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x64.Build.0 = Debug|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x86.Build.0 = Debug|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x64.ActiveCfg = Release|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x64.Build.0 = Release|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x86.ActiveCfg = Release|Any CPU
+ {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x86.Build.0 = Release|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x64.Build.0 = Debug|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x86.Build.0 = Debug|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x64.ActiveCfg = Release|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x64.Build.0 = Release|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x86.ActiveCfg = Release|Any CPU
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1120,6 +1148,8 @@ Global
{CA132CAB-FF4F-4312-B3A3-66DE9D360F27} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69}
{16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69}
{D55E2D57-8374-4573-999B-6E64E109C25F} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69}
+ {6702F1BE-3A11-4DFB-93C3-50065789D814} = {AAB54E74-20B1-42ED-BC3D-CE9F7BC7FD12}
+ {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {503678A4-B8D1-4486-8915-405A3E9CF0EB}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj
new file mode 100644
index 000000000..f64018a6e
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj
@@ -0,0 +1,48 @@
+
+
+
+
+ netstandard2.0
+ latest
+ enable
+ disable
+
+ Roslyn analyzers and code fixes for Amazon.Lambda.DurableExecution — catches durable-execution determinism and authoring mistakes (DE001-DE004) at build time.
+ Amazon.Lambda.DurableExecution.Analyzers
+ Amazon.Lambda.DurableExecution.Analyzers
+ AWS;Amazon;Lambda;Durable;Workflow;Analyzer;Roslyn
+
+
+ false
+ false
+ true
+
+
+ ..\..\..\buildtools\public.snk
+ true
+
+ true
+ true
+
+
+ $(NoWarn);RS1032;RS1033
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Shipped.md b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Shipped.md
new file mode 100644
index 000000000..f50bb1fe2
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Shipped.md
@@ -0,0 +1,2 @@
+; Shipped analyzer releases
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Unshipped.md b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Unshipped.md
new file mode 100644
index 000000000..ceae365d0
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,11 @@
+; Unshipped analyzer release
+; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|----------------------------------------------------------------------
+DE001 | AWSLambdaDurableExecution | Warning | Non-deterministic call outside a step. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de001
+DE002 | AWSLambdaDurableExecution | Warning | Nested durable operation inside a step body. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de002
+DE003 | AWSLambdaDurableExecution | Warning | Mutable variable captured and modified inside a durable operation. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de003
+DE004 | AWSLambdaDurableExecution | Info | Task.WhenAll/WhenAny over durable tasks; prefer ParallelAsync/MapAsync. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de004
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableDiagnostics.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableDiagnostics.cs
new file mode 100644
index 000000000..448fea326
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableDiagnostics.cs
@@ -0,0 +1,82 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using Microsoft.CodeAnalysis;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// Diagnostic descriptors for the durable-execution analyzers. These mirror the
+ /// JavaScript SDK's ESLint plugin rules (no-non-deterministic-outside-step,
+ /// no-nested-durable-operations, no-closure-in-durable-operations) and add a
+ /// .NET-specific rule (DE004) for the Task.WhenAll/WhenAny pattern.
+ ///
+ public static class DurableDiagnostics
+ {
+ internal const string Category = "AWSLambdaDurableExecution";
+
+ private const string HelpRoot =
+ "https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md";
+
+ ///
+ /// DE001 — a non-deterministic API (DateTime.Now, Guid.NewGuid, Random, …) is used in
+ /// workflow code outside a step. On replay the workflow re-runs from the top, so the value
+ /// differs between the original run and replays, corrupting checkpoint-derived state.
+ ///
+ public static readonly DiagnosticDescriptor NonDeterministicCallOutsideStep = new DiagnosticDescriptor(
+ id: "DE001",
+ title: "Non-deterministic call outside a step",
+ messageFormat: "Non-deterministic operation '{0}' is used in workflow code outside a step. Move it inside a step (e.g. context.StepAsync(...)) so its result is checkpointed and replays consistently",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "Durable workflow code re-executes from the top on every invocation. Values from non-deterministic APIs differ between the original execution and replays unless they are captured inside a step.",
+ helpLinkUri: HelpRoot + "#de001");
+
+ ///
+ /// DE002 — a durable operation is invoked inside a step body by capturing the outer
+ /// IDurableContext. Step bodies must contain only plain, deterministic code;
+ /// nesting durable operations requires RunInChildContextAsync.
+ ///
+ public static readonly DiagnosticDescriptor NestedDurableOperationInsideStep = new DiagnosticDescriptor(
+ id: "DE002",
+ title: "Nested durable operation inside a step body",
+ messageFormat: "Durable operation '{0}' is called on the outer durable context '{1}' inside a {2} body. Step bodies must contain only plain, deterministic code; nest durable operations with context.RunInChildContextAsync(...) instead",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "A step body is replayed verbatim on every retry. Calling another durable operation from inside it produces unpredictable behavior; use RunInChildContextAsync to group durable operations.",
+ helpLinkUri: HelpRoot + "#de002");
+
+ ///
+ /// DE003 — a variable captured from an outer scope is mutated inside a durable-operation
+ /// delegate. On replay the operation returns its cached result without re-executing the body,
+ /// so the write never happens and the captured variable holds stale state.
+ ///
+ public static readonly DiagnosticDescriptor MutableCaptureInDurableOperation = new DiagnosticDescriptor(
+ id: "DE003",
+ title: "Mutable variable captured and modified inside a durable operation",
+ messageFormat: "Variable '{0}' is captured from an outer scope and modified inside a durable operation. On replay the operation returns its cached result without re-executing the body, so this write is lost and '{0}' becomes stale. Return the value from the operation and assign it (e.g. {0} = await context.StepAsync(...)) instead",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ description: "Reading a captured variable inside a durable operation is safe; mutating it is not, because the body is skipped on replay.",
+ helpLinkUri: HelpRoot + "#de003");
+
+ ///
+ /// DE004 — Task.WhenAll/Task.WhenAny is called with tasks produced by durable
+ /// operations. This is not incorrect (operation IDs are allocated deterministically), but it
+ /// bypasses completion policies, concurrency limits, branch naming, and IBatchResult output.
+ /// Advisory (Info) only.
+ ///
+ public static readonly DiagnosticDescriptor DurableTaskInTaskCombinator = new DiagnosticDescriptor(
+ id: "DE004",
+ title: "Prefer ParallelAsync/MapAsync over Task.WhenAll/WhenAny for durable tasks",
+ messageFormat: "'{0}' over durable tasks bypasses completion policies, concurrency limits, branch naming, and IBatchResult output. Use context.ParallelAsync (or MapAsync) so concurrent durable operations get framework coordination and complete execution traces",
+ category: Category,
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: true,
+ description: "Task.WhenAll/WhenAny work correctly with durable tasks, but ParallelAsync/MapAsync are preferred for completion policies, concurrency control, and observability.",
+ helpLinkUri: HelpRoot + "#de004");
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableKnownSymbols.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableKnownSymbols.cs
new file mode 100644
index 000000000..40b05ec51
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableKnownSymbols.cs
@@ -0,0 +1,344 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// How a delegate passed to a durable operation executes, which determines what the
+ /// analyzers allow inside its body.
+ ///
+ internal enum DurableDelegateRole
+ {
+ /// Not a delegate accepted by a durable operation.
+ None,
+
+ ///
+ /// The delegate body runs inside a checkpointed step (StepAsync func,
+ /// WaitForCallbackAsync submitter, WaitForConditionAsync check). Non-deterministic code is
+ /// allowed here because the result is checkpointed; nested durable operations are not.
+ ///
+ StepWrapped,
+
+ ///
+ /// The delegate body runs as a child sub-workflow (RunInChildContextAsync func, ParallelAsync
+ /// branch, MapAsync func). It is still workflow code (must be deterministic), but nested
+ /// durable operations are allowed.
+ ///
+ ChildContext,
+ }
+
+ ///
+ /// Per-compilation cache of the durable-execution and BCL symbols the analyzers match against.
+ /// Resolved once in a compilation-start action; if IDurableContext is not present
+ /// the compilation does not use durable execution and the analyzers register nothing.
+ ///
+ internal sealed class DurableKnownSymbols
+ {
+ internal const string IDurableContextMetadataName = "Amazon.Lambda.DurableExecution.IDurableContext";
+
+ /// The names of the durable operations declared on IDurableContext.
+ private static readonly ImmutableHashSet DurableOperationNames = ImmutableHashSet.Create(
+ "StepAsync",
+ "WaitAsync",
+ "RunInChildContextAsync",
+ "CreateCallbackAsync",
+ "WaitForCallbackAsync",
+ "InvokeAsync",
+ "WaitForConditionAsync",
+ "ParallelAsync",
+ "MapAsync");
+
+ internal INamedTypeSymbol DurableContext { get; }
+
+ // BCL types backing the DE001 non-determinism catalog. Any may be null on a given target framework.
+ private readonly INamedTypeSymbol? _dateTime;
+ private readonly INamedTypeSymbol? _dateTimeOffset;
+ private readonly INamedTypeSymbol? _guid;
+ private readonly INamedTypeSymbol? _random;
+ private readonly INamedTypeSymbol? _stopwatch;
+ private readonly INamedTypeSymbol? _environment;
+ private readonly INamedTypeSymbol? _path;
+ private readonly INamedTypeSymbol? _randomNumberGenerator;
+ private readonly INamedTypeSymbol? _rngCryptoServiceProvider;
+ private readonly INamedTypeSymbol? _task;
+ private readonly INamedTypeSymbol? _taskOfT;
+
+ private DurableKnownSymbols(Compilation compilation, INamedTypeSymbol durableContext)
+ {
+ DurableContext = durableContext;
+ _dateTime = compilation.GetTypeByMetadataName("System.DateTime");
+ _dateTimeOffset = compilation.GetTypeByMetadataName("System.DateTimeOffset");
+ _guid = compilation.GetTypeByMetadataName("System.Guid");
+ _random = compilation.GetTypeByMetadataName("System.Random");
+ _stopwatch = compilation.GetTypeByMetadataName("System.Diagnostics.Stopwatch");
+ _environment = compilation.GetTypeByMetadataName("System.Environment");
+ _path = compilation.GetTypeByMetadataName("System.IO.Path");
+ _randomNumberGenerator = compilation.GetTypeByMetadataName("System.Security.Cryptography.RandomNumberGenerator");
+ _rngCryptoServiceProvider = compilation.GetTypeByMetadataName("System.Security.Cryptography.RNGCryptoServiceProvider");
+ _task = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task");
+ _taskOfT = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task`1");
+ }
+
+ ///
+ /// Resolves the durable symbols. Returns null when the compilation does not reference
+ /// Amazon.Lambda.DurableExecution, so callers register no per-node work.
+ ///
+ internal static DurableKnownSymbols? TryCreate(Compilation compilation)
+ {
+ var durableContext = compilation.GetTypeByMetadataName(IDurableContextMetadataName);
+ return durableContext is null ? null : new DurableKnownSymbols(compilation, durableContext);
+ }
+
+ ///
+ /// True if is IDurableContext or implements it (covers the
+ /// concrete DurableContext and any user implementation).
+ ///
+ internal bool IsDurableContextType(ITypeSymbol? type)
+ {
+ if (type is null)
+ {
+ return false;
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, DurableContext))
+ {
+ return true;
+ }
+
+ foreach (var iface in type.AllInterfaces)
+ {
+ if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, DurableContext))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// True if is one of the durable operations on a durable context.
+ /// Matched by symbol (containing type is/implements IDurableContext) plus an explicit
+ /// name allowlist, so property getters and ConfigureLogger are excluded, and an
+ /// unrelated obj.StepAsync() on some other type is never matched.
+ ///
+ internal bool IsDurableOperation(IMethodSymbol? method, out string operationName, out DurableDelegateRole role)
+ {
+ operationName = string.Empty;
+ role = DurableDelegateRole.None;
+
+ if (method is null)
+ {
+ return false;
+ }
+
+ var name = method.OriginalDefinition.Name;
+ if (!DurableOperationNames.Contains(name))
+ {
+ return false;
+ }
+
+ if (!IsDurableContextType(method.OriginalDefinition.ContainingType))
+ {
+ return false;
+ }
+
+ operationName = name;
+ role = RoleFor(name);
+ return true;
+ }
+
+ private static DurableDelegateRole RoleFor(string operationName)
+ {
+ switch (operationName)
+ {
+ case "StepAsync":
+ case "WaitForCallbackAsync":
+ case "WaitForConditionAsync":
+ return DurableDelegateRole.StepWrapped;
+ case "RunInChildContextAsync":
+ case "ParallelAsync":
+ case "MapAsync":
+ return DurableDelegateRole.ChildContext;
+ default:
+ return DurableDelegateRole.None;
+ }
+ }
+
+ /// True if is Task.WhenAll or Task.WhenAny.
+ internal bool IsTaskCombinator(IMethodSymbol? method, out string friendlyName)
+ {
+ friendlyName = string.Empty;
+ if (method is null || _task is null)
+ {
+ return false;
+ }
+
+ var def = method.OriginalDefinition;
+ if (!SymbolEqualityComparer.Default.Equals(def.ContainingType, _task))
+ {
+ return false;
+ }
+
+ if (def.Name == "WhenAll")
+ {
+ friendlyName = "Task.WhenAll";
+ return true;
+ }
+
+ if (def.Name == "WhenAny")
+ {
+ friendlyName = "Task.WhenAny";
+ return true;
+ }
+
+ return false;
+ }
+
+ /// True if the type is Task or Task<T> (a candidate durable task).
+ internal bool IsTaskType(ITypeSymbol? type)
+ {
+ if (type is null)
+ {
+ return false;
+ }
+
+ var def = type.OriginalDefinition;
+ return SymbolEqualityComparer.Default.Equals(def, _task)
+ || SymbolEqualityComparer.Default.Equals(def, _taskOfT);
+ }
+
+ ///
+ /// Returns the friendly name (e.g. "DateTime.Now") if the operation reads/calls/creates
+ /// a non-deterministic API from the DE001 catalog; otherwise null.
+ ///
+ internal string? TryGetNonDeterministicApi(IOperation operation)
+ {
+ switch (operation)
+ {
+ case IPropertyReferenceOperation pr:
+ return MatchProperty(pr.Property);
+ case IInvocationOperation inv:
+ return MatchMethod(inv.TargetMethod);
+ case IObjectCreationOperation oc:
+ return MatchObjectCreation(oc);
+ default:
+ return null;
+ }
+ }
+
+ private string? MatchProperty(IPropertySymbol property)
+ {
+ var owner = property.ContainingType?.OriginalDefinition;
+ var name = property.Name;
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _dateTime)
+ && (name == "Now" || name == "UtcNow" || name == "Today"))
+ {
+ return "DateTime." + name;
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _dateTimeOffset)
+ && (name == "Now" || name == "UtcNow"))
+ {
+ return "DateTimeOffset." + name;
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _environment)
+ && (name == "TickCount" || name == "TickCount64"))
+ {
+ return "Environment." + name;
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _stopwatch)
+ && (name == "Elapsed" || name == "ElapsedMilliseconds" || name == "ElapsedTicks"))
+ {
+ return "Stopwatch." + name;
+ }
+
+ // Random.Shared is seeded non-deterministically. (A user-seeded `new Random(42)` is
+ // deterministic, so we flag the seedless ctor / Random.Shared, not the .Next() call.)
+ if (SymbolEqualityComparer.Default.Equals(owner, _random) && name == "Shared")
+ {
+ return "Random.Shared";
+ }
+
+ return null;
+ }
+
+ private string? MatchMethod(IMethodSymbol method)
+ {
+ var owner = method.ContainingType?.OriginalDefinition;
+ var name = method.Name;
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _guid) && name == "NewGuid")
+ {
+ return "Guid.NewGuid()";
+ }
+
+ // Note: Random instance methods (.Next() etc.) are intentionally NOT flagged here — a
+ // user-seeded `new Random(42)` is deterministic. Non-determinism is introduced by the
+ // seedless `new Random()` ctor (MatchObjectCreation) and `Random.Shared` (MatchProperty).
+ if (SymbolEqualityComparer.Default.Equals(owner, _stopwatch)
+ && (name == "GetTimestamp" || name == "StartNew"))
+ {
+ return "Stopwatch." + name + "()";
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _randomNumberGenerator)
+ && (name == "GetBytes" || name == "GetInt32" || name == "Fill"
+ || name == "GetString" || name == "GetHexString"))
+ {
+ return "RandomNumberGenerator." + name + "()";
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(owner, _path)
+ && (name == "GetTempFileName" || name == "GetRandomFileName"))
+ {
+ return "Path." + name + "()";
+ }
+
+ return null;
+ }
+
+ private string? MatchObjectCreation(IObjectCreationOperation oc)
+ {
+ var type = oc.Constructor?.ContainingType?.OriginalDefinition;
+
+ // Only the seedless Random ctor is non-deterministic; new Random(seed) is fine.
+ if (SymbolEqualityComparer.Default.Equals(type, _random) && oc.Arguments.Length == 0)
+ {
+ return "new Random()";
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(type, _rngCryptoServiceProvider))
+ {
+ return "new RNGCryptoServiceProvider()";
+ }
+
+ return null;
+ }
+
+ ///
+ /// True if contains an IDurableContext-typed parameter,
+ /// which marks a method/local-function/lambda as durable workflow code.
+ ///
+ internal bool HasDurableContextParameter(IEnumerable parameters)
+ {
+ foreach (var p in parameters)
+ {
+ if (IsDurableContextType(p.Type))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableScope.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableScope.cs
new file mode 100644
index 000000000..5ec1846df
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableScope.cs
@@ -0,0 +1,201 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// Shared helpers for reasoning about where an operation sits relative to durable-operation
+ /// delegates. Used by DE001 (is this non-deterministic call inside a step?) and DE002 (is this
+ /// durable call inside a step body?).
+ ///
+ internal static class DurableScope
+ {
+ ///
+ /// Describes the nearest enclosing durable-operation delegate of an operation, if any.
+ ///
+ internal readonly struct EnclosingDelegate
+ {
+ internal EnclosingDelegate(DurableDelegateRole role, string operationName, IAnonymousFunctionOperation function)
+ {
+ Role = role;
+ OperationName = operationName;
+ Function = function;
+ }
+
+ internal DurableDelegateRole Role { get; }
+
+ internal string OperationName { get; }
+
+ internal IAnonymousFunctionOperation Function { get; }
+
+ internal bool Found => Role != DurableDelegateRole.None;
+ }
+
+ ///
+ /// Walks up the operation tree from and returns the nearest
+ /// enclosing lambda that is an argument to a durable operation, classified by its role.
+ /// Non-durable lambdas (e.g. a Select projection or Task.Run) are skipped so a
+ /// non-deterministic call nested in such a lambda is still attributed to the durable step that
+ /// contains it.
+ ///
+ internal static EnclosingDelegate FindNearestDurableDelegate(IOperation operation, DurableKnownSymbols symbols)
+ {
+ for (var current = operation.Parent; current is not null; current = current.Parent)
+ {
+ if (current is IAnonymousFunctionOperation lambda
+ && TryClassifyDelegate(lambda, symbols, out var role, out var opName))
+ {
+ return new EnclosingDelegate(role, opName, lambda);
+ }
+ }
+
+ return default;
+ }
+
+ ///
+ /// True if any enclosing scope (method, local function, or lambda) of
+ /// is durable workflow code — i.e. declares an
+ /// IDurableContext parameter. This is the single shared scoping primitive: it covers
+ /// the [DurableExecution] annotation path, the hand-wired
+ /// DurableFunction.WrapAsync(async (input, ctx) => …) lambda, and child/parallel/map
+ /// branch delegates.
+ ///
+ internal static bool IsInWorkflowCode(IOperation operation, DurableKnownSymbols symbols)
+ {
+ for (var current = operation.Parent; current is not null; current = current.Parent)
+ {
+ switch (current)
+ {
+ case IAnonymousFunctionOperation lambda
+ when symbols.HasDurableContextParameter(lambda.Symbol.Parameters):
+ return true;
+ case ILocalFunctionOperation local
+ when symbols.HasDurableContextParameter(local.Symbol.Parameters):
+ return true;
+ case IMethodBodyOperation:
+ case IBlockOperation { Parent: null }:
+ break;
+ }
+ }
+
+ // The enclosing method symbol (top-level body, not reachable as an IOperation parent).
+ var enclosing = operation.SemanticModel?.GetEnclosingSymbol(operation.Syntax.SpanStart) as IMethodSymbol;
+ while (enclosing is not null)
+ {
+ if (symbols.HasDurableContextParameter(enclosing.Parameters))
+ {
+ return true;
+ }
+
+ enclosing = enclosing.ContainingSymbol as IMethodSymbol;
+ }
+
+ return false;
+ }
+
+ ///
+ /// If is passed as an argument to a durable operation, reports which
+ /// operation and the delegate's role. Maps the lambda's argument position to the corresponding
+ /// parameter so the WaitForCallbackAsync submitter / WaitForConditionAsync check (which are
+ /// step-wrapped) and the ParallelAsync/MapAsync branches (child context) are classified
+ /// correctly — even though they are not the operation's "first" delegate.
+ ///
+ internal static bool TryClassifyDelegate(
+ IAnonymousFunctionOperation lambda,
+ DurableKnownSymbols symbols,
+ out DurableDelegateRole role,
+ out string operationName)
+ {
+ role = DurableDelegateRole.None;
+ operationName = string.Empty;
+
+ // The lambda may be a direct argument (StepAsync(lambda)), wrapped in a delegate
+ // conversion, or nested inside the array/collection that builds the branches list of
+ // ParallelAsync ([ (c, ct) => …, (c, ct) => … ]). Walk up through those expression
+ // wrappers to the enclosing argument, but stop at any statement, block, or another
+ // anonymous function so we never attribute the lambda to a non-enclosing invocation.
+ // This avoids naming ICollectionExpressionOperation, which is not in all Roslyn versions.
+ IOperation? argument = lambda.Parent;
+ while (argument is not null
+ && argument is not IArgumentOperation
+ && argument is not IAnonymousFunctionOperation
+ && argument is not IBlockOperation
+ && argument is not IExpressionStatementOperation)
+ {
+ argument = argument.Parent;
+ }
+
+ if (argument is not IArgumentOperation arg || arg.Parent is not IInvocationOperation invocation)
+ {
+ return false;
+ }
+
+ if (!symbols.IsDurableOperation(invocation.TargetMethod, out operationName, out role))
+ {
+ role = DurableDelegateRole.None;
+ operationName = string.Empty;
+ return false;
+ }
+
+ return role != DurableDelegateRole.None;
+ }
+
+ ///
+ /// Collects the symbols (parameters and locals) declared within ,
+ /// including nested lambdas/local functions, so DE003 can tell a captured outer variable from a
+ /// delegate-local one.
+ ///
+ internal static HashSet CollectDeclaredSymbols(IAnonymousFunctionOperation function)
+ {
+ // The comparer IS supplied; this suppresses a known RS1024 false positive in the 4.0.1
+ // analyzer pack that flags `new HashSet(SymbolEqualityComparer.Default)`.
+#pragma warning disable RS1024 // Compare symbols correctly
+ var declared = new HashSet(SymbolEqualityComparer.Default);
+#pragma warning restore RS1024
+
+ foreach (var p in function.Symbol.Parameters)
+ {
+ declared.Add(p);
+ }
+
+ CollectDescendantDeclarations(function.Body, declared);
+ return declared;
+ }
+
+ private static void CollectDescendantDeclarations(IOperation? operation, HashSet declared)
+ {
+ if (operation is null)
+ {
+ return;
+ }
+
+ foreach (var child in operation.Descendants())
+ {
+ switch (child)
+ {
+ case IVariableDeclaratorOperation decl:
+ declared.Add(decl.Symbol);
+ break;
+ case IAnonymousFunctionOperation nested:
+ foreach (var p in nested.Symbol.Parameters)
+ {
+ declared.Add(p);
+ }
+
+ break;
+ case ILocalFunctionOperation localFn:
+ foreach (var p in localFn.Symbol.Parameters)
+ {
+ declared.Add(p);
+ }
+
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorAnalyzer.cs
new file mode 100644
index 000000000..1b232c4b9
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorAnalyzer.cs
@@ -0,0 +1,200 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// DE004 — flags Task.WhenAll/Task.WhenAny called with tasks produced by durable
+ /// operations. This is advisory (Info): the combinators work correctly with durable tasks because
+ /// operation IDs are allocated deterministically, but they bypass completion policies, concurrency
+ /// limits, branch naming, and structured IBatchResult output, so ParallelAsync /
+ /// MapAsync are preferred.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class DurableTaskCombinatorAnalyzer : DiagnosticAnalyzer
+ {
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(DurableDiagnostics.DurableTaskInTaskCombinator);
+
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+
+ context.RegisterCompilationStartAction(compilationStart =>
+ {
+ var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation);
+ if (symbols is null)
+ {
+ return;
+ }
+
+ compilationStart.RegisterOperationAction(
+ ctx => AnalyzeInvocation(ctx, symbols),
+ OperationKind.Invocation);
+ });
+ }
+
+ private static void AnalyzeInvocation(OperationAnalysisContext context, DurableKnownSymbols symbols)
+ {
+ var invocation = (IInvocationOperation)context.Operation;
+
+ if (!symbols.IsTaskCombinator(invocation.TargetMethod, out var friendlyName))
+ {
+ return;
+ }
+
+ // Gather the task expressions passed in and report if any is produced by a durable op.
+ foreach (var argument in invocation.Arguments)
+ {
+ if (ArgumentContainsDurableTask(argument.Value, symbols))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ DurableDiagnostics.DurableTaskInTaskCombinator,
+ invocation.Syntax.GetLocation(),
+ friendlyName));
+ return;
+ }
+ }
+ }
+
+ ///
+ /// True if the argument value contains (directly, or via the local it references) at least one
+ /// task produced by a durable-context operation.
+ ///
+ private static bool ArgumentContainsDurableTask(IOperation value, DurableKnownSymbols symbols)
+ {
+ // Strip params-array / collection conversions.
+ value = Unwrap(value);
+
+ // Inline: Task.WhenAll(ctx.StepAsync(a), ctx.StepAsync(b)) or new[] { ... } or [ ... ].
+ foreach (var op in value.DescendantsAndSelf())
+ {
+ if (op is IInvocationOperation inv && IsDurableTaskProducer(inv, symbols))
+ {
+ return true;
+ }
+ }
+
+ // Indirect: Task[] tasks = ...; await Task.WhenAll(tasks). Follow a local to its
+ // initializer and to assignments/Add calls in the enclosing method body.
+ if (value is ILocalReferenceOperation localRef)
+ {
+ return LocalIsPopulatedWithDurableTasks(localRef, symbols);
+ }
+
+ return false;
+ }
+
+ private static IOperation Unwrap(IOperation op)
+ {
+ while (op is IConversionOperation conv)
+ {
+ op = conv.Operand;
+ }
+
+ return op;
+ }
+
+ private static bool IsDurableTaskProducer(IInvocationOperation invocation, DurableKnownSymbols symbols)
+ {
+ if (!symbols.IsDurableOperation(invocation.TargetMethod, out _, out _))
+ {
+ return false;
+ }
+
+ return invocation.Instance is not null && symbols.IsDurableContextType(invocation.Instance.Type);
+ }
+
+ ///
+ /// Bounded scan: walks the enclosing method/lambda body for the declaration of the referenced
+ /// local and any assignments or List.Add calls that store a durable task into it.
+ ///
+ private static bool LocalIsPopulatedWithDurableTasks(ILocalReferenceOperation localRef, DurableKnownSymbols symbols)
+ {
+ var local = localRef.Local;
+
+ // Find the enclosing body operation that contains the WhenAll call.
+ IOperation root = localRef;
+ while (root.Parent is not null)
+ {
+ root = root.Parent;
+ }
+
+ foreach (var op in root.DescendantsAndSelf())
+ {
+ switch (op)
+ {
+ case IVariableDeclaratorOperation decl
+ when SymbolEqualityComparer.Default.Equals(decl.Symbol, local)
+ && decl.Initializer is not null:
+ if (ContainsDurableTaskProducer(decl.Initializer.Value, symbols))
+ {
+ return true;
+ }
+
+ break;
+
+ case ISimpleAssignmentOperation assign
+ when TargetsLocal(assign.Target, local):
+ if (ContainsDurableTaskProducer(assign.Value, symbols))
+ {
+ return true;
+ }
+
+ break;
+
+ case IInvocationOperation addCall
+ when addCall.TargetMethod.Name == "Add"
+ && addCall.Instance is ILocalReferenceOperation listRef
+ && SymbolEqualityComparer.Default.Equals(listRef.Local, local):
+ foreach (var addArg in addCall.Arguments)
+ {
+ if (ContainsDurableTaskProducer(addArg.Value, symbols))
+ {
+ return true;
+ }
+ }
+
+ break;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TargetsLocal(IOperation target, ILocalSymbol local)
+ {
+ // Direct local assignment, or element assignment tasks[i] = ...
+ switch (Unwrap(target))
+ {
+ case ILocalReferenceOperation l:
+ return SymbolEqualityComparer.Default.Equals(l.Local, local);
+ case IArrayElementReferenceOperation arr when arr.ArrayReference is ILocalReferenceOperation arrLocal:
+ return SymbolEqualityComparer.Default.Equals(arrLocal.Local, local);
+ default:
+ return false;
+ }
+ }
+
+ private static bool ContainsDurableTaskProducer(IOperation value, DurableKnownSymbols symbols)
+ {
+ foreach (var op in Unwrap(value).DescendantsAndSelf())
+ {
+ if (op is IInvocationOperation inv && IsDurableTaskProducer(inv, symbols))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorCodeFixProvider.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorCodeFixProvider.cs
new file mode 100644
index 000000000..6bd52acce
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorCodeFixProvider.cs
@@ -0,0 +1,233 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Composition;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Formatting;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// Code fix for DE004: rewrites await Task.WhenAll(ctx.StepAsync(a), ctx.StepAsync(b)) into
+ /// await ctx.ParallelAsync(new[] { (c, ct) => c.StepAsync(a), (c, ct) => c.StepAsync(b) })
+ /// for the one provably-safe shape: an inline list of direct durable calls on a single shared
+ /// context whose aggregate result is discarded. Every other shape (result consumed, mixed task
+ /// types, a variable instead of an inline list, WhenAny) is left diagnostic-only.
+ ///
+ [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(DurableTaskCombinatorCodeFixProvider))]
+ [Shared]
+ public sealed class DurableTaskCombinatorCodeFixProvider : CodeFixProvider
+ {
+ ///
+ public override ImmutableArray FixableDiagnosticIds { get; } =
+ ImmutableArray.Create(DurableDiagnostics.DurableTaskInTaskCombinator.Id);
+
+ /// Fix-all is disabled — each conversion changes durable-operation structure and is reviewed individually.
+ public override FixAllProvider? GetFixAllProvider() => null;
+
+ ///
+ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root is null)
+ {
+ return;
+ }
+
+ var diagnostic = context.Diagnostics[0];
+ var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true);
+ var invocation = node.FirstAncestorOrSelf();
+ if (invocation is null)
+ {
+ return;
+ }
+
+ // Only WhenAll (not WhenAny) and only when its result is discarded (the await is an
+ // expression statement, not consumed by an assignment / argument / return).
+ if (GetCombinatorName(invocation) != "WhenAll" || !ResultIsDiscarded(invocation))
+ {
+ return;
+ }
+
+ // All arguments must be inline durable calls of the SAME receiver identifier.
+ if (!TryGetHomogeneousDurableCalls(invocation, out var receiver, out var calls))
+ {
+ return;
+ }
+
+ var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
+ if (semanticModel is null)
+ {
+ return;
+ }
+
+ // Determine the shared result type T (each durable call returns Task). Bail if the calls
+ // are not all the same T — that is not the provably-safe homogeneous shape.
+ var elementType = TryGetSharedResultType(calls, semanticModel, context.CancellationToken);
+ if (elementType is null)
+ {
+ return;
+ }
+
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: $"Convert to {receiver}.ParallelAsync(...)",
+ createChangedDocument: ct => ConvertAsync(context.Document, root, invocation, receiver, calls, elementType, ct),
+ equivalenceKey: "ConvertToParallelAsync"),
+ diagnostic);
+ }
+
+ private static string? TryGetSharedResultType(
+ List calls,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken)
+ {
+ string? shared = null;
+ foreach (var call in calls)
+ {
+ if (semanticModel.GetTypeInfo(call, cancellationToken).Type is not INamedTypeSymbol taskType
+ || taskType.TypeArguments.Length != 1)
+ {
+ return null; // Non-generic Task (void step) — not handled by this fix.
+ }
+
+ var display = taskType.TypeArguments[0].ToDisplayString();
+ if (shared is null)
+ {
+ shared = display;
+ }
+ else if (shared != display)
+ {
+ return null; // Heterogeneous result types — bail.
+ }
+ }
+
+ return shared;
+ }
+
+ private static string? GetCombinatorName(InvocationExpressionSyntax invocation)
+ {
+ return invocation.Expression switch
+ {
+ MemberAccessExpressionSyntax ma => ma.Name.Identifier.ValueText,
+ _ => null,
+ };
+ }
+
+ private static bool ResultIsDiscarded(InvocationExpressionSyntax invocation)
+ {
+ // The WhenAll invocation is typically wrapped in an await; the await must stand alone.
+ SyntaxNode current = invocation;
+ if (current.Parent is AwaitExpressionSyntax awaitExpr)
+ {
+ current = awaitExpr;
+ }
+
+ return current.Parent is ExpressionStatementSyntax;
+ }
+
+ private static bool TryGetHomogeneousDurableCalls(
+ InvocationExpressionSyntax invocation,
+ out string receiver,
+ out List calls)
+ {
+ receiver = string.Empty;
+ calls = new List();
+
+ var arguments = invocation.ArgumentList.Arguments;
+ if (arguments.Count < 2)
+ {
+ return false; // Nothing to parallelize.
+ }
+
+ string? sharedReceiver = null;
+ foreach (var argument in arguments)
+ {
+ if (argument.Expression is not InvocationExpressionSyntax call
+ || call.Expression is not MemberAccessExpressionSyntax memberAccess
+ || memberAccess.Expression is not IdentifierNameSyntax receiverId)
+ {
+ return false; // Not a direct ctx.Method(...) call.
+ }
+
+ if (sharedReceiver is null)
+ {
+ sharedReceiver = receiverId.Identifier.ValueText;
+ }
+ else if (sharedReceiver != receiverId.Identifier.ValueText)
+ {
+ return false; // Mixed receivers — bail.
+ }
+
+ calls.Add(call);
+ }
+
+ receiver = sharedReceiver!;
+ return true;
+ }
+
+ private static Task ConvertAsync(
+ Document document,
+ SyntaxNode root,
+ InvocationExpressionSyntax whenAll,
+ string receiver,
+ List calls,
+ string elementType,
+ CancellationToken cancellationToken)
+ {
+ // Build one branch lambda per call: (c, ct) => c.StepAsync(...originalArgs...)
+ var branchLambdas = calls.Select(call =>
+ {
+ var memberAccess = (MemberAccessExpressionSyntax)call.Expression;
+ var rebound = call.WithExpression(
+ memberAccess.WithExpression(SyntaxFactory.IdentifierName("c")));
+
+ return (ExpressionSyntax)SyntaxFactory.ParenthesizedLambdaExpression(
+ SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(new[]
+ {
+ SyntaxFactory.Parameter(SyntaxFactory.Identifier("c")),
+ SyntaxFactory.Parameter(SyntaxFactory.Identifier("ct")),
+ })),
+ rebound);
+ }).ToArray();
+
+ // Explicitly-typed array — a lambda has no inferable type, so `new[] { … }` would not
+ // compile; emit `new Func>[] { … }`.
+ var arrayType = SyntaxFactory.ArrayType(
+ SyntaxFactory.ParseTypeName(
+ $"System.Func>"),
+ SyntaxFactory.SingletonList(
+ SyntaxFactory.ArrayRankSpecifier(
+ SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.OmittedArraySizeExpression()))));
+
+ var arrayLiteral = SyntaxFactory.ArrayCreationExpression(
+ arrayType,
+ SyntaxFactory.InitializerExpression(
+ SyntaxKind.ArrayInitializerExpression,
+ SyntaxFactory.SeparatedList(branchLambdas)));
+
+ var parallelCall = SyntaxFactory.InvocationExpression(
+ SyntaxFactory.MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ SyntaxFactory.IdentifierName(receiver),
+ SyntaxFactory.IdentifierName("ParallelAsync")),
+ SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.Argument(arrayLiteral))))
+ .WithTriviaFrom(whenAll)
+ .WithAdditionalAnnotations(Formatter.Annotation);
+
+ var newRoot = root.ReplaceNode(whenAll, parallelCall);
+ return Task.FromResult(document.WithSyntaxRoot(newRoot));
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/MutableCaptureAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/MutableCaptureAnalyzer.cs
new file mode 100644
index 000000000..b2ba45da0
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/MutableCaptureAnalyzer.cs
@@ -0,0 +1,157 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// DE003 — flags mutation of a variable captured from an outer scope inside a durable-operation
+ /// delegate (StepAsync, RunInChildContextAsync, WaitForConditionAsync, WaitForCallbackAsync, and
+ /// the ParallelAsync / MapAsync branches). On replay the delegate body is skipped and the cached
+ /// result returned, so the write never happens and the captured variable holds stale state.
+ /// Reading a captured variable is safe and not flagged.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class MutableCaptureAnalyzer : DiagnosticAnalyzer
+ {
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(DurableDiagnostics.MutableCaptureInDurableOperation);
+
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+
+ context.RegisterCompilationStartAction(compilationStart =>
+ {
+ var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation);
+ if (symbols is null)
+ {
+ return;
+ }
+
+ compilationStart.RegisterOperationAction(
+ ctx => AnalyzeInvocation(ctx, symbols),
+ OperationKind.Invocation);
+ });
+ }
+
+ private static void AnalyzeInvocation(OperationAnalysisContext context, DurableKnownSymbols symbols)
+ {
+ var invocation = (IInvocationOperation)context.Operation;
+
+ if (!symbols.IsDurableOperation(invocation.TargetMethod, out _, out _))
+ {
+ return;
+ }
+
+ if (invocation.Instance is null || !symbols.IsDurableContextType(invocation.Instance.Type))
+ {
+ return;
+ }
+
+ // Each delegate argument is analyzed independently. ParallelAsync takes a list of branch
+ // delegates, so a single invocation can carry several lambdas (nested in array/collection
+ // wrappers). We collect only the OUTERMOST lambdas (the actual step/branch delegates);
+ // a lambda nested inside a delegate body is covered by that delegate's recursive analysis,
+ // so collecting it again would double-report the same captured write.
+ foreach (var argument in invocation.Arguments)
+ {
+ foreach (var lambda in OutermostLambdas(argument))
+ {
+ AnalyzeDelegate(context, lambda);
+ }
+ }
+ }
+
+ private static IEnumerable OutermostLambdas(IOperation root)
+ {
+ foreach (var op in root.Descendants())
+ {
+ // Keep a lambda only if no other lambda sits between it and the argument root, so we
+ // return the branch/step delegates themselves, not lambdas nested inside their bodies.
+ if (op is IAnonymousFunctionOperation lambda && !HasEnclosingLambdaBelow(lambda, root))
+ {
+ yield return lambda;
+ }
+ }
+ }
+
+ private static bool HasEnclosingLambdaBelow(IOperation operation, IOperation root)
+ {
+ for (var parent = operation.Parent; parent is not null && !ReferenceEquals(parent, root); parent = parent.Parent)
+ {
+ if (parent is IAnonymousFunctionOperation)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static void AnalyzeDelegate(OperationAnalysisContext context, IAnonymousFunctionOperation lambda)
+ {
+ var declaredInDelegate = DurableScope.CollectDeclaredSymbols(lambda);
+
+ foreach (var descendant in lambda.Body.Descendants())
+ {
+ var targetOp = GetAssignmentTargetOperation(descendant);
+ if (targetOp is null)
+ {
+ continue;
+ }
+
+ var target = GetReferencedSymbol(targetOp);
+ if (target is null)
+ {
+ continue;
+ }
+
+ // Captured iff the mutated symbol is not declared within this delegate (or a lambda
+ // nested inside it). Reads never reach here because only assignment targets are
+ // collected, so reading a captured variable is inherently allowed.
+ if (!declaredInDelegate.Contains(target))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ DurableDiagnostics.MutableCaptureInDurableOperation,
+ targetOp.Syntax.GetLocation(),
+ target.Name));
+ }
+ }
+ }
+
+ ///
+ /// Returns the target operation being mutated by an assignment, compound assignment, coalesce
+ /// assignment, or increment/decrement; otherwise null.
+ ///
+ private static IOperation? GetAssignmentTargetOperation(IOperation operation)
+ {
+ return operation switch
+ {
+ ISimpleAssignmentOperation simple => simple.Target,
+ ICompoundAssignmentOperation compound => compound.Target,
+ ICoalesceAssignmentOperation coalesce => coalesce.Target,
+ IIncrementOrDecrementOperation incDec => incDec.Target,
+ _ => null,
+ };
+ }
+
+ private static ISymbol? GetReferencedSymbol(IOperation targetOp)
+ {
+ return targetOp switch
+ {
+ ILocalReferenceOperation local => local.Local,
+ IParameterReferenceOperation parameter => parameter.Parameter,
+ _ => null,
+ };
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NestedDurableOperationAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NestedDurableOperationAnalyzer.cs
new file mode 100644
index 000000000..f32184181
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NestedDurableOperationAnalyzer.cs
@@ -0,0 +1,124 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// DE002 — flags a durable operation invoked inside a step-wrapped delegate (a StepAsync body, a
+ /// WaitForCallback submitter, or a WaitForCondition check) by capturing the outer durable context.
+ /// In .NET a step delegate receives IStepContext, which exposes no durable operations, so
+ /// the only way to compile a nested durable call is to capture the outer IDurableContext —
+ /// which is exactly what this rule detects. Nesting durable operations requires
+ /// RunInChildContextAsync.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class NestedDurableOperationAnalyzer : DiagnosticAnalyzer
+ {
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(DurableDiagnostics.NestedDurableOperationInsideStep);
+
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+
+ context.RegisterCompilationStartAction(compilationStart =>
+ {
+ var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation);
+ if (symbols is null)
+ {
+ return;
+ }
+
+ compilationStart.RegisterOperationAction(
+ ctx => AnalyzeInvocation(ctx, symbols),
+ OperationKind.Invocation);
+ });
+ }
+
+ private static void AnalyzeInvocation(OperationAnalysisContext context, DurableKnownSymbols symbols)
+ {
+ var invocation = (IInvocationOperation)context.Operation;
+
+ // Is this call itself a durable operation?
+ if (!symbols.IsDurableOperation(invocation.TargetMethod, out var nestedOpName, out _))
+ {
+ return;
+ }
+
+ // The receiver must be a durable context. Static/extension calls (Instance == null) and
+ // non-durable receivers are ignored.
+ if (invocation.Instance is null || !symbols.IsDurableContextType(invocation.Instance.Type))
+ {
+ return;
+ }
+
+ // Find the nearest enclosing durable delegate. We only care about step-wrapped bodies;
+ // nesting inside a child-context / parallel / map branch is legitimate.
+ var enclosing = DurableScope.FindNearestDurableDelegate(invocation, symbols);
+ if (!enclosing.Found || enclosing.Role != DurableDelegateRole.StepWrapped)
+ {
+ return;
+ }
+
+ // The receiver must be captured from OUTSIDE this step delegate. A step delegate's own
+ // parameter is IStepContext (not a durable context), so any durable-context receiver here
+ // is necessarily captured — but we verify the symbol is not declared inside the delegate
+ // to stay correct for child/parallel branches that legitimately receive their own context.
+ var receiverSymbol = GetReferencedSymbol(invocation.Instance);
+ if (receiverSymbol is not null)
+ {
+ var declaredInDelegate = DurableScope.CollectDeclaredSymbols(enclosing.Function);
+ if (declaredInDelegate.Contains(receiverSymbol))
+ {
+ return; // Receiver is local to the step delegate — not a captured outer context.
+ }
+ }
+
+ var contextName = receiverSymbol?.Name ?? "context";
+ var bodyKind = DescribeBody(enclosing.OperationName);
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ DurableDiagnostics.NestedDurableOperationInsideStep,
+ invocation.Syntax.GetLocation(),
+ nestedOpName,
+ contextName,
+ bodyKind));
+ }
+
+ private static ISymbol? GetReferencedSymbol(IOperation receiver)
+ {
+ switch (receiver)
+ {
+ case ILocalReferenceOperation local:
+ return local.Local;
+ case IParameterReferenceOperation parameter:
+ return parameter.Parameter;
+ case IFieldReferenceOperation field:
+ return field.Field;
+ default:
+ return null;
+ }
+ }
+
+ private static string DescribeBody(string enclosingOperationName)
+ {
+ switch (enclosingOperationName)
+ {
+ case "WaitForConditionAsync":
+ return "condition-check";
+ case "WaitForCallbackAsync":
+ return "callback-submitter";
+ default:
+ return "step";
+ }
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallAnalyzer.cs
new file mode 100644
index 000000000..ea3c00b8d
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallAnalyzer.cs
@@ -0,0 +1,77 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// DE001 — flags non-deterministic API usage (DateTime.Now, Guid.NewGuid(), Random, …) in
+ /// durable workflow code that is not inside a step. On replay the workflow re-runs from the top,
+ /// so such values differ between the original execution and replays. The sanctioned place for
+ /// non-determinism is inside a step (or a WaitForCallback submitter / WaitForCondition check,
+ /// which the SDK also runs inside a step), where the result is checkpointed.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public sealed class NonDeterministicCallAnalyzer : DiagnosticAnalyzer
+ {
+ ///
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(DurableDiagnostics.NonDeterministicCallOutsideStep);
+
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+
+ context.RegisterCompilationStartAction(compilationStart =>
+ {
+ var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation);
+ if (symbols is null)
+ {
+ return; // Project does not reference the durable SDK; nothing to analyze.
+ }
+
+ compilationStart.RegisterOperationAction(
+ ctx => AnalyzeOperation(ctx, symbols),
+ OperationKind.PropertyReference,
+ OperationKind.Invocation,
+ OperationKind.ObjectCreation);
+ });
+ }
+
+ private static void AnalyzeOperation(OperationAnalysisContext context, DurableKnownSymbols symbols)
+ {
+ var operation = context.Operation;
+
+ var api = symbols.TryGetNonDeterministicApi(operation);
+ if (api is null)
+ {
+ return;
+ }
+
+ // Only flag inside durable workflow code (a method/lambda taking an IDurableContext).
+ if (!DurableScope.IsInWorkflowCode(operation, symbols))
+ {
+ return;
+ }
+
+ // Suppress when the nearest enclosing durable delegate is step-wrapped — non-determinism
+ // is allowed (and expected) there because its result is checkpointed.
+ var enclosing = DurableScope.FindNearestDurableDelegate(operation, symbols);
+ if (enclosing.Found && enclosing.Role == DurableDelegateRole.StepWrapped)
+ {
+ return;
+ }
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ DurableDiagnostics.NonDeterministicCallOutsideStep,
+ operation.Syntax.GetLocation(),
+ api));
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallCodeFixProvider.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallCodeFixProvider.cs
new file mode 100644
index 000000000..8d839635c
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallCodeFixProvider.cs
@@ -0,0 +1,231 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Collections.Immutable;
+using System.Composition;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Formatting;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers
+{
+ ///
+ /// Code fix for DE001: wraps a non-deterministic expression in a step so its value is checkpointed
+ /// (await context.StepAsync((_, _) => Task.FromResult(E))). Offered as a single-occurrence
+ /// quick fix only — never a fix-all — because inserting a step shifts the position-derived
+ /// operation IDs of subsequent durable calls, which would break replay for already-running
+ /// executions. The fix is withheld for shapes it cannot safely rewrite.
+ ///
+ [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(NonDeterministicCallCodeFixProvider))]
+ [Shared]
+ public sealed class NonDeterministicCallCodeFixProvider : CodeFixProvider
+ {
+ private const string IDurableContextMetadataName = DurableKnownSymbols.IDurableContextMetadataName;
+
+ ///
+ public override ImmutableArray FixableDiagnosticIds { get; } =
+ ImmutableArray.Create(DurableDiagnostics.NonDeterministicCallOutsideStep.Id);
+
+ ///
+ /// Fix-all is intentionally disabled: inserting a step shifts the position-derived operation
+ /// IDs of every subsequent durable call, which would break replay for in-flight executions.
+ ///
+ public override FixAllProvider? GetFixAllProvider() => null;
+
+ ///
+ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root is null)
+ {
+ return;
+ }
+
+ var diagnostic = context.Diagnostics[0];
+ var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true);
+ var expression = node as ExpressionSyntax ?? node.FirstAncestorOrSelf();
+ if (expression is null)
+ {
+ return;
+ }
+
+ var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
+ if (semanticModel is null)
+ {
+ return;
+ }
+
+ if (!IsSafelyWrappable(expression, semanticModel, context.CancellationToken, out var contextName))
+ {
+ return;
+ }
+
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: $"Wrap in {contextName}.StepAsync(...)",
+ createChangedDocument: ct => WrapInStepAsync(context.Document, root, expression, contextName, ct),
+ equivalenceKey: "WrapInStepAsync"),
+ diagnostic);
+ }
+
+ private static bool IsSafelyWrappable(
+ ExpressionSyntax expression,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken,
+ out string contextName)
+ {
+ contextName = "context";
+
+ // Guard: the expression must not itself contain an await, out/ref/in argument, or be a
+ // method group / lambda — wrapping any of those produces non-compiling or wrong code.
+ if (expression.DescendantNodesAndSelf().Any(n => n is AwaitExpressionSyntax))
+ {
+ return false;
+ }
+
+ foreach (var argument in expression.DescendantNodes().OfType())
+ {
+ if (!argument.RefKindKeyword.IsKind(SyntaxKind.None))
+ {
+ return false;
+ }
+ }
+
+ // The expression must produce a usable value (object creation of e.g. Random is flagged by
+ // DE001 but is not meaningfully wrappable into a checkpointed value — skip it).
+ if (expression is ObjectCreationExpressionSyntax)
+ {
+ return false;
+ }
+
+ var typeInfo = semanticModel.GetTypeInfo(expression, cancellationToken);
+ if (typeInfo.Type is null || typeInfo.Type.TypeKind == TypeKind.Error || typeInfo.Type.SpecialType == SpecialType.System_Void)
+ {
+ return false;
+ }
+
+ // await is only legal inside an async context; require one.
+ if (!IsInAsyncContext(expression))
+ {
+ return false;
+ }
+
+ // Find an in-scope IDurableContext to call StepAsync on.
+ var ctxName = FindDurableContextName(expression, semanticModel, cancellationToken);
+ if (ctxName is null)
+ {
+ return false;
+ }
+
+ contextName = ctxName;
+ return true;
+ }
+
+ private static bool IsInAsyncContext(SyntaxNode node)
+ {
+ for (var current = node.Parent; current is not null; current = current.Parent)
+ {
+ switch (current)
+ {
+ case MethodDeclarationSyntax m:
+ return m.Modifiers.Any(SyntaxKind.AsyncKeyword);
+ case LocalFunctionStatementSyntax lf:
+ return lf.Modifiers.Any(SyntaxKind.AsyncKeyword);
+ case AnonymousFunctionExpressionSyntax af:
+ return af.AsyncKeyword.IsKind(SyntaxKind.AsyncKeyword);
+ }
+ }
+
+ return false;
+ }
+
+ private static string? FindDurableContextName(
+ ExpressionSyntax expression,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken)
+ {
+ var durableContextType = semanticModel.Compilation.GetTypeByMetadataName(IDurableContextMetadataName);
+ if (durableContextType is null)
+ {
+ return null;
+ }
+
+ // Walk enclosing lambdas/methods looking for an IDurableContext-typed parameter.
+ for (var current = expression.Parent; current is not null; current = current.Parent)
+ {
+ var parameters = current switch
+ {
+ ParenthesizedLambdaExpressionSyntax pl => pl.ParameterList.Parameters,
+ SimpleLambdaExpressionSyntax sl => SyntaxFactory.SeparatedList(new[] { sl.Parameter }),
+ MethodDeclarationSyntax m => m.ParameterList.Parameters,
+ LocalFunctionStatementSyntax lf => lf.ParameterList.Parameters,
+ _ => default,
+ };
+
+ foreach (var parameter in parameters)
+ {
+ var symbol = semanticModel.GetDeclaredSymbol(parameter, cancellationToken) as IParameterSymbol;
+ if (symbol is not null && Implements(symbol.Type, durableContextType))
+ {
+ return symbol.Name;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static bool Implements(ITypeSymbol type, INamedTypeSymbol durableContextType)
+ {
+ if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, durableContextType))
+ {
+ return true;
+ }
+
+ return type.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, durableContextType));
+ }
+
+ private static Task WrapInStepAsync(
+ Document document,
+ SyntaxNode root,
+ ExpressionSyntax expression,
+ string contextName,
+ CancellationToken cancellationToken)
+ {
+ // await {ctx}.StepAsync((_, _) => System.Threading.Tasks.Task.FromResult(E))
+ var lambda = SyntaxFactory.ParenthesizedLambdaExpression(
+ SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(new[]
+ {
+ SyntaxFactory.Parameter(SyntaxFactory.Identifier("_")),
+ SyntaxFactory.Parameter(SyntaxFactory.Identifier("__")),
+ })),
+ SyntaxFactory.InvocationExpression(
+ SyntaxFactory.ParseExpression("System.Threading.Tasks.Task.FromResult"),
+ SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.Argument(expression.WithoutTrivia())))));
+
+ var stepCall = SyntaxFactory.AwaitExpression(
+ SyntaxFactory.InvocationExpression(
+ SyntaxFactory.MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ SyntaxFactory.IdentifierName(contextName),
+ SyntaxFactory.IdentifierName("StepAsync")),
+ SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.Argument(lambda)))));
+
+ var replacement = stepCall
+ .WithLeadingTrivia(expression.GetLeadingTrivia())
+ .WithTrailingTrivia(expression.GetTrailingTrivia())
+ .WithAdditionalAnnotations(Formatter.Annotation);
+
+ var newRoot = root.ReplaceNode(expression, replacement);
+ return Task.FromResult(document.WithSyntaxRoot(newRoot));
+ }
+ }
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj b/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj
index cb03b2715..4ba44dfc1 100644
--- a/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj
+++ b/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj
@@ -6,7 +6,7 @@
$(DefaultPackageTargets)
Amazon Lambda .NET SDK for Durable Execution - write multi-step workflows that persist state automatically.
Amazon.Lambda.DurableExecution
- 0.1.1-preview
+ 0.1.2-preview
Amazon.Lambda.DurableExecution
Amazon.Lambda.DurableExecution
AWS;Amazon;Lambda;Durable;Workflow
@@ -32,6 +32,26 @@
+
+
+ ..\Amazon.Lambda.DurableExecution.Analyzers\
+
+
+
+
+
+
+
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs
index 04ebee556..960be20b4 100644
--- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs
+++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs
@@ -37,7 +37,8 @@ public static Task WrapAsync(
Func> workflow,
DurableExecutionInvocationInput invocationInput,
ILambdaContext lambdaContext)
- => WrapAsyncCore(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value);
+ => WrapAsyncCore(workflow, invocationInput, lambdaContext,
+ new LambdaDurableServiceClient(_cachedLambdaClient.Value));
///
/// Wrap a workflow (typed input + output) with explicit Lambda client.
@@ -47,7 +48,8 @@ public static Task WrapAsync(
DurableExecutionInvocationInput invocationInput,
ILambdaContext lambdaContext,
IAmazonLambda lambdaClient)
- => WrapAsyncCore(workflow, invocationInput, lambdaContext, lambdaClient);
+ => WrapAsyncCore(workflow, invocationInput, lambdaContext,
+ new LambdaDurableServiceClient(lambdaClient));
///
/// Wrap a void workflow (typed input, no output).
@@ -68,20 +70,32 @@ public static Task WrapAsync(
IAmazonLambda lambdaClient)
=> WrapAsyncCore(
async (input, ctx) => { await workflow(input, ctx); return null; },
- invocationInput, lambdaContext, lambdaClient);
+ invocationInput, lambdaContext,
+ new LambdaDurableServiceClient(lambdaClient));
+
+ ///
+ /// Internal overload for the testing package — accepts an
+ /// directly so the testing SDK can
+ /// inject an in-memory implementation.
+ ///
+ internal static Task WrapAsync(
+ Func> workflow,
+ DurableExecutionInvocationInput invocationInput,
+ ILambdaContext lambdaContext,
+ IDurableServiceClient serviceClient)
+ => WrapAsyncCore(workflow, invocationInput, lambdaContext, serviceClient);
private static async Task WrapAsyncCore(
Func> workflow,
DurableExecutionInvocationInput invocationInput,
ILambdaContext lambdaContext,
- IAmazonLambda lambdaClient)
+ IDurableServiceClient serviceClient)
{
var serializer = LambdaSerializerHelper.GetRequired(lambdaContext);
var state = new ExecutionState();
state.LoadFromCheckpoint(invocationInput.InitialExecutionState);
- var serviceClient = new LambdaDurableServiceClient(lambdaClient);
var checkpointToken = invocationInput.CheckpointToken;
var nextMarker = invocationInput.InitialExecutionState?.NextMarker;
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Services/IDurableServiceClient.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Services/IDurableServiceClient.cs
new file mode 100644
index 000000000..8ccbf13c9
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution/Services/IDurableServiceClient.cs
@@ -0,0 +1,27 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using SdkOperationUpdate = Amazon.Lambda.Model.OperationUpdate;
+
+namespace Amazon.Lambda.DurableExecution.Services;
+
+///
+/// Abstraction over the durable execution service RPCs. The production
+/// implementation () calls the real
+/// AWS Lambda APIs; the testing package injects an in-memory fake.
+///
+internal interface IDurableServiceClient
+{
+ Task CheckpointAsync(
+ string durableExecutionArn,
+ string? checkpointToken,
+ IReadOnlyList pendingOperations,
+ Action>? onNewOperations = null,
+ CancellationToken cancellationToken = default);
+
+ Task<(List Operations, string? NextMarker)> GetExecutionStateAsync(
+ string durableExecutionArn,
+ string? checkpointToken,
+ string marker,
+ CancellationToken cancellationToken = default);
+}
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs
index 7fae2ee10..818e95bd0 100644
--- a/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs
+++ b/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs
@@ -19,7 +19,7 @@ namespace Amazon.Lambda.DurableExecution.Services;
///
/// Calls the real AWS Lambda Durable Execution APIs via the AWSSDK.Lambda client.
///
-internal sealed class LambdaDurableServiceClient
+internal sealed class LambdaDurableServiceClient : IDurableServiceClient
{
private readonly IAmazonLambda _lambdaClient;
diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md b/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md
new file mode 100644
index 000000000..bc498c10b
--- /dev/null
+++ b/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md
@@ -0,0 +1,130 @@
+# Durable Execution Analyzers (DE001–DE004)
+
+`Amazon.Lambda.DurableExecution` ships a set of Roslyn analyzers that run in the IDE and during
+`dotnet build` to catch the most common durable-execution authoring mistakes before they become
+confusing runtime failures. The analyzers are bundled inside the `Amazon.Lambda.DurableExecution`
+NuGet package (`analyzers/dotnet/cs`), so they activate automatically for any project that references
+the package — no extra reference is required. They only run on projects that reference the durable SDK
+and only inside durable *workflow code* (a method, local function, or lambda that takes an
+`IDurableContext` parameter), so unrelated code in the same project is unaffected.
+
+These analyzers are the .NET counterpart of the JavaScript SDK's
+[`@aws/durable-execution-sdk-js-eslint-plugin`](https://github.com/aws/aws-durable-execution-sdk-js/tree/main/packages/aws-durable-execution-sdk-js-eslint-plugin),
+re-implemented with Roslyn's semantic model so durable operations are matched by symbol
+(`Amazon.Lambda.DurableExecution.IDurableContext`) rather than by method name.
+
+## Why determinism matters
+
+A durable workflow has no persisted program counter. On every invocation — including every replay
+after a wait, retry, or failure — your handler runs again from the top. Each durable call
+(`StepAsync`, `WaitAsync`, …) looks up its checkpoint and either replays the cached result or runs
+fresh. For the cached results to line up with the code, the workflow must execute the **same
+operations, in the same order, every time**. Code *outside* a step must therefore be deterministic;
+code *inside* a step may be non-deterministic because the step's result is checkpointed once.
+
+## Rules
+
+| ID | Severity | Rule |
+|-------|----------|------|
+| DE001 | Warning | Non-deterministic call outside a step |
+| DE002 | Warning | Nested durable operation inside a step body |
+| DE003 | Warning | Mutable variable captured and modified inside a durable operation |
+| DE004 | Info | `Task.WhenAll`/`Task.WhenAny` over durable tasks |
+
+### DE001 — Non-deterministic call outside a step
+
+Flags `DateTime.Now`/`UtcNow`/`Today`, `DateTimeOffset.Now`/`UtcNow`, `Guid.NewGuid()`,
+`new Random()` / `Random.Shared`, `Stopwatch.GetTimestamp()`/`StartNew()`/`Elapsed*`,
+`Environment.TickCount`/`TickCount64`, `RandomNumberGenerator`/`RNGCryptoServiceProvider`, and
+`Path.GetTempFileName()`/`GetRandomFileName()` when used in workflow code outside a step. The value
+would differ between the original execution and replays, corrupting checkpoint-derived state. A
+seeded `new Random(42)` is deterministic and is **not** flagged.
+
+```csharp
+// ❌ Flagged — different value on replay
+var now = DateTime.UtcNow;
+await context.StepAsync((s, ct) => DoWork(now));
+
+// ✅ Captured inside a step
+var now = await context.StepAsync((s, ct) => Task.FromResult(DateTime.UtcNow));
+await context.StepAsync((s, ct) => DoWork(now));
+```
+
+A code fix is offered that wraps the expression in `context.StepAsync(...)`. It is a single-occurrence
+quick fix only (no Fix All), because inserting a step shifts the position-derived operation IDs of
+subsequent durable calls and would break replay for already-running executions.
+
+### DE002 — Nested durable operation inside a step body
+
+Flags any durable operation (`StepAsync`, `WaitAsync`, `ParallelAsync`, `MapAsync`, `InvokeAsync`,
+`RunInChildContextAsync`, `CreateCallbackAsync`, `WaitForCallbackAsync`) invoked inside a *step-wrapped*
+delegate — a `StepAsync` body, a `WaitForCallbackAsync` submitter, or a `WaitForConditionAsync` check —
+by capturing the outer `IDurableContext`. Step bodies are leaf operations; group durable operations
+with `RunInChildContextAsync` instead.
+
+```csharp
+// ❌ Flagged — durable op nested in a step body
+await context.StepAsync(async (s, ct) =>
+{
+ await context.WaitAsync(TimeSpan.FromSeconds(1)); // uses the captured outer context
+});
+
+// ✅ Group with a child context
+await context.RunInChildContextAsync(async (child, ct) =>
+{
+ await child.StepAsync((s, c) => DoWork());
+ await child.WaitAsync(TimeSpan.FromSeconds(1));
+});
+```
+
+### DE003 — Mutable variable captured and modified inside a durable operation
+
+Flags assignment, compound assignment, or increment/decrement of a variable captured from an outer
+scope inside a durable-operation delegate (`StepAsync`, `RunInChildContextAsync`,
+`WaitForConditionAsync`, `WaitForCallbackAsync`, and `ParallelAsync`/`MapAsync` branches). On replay the
+body is skipped and the cached result returned, so the write never happens. Reading a captured variable
+is safe and is not flagged.
+
+```csharp
+// ❌ Flagged — write is lost on replay
+int total = 0;
+await context.StepAsync((s, ct) => { total += 1; return Task.CompletedTask; });
+
+// ✅ Return the value and assign it
+total = await context.StepAsync((s, ct) => Task.FromResult(total + 1));
+```
+
+### DE004 — `Task.WhenAll`/`Task.WhenAny` over durable tasks
+
+Advisory (Info). `Task.WhenAll`/`Task.WhenAny` work correctly with durable tasks (operation IDs are
+allocated deterministically), but they bypass completion policies, concurrency limits, branch naming,
+and structured `IBatchResult` output. Prefer `ParallelAsync`/`MapAsync`.
+
+```csharp
+// ℹ️ Suggested — works, but prefer ParallelAsync
+await Task.WhenAll(context.StepAsync(a), context.StepAsync(b));
+
+// ✅ Preferred
+await context.ParallelAsync(new Func>[]
+{
+ (c, ct) => c.StepAsync((s, t) => A()),
+ (c, ct) => c.StepAsync((s, t) => B()),
+});
+```
+
+A code fix converts the simplest safe shape (an inline list of same-typed durable calls on one context
+whose aggregate result is discarded) into `ParallelAsync`.
+
+## Configuring severity
+
+Each rule's severity can be overridden per project via `.editorconfig`:
+
+```ini
+# Treat the non-determinism rule as an error, silence the WhenAll suggestion.
+dotnet_diagnostic.DE001.severity = error
+dotnet_diagnostic.DE004.severity = none
+```
+
+Note: if your project sets `true`, the Warning-level
+rules (DE001–DE003) will fail the build. Lower their severity in `.editorconfig` if you prefer them as
+warnings during the preview.
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj
new file mode 100644
index 000000000..a9f3df12c
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net8.0
+ latest
+ enable
+ false
+ false
+
+ $(NoWarn);CS0618
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableStubs.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableStubs.cs
new file mode 100644
index 000000000..2e7c36ebd
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableStubs.cs
@@ -0,0 +1,67 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ ///
+ /// A faithful (signature-only) source copy of the durable-execution surface the analyzers match
+ /// against. Injected into every test compilation as source so tests do not need to reference the
+ /// real Amazon.Lambda.DurableExecution package (which pulls AWSSDK and is awkward in the analyzer
+ /// test harness). The analyzers resolve these by metadata name, so the stub namespace and member
+ /// shapes must match the real SDK exactly.
+ ///
+ internal static class DurableStubs
+ {
+ internal const string Source = @"
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Amazon.Lambda.DurableExecution
+{
+ public interface IStepContext { int AttemptNumber { get; } string OperationId { get; } }
+ public interface IConditionCheckContext { }
+ public interface IWaitForCallbackContext { }
+ public interface IExecutionContext { string DurableExecutionArn { get; } }
+ public interface ICallback { string CallbackId { get; } Task GetResultAsync(CancellationToken ct = default); }
+
+ public sealed class StepConfig { }
+ public sealed class ChildContextConfig { }
+ public sealed class CallbackConfig { }
+ public sealed class WaitForCallbackConfig { }
+ public sealed class InvokeConfig { }
+ public sealed class ParallelConfig { }
+ public sealed class MapConfig { }
+ public sealed class WaitForConditionConfig { }
+ public readonly struct DurableBranch { public DurableBranch(string name, Func> func) { } }
+ public interface IBatchResult { }
+
+ public interface IDurableContext
+ {
+ IExecutionContext ExecutionContext { get; }
+
+ Task StepAsync(Func> func, string name = null, StepConfig config = null, CancellationToken cancellationToken = default);
+ Task StepAsync(Func func, string name = null, StepConfig config = null, CancellationToken cancellationToken = default);
+
+ Task WaitAsync(TimeSpan duration, string name = null, CancellationToken cancellationToken = default);
+
+ Task RunInChildContextAsync(Func> func, string name = null, ChildContextConfig config = null, CancellationToken cancellationToken = default);
+ Task RunInChildContextAsync(Func func, string name = null, ChildContextConfig config = null, CancellationToken cancellationToken = default);
+
+ Task> CreateCallbackAsync(string name = null, CallbackConfig config = null, CancellationToken cancellationToken = default);
+ Task WaitForCallbackAsync(Func submitter, string name = null, WaitForCallbackConfig config = null, CancellationToken cancellationToken = default);
+
+ Task InvokeAsync(string functionName, TPayload payload, string name = null, InvokeConfig config = null, CancellationToken cancellationToken = default);
+
+ Task WaitForConditionAsync(Func> check, WaitForConditionConfig config, string name = null, CancellationToken cancellationToken = default);
+
+ Task> ParallelAsync(IReadOnlyList>> branches, string name = null, ParallelConfig config = null, CancellationToken cancellationToken = default);
+ Task> ParallelAsync(IReadOnlyList> branches, string name = null, ParallelConfig config = null, CancellationToken cancellationToken = default);
+
+ Task> MapAsync(IReadOnlyList items, Func, CancellationToken, Task> func, string name = null, MapConfig config = null, CancellationToken cancellationToken = default);
+ }
+}
+";
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorAnalyzerTests.cs
new file mode 100644
index 000000000..d79e02e31
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorAnalyzerTests.cs
@@ -0,0 +1,108 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.Testing;
+using Xunit;
+using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier<
+ Amazon.Lambda.DurableExecution.Analyzers.DurableTaskCombinatorAnalyzer>;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ public class DurableTaskCombinatorAnalyzerTests
+ {
+ private const string Usings = @"
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.Lambda.DurableExecution;
+";
+
+ [Fact]
+ public async Task WhenAll_OverInlineDurableTasks_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await {|#0:Task.WhenAll(
+ context.StepAsync((s, ct) => Task.FromResult(1)),
+ context.StepAsync((s, ct) => Task.FromResult(2)))|};
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator)
+ .WithLocation(0).WithArguments("Task.WhenAll");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task WhenAny_OverInlineDurableTasks_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await {|#0:Task.WhenAny(
+ context.StepAsync((s, ct) => Task.FromResult(1)),
+ context.StepAsync((s, ct) => Task.FromResult(2)))|};
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator)
+ .WithLocation(0).WithArguments("Task.WhenAny");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task WhenAll_OverTaskArrayLocal_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var tasks = new List>();
+ tasks.Add(context.StepAsync((s, ct) => Task.FromResult(1)));
+ await {|#0:Task.WhenAll(tasks)|};
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator)
+ .WithLocation(0).WithArguments("Task.WhenAll");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task WhenAll_OverNonDurableTasks_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await Task.WhenAll(Task.Delay(1), Task.Delay(2));
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task ParallelAsync_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.ParallelAsync(new Func>[]
+ {
+ (c, ct) => c.StepAsync((s, t) => Task.FromResult(1)),
+ (c, ct) => c.StepAsync((s, t) => Task.FromResult(2)),
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorCodeFixTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorCodeFixTests.cs
new file mode 100644
index 000000000..12e7a4d28
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorCodeFixTests.cs
@@ -0,0 +1,49 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.Testing;
+using Xunit;
+using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.CodeFixVerifier<
+ Amazon.Lambda.DurableExecution.Analyzers.DurableTaskCombinatorAnalyzer,
+ Amazon.Lambda.DurableExecution.Analyzers.DurableTaskCombinatorCodeFixProvider>;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ public class DurableTaskCombinatorCodeFixTests
+ {
+ private const string Usings = @"
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.Lambda.DurableExecution;
+";
+
+ [Fact]
+ public async Task ConvertsWhenAll_ToParallelAsync_WhenResultDiscarded()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await {|#0:Task.WhenAll(
+ context.StepAsync((s, ct) => Task.FromResult(1)),
+ context.StepAsync((s, ct) => Task.FromResult(2)))|};
+ }
+}";
+ var fixedSource = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.ParallelAsync(new System.Func>[] { (c, ct) => c.StepAsync((s, ct) => Task.FromResult(1)), (c, ct) => c.StepAsync((s, ct) => Task.FromResult(2)) });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator)
+ .WithLocation(0).WithArguments("Task.WhenAll");
+ await Verify.VerifyAsync(source, fixedSource, expected);
+ }
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/MutableCaptureAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/MutableCaptureAnalyzerTests.cs
new file mode 100644
index 000000000..52c487f78
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/MutableCaptureAnalyzerTests.cs
@@ -0,0 +1,184 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.Testing;
+using Xunit;
+using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier<
+ Amazon.Lambda.DurableExecution.Analyzers.MutableCaptureAnalyzer>;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ public class MutableCaptureAnalyzerTests
+ {
+ private const string Usings = @"
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.Lambda.DurableExecution;
+";
+
+ [Fact]
+ public async Task SimpleAssignment_OfCapturedVariable_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int counter = 0;
+ await context.StepAsync((s, ct) => { {|#0:counter|} = 5; return Task.CompletedTask; });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation)
+ .WithLocation(0).WithArguments("counter");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task CompoundAssignment_OfCapturedVariable_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int total = 0;
+ await context.StepAsync((s, ct) => { {|#0:total|} += 1; return Task.CompletedTask; });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation)
+ .WithLocation(0).WithArguments("total");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task Increment_OfCapturedVariable_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int n = 0;
+ await context.StepAsync((s, ct) => { {|#0:n|}++; return Task.CompletedTask; });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation)
+ .WithLocation(0).WithArguments("n");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task ReadingCapturedVariable_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int seed = 41;
+ await context.StepAsync((s, ct) => Task.FromResult(seed + 1));
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task MutatingDelegateLocal_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync((s, ct) =>
+ {
+ int local = 0;
+ local += 1;
+ return Task.FromResult(local);
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task ShadowingLocal_IsNotFlagged()
+ {
+ // An inner local that shadows an outer name is delegate-local by symbol identity.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int total = 100;
+ await context.StepAsync((s, ct) =>
+ {
+ int total2 = 0;
+ total2 += 1;
+ return Task.FromResult(total2);
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task MutatingCapturedVariable_InChildContext_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int flag = 0;
+ await context.RunInChildContextAsync((child, ct) => { {|#0:flag|} = 1; return Task.CompletedTask; });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation)
+ .WithLocation(0).WithArguments("flag");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task MutatingPerItemAndBranchLocals_InParallel_IsNotFlagged()
+ {
+ // The branch delegate's own parameters and locals are not captures.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.ParallelAsync(new Func>[]
+ {
+ (branch, ct) => { int x = 0; x += 1; return Task.FromResult(x); },
+ (branch, ct) => Task.FromResult(2),
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task MutatingCapturedVariable_FromParallelBranch_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ int shared = 0;
+ await context.ParallelAsync(new Func>[]
+ {
+ (branch, ct) => { {|#0:shared|} += 1; return Task.FromResult(shared); },
+ });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation)
+ .WithLocation(0).WithArguments("shared");
+ await Verify.VerifyAsync(source, expected);
+ }
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NestedDurableOperationAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NestedDurableOperationAnalyzerTests.cs
new file mode 100644
index 000000000..6493dba53
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NestedDurableOperationAnalyzerTests.cs
@@ -0,0 +1,153 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.Testing;
+using Xunit;
+using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier<
+ Amazon.Lambda.DurableExecution.Analyzers.NestedDurableOperationAnalyzer>;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ public class NestedDurableOperationAnalyzerTests
+ {
+ private const string Usings = @"
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.Lambda.DurableExecution;
+";
+
+ [Fact]
+ public async Task DurableOp_InsideStepBody_ViaCapturedContext_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync(async (s, ct) =>
+ {
+ await {|#0:context.WaitAsync(TimeSpan.FromSeconds(1))|};
+ });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep)
+ .WithLocation(0).WithArguments("WaitAsync", "context", "step");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task NestedStepAsync_InsideStepBody_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync(async (s, ct) =>
+ {
+ await {|#0:context.StepAsync((s2, ct2) => Task.CompletedTask)|};
+ });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep)
+ .WithLocation(0).WithArguments("StepAsync", "context", "step");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task DurableOp_InsideChildContext_OnChildContext_IsNotFlagged()
+ {
+ // Using the child's own context inside a child context is the SANCTIONED nesting pattern.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.RunInChildContextAsync(async (child, ct) =>
+ {
+ await child.StepAsync((s, c) => Task.CompletedTask);
+ await child.WaitAsync(TimeSpan.FromSeconds(1));
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task DurableOp_DirectlyInWorkflowBody_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync((s, ct) => Task.CompletedTask);
+ await context.WaitAsync(TimeSpan.FromSeconds(1));
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task DurableOp_InsideConditionCheck_ViaCapturedContext_IsFlagged()
+ {
+ // WaitForConditionAsync's check delegate is step-wrapped at runtime.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.WaitForConditionAsync(async (state, cc, ct) =>
+ {
+ await {|#0:context.WaitAsync(TimeSpan.FromSeconds(1))|};
+ return state + 1;
+ }, new WaitForConditionConfig());
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep)
+ .WithLocation(0).WithArguments("WaitAsync", "context", "condition-check");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task DurableOp_InsideCallbackSubmitter_ViaCapturedContext_IsFlagged()
+ {
+ // WaitForCallbackAsync's submitter delegate is step-wrapped at runtime.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.WaitForCallbackAsync(async (callbackId, cc, ct) =>
+ {
+ await {|#0:context.StepAsync((s, c) => Task.CompletedTask)|};
+ });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep)
+ .WithLocation(0).WithArguments("StepAsync", "context", "callback-submitter");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task ConfigureLogger_LikeMembers_NotTreatedAsDurableOps()
+ {
+ // Reading ExecutionContext (a property, not a durable op) inside a step is fine.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync((s, ct) =>
+ {
+ var arn = context.ExecutionContext.DurableExecutionArn;
+ return Task.FromResult(arn);
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallAnalyzerTests.cs
new file mode 100644
index 000000000..144344689
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallAnalyzerTests.cs
@@ -0,0 +1,211 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.Testing;
+using Xunit;
+using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier<
+ Amazon.Lambda.DurableExecution.Analyzers.NonDeterministicCallAnalyzer>;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ public class NonDeterministicCallAnalyzerTests
+ {
+ private const string Usings = @"
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.Lambda.DurableExecution;
+";
+
+ [Fact]
+ public async Task DateTimeNow_InWorkflowBody_OutsideStep_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var now = {|#0:DateTime.Now|};
+ await context.StepAsync((s, ct) => Task.FromResult(now));
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("DateTime.Now");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task DateTimeUtcNow_InsideStep_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync((s, ct) => Task.FromResult(DateTime.UtcNow));
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task GuidNewGuid_OutsideStep_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var id = {|#0:Guid.NewGuid()|};
+ await context.StepAsync((s, ct) => Task.FromResult(id.ToString()));
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("Guid.NewGuid()");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task NewRandom_OutsideStep_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var r = {|#0:new Random()|};
+ await context.StepAsync((s, ct) => Task.FromResult(r.Next()));
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("new Random()");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task SeededRandom_IsNotFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var r = new Random(42);
+ await context.StepAsync((s, ct) => Task.FromResult(r.Next()));
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task RandomShared_OutsideStep_IsFlagged()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var n = {|#0:Random.Shared|}.Next();
+ await context.StepAsync((s, ct) => Task.FromResult(n));
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("Random.Shared");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task NonDeterminism_NestedInNonDurableLambdaInsideStep_IsNotFlagged()
+ {
+ // DateTime.UtcNow inside a LINQ projection that itself runs inside a step is fine —
+ // the enclosing durable delegate (StepAsync) is step-wrapped.
+ var source = Usings + @"
+using System.Linq;
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.StepAsync((s, ct) =>
+ {
+ var times = Enumerable.Range(0, 3).Select(x => DateTime.UtcNow).ToArray();
+ return Task.FromResult(times.Length);
+ });
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task DateTimeNow_InNonWorkflowMethod_IsNotFlagged()
+ {
+ // A method with no IDurableContext parameter is not workflow code.
+ var source = Usings + @"
+class W
+{
+ public DateTime Helper() => DateTime.Now;
+}";
+ await Verify.VerifyAsync(source);
+ }
+
+ [Fact]
+ public async Task DateTimeNow_InsideChildContextBranch_IsFlagged()
+ {
+ // A child-context body is still workflow code that must be deterministic.
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ await context.RunInChildContextAsync(async (child, ct) =>
+ {
+ var now = {|#0:DateTime.Now|};
+ await child.StepAsync((s, c) => Task.FromResult(now));
+ });
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("DateTime.Now");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task UnrelatedStepMethodOnOtherType_DoesNotSuppress()
+ {
+ // Semantic matching: an unrelated type's StepAsync must NOT be treated as a durable step,
+ // so it does not suppress non-determinism. DateTime.Now nested in it is still workflow code
+ // outside any real durable step, so it IS flagged.
+ var source = Usings + @"
+class NotDurable { public Task StepAsync(Func> f) => f(); }
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var other = new NotDurable();
+ await other.StepAsync(() => Task.FromResult({|#0:DateTime.Now|}));
+ await context.StepAsync((s, ct) => Task.CompletedTask);
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("DateTime.Now");
+ await Verify.VerifyAsync(source, expected);
+ }
+
+ [Fact]
+ public async Task UnrelatedTypeProducesNoDiagnostic_OutsideWorkflow()
+ {
+ // The same unrelated StepAsync, but in a method with no IDurableContext — not workflow code.
+ var source = Usings + @"
+class NotDurable { public Task StepAsync(Func> f) => f(); }
+class W
+{
+ public async Task Helper()
+ {
+ var other = new NotDurable();
+ await other.StepAsync(() => Task.FromResult(DateTime.Now));
+ }
+}";
+ await Verify.VerifyAsync(source);
+ }
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallCodeFixTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallCodeFixTests.cs
new file mode 100644
index 000000000..ccedd3171
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallCodeFixTests.cs
@@ -0,0 +1,48 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis.Testing;
+using Xunit;
+using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.CodeFixVerifier<
+ Amazon.Lambda.DurableExecution.Analyzers.NonDeterministicCallAnalyzer,
+ Amazon.Lambda.DurableExecution.Analyzers.NonDeterministicCallCodeFixProvider>;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ public class NonDeterministicCallCodeFixTests
+ {
+ private const string Usings = @"
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.Lambda.DurableExecution;
+";
+
+ [Fact]
+ public async Task WrapsDateTimeNow_InStepAsync()
+ {
+ var source = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var now = {|#0:DateTime.UtcNow|};
+ return now;
+ }
+}";
+ var fixedSource = Usings + @"
+class W
+{
+ public async Task Run(string input, IDurableContext context)
+ {
+ var now = await context.StepAsync((_, __) => System.Threading.Tasks.Task.FromResult(DateTime.UtcNow));
+ return now;
+ }
+}";
+ var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep)
+ .WithLocation(0).WithArguments("DateTime.UtcNow");
+ await Verify.VerifyAsync(source, fixedSource, expected);
+ }
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Verifiers.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Verifiers.cs
new file mode 100644
index 000000000..4e5e77810
--- /dev/null
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Verifiers.cs
@@ -0,0 +1,74 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp.Testing;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Testing;
+using Microsoft.CodeAnalysis.Testing.Verifiers;
+
+namespace Amazon.Lambda.DurableExecution.Analyzers.Tests
+{
+ ///
+ /// Analyzer-only verifier that injects the durable stub surface into every test compilation and
+ /// targets the net8.0 reference assemblies.
+ ///
+ internal static class AnalyzerVerifier
+ where TAnalyzer : DiagnosticAnalyzer, new()
+ {
+ internal static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) =>
+ new DiagnosticResult(descriptor);
+
+ internal static Task VerifyAsync(string source, params DiagnosticResult[] expected)
+ {
+ var test = new Test { TestCode = source };
+ test.ExpectedDiagnostics.AddRange(expected);
+ return test.RunAsync(CancellationToken.None);
+ }
+
+ private sealed class Test : CSharpAnalyzerTest
+ {
+ public Test()
+ {
+ ReferenceAssemblies = ReferenceAssemblies.Net.Net80;
+ TestState.Sources.Add(DurableStubs.Source);
+ }
+ }
+ }
+
+ ///
+ /// Code-fix verifier mirroring with the stub injected
+ /// into both the pre-fix and post-fix compilations.
+ ///
+ internal static class CodeFixVerifier
+ where TAnalyzer : DiagnosticAnalyzer, new()
+ where TCodeFix : CodeFixProvider, new()
+ {
+ internal static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) =>
+ new DiagnosticResult(descriptor);
+
+ internal static Task VerifyAsync(string source, string fixedSource, params DiagnosticResult[] expected)
+ {
+ var test = new Test
+ {
+ TestCode = source,
+ FixedCode = fixedSource,
+ };
+ test.ExpectedDiagnostics.AddRange(expected);
+ return test.RunAsync(CancellationToken.None);
+ }
+
+ private sealed class Test : CSharpCodeFixTest
+ {
+ public Test()
+ {
+ ReferenceAssemblies = ReferenceAssemblies.Net.Net80;
+ TestState.Sources.Add(DurableStubs.Source);
+ FixedState.Sources.Add(DurableStubs.Source);
+ }
+ }
+ }
+}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Amazon.Lambda.DurableExecution.IntegrationTests.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Amazon.Lambda.DurableExecution.IntegrationTests.csproj
index 7eb196bf1..5091e5bf7 100644
--- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Amazon.Lambda.DurableExecution.IntegrationTests.csproj
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Amazon.Lambda.DurableExecution.IntegrationTests.csproj
@@ -4,7 +4,7 @@
- $(DefaultPackageTargets)
+ net10.0
enable
enable
false
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackFailedTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackFailedTest.cs
index 3a1e6c2c9..f29937dfe 100644
--- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackFailedTest.cs
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackFailedTest.cs
@@ -46,13 +46,17 @@ public async Task CallbackFailed_SurfacesAsCallbackFailedException()
var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(120));
Assert.Equal("FAILED", status, ignoreCase: true);
- // The workflow's surfaced exception is CallbackFailedException — the SDK
- // wraps the external error message into the exception's Message. Verify
- // the recorded error type is the SDK's CallbackFailedException and that
- // the original failure message survives.
+ // The workflow surfaces CallbackFailedException to the user, but the
+ // terminal ErrorObject records the ORIGINAL error identity reported by the
+ // external system — ErrorObject.FromException deliberately unwraps
+ // CallbackException to the underlying error (here the RejecterFunction's
+ // "ApprovalRejected" type), matching the Java/Python/JS SDKs and the
+ // unwrapping covered by ModelsTests.ErrorObject_FromException_UnwrapsCallbackException.
+ // Verify the recorded type is the external error type and the original
+ // failure message survives.
var execution = await deployment.GetExecutionAsync(arn!);
Assert.NotNull(execution.Error);
- Assert.Equal(typeof(CallbackFailedException).FullName, execution.Error.ErrorType);
+ Assert.Equal("ApprovalRejected", execution.Error.ErrorType);
Assert.Contains("rejected", execution.Error.ErrorMessage);
// History records both Started and Failed for the same callback.
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackTimeoutTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackTimeoutTest.cs
index 7f50091c9..820d843f9 100644
--- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackTimeoutTest.cs
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackTimeoutTest.cs
@@ -47,12 +47,18 @@ public async Task CallbackTimeout_SurfacesAsCallbackTimeoutException()
var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(120));
Assert.Equal("FAILED", status, ignoreCase: true);
- // The execution surfaces the SDK's CallbackTimeoutException to the user.
- // ErrorObject.FromException records ErrorType as the FullName; verify both
- // the type and that the recorded message mentions "timed out".
+ // The workflow surfaces CallbackTimeoutException to the user, but the
+ // terminal ErrorObject records the ORIGINAL/underlying error identity —
+ // ErrorObject.FromException deliberately unwraps CallbackException to the
+ // error the service recorded on the timed-out callback (the service's
+ // "Callback.Timeout" code), matching the Java/Python/JS SDKs and the
+ // unwrapping covered by ModelsTests.ErrorObject_FromException_UnwrapsCallbackException.
+ // Sibling failure tests (ChildContextFailsTest, InvokeFailureTest) assert
+ // the same underlying-type contract. Verify the recorded type is the
+ // service timeout code and the message still mentions "timed out".
var execution = await deployment.GetExecutionAsync(arn!);
Assert.NotNull(execution.Error);
- Assert.Equal(typeof(CallbackTimeoutException).FullName, execution.Error.ErrorType);
+ Assert.Equal("Callback.Timeout", execution.Error.ErrorType);
Assert.Contains("timed out", execution.Error.ErrorMessage, StringComparison.OrdinalIgnoreCase);
// History records both Started and TimedOut for the same callback.
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ClassLibraryTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ClassLibraryTest.cs
index 3a2897ce7..d809e56d5 100644
--- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ClassLibraryTest.cs
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ClassLibraryTest.cs
@@ -10,7 +10,7 @@
namespace Amazon.Lambda.DurableExecution.IntegrationTests;
///
-/// Proves a durable function works on the managed dotnet10 runtime using the
+/// Proves a durable function works on a managed dotnet runtime using the
/// class-library programming model — a plain Handler method with no
/// Main/LambdaBootstrap loop, deployed via an Assembly::Type::Method
/// handler string. Confirms the RuntimeSupport durable-execution changes are live in
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs
index 7d005d710..cd7448171 100644
--- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs
@@ -19,14 +19,17 @@ namespace Amazon.Lambda.DurableExecution.IntegrationTests;
/// All resources are torn down on DisposeAsync.
///
///
-/// Durable functions deploy as a plain zip package on the managed dotnet10 runtime
+/// Durable functions deploy as a plain zip package on a managed dotnet runtime
/// (executable model, Handler=bootstrap) — no container image or ECR repository
/// required. Each test function is published framework-dependent for linux-x64 and zipped.
+/// This harness pins a single managed runtime (see ) for CI;
+/// durable execution itself is not tied to that specific runtime version.
///
internal sealed class DurableFunctionDeployment : IAsyncDisposable
{
- // The managed dotnet runtime that supports durable configuration. Executable model:
+ // The managed dotnet runtime this harness deploys against. Executable model:
// `dotnet publish` emits a native `bootstrap` shim and the runtime execs it.
+ // This is just the runtime CI exercises — durable execution is not tied to it.
private const string ManagedRuntime = "dotnet10";
private const string BootstrapHandler = "bootstrap";
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/MapFailureToleranceTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/MapFailureToleranceTest.cs
index 06ab716c0..25d773f03 100644
--- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/MapFailureToleranceTest.cs
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/MapFailureToleranceTest.cs
@@ -61,9 +61,18 @@ public async Task Map_FailureToleranceExceeded_FailsWorkflow()
events.Count(e => e.EventType == EventType.ContextFailed) >= 2,
$"Expected >= 2 ContextFailed events; got {events.Count(e => e.EventType == EventType.ContextFailed)}");
- // The parent context (named "tolerance") records the aggregate failure.
- var parentFailed = events.FirstOrDefault(e =>
+ // The parent context (named "tolerance") is checkpointed ContextSucceeded
+ // even when the failure tolerance is exceeded: ConcurrentOperation always
+ // writes the parent batch summary with action SUCCEED (the completion
+ // reason lives inside the payload), then the SDK throws MapException
+ // AFTER the checkpoint. This matches the Python/JS/Java wire format. The
+ // workflow-level failure is asserted above via PollForCompletionAsync ==
+ // FAILED and the MapException error type — the parent CONTEXT itself is
+ // NOT recorded as ContextFailed.
+ var parentSucceeded = events.FirstOrDefault(e =>
+ e.EventType == EventType.ContextSucceeded && e.Name == "tolerance");
+ Assert.NotNull(parentSucceeded);
+ Assert.DoesNotContain(events, e =>
e.EventType == EventType.ContextFailed && e.Name == "tolerance");
- Assert.NotNull(parentFailed);
}
}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ParallelFailureToleranceTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ParallelFailureToleranceTest.cs
index 9ee25ac2f..46d6de202 100644
--- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ParallelFailureToleranceTest.cs
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ParallelFailureToleranceTest.cs
@@ -60,14 +60,23 @@ public async Task Parallel_FailureToleranceExceeded_FailsWorkflow()
var events = history.Events ?? new List();
// At least 2 branches failed (the third may or may not have been
- // dispatched depending on race; the parent CONTEXT itself also fails).
+ // dispatched depending on race).
Assert.True(
events.Count(e => e.EventType == EventType.ContextFailed) >= 2,
$"Expected >= 2 ContextFailed events; got {events.Count(e => e.EventType == EventType.ContextFailed)}");
- // The parent context (named "tolerance") records the aggregate failure.
- var parentFailed = events.FirstOrDefault(e =>
+ // The parent context (named "tolerance") is checkpointed ContextSucceeded
+ // even when the failure tolerance is exceeded: ConcurrentOperation always
+ // writes the parent batch summary with action SUCCEED (the completion
+ // reason lives inside the payload), then the SDK throws ParallelException
+ // AFTER the checkpoint. This matches the Python/JS/Java wire format. The
+ // workflow-level failure is asserted above via PollForCompletionAsync ==
+ // FAILED and the ParallelException error type — the parent CONTEXT itself
+ // is NOT recorded as ContextFailed.
+ var parentSucceeded = events.FirstOrDefault(e =>
+ e.EventType == EventType.ContextSucceeded && e.Name == "tolerance");
+ Assert.NotNull(parentSucceeded);
+ Assert.DoesNotContain(events, e =>
e.EventType == EventType.ContextFailed && e.Name == "tolerance");
- Assert.NotNull(parentFailed);
}
}
diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/WaitForCallbackSubmitterFailsTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/WaitForCallbackSubmitterFailsTest.cs
index e172a4ab0..e33fb62b0 100644
--- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/WaitForCallbackSubmitterFailsTest.cs
+++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/WaitForCallbackSubmitterFailsTest.cs
@@ -37,13 +37,16 @@ public async Task WaitForCallback_SubmitterThrows_SurfacesAsCallbackSubmitterExc
var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(120));
Assert.Equal("FAILED", status, ignoreCase: true);
- // The workflow surfaces CallbackSubmitterException — the SDK's wrapper
- // type around the failed submitter step. Verify both the recorded
- // ErrorType and that the original "submitter intentional failure"
- // message survives in the error chain.
+ // The workflow surfaces CallbackSubmitterException to the user, but the
+ // terminal ErrorObject records the ORIGINAL error identity that the
+ // submitter threw. CallbackSubmitterException carries the failed step's
+ // ErrorType (the submitter's InvalidOperationException), and
+ // ErrorObject.FromException deliberately unwraps CallbackException to that
+ // underlying type — matching the Java/Python/JS SDKs and the unwrapping
+ // covered by ModelsTests.ErrorObject_FromException_UnwrapsCallbackException.
var execution = await deployment.GetExecutionAsync(arn!);
Assert.NotNull(execution.Error);
- Assert.Equal(typeof(CallbackSubmitterException).FullName, execution.Error.ErrorType);
+ Assert.Equal(typeof(InvalidOperationException).FullName, execution.Error.ErrorType);
// ErrorObject.FromException records the outer exception's Message; that
// message should reference the submitter failure context. Be lenient
// about exact wording since the SDK may prepend / wrap the inner.
diff --git a/Libraries/test/IntegrationTests.Helpers/RetryHelper.cs b/Libraries/test/IntegrationTests.Helpers/RetryHelper.cs
new file mode 100644
index 000000000..649b53716
--- /dev/null
+++ b/Libraries/test/IntegrationTests.Helpers/RetryHelper.cs
@@ -0,0 +1,57 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+using System;
+using System.Threading.Tasks;
+
+namespace IntegrationTests.Helpers
+{
+ ///
+ /// Helpers for polling on eventually-consistent conditions in integration tests.
+ ///
+ public static class RetryHelper
+ {
+ ///
+ /// Polls until it returns true or elapses.
+ /// Useful for gating tests on resources that report ready (e.g. CloudFormation CREATE_COMPLETE)
+ /// before they are fully propagated and serving traffic (e.g. API Gateway stages/authorizers).
+ ///
+ /// The condition to evaluate. Returning true ends the wait.
+ /// Maximum total time to keep polling.
+ /// Delay between attempts. Defaults to 5 seconds.
+ /// true if the condition was met before the timeout; otherwise false.
+ public static async Task WaitForConditionAsync(
+ Func> condition,
+ TimeSpan timeout,
+ TimeSpan? pollInterval = null)
+ {
+ if (condition == null) throw new ArgumentNullException(nameof(condition));
+
+ var interval = pollInterval ?? TimeSpan.FromSeconds(5);
+ var deadline = DateTime.UtcNow + timeout;
+
+ while (true)
+ {
+ try
+ {
+ if (await condition())
+ {
+ return true;
+ }
+ }
+ catch
+ {
+ // Swallow transient errors (e.g. connection resets while the endpoint warms up)
+ // and keep polling until the deadline.
+ }
+
+ if (DateTime.UtcNow >= deadline)
+ {
+ return false;
+ }
+
+ await Task.Delay(interval);
+ }
+ }
+ }
+}
diff --git a/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/IntegrationTestContextFixture.cs b/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/IntegrationTestContextFixture.cs
index f6a9138f4..06cba6a17 100644
--- a/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/IntegrationTestContextFixture.cs
+++ b/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/IntegrationTestContextFixture.cs
@@ -90,8 +90,42 @@ public async Task InitializeAsync()
await LambdaHelper.WaitTillNotPending(LambdaFunctions.Where(x => x.Name != null).Select(x => x.Name!).ToList());
- // Wait an additional 10 seconds for any other eventual consistency state to finish up.
- await Task.Delay(10000);
+ // CloudFormation reports CREATE_COMPLETE before API Gateway has fully propagated the deployed
+ // stage and the Lambda authorizer invoke permissions to the edge. During that window, requests
+ // on the authorizer "allow" path can transiently return 403. REST APIs (v1) settle slower than
+ // HTTP APIs (v2), so poll a known allow-path endpoint on each API until it serves traffic
+ // correctly rather than relying on a fixed sleep.
+ await WarmUpApisAsync();
+ }
+
+ ///
+ /// Polls a representative "allow path" endpoint on each deployed API until the custom authorizer
+ /// is fully wired and the request succeeds (or a 401 from the backend), confirming the API is
+ /// serving traffic before the test suite runs.
+ ///
+ private async Task WarmUpApisAsync()
+ {
+ var timeout = TimeSpan.FromMinutes(2);
+ var pollInterval = TimeSpan.FromSeconds(5);
+
+ // A warmed-up authorizer returns a non-403 response on the allow path: either 200 (context
+ // present) or 401 (backend rejects missing context). A 403 means API Gateway could not yet
+ // invoke/attach the authorizer, so keep waiting.
+ async Task EndpointIsReady(string url)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "valid-token");
+ var response = await HttpClient.SendAsync(request);
+ return response.StatusCode != System.Net.HttpStatusCode.Forbidden;
+ }
+
+ var restReady = await RetryHelper.WaitForConditionAsync(
+ () => EndpointIsReady($"{RestApiUrl}/api/rest-user-info"), timeout, pollInterval);
+ Console.WriteLine($"[IntegrationTest] REST API warm-up {(restReady ? "succeeded" : "timed out")}.");
+
+ var httpReady = await RetryHelper.WaitForConditionAsync(
+ () => EndpointIsReady($"{HttpApiUrl}/api/user-info"), timeout, pollInterval);
+ Console.WriteLine($"[IntegrationTest] HTTP API warm-up {(httpReady ? "succeeded" : "timed out")}.");
}
public async Task DisposeAsync()
diff --git a/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/TestCustomAuthorizerApp.IntegrationTests.csproj b/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/TestCustomAuthorizerApp.IntegrationTests.csproj
index 02540483e..bc3018c9c 100644
--- a/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/TestCustomAuthorizerApp.IntegrationTests.csproj
+++ b/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/TestCustomAuthorizerApp.IntegrationTests.csproj
@@ -1,7 +1,7 @@
- net8.0;net10.0
+ net10.0
enable
enable
Library
diff --git a/Libraries/test/TestExecutableServerlessApp/aws-lambda-tools-defaults.json b/Libraries/test/TestExecutableServerlessApp/aws-lambda-tools-defaults.json
index a5f516588..71140c33d 100644
--- a/Libraries/test/TestExecutableServerlessApp/aws-lambda-tools-defaults.json
+++ b/Libraries/test/TestExecutableServerlessApp/aws-lambda-tools-defaults.json
@@ -8,7 +8,7 @@
"profile": "default",
"region": "us-west-2",
"configuration": "Release",
- "framework": "net6.0",
+ "framework": "net10.0",
"s3-prefix": "TestServerlessApp/",
"template": "serverless.template",
"template-parameters": "",
diff --git a/Libraries/test/TestServerlessApp.ALB/aws-lambda-tools-defaults.json b/Libraries/test/TestServerlessApp.ALB/aws-lambda-tools-defaults.json
index 49464b51c..dad3e9b14 100644
--- a/Libraries/test/TestServerlessApp.ALB/aws-lambda-tools-defaults.json
+++ b/Libraries/test/TestServerlessApp.ALB/aws-lambda-tools-defaults.json
@@ -8,7 +8,7 @@
"profile": "",
"region": "us-west-2",
"configuration": "Release",
- "framework": "net6.0",
+ "framework": "net10.0",
"s3-bucket" : "test-alb-app-dce31eae",
"stack-name" : "test-alb-app-dce31eae",
"template": "serverless.template",
diff --git a/buildtools/build.proj b/buildtools/build.proj
index 0b80ec612..c2ea686e7 100644
--- a/buildtools/build.proj
+++ b/buildtools/build.proj
@@ -218,8 +218,12 @@
-
-
+
+
+
+
+