From da9d55e5c490010a6e640808f4f58f04293ab929 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:25:18 +0000 Subject: [PATCH 1/6] Initial plan From 6b318cf0e48488aa0b79ad557a46e4efc4c75aff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:33:20 +0000 Subject: [PATCH 2/6] plan: improve test quality for pkg/agentdrain/anomaly_test.go Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../daily-elixir-credo-snippet-audit.lock.yml | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/.github/workflows/daily-elixir-credo-snippet-audit.lock.yml b/.github/workflows/daily-elixir-credo-snippet-audit.lock.yml index dd8b212e3e1..3b3a0b65401 100644 --- a/.github/workflows/daily-elixir-credo-snippet-audit.lock.yml +++ b/.github/workflows/daily-elixir-credo-snippet-audit.lock.yml @@ -1,5 +1,5 @@ # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"6cf2dc5cf3f6169a8fc2ae8262681f558655296a60ef8bc1a96550c0f7b3520f","body_hash":"2f1463b9044ab5b109d77da64d2959d9e4394f83c383cfd35a015e481cef9acb","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.201"}} -# gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"erlef/setup-beam","sha":"fc68ffb90438ef2936bbb3251622353b3dcb2f93","version":"v1.24.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27","digest":"sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27@sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27","digest":"sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27@sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27","digest":"sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27@sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27","digest":"sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27@sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.0","digest":"sha256:9dbdf42842c224a95016df1d2a85a2901e04204c242079343b302a307d2b8031","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.0@sha256:9dbdf42842c224a95016df1d2a85a2901e04204c242079343b302a307d2b8031"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.5.0","digest":"sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4","pinned_image":"ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4"}]} +# gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"erlef/setup-beam","sha":"fc68ffb90438ef2936bbb3251622353b3dcb2f93","version":"v1.24.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27","digest":"sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27@sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27","digest":"sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27@sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27","digest":"sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27@sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27","digest":"sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27@sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.1","digest":"sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.1@sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.5.0","digest":"sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4","pinned_image":"ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ @@ -55,7 +55,7 @@ # - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27@sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3 # - ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27@sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a # - ghcr.io/github/gh-aw-firewall/squid:0.27.27@sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409 -# - ghcr.io/github/gh-aw-mcpg:v0.4.0@sha256:9dbdf42842c224a95016df1d2a85a2901e04204c242079343b302a307d2b8031 +# - ghcr.io/github/gh-aw-mcpg:v0.4.1@sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32 # - ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b # - ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4 @@ -542,7 +542,7 @@ jobs: GH_AW_SKILL_DIR: ".claude/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.27@sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27@sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3 ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27@sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a ghcr.io/github/gh-aw-firewall/squid:0.27.27@sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409 ghcr.io/github/gh-aw-mcpg:v0.4.0@sha256:9dbdf42842c224a95016df1d2a85a2901e04204c242079343b302a307d2b8031 ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.27@sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27@sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3 ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27@sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a ghcr.io/github/gh-aw-firewall/squid:0.27.27@sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409 ghcr.io/github/gh-aw-mcpg:v0.4.1@sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32 ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4 - name: Generate Safe Outputs Config run: | mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" @@ -719,7 +719,7 @@ jobs: * ) DOCKER_SOCK_PATH=/var/run/docker.sock ;; esac DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0') - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.0' + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network bridge -p 127.0.0.1:'"${MCP_GATEWAY_PORT}"':'"${MCP_GATEWAY_PORT}"' --name awmg-mcpg --add-host host.docker.internal:host-gateway --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_POLICY_ALLOW_CREATE_PULL_REQUEST -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e RUNNER_TEMP -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw -v '"${RUNNER_TEMP}"'/gh-aw/safeoutputs:'"${RUNNER_TEMP}"'/gh-aw/safeoutputs:rw ghcr.io/github/gh-aw-mcpg:v0.4.1' GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) cat << GH_AW_MCP_CONFIG_7cbd56920d45855d_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" @@ -799,7 +799,7 @@ jobs: GITHUB_COPILOT_BASE_URL: ${{ env.GITHUB_COPILOT_BASE_URL }} GH_AW_NETWORK_ISOLATION: 'true' CLI_PROXY_POLICY: '{"allow-only":{"repos":"all","min-integrity":"none"}}' - CLI_PROXY_IMAGE: 'ghcr.io/github/gh-aw-mcpg:v0.4.0' + CLI_PROXY_IMAGE: 'ghcr.io/github/gh-aw-mcpg:v0.4.1' run: | bash "${RUNNER_TEMP}/gh-aw/actions/start_cli_proxy.sh" - name: Execute Claude Code CLI @@ -999,6 +999,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent-stdio.log + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -1020,15 +1021,7 @@ jobs: continue-on-error: true env: AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs - run: | - # Best-effort permission fix for artifact upload (AWF cleanup may not have run) - sudo -n chmod -R a+rX /tmp/gh-aw/sandbox/firewall 2>/dev/null || chmod -R a+rX /tmp/gh-aw/sandbox/firewall 2>/dev/null || true - # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) - if command -v awf &> /dev/null; then - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" - else - echo 'AWF binary not installed, skipping firewall log summary' - fi + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_firewall_logs.sh" --rootless - name: Parse token usage for step summary if: always() continue-on-error: true From 3ff57c154dff8716aff918a8f0961da93391c446 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:38:55 +0000 Subject: [PATCH 3/6] improve test quality for pkg/agentdrain/anomaly_test.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add nil-guard to Analyze in anomaly.go to prevent panic on nil result - Add TestAnalyze_NilResult test verifying nil-guard behavior - Refactor TestAnalyzeEvent to use t.Run sub-tests - Add TestAnalyzeEvent_Variants table covering empty stage and nil fields - Add t.Parallel() to sub-tests in stateless table-driven tests - Upgrade assert.Nil → require.Nil in error path of TestNewAnomalyDetector_ThresholdBoundaries - Add anomalyScore helper to keep expected-score values in sync with weights Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/agentdrain/anomaly.go | 3 + pkg/agentdrain/anomaly_test.go | 160 +++++++++++++++++++++++++++------ 2 files changed, 138 insertions(+), 25 deletions(-) diff --git a/pkg/agentdrain/anomaly.go b/pkg/agentdrain/anomaly.go index 8b8d41be904..b6d3d32d17c 100644 --- a/pkg/agentdrain/anomaly.go +++ b/pkg/agentdrain/anomaly.go @@ -35,6 +35,9 @@ func NewAnomalyDetector(simThreshold float64, rareClusterThreshold int) (*Anomal // - isNew indicates the line created a brand-new cluster. // - cluster is the cluster that was matched or created. func (d *AnomalyDetector) Analyze(result *MatchResult, isNew bool, cluster *Cluster) *AnomalyReport { + if result == nil { + return &AnomalyReport{Reason: buildReason(&AnomalyReport{})} + } report := &AnomalyReport{ IsNewTemplate: isNew, // LowSimilarity is mutually exclusive with IsNewTemplate: brand-new templates are diff --git a/pkg/agentdrain/anomaly_test.go b/pkg/agentdrain/anomaly_test.go index 52ae195d835..a31d67b5fba 100644 --- a/pkg/agentdrain/anomaly_test.go +++ b/pkg/agentdrain/anomaly_test.go @@ -9,6 +9,32 @@ import ( "github.com/stretchr/testify/require" ) +// anomalyScore computes the expected normalized anomaly score from the individual flag weights, +// mirroring the scoring logic in Analyze. Using this helper keeps test expectations in sync +// with the weight constants defined in the source. +func anomalyScore(isNew, lowSim, rare bool) float64 { + const ( + weightNew = 1.0 + weightLow = 0.7 + weightRare = 0.3 + maxScore = 2.0 + ) + var score float64 + if isNew { + score += weightNew + } + if lowSim { + score += weightLow + } + if rare { + score += weightRare + } + if score > maxScore { + score = maxScore + } + return score / maxScore +} + func TestAnomalyDetector_Analyze(t *testing.T) { tests := []struct { name string @@ -217,6 +243,7 @@ func TestAnomalyDetector_Analyze(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() d, err := NewAnomalyDetector(tt.simThreshold, tt.rareThreshold) require.NoError(t, err, "NewAnomalyDetector should succeed with valid thresholds") require.NotNil(t, d, "NewAnomalyDetector should return a non-nil detector") @@ -272,11 +299,12 @@ func TestNewAnomalyDetector_ThresholdBoundaries(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() detector, err := NewAnomalyDetector(tt.simThreshold, tt.rareThreshold) if tt.wantErr != "" { require.Error(t, err, "NewAnomalyDetector should reject invalid thresholds") assert.Contains(t, err.Error(), tt.wantErr, "error should describe invalid threshold") - assert.Nil(t, detector, "NewAnomalyDetector should return nil detector on validation error") + require.Nil(t, detector, "NewAnomalyDetector should return nil detector on validation error") return } @@ -358,6 +386,7 @@ func TestBuildReason(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() r := &AnomalyReport{ IsNewTemplate: tt.isNewTemplate, LowSimilarity: tt.lowSimilarity, @@ -384,32 +413,112 @@ func TestAnalyzeEvent(t *testing.T) { Fields: map[string]string{"status": "ok"}, } - // Step 1: first occurrence trains the template and is flagged as new. - resultFirst, reportFirst, errFirst := m.AnalyzeEvent(evtPlan) - require.NoError(t, errFirst, "AnalyzeEvent should not fail for first event") - require.NotNil(t, resultFirst, "AnalyzeEvent should return a non-nil result") - require.NotNil(t, reportFirst, "AnalyzeEvent should return a non-nil report") - assert.True(t, reportFirst.IsNewTemplate, "IsNewTemplate mismatch for first event") - assert.InDelta(t, 0.65, reportFirst.AnomalyScore, 1e-9, "AnomalyScore mismatch for first event") - assert.Equal(t, "new log template discovered; rare cluster (few observations)", reportFirst.Reason, "Reason mismatch for first event") + // Sub-tests are sequential: each step mutates the shared miner state and depends + // on the state from the previous step. + + t.Run("first occurrence trains template and is flagged new", func(t *testing.T) { + resultFirst, reportFirst, errFirst := m.AnalyzeEvent(evtPlan) + require.NoError(t, errFirst, "AnalyzeEvent should not fail for first event") + require.NotNil(t, resultFirst, "AnalyzeEvent should return a non-nil result") + require.NotNil(t, reportFirst, "AnalyzeEvent should return a non-nil report") + assert.True(t, reportFirst.IsNewTemplate, "IsNewTemplate mismatch for first event") + assert.InDelta(t, anomalyScore(true, false, true), reportFirst.AnomalyScore, 1e-9, "AnomalyScore mismatch for first event") + assert.Equal(t, "new log template discovered; rare cluster (few observations)", reportFirst.Reason, "Reason mismatch for first event") + }) - // Step 2: second identical occurrence reuses the trained template. - resultSecond, reportSecond, errSecond := m.AnalyzeEvent(evtPlan) - require.NoError(t, errSecond, "AnalyzeEvent should not fail for second identical event") - require.NotNil(t, resultSecond, "AnalyzeEvent should return a non-nil result") - require.NotNil(t, reportSecond, "AnalyzeEvent should return a non-nil report") - assert.False(t, reportSecond.IsNewTemplate, "IsNewTemplate mismatch for second identical event") - assert.InDelta(t, 0.15, reportSecond.AnomalyScore, 1e-9, "AnomalyScore mismatch for second identical event") - assert.Equal(t, "rare cluster (few observations)", reportSecond.Reason, "Reason mismatch for second identical event") + t.Run("second identical occurrence reuses trained template", func(t *testing.T) { + resultSecond, reportSecond, errSecond := m.AnalyzeEvent(evtPlan) + require.NoError(t, errSecond, "AnalyzeEvent should not fail for second identical event") + require.NotNil(t, resultSecond, "AnalyzeEvent should return a non-nil result") + require.NotNil(t, reportSecond, "AnalyzeEvent should return a non-nil report") + assert.False(t, reportSecond.IsNewTemplate, "IsNewTemplate mismatch for second identical event") + assert.InDelta(t, anomalyScore(false, false, true), reportSecond.AnomalyScore, 1e-9, "AnomalyScore mismatch for second identical event") + assert.Equal(t, "rare cluster (few observations)", reportSecond.Reason, "Reason mismatch for second identical event") + }) + + t.Run("distinct event creates separate new template", func(t *testing.T) { + resultDistinct, reportDistinct, errDistinct := m.AnalyzeEvent(evtFinish) + require.NoError(t, errDistinct, "AnalyzeEvent should not fail for distinct event") + require.NotNil(t, resultDistinct, "AnalyzeEvent should return a non-nil result") + require.NotNil(t, reportDistinct, "AnalyzeEvent should return a non-nil report") + assert.True(t, reportDistinct.IsNewTemplate, "IsNewTemplate mismatch for distinct event") + assert.InDelta(t, anomalyScore(true, false, true), reportDistinct.AnomalyScore, 1e-9, "AnomalyScore mismatch for distinct event") + assert.Equal(t, "new log template discovered; rare cluster (few observations)", reportDistinct.Reason, "Reason mismatch for distinct event") + }) +} + +// TestAnalyzeEvent_Variants covers edge-case event shapes: empty stage and nil/empty fields. +func TestAnalyzeEvent_Variants(t *testing.T) { + tests := []struct { + name string + evt AgentEvent + wantErr bool + wantErrMsg string + }{ + { + name: "empty stage with fields succeeds", + evt: AgentEvent{ + Stage: "", + Fields: map[string]string{"action": "start"}, + }, + }, + { + name: "stage with nil fields succeeds", + evt: AgentEvent{ + Stage: "plan", + Fields: nil, + }, + }, + { + name: "empty stage and nil fields returns error", + evt: AgentEvent{Stage: "", Fields: nil}, + wantErr: true, + wantErrMsg: "empty event after masking", + }, + { + name: "empty stage and empty fields returns error", + evt: AgentEvent{Stage: "", Fields: map[string]string{}}, + wantErr: true, + wantErrMsg: "empty event after masking", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg := DefaultConfig() + m, err := NewMiner(cfg) + require.NoError(t, err, "NewMiner should succeed") + + result, report, err := m.AnalyzeEvent(tt.evt) + if tt.wantErr { + require.Error(t, err, "AnalyzeEvent should return an error") + assert.Contains(t, err.Error(), tt.wantErrMsg, "error message mismatch") + assert.Nil(t, result, "AnalyzeEvent should return nil result on error") + assert.Nil(t, report, "AnalyzeEvent should return nil report on error") + return + } + require.NoError(t, err, "AnalyzeEvent should not return an error") + require.NotNil(t, result, "AnalyzeEvent should return a non-nil result") + require.NotNil(t, report, "AnalyzeEvent should return a non-nil report") + }) + } +} + +// TestAnalyze_NilResult ensures that Analyze does not panic when result is nil +// and instead returns a zero-value report with the "no anomaly detected" reason. +func TestAnalyze_NilResult(t *testing.T) { + d, err := NewAnomalyDetector(0.4, 2) + require.NoError(t, err, "NewAnomalyDetector should succeed") - // Step 3: a distinct event creates a separate new template. - resultDistinct, reportDistinct, errDistinct := m.AnalyzeEvent(evtFinish) - require.NoError(t, errDistinct, "AnalyzeEvent should not fail for distinct event") - require.NotNil(t, resultDistinct, "AnalyzeEvent should return a non-nil result") - require.NotNil(t, reportDistinct, "AnalyzeEvent should return a non-nil report") - assert.True(t, reportDistinct.IsNewTemplate, "IsNewTemplate mismatch for distinct event") - assert.InDelta(t, 0.65, reportDistinct.AnomalyScore, 1e-9, "AnomalyScore mismatch for distinct event") - assert.Equal(t, "new log template discovered; rare cluster (few observations)", reportDistinct.Reason, "Reason mismatch for distinct event") + // Nil result must not panic; the nil-guard in Analyze returns a safe report. + report := d.Analyze(nil, false, nil) + require.NotNil(t, report, "Analyze should return a non-nil report even for nil result") + assert.Equal(t, "no anomaly detected", report.Reason, "nil result should produce no-anomaly reason") + assert.InDelta(t, 0.0, report.AnomalyScore, 1e-9, "nil result should produce zero anomaly score") + assert.False(t, report.IsNewTemplate, "nil result should not set IsNewTemplate") + assert.False(t, report.LowSimilarity, "nil result should not set LowSimilarity") + assert.False(t, report.RareCluster, "nil result should not set RareCluster") } func TestAnalyze_FlagMutualExclusivity(t *testing.T) { @@ -453,6 +562,7 @@ func TestAnalyze_FlagMutualExclusivity(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() report := d.Analyze(tt.result, tt.isNew, tt.cluster) require.NotNil(t, report, "Analyze should always return a non-nil report") assert.Equal(t, tt.wantLow, report.LowSimilarity, "LowSimilarity mismatch") From 923a4e86b1b91382a6ebcf8a7586fcc00c23baa0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 04:22:55 +0000 Subject: [PATCH 4/6] docs(adr): add draft ADR-44455 for nil-safe Analyze boundary and test score helper --- ...-analyze-boundary-and-test-score-helper.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/adr/44455-nil-safe-analyze-boundary-and-test-score-helper.md diff --git a/docs/adr/44455-nil-safe-analyze-boundary-and-test-score-helper.md b/docs/adr/44455-nil-safe-analyze-boundary-and-test-score-helper.md new file mode 100644 index 00000000000..4ce5d2fbcef --- /dev/null +++ b/docs/adr/44455-nil-safe-analyze-boundary-and-test-score-helper.md @@ -0,0 +1,44 @@ +# ADR-44455: Nil-safe `Analyze` boundary and test score helper for anomaly detection + +**Date**: 2026-07-09 +**Status**: Draft +**Deciders**: Unknown (generated from PR #44455 by adr-writer agent) + +--- + +### Context + +`AnomalyDetector.Analyze` accepted a `*MatchResult` pointer but had no nil-guard. In production `AnalyzeEvent` calls `Analyze` after a clustering operation that can produce a nil result on certain error paths, causing an unrecovered panic. Additionally, test assertions in `anomaly_test.go` hard-coded magic float literals (e.g., `0.65`, `0.15`) that duplicated the weight constants defined in `Analyze`, making tests brittle when weights change. + +### Decision + +We will add a nil-guard at the top of `Analyze` that returns a zero-value `AnomalyReport` (score 0, all flags false, reason "no anomaly detected") when the caller passes `nil`. We will also introduce an `anomalyScore` test-only helper that mirrors the weight constants from `Analyze`, so test expectations stay in sync with production scoring logic without duplicating magic literals. + +### Alternatives Considered + +#### Alternative 1: Panic with a descriptive message on nil input + +Treat nil as a programming error — add `if result == nil { panic("Analyze: nil MatchResult") }`. This makes misuse louder, forcing callers to fix the bug rather than silently swallowing it. Rejected because the call site in `AnalyzeEvent` legitimately reaches this path during error handling and a panic there would surface as an unrecoverable runtime failure rather than a handled error. + +#### Alternative 2: Change the API to `(*AnomalyReport, error)` + +Return `(nil, error)` when `result == nil`, surfacing the anomaly as an explicit error the caller must handle. This is the most type-safe option and makes nil-path observable at compile time. Rejected because it requires changing every call site across the package and is a larger API break than the targeted bug fix warrants; callers currently rely on `Analyze` always returning a non-nil `*AnomalyReport`. + +### Consequences + +#### Positive +- Eliminates the production panic on nil `*MatchResult`; the anomaly pipeline continues operating safely. +- Test expectations are derived from production weight constants via the helper, so a weight change in `Analyze` propagates automatically to test failures rather than silently passing. +- `t.Parallel()` added to all stateless sub-tests reduces wall-clock test time. + +#### Negative +- A caller that passes nil due to a logic bug gets no feedback — the nil maps silently to "no anomaly detected", which could mask incorrect upstream behavior. +- The `anomalyScore` helper duplicates the scoring algorithm in test code; if the scoring structure changes significantly (e.g., new flags added) the helper must be updated manually alongside the production code. + +#### Neutral +- `TestAnalyzeEvent` sub-tests are not parallelised because they share a miner with accumulated state; the sequential dependency is now made explicit in comments. +- `assert.Nil` → `require.Nil` in the error branch of `TestNewAnomalyDetector_ThresholdBoundaries` prevents further assertions on a nil detector after a validation error, which is a test correctness improvement with no production impact. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 95d68be459574ce85ddc3997e90ea9cb76c10c6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 04:56:40 +0000 Subject: [PATCH 5/6] fix(agentdrain): address all review thread feedback on anomaly nil-guard and tests - Export AnomalyWeightNew/Low/Rare and AnomalyMaxScore constants from anomaly.go; use them in scoring loop for compile-time sync between production and tests - Nil-guard: add anomalyLog.Printf observability line; replace buildReason indirection with explicit 'no anomaly detected' literal (single allocation) - anomalyScore helper: rewrite to use exported constants; update comment to correctly say 'exported constants' not 'weight constants defined in the source' - TestAnalyze_NilResult: add t.Parallel(); add second assertion block confirming isNew=true and non-nil cluster are ignored when result is nil - TestAnalyzeEvent: strengthen 'do NOT add t.Parallel()' comment; change IsNewTemplate and AnomalyScore assertions to require in first sub-test (gate) - Lock files regenerated (test-dispatcher drift) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/agentdrain/anomaly.go | 27 ++++++++++++++-------- pkg/agentdrain/anomaly_test.go | 41 ++++++++++++++++++++-------------- 2 files changed, 42 insertions(+), 26 deletions(-) diff --git a/pkg/agentdrain/anomaly.go b/pkg/agentdrain/anomaly.go index b6d3d32d17c..0d80b79dd42 100644 --- a/pkg/agentdrain/anomaly.go +++ b/pkg/agentdrain/anomaly.go @@ -9,6 +9,15 @@ import ( var anomalyLog = logger.New("agentdrain:anomaly") +// Scoring weights used by Analyze. Exported so tests can reference them directly +// and stay in sync with production logic at compile time. +const ( + AnomalyWeightNew = 1.0 + AnomalyWeightLow = 0.7 + AnomalyWeightRare = 0.3 + AnomalyMaxScore = 2.0 +) + // AnomalyDetector evaluates match results and produces AnomalyReports. type AnomalyDetector struct { threshold float64 @@ -36,7 +45,8 @@ func NewAnomalyDetector(simThreshold float64, rareClusterThreshold int) (*Anomal // - cluster is the cluster that was matched or created. func (d *AnomalyDetector) Analyze(result *MatchResult, isNew bool, cluster *Cluster) *AnomalyReport { if result == nil { - return &AnomalyReport{Reason: buildReason(&AnomalyReport{})} + anomalyLog.Printf("Analyze: nil result, returning zero-value report") + return &AnomalyReport{Reason: "no anomaly detected"} } report := &AnomalyReport{ IsNewTemplate: isNew, @@ -49,22 +59,21 @@ func (d *AnomalyDetector) Analyze(result *MatchResult, isNew bool, cluster *Clus // Weighted anomaly score. var score float64 if report.IsNewTemplate { - score += 1.0 + score += AnomalyWeightNew } if report.LowSimilarity { - score += 0.7 + score += AnomalyWeightLow } if report.RareCluster { - score += 0.3 + score += AnomalyWeightRare } // Normalize to [0, 1]. - const maxScore = 2.0 - // Defensive guard: with current mutually exclusive flags the score cannot exceed maxScore, + // Defensive guard: with current mutually exclusive flags the score cannot exceed AnomalyMaxScore, // but keep clamping in case future weighting or flag logic changes. - if score > maxScore { - score = maxScore + if score > AnomalyMaxScore { + score = AnomalyMaxScore } - report.AnomalyScore = score / maxScore + report.AnomalyScore = score / AnomalyMaxScore report.Reason = buildReason(report) if anomalyLog.Enabled() { diff --git a/pkg/agentdrain/anomaly_test.go b/pkg/agentdrain/anomaly_test.go index a31d67b5fba..3205ea989fa 100644 --- a/pkg/agentdrain/anomaly_test.go +++ b/pkg/agentdrain/anomaly_test.go @@ -10,29 +10,24 @@ import ( ) // anomalyScore computes the expected normalized anomaly score from the individual flag weights, -// mirroring the scoring logic in Analyze. Using this helper keeps test expectations in sync -// with the weight constants defined in the source. +// mirroring the scoring logic in Analyze. Using the exported constants (AnomalyWeightNew, +// AnomalyWeightLow, AnomalyWeightRare, AnomalyMaxScore) gives a compile-time sync guarantee: +// if production weights change, this helper diverges at compile time, not silently at runtime. func anomalyScore(isNew, lowSim, rare bool) float64 { - const ( - weightNew = 1.0 - weightLow = 0.7 - weightRare = 0.3 - maxScore = 2.0 - ) var score float64 if isNew { - score += weightNew + score += AnomalyWeightNew } if lowSim { - score += weightLow + score += AnomalyWeightLow } if rare { - score += weightRare + score += AnomalyWeightRare } - if score > maxScore { - score = maxScore + if score > AnomalyMaxScore { + score = AnomalyMaxScore } - return score / maxScore + return score / AnomalyMaxScore } func TestAnomalyDetector_Analyze(t *testing.T) { @@ -414,15 +409,16 @@ func TestAnalyzeEvent(t *testing.T) { } // Sub-tests are sequential: each step mutates the shared miner state and depends - // on the state from the previous step. + // on the state from the previous step. Do NOT add t.Parallel() to any of these + // sub-tests — doing so would cause a data race on the shared Miner. t.Run("first occurrence trains template and is flagged new", func(t *testing.T) { resultFirst, reportFirst, errFirst := m.AnalyzeEvent(evtPlan) require.NoError(t, errFirst, "AnalyzeEvent should not fail for first event") require.NotNil(t, resultFirst, "AnalyzeEvent should return a non-nil result") require.NotNil(t, reportFirst, "AnalyzeEvent should return a non-nil report") - assert.True(t, reportFirst.IsNewTemplate, "IsNewTemplate mismatch for first event") - assert.InDelta(t, anomalyScore(true, false, true), reportFirst.AnomalyScore, 1e-9, "AnomalyScore mismatch for first event") + require.True(t, reportFirst.IsNewTemplate, "IsNewTemplate mismatch for first event") // gate for steps 2+3 + require.InDelta(t, anomalyScore(true, false, true), reportFirst.AnomalyScore, 1e-9, "AnomalyScore mismatch for first event") assert.Equal(t, "new log template discovered; rare cluster (few observations)", reportFirst.Reason, "Reason mismatch for first event") }) @@ -508,6 +504,7 @@ func TestAnalyzeEvent_Variants(t *testing.T) { // TestAnalyze_NilResult ensures that Analyze does not panic when result is nil // and instead returns a zero-value report with the "no anomaly detected" reason. func TestAnalyze_NilResult(t *testing.T) { + t.Parallel() d, err := NewAnomalyDetector(0.4, 2) require.NoError(t, err, "NewAnomalyDetector should succeed") @@ -519,6 +516,16 @@ func TestAnalyze_NilResult(t *testing.T) { assert.False(t, report.IsNewTemplate, "nil result should not set IsNewTemplate") assert.False(t, report.LowSimilarity, "nil result should not set LowSimilarity") assert.False(t, report.RareCluster, "nil result should not set RareCluster") + + // Confirm that isNew=true and a non-nil cluster are silently ignored when result is nil. + // The nil-guard short-circuits before reading those arguments; callers should not rely on + // them being honoured when result is absent. + cluster := &Cluster{ID: 1, Template: []string{"x"}, Size: 1} + reportWithArgs := d.Analyze(nil, true, cluster) + require.NotNil(t, reportWithArgs, "Analyze should return a non-nil report for nil result with non-nil args") + assert.Equal(t, "no anomaly detected", reportWithArgs.Reason, "isNew and cluster must be ignored when result is nil") + assert.InDelta(t, 0.0, reportWithArgs.AnomalyScore, 1e-9, "AnomalyScore must be zero when result is nil") + assert.False(t, reportWithArgs.IsNewTemplate, "IsNewTemplate must be false when result is nil") } func TestAnalyze_FlagMutualExclusivity(t *testing.T) { From 9cfa5efdf47aeb37a4e8c1219b2104a707a7a876 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 04:57:27 +0000 Subject: [PATCH 6/6] fix(agentdrain): clarify both require assertions are gates in TestAnalyzeEvent Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/agentdrain/anomaly_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/agentdrain/anomaly_test.go b/pkg/agentdrain/anomaly_test.go index 3205ea989fa..5628dc76691 100644 --- a/pkg/agentdrain/anomaly_test.go +++ b/pkg/agentdrain/anomaly_test.go @@ -417,7 +417,9 @@ func TestAnalyzeEvent(t *testing.T) { require.NoError(t, errFirst, "AnalyzeEvent should not fail for first event") require.NotNil(t, resultFirst, "AnalyzeEvent should return a non-nil result") require.NotNil(t, reportFirst, "AnalyzeEvent should return a non-nil report") - require.True(t, reportFirst.IsNewTemplate, "IsNewTemplate mismatch for first event") // gate for steps 2+3 + // Both assertions below are gates for steps 2+3: a failure here stops the test + // before exercising state that is now invalid. + require.True(t, reportFirst.IsNewTemplate, "IsNewTemplate mismatch for first event") require.InDelta(t, anomalyScore(true, false, true), reportFirst.AnomalyScore, 1e-9, "AnomalyScore mismatch for first event") assert.Equal(t, "new log template discovered; rare cluster (few observations)", reportFirst.Reason, "Reason mismatch for first event") })