For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace AWS SES outbound email with SendGrid (Web API v3) for noreply@mail.getorcha.com, remove outbound SES code/infra, and fix the hard-coded sender in the settings handler.
Architecture: A single side-effecting multimethod com.getorcha.email/send! dispatches on a :provider key (:sendgrid via the existing traced HTTP wrapper, :log for local/test). Provider, API key, and sender live in the existing :com.getorcha/notifications config map, gated by aero #profile. No new Integrant component. DNS and CDK changes are driver-only (live AWS).
Tech Stack: Clojure, aero config, hato (via com.getorcha.http.client), Integrant, AWS CDK (Python), Route53.
Spec: docs/superpowers/specs/2026-05-17-sendgrid-outbound-email-design.html
[Subagent-delegable], AWS-free, unit-testable. They can proceed in parallel with DNS propagation.[Driver-only] — live AWS, confirmations, sequencing judgment. The driver executes these personally.p=quarantine; pct=100 with strict alignment quarantines 100% of mail on any DKIM misconfig.Recommended order: Tasks 1–6 → Task 7 → Task 8 → gate → Tasks 9–12.
src/com/getorcha/email.clj — provider-dispatched send! (the only new source file; one responsibility).test/com/getorcha/email_test.clj — unit tests for both providers.infra/scripts/sendgrid-dns.sh — idempotent Route53 UPSERT change-batch for the management-account zone.resources/com/getorcha/config.edn — add :provider / :api-key to :com.getorcha/notifications.src/com/getorcha/notifications.clj — rewire -channel-notify! :email and notify-admins!.src/com/getorcha/app/http/settings/notifications.clj — drop hard-coded sender, rewire send-verification-email! + 2 callers.src/com/getorcha/aws.clj — delete build-ses-client, send-email!, :ses client wiring, SESv2 imports.test/com/getorcha/notifications_test.clj — remove the aws/build-ses-client redef.infra/stacks/foundation_stack.py — add SSM param tuple; remove VerifySendingDomain + its CfnOutput.infra/stacks/compute_stack.py — remove the SES ses:SendEmail/SendRawEmail policy statement.infra/scripts/update-secrets.sh — add sendgrid-api-key to the PARAMETERS array.infra/secrets — append the /v1-orcha/sendgrid-api-key=… line. Gitignored via infra/.gitignore.com.getorcha.email namespace [Subagent-delegable]Files:
Create: src/com/getorcha/email.clj
Test: test/com/getorcha/email_test.clj
Step 1: Write the failing tests
(ns com.getorcha.email-test
(:require
[clojure.test :refer [deftest is testing]]
[com.getorcha.email :as email]
[com.getorcha.http.client :as http]))
(deftest log-provider-does-not-send
(testing ":log provider returns without calling HTTP"
(let [called? (atom false)]
(with-redefs [http/request (fn [_] (reset! called? true) {:status 202})]
(email/send! {:provider :log :sender "noreply@mail.getorcha.com"}
{:to "user@example.com" :subject "Hi" :body "Body"}))
(is (false? @called?)))))
(deftest sendgrid-builds-v3-request
(testing ":sendgrid posts the v3 payload with Bearer auth"
(let [captured (atom nil)]
(with-redefs [http/request (fn [opts] (reset! captured opts) {:status 202 :body ""})]
(email/send! {:provider :sendgrid
:sender "noreply@mail.getorcha.com"
:api-key "SG.testkey"}
{:to "user@example.com" :subject "Subj" :body "Body"}))
(let [{:keys [url method headers content-type form-params]} @captured]
(is (= "https://api.sendgrid.com/v3/mail/send" url))
(is (= :post method))
(is (= "Bearer SG.testkey" (get headers "Authorization")))
(is (= :json content-type))
(is (= {:email "noreply@mail.getorcha.com"} (:from form-params)))
(is (= [{:to [{:email "user@example.com"}]}] (:personalizations form-params)))
(is (= [{:type "text/plain" :value "Body"}] (:content form-params)))
(is (= "Subj" (:subject form-params)))))))
(deftest sendgrid-accepts-vector-recipients
(testing ":sendgrid normalizes a vector of recipients"
(let [captured (atom nil)]
(with-redefs [http/request (fn [opts] (reset! captured opts) {:status 202})]
(email/send! {:provider :sendgrid :sender "noreply@mail.getorcha.com" :api-key "k"}
{:to ["a@example.com" "b@example.com"] :subject "S" :body "B"}))
(is (= [{:to [{:email "a@example.com"} {:email "b@example.com"}]}]
(:personalizations (:form-params @captured)))))))
(deftest sendgrid-throws-on-non-2xx
(testing ":sendgrid throws ex-info with status and body on failure"
(with-redefs [http/request (fn [_] {:status 401 :body "unauthorized"})]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo #"SendGrid send failed"
(email/send! {:provider :sendgrid :sender "x@mail.getorcha.com" :api-key "k"}
{:to "u@example.com" :subject "S" :body "B"}))))))
(deftest unknown-provider-throws
(testing "unconfigured provider fails loudly"
(is (thrown? clojure.lang.ExceptionInfo
(email/send! {:provider :carrier-pigeon}
{:to "u@example.com" :subject "S" :body "B"})))))
Run: clj -X:test:silent :nses '[com.getorcha.email-test]' 2>&1 | grep -E "(FAIL|ERROR|Execution error|Ran .* tests)"
Expected: failure — namespace com.getorcha.email does not exist.
(ns com.getorcha.email
"Outbound email. Provider-dispatched side-effecting send.
`send!` takes the notifications config map (carrying `:provider`,
`:sender`, and for SendGrid `:api-key`) and a message map
`{:to :subject :body}`. `:to` is a string or a vector of strings.
Providers:
:sendgrid - SendGrid Web API v3 over the traced HTTP client.
:log - logs instead of sending (local/test).
Provider selection is config, not lifecycle — there is no component."
(:require
[clojure.tools.logging :as log]
[com.getorcha.http.client :as http]))
(defmulti send!
"Sends a plain-text email. Dispatches on the config's `:provider`.
Throws `ex-info` on delivery failure or unknown provider."
(fn [{:keys [provider] :as _email-config} _message] provider))
(defmethod send! :log
[{:keys [sender] :as _email-config} {:keys [to subject body] :as _message}]
(log/info "[LOCAL DEV] Would send email"
{:from sender :to to :subject subject :body body})
nil)
(defmethod send! :sendgrid
[{:keys [api-key sender] :as _email-config} {:keys [to subject body] :as _message}]
(let [recipients (if (string? to) [to] to)
payload {:personalizations [{:to (mapv (fn [addr] {:email addr}) recipients)}]
:from {:email sender}
:subject subject
:content [{:type "text/plain" :value body}]}
{:keys [status body] :as _response}
(http/request {:url "https://api.sendgrid.com/v3/mail/send"
:method :post
:headers {"Authorization" (str "Bearer " api-key)}
:content-type :json
:form-params payload})]
(when-not (<= 200 status 299)
(throw (ex-info "SendGrid send failed" {:status status :body body})))
nil))
(defmethod send! :default
[{:keys [provider] :as _email-config} _message]
(throw (ex-info "Unknown email provider" {:provider provider})))
Run: clj -X:test:silent :nses '[com.getorcha.email-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran .* tests)"
Expected: Ran 5 tests with 0 failures, 0 errors.
Run: clj-kondo --lint src/com/getorcha/email.clj test/com/getorcha/email_test.clj
Expected: linting took ... with no errors/warnings/info. Fix any reported, however small.
git add orcha/src/com/getorcha/email.clj orcha/test/com/getorcha/email_test.clj
git commit -m "feat(email): add provider-dispatched send! (sendgrid, log)"
[Subagent-delegable]Files:
Modify: resources/com/getorcha/config.edn:81-86
Step 1: Add :provider and :api-key to the notifications map
Replace lines 81–86 (the :com.getorcha/notifications map) with:
:com.getorcha/notifications
{:sender "noreply@mail.getorcha.com"
:provider #profile {#{:local-dev :demo :test} :log
:default :sendgrid}
:api-key #profile {#{:local-dev :demo :test} nil
:default #orcha/param "/v1-orcha/sendgrid-api-key"}
:admin {:email "admin-prod@getorcha.com"
:slack #profile {:local nil
:test nil
:default "https://hooks.slack.com/services/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"}}}
Rationale: #profile with a set key and nested #orcha/param is an existing pattern (config.edn:50-53). Test fixtures run profile :test (test/com/getorcha/test/fixtures.clj:78), so :test must resolve :provider to :log and :api-key to nil — the unselected #profile branch's #orcha/param is never evaluated by aero, so tests need no SSM.
Run: clj -X:test:silent :nses '[com.getorcha.notifications-test]' 2>&1 | grep -E "(FAIL|ERROR|Execution error|Ran .* tests)"
Expected: it still runs (failures from Task 3 not yet applied are acceptable here ONLY if pre-existing; if this is run before Task 3, expect existing tests to pass since config shape is additive). If config fails to parse, aero will throw at load — fix the EDN.
git add orcha/resources/com/getorcha/config.edn
git commit -m "feat(email): add provider/api-key to notifications config"
[Subagent-delegable]Files:
Modify: src/com/getorcha/notifications.clj:37-63
Modify: test/com/getorcha/notifications_test.clj:36 (remove obsolete redef)
Step 1: Update the existing test to drop the SES redef
In test/com/getorcha/notifications_test.clj, the test test-notify-dispatches-to-email-channel wraps the body in (with-redefs [aws/build-ses-client (constantly nil)] ...). Remove that with-redefs form, keeping its body. The :test profile now selects :provider :log, so the email channel logs and reports :sent without SES. Also remove the now-unused [com.getorcha.aws :as aws] from the test ns :require only if no other test in the file uses aws/... (grep first: grep -n "aws/" test/com/getorcha/notifications_test.clj).
Run: clj -X:test:silent :nses '[com.getorcha.notifications-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran .* tests)"
Expected: FAIL/ERROR — -channel-notify! :email still calls aws/send-email! / get-in aws [:clients :ses], which no longer matches the :log path.
-channel-notify! :emailReplace notifications.clj:37-63 with:
(defmethod -channel-notify! :email
[{:notification-channel/keys [id]
:notification-channel-email/keys [email-address]
:as _channel}
{:keys [title body] :as _message}
{:keys [notifications] :as _context}]
(try
(email/send! notifications {:to email-address :subject title :body body})
(log/info "Sent email notification" {:channel-id id :to email-address})
{:status :sent}
(catch Exception e
(log/error e "Failed to send email notification"
{:channel-id id :to email-address})
{:status :failed :error (.getMessage e)})))
Add [com.getorcha.email :as email] to the notifications.clj ns :require (alphabetical order). Verify [clojure.tools.logging :as log] is already required (it is — used elsewhere in the file).
Run: clj -X:test:silent :nses '[com.getorcha.notifications-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran .* tests)"
Expected: Ran N tests, 0 failures, 0 errors.
Run: clj-kondo --lint src/com/getorcha/notifications.clj test/com/getorcha/notifications_test.clj
Expected: clean.
git add orcha/src/com/getorcha/notifications.clj orcha/test/com/getorcha/notifications_test.clj
git commit -m "feat(email): route user email channel through email/send!"
[Subagent-delegable]Files:
Modify: src/com/getorcha/notifications.clj:202-231
Step 1: Rewrite the admin email branch
In notify-admins!, change the context destructure from {:keys [aws db-pool notifications] :as _context} to {:keys [db-pool notifications] :as _context} (drop aws — verify no other aws use remains in notify-admins! with grep -n "aws" src/com/getorcha/notifications.clj scoped to the function body lines 202–260).
Replace the email block (lines 223–231, the (when (and email (not= organization-slug "orcha")) ...) form) with:
(when (and email (not= organization-slug "orcha"))
(try
(email/send! notifications {:to email :subject admin-title :body admin-body})
(catch Exception e
(log/warn e "Failed to send admin email notification" {:to email}))))
Leave the Slack block (line 232 onward) unchanged.
Run: clj -X:test:silent :nses '[com.getorcha.notifications-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran .* tests)"
Expected: 0 failures, 0 errors.
Run: clj-kondo --lint src/com/getorcha/notifications.clj
Expected: clean (no unused-binding warning for aws).
git add orcha/src/com/getorcha/notifications.clj
git commit -m "feat(email): route admin email through email/send!"
[Subagent-delegable]Files:
Modify: src/com/getorcha/app/http/settings/notifications.clj:220 (delete const), :352-366 (signature), :377 & :432 (add-email caller), :500 & :539-540 (resend-email caller)
Step 1: Delete the hard-coded sender constant
Remove line 220 entirely:
(def ^:private sender-email "noreply@mail.getorcha.com")
send-verification-email!Replace notifications.clj (settings) lines 352–366 with:
(defn ^:private send-verification-email!
"Sends the channel verification email via the configured provider."
[notifications base-url email-address token]
(let [verify-url (str base-url "/settings/notifications/email/verify?token=" token)
subject "Verify your notification email"
body (str "Click the link below to verify your email address for Orcha notifications:\n\n"
verify-url "\n\n"
"This link expires in 24 hours.\n\n"
"If you didn't request this, you can ignore this email.")]
(email/send! notifications {:to email-address :subject subject :body body})))
Add [com.getorcha.email :as email] to this ns's :require (alphabetical). Remove [com.getorcha.aws :as aws] from the :require only if no other aws/ usage remains in the file (grep -n "aws/" src/com/getorcha/app/http/settings/notifications.clj; the (get-in aws [:clients :ses]) callers are removed in steps 3–4 — if aws/ count reaches 0, drop the require; otherwise keep it).
add-email callerIn add-email (starts ~line 373), change the request destructure {:keys [aws db-pool form-params providers ::reitit/router] :as request} to {:keys [notifications db-pool form-params providers ::reitit/router] :as request} (replace aws with notifications; aws is only used at the old line 432).
Change the call at line 432 from:
(send-verification-email! (get-in aws [:clients :ses]) base-url email-address token)
to:
(send-verification-email! notifications base-url email-address token)
resend-email callerIn resend-email (starts ~line 497), change destructure {:keys [aws db-pool identity path-params providers ::reitit/router] :as request} to {:keys [notifications db-pool identity path-params providers ::reitit/router] :as request}.
Change lines 539–540 from:
(send-verification-email! (get-in aws [:clients :ses]) base-url
(:notification-channel-email/email-address channel) token)
to:
(send-verification-email! notifications base-url
(:notification-channel-email/email-address channel) token)
:notifications reaches these handlersThe HTTP app component injects :notifications #ref [:com.getorcha/notifications] (config.edn:152) alongside :providers (:153). add-email/resend-email already destructure providers from the request, so notifications arrives by the same mechanism. Verify by reading the component at config.edn:147-160 — both keys are siblings. No wiring change needed.
Run: clj -X:test:silent :nses '[com.getorcha.notifications-test]' 2>&1 | grep -E "(FAIL|ERROR|Ran .* tests)"
Run: clj-kondo --lint src/com/getorcha/app/http/settings/notifications.clj
Expected: tests 0 failures/errors; lint clean (no unused aws binding).
git add orcha/src/com/getorcha/app/http/settings/notifications.clj
git commit -m "feat(email): config-driven sender for verification email"
[Subagent-delegable]Files:
Modify: src/com/getorcha/aws.clj:26-28 (imports), :417-464 (build-ses-client, send-email!), :504 & :512 (client wiring)
Step 1: Find every remaining reference
Run: grep -rn "send-email!\|build-ses-client\|:clients :ses\|\\[:clients :ses\\]\|SesV2\|sesv2" src test --include='*.clj'
Expected after edits: zero matches outside deletions. Use this list to confirm nothing else depends on the SES send path (inbound uses S3/SQS, not the SESv2 client — do NOT touch the :ses-emails S3 bucket or any receiving code).
In aws.clj, delete lines 26–28:
(software.amazon.awssdk.services.sesv2 SesV2Client)
(software.amazon.awssdk.services.sesv2.model
Body Content Destination EmailContent SendEmailRequest)
build-ses-client and send-email!Delete the entire ;; SES section: aws.clj:417-464 (the ;; SES banner comment, build-ses-client, and send-email!), through to the blank lines before ;; Integrant Component.
In the AWS state init (around lines 498–512), delete the ses-fut binding (line 504: ses-fut (future (build-ses-client config))] → remove, and move the ] to close the let binding vector after kms-fut) and delete the :ses @ses-fut entry from the :clients map (line 512). Resulting :clients map ends at :kms @kms-fut.
Run: clj -X:test:silent 2>&1 | grep -A 5 -E "(FAIL in|ERROR in|Execution error|Ran .* tests)"
Expected: 0 failures, 0 errors.
Run: clj-kondo --lint src test dev
Expected: clean. Fix every issue, including info-level.
git add orcha/src/com/getorcha/aws.clj
git commit -m "refactor(email): remove outbound SES client and send-email!"
[Driver-only]Files: none (read-only AWS).
Run (management account profile):
aws route53 list-resource-record-sets --hosted-zone-id Z02414383CQNYTPGX2EIK \
--profile orcha --output json > /tmp/getorcha-zone.json
Inspect /tmp/getorcha-zone.json for: an existing _dmarc.mail.getorcha.com TXT, existing s1._domainkey.mail.getorcha.com / s2._domainkey.mail.getorcha.com, and any record named em8281.mail.getorcha.com, url5843.mail.getorcha.com, or 107596766.mail.getorcha.com. SES Easy-DKIM uses token selectors (not s1/s2), so no DKIM collision is expected.
If a _dmarc.mail.getorcha.com TXT already exists, STOP and report to the user for reconciliation (do not overwrite — only one DMARC record per name is valid). If no conflicts, record "clear" and proceed to Task 8. Existing mail.getorcha.com SPF/inbound records are left untouched.
[Driver-only]Files:
Create: infra/scripts/sendgrid-dns.sh
Step 1: Write the idempotent change-batch script
Create infra/scripts/sendgrid-dns.sh:
#!/usr/bin/env bash
# Idempotent UPSERT of SendGrid (mail.getorcha.com) auth records into the
# management-account root getorcha.com zone. Records sourced from SendGrid
# domain authentication configured for mail.getorcha.com (2026-05-17).
set -euo pipefail
ZONE_ID=Z02414383CQNYTPGX2EIK
PROFILE=orcha
aws route53 change-resource-record-sets --hosted-zone-id "$ZONE_ID" --profile "$PROFILE" \
--change-batch '{
"Comment": "SendGrid domain auth for mail.getorcha.com",
"Changes": [
{"Action":"UPSERT","ResourceRecordSet":{"Name":"url5843.mail.getorcha.com","Type":"CNAME","TTL":300,"ResourceRecords":[{"Value":"sendgrid.net"}]}},
{"Action":"UPSERT","ResourceRecordSet":{"Name":"107596766.mail.getorcha.com","Type":"CNAME","TTL":300,"ResourceRecords":[{"Value":"sendgrid.net"}]}},
{"Action":"UPSERT","ResourceRecordSet":{"Name":"em8281.mail.getorcha.com","Type":"CNAME","TTL":300,"ResourceRecords":[{"Value":"u107596766.wl017.sendgrid.net"}]}},
{"Action":"UPSERT","ResourceRecordSet":{"Name":"s1._domainkey.mail.getorcha.com","Type":"CNAME","TTL":300,"ResourceRecords":[{"Value":"s1.domainkey.u107596766.wl017.sendgrid.net"}]}},
{"Action":"UPSERT","ResourceRecordSet":{"Name":"s2._domainkey.mail.getorcha.com","Type":"CNAME","TTL":300,"ResourceRecords":[{"Value":"s2.domainkey.u107596766.wl017.sendgrid.net"}]}},
{"Action":"UPSERT","ResourceRecordSet":{"Name":"_dmarc.mail.getorcha.com","Type":"TXT","TTL":300,"ResourceRecords":[{"Value":"\"v=DMARC1; p=quarantine; rua=mailto:max@getorcha.com; adkim=s; aspf=s; pct=100\""}]}}
]
}'
chmod +x infra/scripts/sendgrid-dns.sh.
The driver presents the change-batch and the Task 7 pre-flight result to the user and gets explicit confirmation BEFORE running the mutating command. This is a stateful Route53 change in the management account — not CDK, no drift (the root zone is in no stack), but state-changing.
Run: infra/scripts/sendgrid-dns.sh
Expected: a ChangeInfo JSON with "Status": "PENDING".
git add orcha/infra/scripts/sendgrid-dns.sh
git commit -m "infra: SendGrid mail.getorcha.com DNS change-batch script"
After propagation, the user confirms the SendGrid dashboard shows the mail.getorcha.com domain authenticated/verified. Do NOT proceed to Tasks 9–12 until this passes.
[Driver-only]This task only edits CDK source; nothing is deployed until Task 10.
Files:
Modify: infra/stacks/foundation_stack.py (params list, closing ] ~:825; remove VerifySendingDomain ~:600-642)
Modify: infra/stacks/compute_stack.py:267-275
Modify: infra/scripts/update-secrets.sh (PARAMETERS array ~:51)
Step 1: Add the SSM parameter placeholder
In foundation_stack.py, add this tuple to the params list (after the google-drive-* entries, before the closing ] ~:825):
("sendgrid-api-key", "SendgridApiKey", "SendGrid API key for outbound email"),
The existing loop creates it as a plain ssm.StringParameter placeholder (PLACEHOLDER_UPDATE_ME), identical to every sibling secret. No new IAM read grant is needed: compute_stack.py:314-321 already grants ssm:GetParameter on parameter/v1-orcha/*.
update-secrets.shIn infra/scripts/update-secrets.sh, add to the PARAMETERS array (after the google-drive-api-key line ~:51) so --from-file/--verify/--list know it and it gets a description:
"sendgrid-api-key:SendGrid API key for outbound email"
VerifySendingDomainIn foundation_stack.py, delete the SES Sending Domain block: the comment banner (:600-606), :608), the entire sending_domain = "mail.getorcha.com" (cr.AwsCustomResource(self, "VerifySendingDomain", ...) form (:612-635), and the :637-642).CfnOutput(self, "SesSendingDomain", ...) (
In compute_stack.py, delete lines 267–275 (the # SES Policy (send emails for notifications) comment and the service_role.add_to_policy(iam.PolicyStatement(actions=["ses:SendEmail","ses:SendRawEmail"], ...)) form).
Run: cd infra && cdk diff (prod context).
Expected: (a) a new SSM parameter /v1-orcha/sendgrid-api-key (value PLACEHOLDER_UPDATE_ME), (b) removal of the VerifySendingDomain custom resource, (c) removal of the SES IAM statement from the instance role. Confirm the VerifySendingDomain removal is the only stateful deletion and that it targets the mail.getorcha.com sending identity (inbound uses the separate mail.{env}.getorcha.com identity — unaffected). Present the diff to the user before Task 10.
git add orcha/infra/stacks/foundation_stack.py orcha/infra/stacks/compute_stack.py orcha/infra/scripts/update-secrets.sh
git commit -m "infra: SSM placeholder for SendGrid key; remove outbound SES identity + IAM"
[Driver-only]Prerequisite: the Task 8 hard gate (SendGrid domain verified) has passed.
Files: none.
Present the Task 9 Step 5 diff to the user and get explicit confirmation. The deploy deletes the mail.getorcha.com SES sending identity (expected; inbound uses the separate mail.{env}.getorcha.com identity and is unaffected). Then run the project's standard prod CDK deploy (per infra/runbooks/deploy.md).
Confirm: the stack update completes; /v1-orcha/sendgrid-api-key exists (value still the placeholder); the SES IAM statement is gone from the instance role; inbound document-acquisition queues/receipt rules are untouched.
infra/secrets [Driver-only]The API key is created by the user in SendGrid (Settings → API Keys, scope Mail Send). SendGrid's onboarding gates this behind domain authentication, so the key only exists after DNS — which is fine: it is not needed until now, after the Task 8 gate and the Task 10 deploy.
Files:
Modify: infra/secrets — gitignored; NEVER commit it (infra/.gitignore ignores secrets).
Step 1: User adds the key to infra/secrets
The user appends one line to infra/secrets (real production secret; do not echo it into logs/transcripts):
/v1-orcha/sendgrid-api-key=<the SendGrid API key>
From infra/, run the established workflow:
./scripts/update-secrets.sh --from-file secrets
This writes every line as a SecureString with --overwrite to the prod account (AWS_PROFILE=orcha-prod, eu-central-1), overwriting the CDK placeholder. aws/get-parameter already passes WithDecryption=true, so the app reads it transparently.
Run: ./scripts/update-secrets.sh --verify
Expected: /v1-orcha/sendgrid-api-key listed under "Found parameters".
Accepted pattern, not new drift: every sibling secret follows this exact "CDK placeholder →
update-secrets.shoverwrites as SecureString" flow; a latercdk deployresets the value to the placeholder andupdate-secrets.shis re-run, perinfra/runbooks/update-secrets.mdanddeploy.md. This is the team's existing operational model, not a regression introduced here.
[Driver-only]Files: none.
Deploy the merged code (Tasks 1–6) through the normal app pipeline so prod runs with :provider :sendgrid.
Trigger a real outbound path (e.g., add an email notification channel in the prod app → verification email) to a Gmail inbox you control.
In Gmail, open the message → "Show original". Confirm:
DKIM: 'PASS' with domain mail.getorcha.comDMARC: 'PASS'SPF: pass on em8281.mail.getorcha.com (strict SPF won't align, which is fine — DMARC passes via aligned DKIM).If DKIM is not PASS with d=mail.getorcha.com, STOP — every prod email will be quarantined (p=quarantine; pct=100). Re-check the s1/s2 CNAMEs and SendGrid verification before leaving the system on SendGrid.
Send a non-verification notification (admin or user channel) and confirm delivery + that the channel records :sent.
Spec coverage:
infra/secrets.VerifySendingDomain + CfnOutput), applied in the Task 10 deploy.infra/secrets) → 12 (cutover); Task 8 Step 5 gate precedes all of them.infra/secrets (Task 11), plain-text body (Task 1 payload text/plain), no bounce handling (not in scope).Placeholder scan: No TBD/TODO; every code/command step shows concrete content. Line numbers are marked ~ where the file shifts under prior edits — intentional, not a placeholder.
Type consistency: send! signature (send! email-config message) is identical across Task 1 (def), Tasks 3–5 (callers). email-config is always the :com.getorcha/notifications map (carries :provider/:sender/:api-key). Message map keys {:to :subject :body} consistent everywhere. :provider values :sendgrid/:log match config (Task 2) and methods (Task 1).