Skip to content

feat(observability): wire SNS notification action to DLQ CloudWatch alarms (#629) - #739

Merged
ayushtr-aws merged 2 commits into
mainfrom
feat/629-dlq-alarm-sns-notifications
Aug 10, 2026
Merged

ayushtr-aws merged 2 commits into
mainfrom
feat/629-dlq-alarm-sns-notifications

Conversation

@nizar-lahlali

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #117 / #208 (§11.5 "notification channel wiring"). The DLQ-depth alarms shipped without an addAlarmAction, so poison-pill accumulation was only visible by polling the CloudWatch Alarms console. This adds a push notification channel.

  • New reusable OperationalAlerts construct (cdk/src/constructs/operational-alerts.ts): a single stack-wide SNS topic.
  • Wires all three DLQ-depth alarms to it via addAlarmAction(new SnsAction(topic)):
    • FanOutConsumer.dlqDepthAlarm
    • ApprovalMetricsPublisherConsumer.dlqAlarm
    • GitHubScreenshotIntegration.processorDlqDepthAlarm
  • Topic ARN exported as OperationalAlertsTopicArn.

Design notes

  • Customer-managed KMS key, not alias/aws/sns. This is load-bearing: CloudWatch cannot publish to a topic encrypted with the AWS-managed key (its policy can't be edited to grant cloudwatch.amazonaws.com kms:Decrypt/GenerateDataKey*), so the alarm action would fail silently at delivery. The CMK grants CloudWatch exactly those actions and satisfies cdk-nag AwsSolutions-SNS2. TLS-on-publish is enforced via an explicit DenyInsecureTransport topic policy (AwsSolutions-SNS3).
  • Configurable delivery target. -c alertEmail=ops@example.com creates an email subscription (AWS sends a confirmation link). With no context set, the topic ships with no subscription — operators wire Slack / PagerDuty / email manually against the exported ARN. Not hard-coded.

Deploy-role changes (required)

For a fresh cdk deploy to create these resources, the CFN execution role needs SNS + CMK create/lifecycle permissions:

  • Application policy: new SNS statement (arn:aws:sns:*:*:backgroundagent-dev-*).
  • Observability policy: new KMSCustomerManagedKeys statement.
  • Bumped bootstrap bundle 1.3.0 → 1.4.0, regenerated JSON artifacts + template + hash, updated DEPLOYMENT_ROLES.md golden baseline, and the resource-action-map synth-coverage guard.

Docs

  • docs/design/CEDAR_HITL_GATES.md §11.5 + deferred note updated; Starlight mirrors regenerated.

Testing

  • mise run build — green (3839 cdk tests pass).
  • New operational-alerts.test.ts (topic/CMK/key-policy/TLS/subscription/alarm-action, 100% coverage) + 3 new agent.test.ts stack assertions (single topic, all-3-alarms-wired, no-email-by-default).
  • Bootstrap policy/golden/synth-coverage/artifact-sync tests updated and passing.

Note: mise run security:sast:masking fails on this branch, but it fails identically on the clean base (42 pre-existing findings, none in files touched here) — not introduced by this PR.

Closes #629.

…larms (#629)

Follow-up to #117/#208: the DLQ-depth alarms shipped without an
addAlarmAction. Add a reusable OperationalAlerts construct — a
stack-wide, CMK-encrypted SNS topic — and wire all three DLQ-depth
alarms (FanOut, ApprovalMetricsPublisher, GitHubScreenshot processor)
to it via addAlarmAction(SnsAction).

- Topic uses a customer-managed KMS key (not alias/aws/sns): CloudWatch
  cannot publish to an AWS-managed-key topic, so the key policy grants
  cloudwatch.amazonaws.com Decrypt/GenerateDataKey. Satisfies SNS2.
- Delivery target is configurable via -c alertEmail=...; with none set
  the topic ships with no subscription and its ARN is exported for
  manual Slack/PagerDuty/email wiring.
- Bootstrap deploy role: grant SNS topic + CMK create/lifecycle so a
  fresh cdk deploy can create these resources (bump bundle to 1.4.0,
  regenerate artifacts, update DEPLOYMENT_ROLES.md golden baseline).
- Update CEDAR_HITL_GATES.md §11.5 + deferred note; regenerate Starlight.
@nizar-lahlali
nizar-lahlali requested review from a team as code owners August 10, 2026 19:32
Address review findings on the DLQ-alarm SNS wiring:

- Security (confused-deputy): pin aws:SourceAccount on the CloudWatch→KMS
  key grant so an alarm in another account can't induce this key's use,
  matching the lambda-microvm-compute precedent.
- Security (deploy-role blast radius): split KMSCustomerManagedKeys —
  CreateKey/read/tag stay on * (unscopable), but PutKeyPolicy/
  ScheduleKeyDeletion/rotation are now gated on the ABCA=operational-alerts
  resource tag the construct stamps on its key, so the CFN deploy role can
  no longer take over or delete unrelated account CMKs.
- removalPolicy now applies to the topic as well as the key (shared
  lifecycle); prop doc widened accordingly.
- Validate alertEmail at synth — throw on malformed input instead of
  shipping a permanently-unconfirmed subscription.
- Comments: drop the inert AwsSolutions-SNS3 suppression (the CMK already
  satisfies SNS3); correct SNS2-vs-SNS3 and AlarmBase-vs-Alarm claims.
- Tests: assert topic KmsMasterKeyId is a GetAtt to the CMK (not
  anyValue), removalPolicy reaches both resources, malformed-email throws,
  and per-verb bootstrap guards for sns:CreateTopic/Subscribe +
  kms:CreateKey and the tag-scoped lifecycle statement. Tag the third
  stack test #629.

Regenerated bootstrap artifacts + DEPLOYMENT_ROLES.md golden baseline and
Starlight mirror. mise run build green (3844 cdk + 745 cli tests).

@ayushtr-aws ayushtr-aws left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #739 Review — feat(observability): wire SNS notification action to DLQ CloudWatch alarms (#629)

  1. Verdict

Approve with nits. The change is correct, well-tested for its core behavior, CI is green, docs and the bootstrap bundle are complete and in sync. Two things should be tightened before
merge — both are polish, neither is functional: a code comment that overstates a security bound it doesn't actually deliver, and two untested security properties. No blocking defects.

  1. Vision alignment

Directly advances bounded blast radius & observability: DLQ poison-pill accumulation was silent (console-poll only); this closes the §11.5 "notification channel wiring" follow-up so
alarms push. It's purely additive, preserves fire-and-forget, and keeps outcomes reviewable (exported topic ARN, documented in CEDAR_HITL_GATES.md). Issue #629 is approved + assigned to
the author; branch name conforms. Fits cleanly.

  1. Blocking issues

None.

  1. Non-blocking suggestions / nits

N1 — Comment overstates the KMS blast-radius bound (fix wording or tighten the grant). cdk/src/bootstrap/policies/observability.ts:158-166,179-201
The lifecycle statement gates kms:PutKeyPolicy / kms:ScheduleKeyDeletion on aws:ResourceTag/ABCA=operational-alerts, but the sibling KMSCustomerManagedKeys statement grants
kms:TagResource unconditioned on *. The same role can therefore stamp ABCA=operational-alerts onto any account CMK and then satisfy the gate to take over or schedule-delete that foreign
key. So the comments "none can escalate privilege on an existing key" and "bounds the blast radius to keys this solution owns" are not true against a misused/compromised role — the
tag-gate is an accident-guard (works for normal CloudFormation operation), not a security boundary.
In practice the incremental risk is bounded: this is a CloudFormation execution role that already holds iam:CreateRole/PutRolePolicy (infrastructure.ts:63-84), so it is already able to
escalate to KMS admin by other means — which is why I score this a nit, not a blocker. But the misleading wording shouldn't land as-is. Pick one:

  • (a) Reword to state honestly that the tag scopes against accidental CloudFormation targeting, not a compromised-role bound; or
  • (b) Drop kms:ScheduleKeyDeletion (and ideally PutKeyPolicy) from the identity grant and let the key's own resource policy + RemovalPolicy govern deletion — that actually shrinks the
    exposure the comment claims to bound. If TagResource stays, condition it on aws:RequestTag/ABCA/aws:TagKeys to at least document intent.

N2 — DenyInsecureTransport covers only sns:Publish. cdk/src/constructs/operational-alerts.ts:136-143 — canonical secure-transport guardrails deny all actions;
Subscribe/SetTopicAttributes/etc. over non-TLS are still allowed. Belt-and-suspenders (SNS endpoints are HTTPS in practice), but broaden the Deny to sns:* so it's as strong as it reads.

N3 — kms:GenerateDataKey* wildcard is broader than needed. operational-alerts.ts:118 — SNS envelope encryption needs only kms:GenerateDataKey. The * also matches the data-key-pair
variants. Common CDK idiom, key-scoped, low risk — optional tighten.

N4 — No warning when the topic ships with zero subscribers. operational-alerts.ts — with no alertEmail and nothing wired later, alarms publish to a subscriber-less topic and notify no
one while looking wired — the exact silent-DLQ failure #629 exists to fix. Consider Annotations.of(this).addWarning(...) at synth when no subscription exists. (Fail-closed on bad email
input is already correctly handled — good.)

  1. Documentation

Complete and in sync. CEDAR_HITL_GATES.md §11.5 rewritten from "follow-up — deferred" to "shipped (#629)", the §17 future-work item struck through, and DEPLOYMENT_ROLES.md golden
baseline updated with both new statements. I regenerated the Starlight mirror locally and confirmed zero drift — Cedar-hitl-gates.md and Deployment-roles.md mirrors match their sources;
CI's "Fail build on mutation" will pass.

  1. Tests & CI

All 8 CI checks green (build incl. 3844 cdk tests, CodeQL ×3, secrets/deps, PR-lint). The pre-existing security:sast:masking noise is not run in CI here and, as the author notes, is
baseline (unrelated files).

Bootstrap synth-coverage: PASS / complete. This is the highest-risk area for this repo (ADR-002 / #350) and it's done right — BOOTSTRAP_VERSION 1.3.0→1.4.0 (correct minor bump for added
actions), regenerated application.json/observability.json/bootstrap-template.yaml, BOOTSTRAP_HASH matches the version.test.ts snapshot (verified: 4591304…d77d in both),
resource-action-map.ts registers AWS::SNS::Topic/Subscription/TopicPolicy and AWS::KMS::Key, and policies.test.ts adds strong per-verb guards. I verified the SNS resource pattern
arn:aws:sns:::backgroundagent-dev-* matches the auto-generated topic name (stack name defaults to backgroundagent-dev, main.ts:35).

Test gaps (from pr-test-analyzer, both worth adding):

  • The ABCA=operational-alerts tag on the key is never asserted on the construct that stamps it. policies.test.ts asserts the policy expects the tag, but nothing asserts the key carries
    it — the linchpin of the whole scoping model. Drop/rename the tag and every test stays green while production key-lifecycle silently breaks. Add hasResourceProperties('AWS::KMS::Key', {
    Tags: Match.arrayWith([{ Key: 'ABCA', Value: 'operational-alerts' }]) }).
  • The aws:SourceAccount confused-deputy condition is not asserted. operational-alerts.test.ts:72-81 matches only Principal+Action; deleting the conditions block keeps the test green
    (AI005 — tests what it does, not what it should). Add the condition to the objectLike.
  • Minor: the DenyInsecureTransport test omits Principal/Resource (a deny scoped to the wrong ARN would pass); and the 7 construct tests re-synth per-test rather than caching in beforeAll
    (cdk/AGENTS.md convention, #366) — 5 use the identical no-props config. No bundling is re-enabled (correct).
  1. Review agents run
  • security-review (IAM/KMS/SNS least-privilege, confused-deputy, fail-open) — ran. Surfaced N1 (its one MEDIUM), N2, N3, N4; cleared aws:SourceAccount sufficiency, the SNS action set,
    CreateKey-on-*, and dual removalPolicy.
  • pr-test-analyzer (coverage) — ran. Surfaced the three test gaps in §6.
  • comment-analyzer (comment accuracy) — ran. Verified all five load-bearing claims (CloudWatch-can't-use-alias/aws/sns; CMK satisfies SNS2 and SNS3 short-circuits; addAlarmAction on
    AlarmBase; atomic tag-at-create; Resource:'*' is key-local) against cdk-nag source and aws-cdk-lib type defs — all accurate. (Note the tension: comment-analyzer confirmed the
    AWS-behavior claims true, while security-review flagged the blast-radius claim as overstated — N1. Both hold: the mechanics are described correctly; the security guarantee is not.)
  • code-reviewer / silent-failure-hunter / type-design-analyzer — folded into the hand-review rather than run as separate agents: the diff is a single small construct + IAM/doc deltas
    already covered by security + comment + test agents. Error handling is one synth-time throw (fail-closed, tested); the one new type (OperationalAlertsProps) is minimal and
    well-documented. No separate findings.
  1. Human heuristics
  • Proportionality — Pass. A reusable ~172-line construct for a stack-wide channel is right-sized; not over-abstracted (it's genuinely shared by 3 alarms and designed for more).
  • Coherence — Pass. Belongs in cdk/src/constructs/; aws:SourceAccount pinning mirrors the lambda-microvm-compute.ts precedent; bootstrap changes follow the established split.
  • Clarity — Concern (observability.ts:158-166). Names and structure are excellent, but the KMS-lifecycle comment asserts a bound the unconditioned kms:TagResource doesn't deliver (N1).
    This is the one place a future reader is invited to trust something false.
  • Appropriateness — Pass. Maintainable; the load-bearing CMK rationale is documented so the next contributor won't "simplify" it back to alias/aws/sns. Verified against real cdk-nag/AWS
    behavior, not just self-written mocks.

@ayushtr-aws
ayushtr-aws added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 7a6d44b Aug 10, 2026
8 checks passed
@ayushtr-aws
ayushtr-aws deleted the feat/629-dlq-alarm-sns-notifications branch August 10, 2026 21:08
dreamorosi added a commit to dreamorosi/sample-autonomous-cloud-coding-agents that referenced this pull request Aug 28, 2026
…ples#665, model-config stack, DLQ alarms) into feat/645-lambda-microvm-p2

Upstream gained 18 commits across five overlapping areas: the standalone Agent
Registry (aws-samples#548 ADR-022, aws-samples#755, aws-samples#664, aws-samples#665), the ADR-019 tool Gateway (aws-samples#663,
aws-samples#755), the model-configuration stack (aws-samples#752 run.sh, aws-samples#753 docs, aws-samples#754 + aws-samples#768 Opus
5, aws-samples#763 budget docs, aws-samples#764 geo-configurable inference profiles), the Jira
orchestration work (aws-samples#725/aws-samples#726/aws-samples#727, aws-samples#710) and the OperationalAlerts SNS/KMS
channel (aws-samples#208, aws-samples#739). 26 files overlap this branch; 11 needed manual
resolution.

Bootstrap bundle: 1.4.0 -> 1.6.0
--------------------------------

Both sides bumped from the merge-base 1.3.0. Upstream took 1.4.0 (aws-samples#739: SNS
topic + customer-managed-KMS create/lifecycle for OperationalAlerts) and then
1.5.0 (aws-samples#664: Step Functions, Cognito group, CloudFormation nested-stack actions
for the registry), so this branch's `MicrovmPassRoles` statement becomes 1.6.0
rather than re-using a published number — the version is an operator-visible
contract (`CDKToolkit`'s `BootstrapPolicyVersion` output) and the guidance we
ship is a `>=` check.

The policy sets are disjoint and unioned cleanly: theirs edited
`application.ts` / `infrastructure.ts` / `observability.ts`, ours only
`compute-lambda-microvm.ts`. `resource-action-map.ts` auto-merged (their
registry/SNS/KMS entries plus our `iam:PassRole` on `AWS::Lambda::MicrovmImage`
and `AWS::Lambda::NetworkConnector`). Artifacts regenerated with
`mise //cdk:bootstrap:generate` — never hand-edited — and re-run to confirm a
zero diff; new hash `d30eb8e6…`, snapshot updated to match.

Every operator-facing ">= 1.4.0" reference we wrote is now 1.6.0:
DEPLOYMENT_GUIDE.md, DEPLOYMENT_ROLES.md (whose "bootstrapped at 1.3.0 or
earlier" becomes "1.5.0 or earlier"), USER_GUIDE.md, ADR-021 (sub-decision 4 +
the parity table), the `lambda-microvm-compute.ts` synth warning,
`package-microvm-artifact.sh` (4 sites) and `cdk/AGENTS.md`. No test hardcodes
the number.

Geo resolver: our constant becomes a derived value
--------------------------------------------------

aws-samples#764 landed first with `resolveBedrockGeoRegion` + `BEDROCK_GEO_REGIONS` +
`GEO_PREFIX_RE`, and hardcoded the haiku literal a second time as
`` `${bedrockGeoRegion}.anthropic.claude-haiku-4-5-20251001-v1:0` ``. Adopted
their resolver shape and derived our haiku value through it, exactly as the
heads-up on this PR asked:

- `DEFAULT_HAIKU_MODEL_ID` (bare id) is kept and still spliced into
  `DEFAULT_BEDROCK_MODEL_IDS` alongside their new `anthropic.claude-opus-5`
  entry, so grant and delivery cannot drift.
- `DEFAULT_HAIKU_INFERENCE_PROFILE_ID` (a `us.`-baked const) is REPLACED by
  `haikuInferenceProfileId(geoRegion)`. A const could only ever carry one
  geography, which is the split aws-samples#764 exists to prevent.
- Both delivery sites call it with the same resolved geography: the AgentCore
  runtime env block, and the lambda-microvm `platform_config` block — the
  "third site" flagged on aws-samples#746. A geo change that missed the second would leave
  one substrate calling a profile its role does not grant.

aws-samples#768's Opus 5 default needs nothing from `platform_config`: it carries no main
model (that arrives per-task from the repo config), only the auxiliary haiku
id. aws-samples#752's run.sh fix is Docker-invocation-only and does not touch the
`platform_config` env installs in server.py.

Resolved manually
-----------------

- `cdk/src/bootstrap/version.ts` — union bump history, 1.6.0, with the reason
  it is not 1.4.0 recorded in the JSDoc.
- `cdk/src/constructs/bedrock-models.ts` — as above; their Opus 5 entry plus
  our constant in the model list, `haikuInferenceProfileId` seated after
  `resolveBedrockGeoRegion`.
- `cdk/src/stacks/agent.ts` — import unions `haikuInferenceProfileId` with
  their `resolveBedrockGeoRegion`; the runtime env var and our
  `agentPlatformConfig.anthropicDefaultHaikuModel` both derive from
  `bedrockGeoRegion`; their `agentRegistryId` prop sits alongside our
  `agentPlatformConfig` block on the TaskOrchestrator call.
- `cdk/src/constructs/task-orchestrator.ts` — `AGENT_REGISTRY_ID` and our
  `platform_config` env block are both emitted; disjoint keys.
- `agent/src/runner.py` + `agent/tests/test_runner.py` — both helpers land
  after `_resolve_setting_sources` in call order (`_log_claude_cli_version`
  then `_register_gateway_server`), both call sites survive, both test classes
  kept, import lists unioned.
- `docs/guides/DEPLOYMENT_GUIDE.md` — our "Lambda MicroVMs backend
  (experimental)" section and their "Optional Agent Registry" section are both
  additive under the same heading level; kept in that order.
- `cdk/bootstrap/{BOOTSTRAP_VERSION,BOOTSTRAP_HASH,bootstrap-template.yaml}`
  and `test/bootstrap/__snapshots__/version.test.ts.snap` — regenerated, not
  merged.
- The two Starlight mirrors that conflicted (`Per-repo-overrides.md`,
  `Deployment-guide.md`) were regenerated by `mise //docs:sync`, which is
  idempotent on a second run.

Auto-merged, verified by hand (no re-seating needed)
----------------------------------------------------

- `agent/src/server.py` — their `resolved_assets` threading (aws-samples#665) lands in
  `_extract_invocation_params` and `_run_task_background`, both of which the
  MicroVM `/run` hook already reuses; `_spawn_background` forwards `**params`,
  so registry assets reach the guest on this backend for free. Our review-wave
  changes (`_PayloadFetchError`, ARN pinning, the no-`platform_config` 400,
  control-char rejection) are in disjoint regions and their seam-guard tests
  still pass.
- `cdk/src/handlers/shared/orchestrator.ts` — `resolveRegistryAssets` and
  `resolved_assets` go onto the shared `agentPayload`, which the
  lambda-microvm strategy forwards verbatim (inline or via S3), so no strategy
  change was needed. `heartbeatLivenessApplies` / `buildComputeMetadata` /
  `reconcileMicrovmSubstrateState` untouched.
- `cdk/src/handlers/shared/types.ts` + `cli/src/types.ts` — their
  `resolved_assets` sits after `resolved_workflow`, our `agent_heartbeat_at`
  after `completed_at`, in the same order in both packages, so
  `check:types-sync` still matches exactly.
- `agent/README.md`, `docs/design/DEPLOYMENT_ROLES.md`,
  `docs/guides/USER_GUIDE.md` — prose additions in different sections.

Verified: `mise run build` and `mise run drift-prevention` exit 0 (4261 cdk +
768 cli + 1739 agent tests), `//cdk:eslint` and `//cli:eslint` produce no
changes, `//cdk:bootstrap:generate` and `//docs:sync` are both a zero diff on
re-run, link-check clean.
@scottschreckengaust scottschreckengaust added the v1 Version 1 label Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v1 Version 1

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(observability): wire SNS notification action to DLQ CloudWatch alarms

3 participants