Skip to content

fix(senderidentity): adopt provably-owned pre-tag identities, stop stranding the ledger - #908

Merged
jiashuoz merged 18 commits into
mainfrom
fix/sender-identity-adoption
Aug 19, 2026
Merged

fix(senderidentity): adopt provably-owned pre-tag identities, stop stranding the ledger#908
jiashuoz merged 18 commits into
mainfrom
fix/sender-identity-adoption

Conversation

@jiashuoz

Copy link
Copy Markdown
Member

Summary

v1.7.8 (#888) introduced an ownership tag (e2a-managed=sender-identity-v1) on every SES sending identity e2a creates, and started treating any identity lacking that tag as foreign (ErrIdentityNotOwned, never mutated). Every identity created before that release is untagged, so on upgrade Provision/Status hit an untagged GetEmailIdentity response, marked the domain sending_status=failed, and the ErrIdentityNotOwned handler unconditionally DELETEd the domain's row from sender_identity_managed_domains. The periodic reaper only iterates that ledger, so the domain was never automatically revisited again. This was confirmed in production: the ledger emptied and affected domains stayed failed indefinitely until rows were manually re-inserted.

This PR fixes both halves.

Part A — adoption. An untagged identity is now provably e2a's own, and gets tagged in place before proceeding normally, when all of the following hold:

  • e2a has DKIM key material on file for the domain (the caller's stored selector)
  • SES reports the identity as BYODKIM (DkimAttributes.SigningAttributesOrigin == EXTERNAL) — only e2a's own Provision ever supplies signing key material
  • the installed selector (DkimAttributes.Tokens) matches e2a's stored selector for that exact domain

Any other combination — no key material, an AWS-managed/Easy-DKIM identity, or a selector mismatch — still returns ErrIdentityNotOwned untouched, exactly as before adoption existed. This negative case is security-critical: a too-loose rule would let e2a silently take over a different application's SES identity in a shared AWS account — the exact scenario the tag was introduced to prevent for self-hosters. Adoption is permanent behavior (not a one-time migration flag), so it also self-heals a tag that was removed out-of-band, which the existing code comments already flagged as a concern.

Implemented in SESProvider (internal/senderidentity/ses.go):

  • canAdoptIdentity — the pure criteria check
  • adoptIdentity — calls TagResource against the identity's ARN, built from region + the AWS account ID (resolved once via STS GetCallerIdentity at provider construction, since neither GetEmailIdentity nor ListEmailIdentities returns an ARN)
  • Provider.Status widens to Status(ctx, domain, expectedSelector string) so it can make the same adoption judgement Provision does — the worker already holds the selector (state.Selector) at both call sites

Part B — stop deleting the ledger row for a live domain. ForgetSendingIdentityManaged (internal/identity/store.go) now leaves the row alone when the domain still exists in domains with a non-null user_id, expressed as a NOT EXISTS guard inside the DELETE itself rather than a check-then-act at the call site — WithSendingIdentityMutationLock (held by both ErrIdentityNotOwned handlers in worker.go) and the domain-delete transaction's advisory locks use different lock names and don't exclude each other, so a separate existence check beforehand could race a concurrent domain delete. A genuinely deleted domain's row is still removed exactly as before, via FinalizeSendingIdentityTombstone in the disjoint teardown branch (not touched by this change) — verified by both an existing DB test (TestManagedSendingIdentityLedgerSurvivesDomainDelete) and a new worker-level one.

Correcting the task description

The background brief said ErrIdentityNotOwned handling lives "around lines 283 and 597" of worker.go — the actual call sites (on origin/main at the time of this branch) are lines 294 and 603 (store.ForgetSendingIdentityManaged(...)), a few lines off but the same two handlers. Everything else in the brief (the tag constants, ErrIdentityNotOwned semantics, the Tokens field carrying the BYODKIM selector for an EXTERNAL-origin identity, the ARN shape arn:aws:ses:<region>:<account>:identity/<domain>) checked out against the code and the AWS SDK types as described.

Client surface checklist

Not applicable — this is internal SES-provisioning/reconciliation logic with no API, SDK, CLI, or MCP surface.

Operational risk

  • SESProvider now calls sts:GetCallerIdentity once at construction (NewSESProviderFromConfig) to build the ARN TagResource needs. This is an unauthenticated-by-IAM-policy STS action (no permission grant required) that every valid AWS principal can call, so this should not newly fail in any environment where SES itself already works — but it is a new failure mode on the sender-identity startup path (already log.Fatalf on any provider-construction error, unchanged behavior class).
  • Adoption calls a new AWS action, ses:TagResource, against a specific identity ARN. If the runtime IAM policy is scoped tightly (per the existing design doc's IAM guidance), it must already allow TagResource for the tagged-CreateEmailIdentity dependent-authorization requirement from the original v1.7.8 rollout — this PR doesn't need a new grant beyond what that doc already recommends.
  • No schema/migration changes. ForgetSendingIdentityManaged's query changes shape but stays a single indexed DELETE.
  • Rollback: revert-safe. Reverting drops back to today's behavior (no adoption, unconditional ledger delete on ownership failure) — no new state is written that the old binary can't handle.

Test plan

  • go build ./...
  • go test ./internal/senderidentity/... ./internal/identity/... — all pass, including new adoption + ledger-retention tests
  • go test ./... — full suite green (EXIT:0, no FAIL)
  • go vet ./... — clean except pre-existing, unrelated findings in internal/agent/*_test.go (confirmed identical on a clean origin/main checkout, not touched by this PR)

New tests (table-driven, following internal/senderidentity/*_test.go conventions):

  • TestCanAdoptIdentity — the adoption criteria truth table
  • TestSESProvider_ProvisionAdoptsProvablyOwnIdentity / _StatusAdoptsProvablyOwnIdentity — matching selector + EXTERNAL origin adopts, tags, and proceeds normally
  • TestSESProvider_ProvisionRefusesMismatchedSelector / _StatusRefusesMismatchedSelector — the security-critical negative: mismatched selector stays ErrIdentityNotOwned, no tag applied, no mutation
  • TestSESProvider_ProvisionRefusesAWSManagedDkimOrigin — non-EXTERNAL origin stays refused even with a coincidentally matching token
  • TestSESProvider_ProvisionAlreadyTaggedDoesNotRetag — no redundant TagResource call
  • TestSESProvider_ProvisionAdoptionTagFailurePropagates — a transient TagResource error propagates for retry rather than falling back to a false not-owned
  • TestReconcileWorker_NotOwnedRetainsLedgerForLiveDomain / TestSyncWorker_NotOwnedRetainsLedgerForLiveDomain — ledger row survives an ownership failure on a live domain
  • TestReapWorker_GenuineDeleteStillFinalizesTombstone — genuinely deleted domain's tombstone still finalizes
  • TestForgetSendingIdentityManagedRetainsLedgerForLiveOwnedDomain (DB-backed, internal/identity) — the store-level guard directly

🤖 Generated with Claude Code

Jace and others added 8 commits August 18, 2026 16:26
…randing the ledger

v1.7.8 (#888) started tagging every SES identity e2a creates with
e2a-managed=sender-identity-v1 and treating any untagged identity as
foreign (ErrIdentityNotOwned). Every identity created before that
release is untagged, so on upgrade Provision/Status hit an untagged
GetEmailIdentity response, marked the domain sending_status=failed,
and the ErrIdentityNotOwned handler unconditionally deleted the
domain's row from sender_identity_managed_domains. Since the periodic
reaper only iterates that ledger, the domain was never automatically
revisited again — confirmed in production, where the ledger emptied
and affected domains stayed failed indefinitely until rows were
manually re-inserted.

Part A: adoption. An untagged identity is now provably e2a's own, and
gets tagged in place before proceeding normally, when ALL hold:
  - e2a has DKIM key material on file for the domain (the caller's
    stored selector)
  - SES reports the identity as BYODKIM (DkimAttributes.
    SigningAttributesOrigin == EXTERNAL) — only e2a's own Provision
    ever supplies signing key material
  - the installed selector (DkimAttributes.Tokens) matches e2a's
    stored selector for that exact domain
Any other combination (no key material, an AWS-managed/Easy-DKIM
identity, or a selector mismatch) still returns ErrIdentityNotOwned
untouched — this is the security-critical negative case: a too-loose
rule would let e2a silently take over a different application's SES
identity in a shared AWS account, the exact scenario the tag exists to
prevent. Adoption is permanent behavior (not a one-time migration
flag), so it also self-heals a tag removed out-of-band. Implemented in
SESProvider (internal/senderidentity/ses.go): canAdoptIdentity is the
pure criteria check, adoptIdentity calls TagResource against the
identity's ARN (built from region + the AWS account ID, resolved once
via STS GetCallerIdentity since no SES read API returns one). The
Provider.Status signature widens to take the caller's expected
selector so it can make the same judgement Provision does.

Part B: stop deleting the ledger row for a live domain.
ForgetSendingIdentityManaged (internal/identity/store.go) now leaves
the row alone when the domain still exists in `domains` with a
non-null user_id — expressed as a NOT EXISTS guard inside the DELETE
itself (not a check-then-act at the call site) because the mutation
lock and the domain-delete transaction's advisory locks use different
lock names and don't exclude each other. A genuinely deleted domain's
row is still removed exactly as before, via
FinalizeSendingIdentityTombstone in the disjoint teardown branch,
which this change does not touch.

Tests: canAdoptIdentity truth table; Provision/Status adopt-and-proceed
on a matching selector; both still refuse a mismatched selector and an
AWS-managed DKIM origin with no tag applied; an already-tagged identity
is not retagged; a transient TagResource error propagates for retry
instead of a false not-owned; a live owned domain's ledger row survives
an ownership failure; a genuinely deleted domain's tombstone still
finalizes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…onfig

canAdoptIdentity now refuses adoption when the identity carries a
ConfigurationSetName or any Policies. Both are configuration e2a's own
Provision never writes, so their presence is evidence the identity is
doing something e2a didn't ask for, and adoption previously left them
in place:

- ConfigurationSetName controls where SES routes delivery/bounce/
  complaint feedback (including recipient addresses) for sends through
  this identity. When delivery_feedback.ses_configuration_set is unset
  (self-hosts, e2a's own staging), SES falls back to the identity's
  own default config set, so adoption would silently hand that
  feedback to whoever owns the set.
- An identity policy (e.g. granting ses:SendRawEmail to another AWS
  account) would survive adoption and keep applying.

Addresses review finding 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two independent tightenings to canAdoptIdentity's criteria (finding 2
and finding 3 from review):

Finding 2 — canAdoptIdentity's doc claimed "expectedSelector non-empty"
meant "e2a has DKIM key material on file", but LoadSendingIdentityState
can return a non-empty selector with a nil/empty private key (e.g. mid
a domain reclaim). Adopting on the selector alone would tag an identity
e2a cannot actually sign for — and the no-key branch in worker.go would
then find it tagged and let Deprovision actually delete it, where
pre-adoption behavior refused that delete. canAdoptIdentity now takes
an explicit haveKeyMaterial bool and requires it. Provision passes
len(dkimPrivateKeyDER) > 0 directly (it already has the real bytes).
Status's own interface has no key bytes, so Provider.Status widens to
take haveKeyMaterial too; both worker.go call sites now pass
len(state.PrivateKey) > 0.

Finding 3 — the existing criteria (EXTERNAL origin + selector token
match) are both publicly-derivable from OSS source (dkim.SelectorForNow
is a fixed monthly convention), so the match alone has no entropy.
canAdoptIdentity now also requires DkimAttributes.Status == SUCCESS,
which SES only reports once it has cryptographically matched the key
material IT holds against the DNS TXT at
<selector>._domainkey.<domain> — no new provider call needed (GetEmailIdentity
already returns it). Chose strict SUCCESS with no PENDING fallback,
documented in canAdoptIdentity's doc comment: every domain this feature
exists to unstrand was already at e2a's own sending_status=verified
before the ownership-tag regression (which itself required DKIM SUCCESS
under the all-or-nothing rollup), so the incident population is SUCCESS
by construction. A DNS-comparing follow-up in the provider layer is
noted as a possible future hardening, not required here.

Also documents canAdoptIdentity's reachability bound: adoption is only
ever attempted for a domain whose ownership TXT + apex MX e2a's own
verification flow already confirmed via live DNS probe
(domains.verified=true gates every call site in worker.go).

Tests: TestCanAdoptIdentity's truth table gains haveKeyMaterial and two
new negative rows (no key material, DKIM pending/failed);
TestSESProvider_StatusRefusesAdoptionWithoutKeyMaterial and
TestSESProvider_StatusRefusesAdoptionWhileDkimPending pin the two
findings directly against Status().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NewSESProviderFromConfig called sts:GetCallerIdentity synchronously at
construction with context.Background() (no deadline), and
cmd/e2a/main.go wraps any provider-construction error in log.Fatalf. An
STS blip, an egress allowlist covering only SES, an SCP deny, or an
IMDS hiccup then took the whole API down — for a value only adoption's
TagResource call needs (the identity ARN's account-id segment).

The account id is now resolved lazily via sync.Once on first adoption
attempt (accountIDForAdoption), and a resolution failure is non-fatal:
it degrades adoptIdentity to a wrapped ErrIdentityNotOwned ("cannot
adopt"), exactly the pre-adoption-PR behavior, while every other
Provider capability (Provision/Status/Deprovision/List for
already-owned identities) keeps working. NewSESProvider's explicit-
accountID constructor path is unaffected — it never touches STS at all.

Also: callerIdentity.Account == nil is now treated as an error
(accountIDFromCallerIdentity) instead of silently producing
arn:aws:ses:<region>::identity/<domain>.

Folds in the "successful adoption" half of finding 8 (observability):
adoptIdentity now logs on a successful TagResource.

Addresses review finding 4.

Tests: TestAccountIDFromCallerIdentity covers the nil-output/nil-Account
cases; TestSESProvider_AdoptionDegradesWhenAccountIDUnavailable proves
the degrade-to-ErrIdentityNotOwned behavior and that resolveAccountID
is invoked at most once across repeated adoption attempts;
TestSESProvider_NewSESProviderNeverResolvesAccountID pins the
explicit-accountID path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adoptIdentity returned raw AWS TagResource errors, and both call sites
(Provision, Status) commented "transient/permission — retry". For a
permanent error — AccessDeniedException (unmodeled by the SES v2 SDK;
IAM denials arrive generically, so it's matched via smithy's
APIError.ErrorCode() rather than a concrete exception type),
BadRequestException (invalid ARN), or NotFoundException — that never
succeeds on retry: it made the reaper return an error on EVERY hourly
sweep forever (never resolving, never flagged prominently), and made
the reconcile path burn its whole attempt budget before mislabeling
the domain "verification timed out" — sending operators toward DNS
instead of the real IAM problem.

classifyAdoptionError now maps those three into a double-wrapped
ErrIdentityNotOwned (the original error stays reachable via errors.Is
for diagnostics) and leaves everything else — throttling, 5xx, network
— untouched for the caller to retry. Both Provision's and Status's
adoption path already treat ErrIdentityNotOwned as the normal "not
adoptable" outcome (fail closed once, mark failed, no infinite
retry-burning), so this composes with the existing control flow rather
than adding a new one.

Verified against the live prod IAM policy (2026-08): ses:TagResource
IS allowed for e2a.dev's own runtime principal, so the catastrophic
every-sweep-red variant isn't active in production today — but a
self-hoster following the design doc's narrower, conditioned grant
would hit exactly this.

Addresses review finding 5.

Tests: TestClassifyAdoptionError covers all three permanent codes plus
two must-not-classify cases (throttling, an arbitrary error);
TestSESProvider_ProvisionAdoptionAccessDeniedRefusesNotRetries proves
the end-to-end wiring through Provision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes both store.ForgetSendingIdentityManaged calls from worker.go's
ErrIdentityNotOwned handlers (reconcileProviderIdentity and
syncProviderIdentityWithInspection's Provision branch). The in-query
NOT EXISTS guard added by the base PR does not close the race the
commit message claimed: it evaluates against the DELETING
transaction's own snapshot, so if a concurrent domain-delete commits
first, the guard still passes and Forget deletes the tombstone —
orphaning the SES identity forever (there is no other durable handle
back to it). An ownership failure is never evidence of teardown.

Deleting these two calls is a simplification, not a new mechanism:
FinalizeSendingIdentityTombstone (drain-window guarded, called only
from the genuine-teardown branch of syncProviderIdentityWithInspection)
is now the sole ledger-deletion path. The NOT EXISTS guard stays in
ForgetSendingIdentityManaged's SQL as defense-in-depth — the method
itself is still correct and still used nowhere in production code
after this change, which is fine; removing the method entirely is out
of scope for this fix.

Folds in the "refused for a live domain" half of finding 8
(observability): both handlers now log an ALERT-convention line
(matching reaper.go's style) before returning — otherwise a refused
adoption on a live domain is a silent unbounded hourly retry (~3 SES
calls/domain/hour forever, invisible until someone reads recordReaperError's
own ALERT, which this path never reaches since it doesn't propagate an
error).

Removes TestReconcileWorker_NotOwnedForgetFailureStillFires and
TestSyncWorker_NotOwnedForgetFailureStillFires, which pinned the
now-removed "Forget failure must propagate" behavior. Strengthens the
two retained TestXWorker_NotOwnedRetainsLedgerForLiveDomain tests by
poisoning store.forgetErr: Work succeeding now proves Forget is never
even invoked on this path, not just that its failure doesn't leak.

Addresses review finding 6 and half of finding 8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mains

FinalizeSendingIdentityTombstone only ever age-gated its DELETE
(updated_at older than olderThan). "Never forget a live owned domain's
row" was therefore only a property of ForgetSendingIdentityManaged,
not a store-wide invariant — this method is the disjoint teardown
branch's deletion path and had no equivalent guard.

Concrete gap: a verified, live, owned domain with NULL
dkim_selector/dkim_private_key hits the no-key branch of
syncProviderIdentityWithInspection, which calls this method under the
reaper's finalizeDeletion=true. Once the row aged past the drain
window, it was deleted even though the domain is still live —
stranding it exactly like the original ForgetSendingIdentityManaged
incident, just reached via a different trigger (missing key material
instead of a missing ownership tag).

Adds the same NOT EXISTS (domains WHERE user_id IS NOT NULL) guard
ForgetSendingIdentityManaged already uses. A domain torn down for real
(DELETE FROM domains) satisfies NOT EXISTS immediately, so this is a
tightening for genuine teardown, not a behavior change — confirmed by
the existing TestSendingIdentityTombstoneFinalizeRespectsAge, updated
to delete the domain row first so it isolates the age gate as before.

Mirrors the same guard in the in-memory fakeStore
(internal/senderidentity/fakestore_test.go) used by worker-level tests.

Addresses review finding 7.

Tests: TestFinalizeSendingIdentityTombstoneRetainsLedgerForLiveOwnedDomain
(DB-backed, internal/identity) pins the guard directly at the store
layer. TestReapWorker_NoKeyMaterialLiveDomainRetainsLedger
(internal/senderidentity) reproduces the concrete gap end-to-end
through the reaper's no-key branch — verified to fail without the
fakeStore guard and pass with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four corrections flagged as a merge blocker by the adversarial reviewer:

1. The amendment claimed "Steps 4, 6, and 7 (IAM hardening) are
   unaffected." Wrong in the way that matters: the doc justified the
   aws:ResourceTag IAM condition as closing a race "that a client-side
   ownership check cannot close" — but adoption now writes that tag
   itself, so the two controls are no longer independent the way the
   original phrasing implied. Rewritten to explain precisely what IAM
   still does (stop an untrusted OTHER principal) and what it no
   longer independently catches (a bug in canAdoptIdentity itself).
   Steps 6/7 (blue/green choreography) really are unaffected.

2. The normative bullet (~:64-70) said untagged identities "will
   neither update nor delete" — added "unless provably e2a's own (see
   canAdoptIdentity)".

3. Documented the domain-verification gate as the real bound on
   attacker-controlled input: adoption is only reachable once
   domains.verified=true, which requires a live DNS probe of BOTH the
   ownership TXT and an apex MX pointing at the relay — no customer can
   aim adoption at a domain they don't control. Added to both the
   amendment and canAdoptIdentity's doc comment in ses.go (prior
   commit) — it was load-bearing and undocumented before this.

4. Restated the existing "do not run two e2a installs sharing this tag
   value in one AWS account/region" constraint inside the amendment
   itself: adoption makes violating it silent (no error either side)
   and escalates it (an adopted identity becomes deletable by
   Deprovision where it was previously flatly protected).

Also brought the amendment's adoption-criteria summary and the
Verification section up to date with this batch's additions (foreign
config refusal, real key material, DKIM SUCCESS, permanent-error
classification, lazy/non-fatal STS, observability logging), and added
a Deferred entry for the DNS-comparing hardening noted as a possible
follow-up rather than required now.

Addresses review finding 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jiashuoz

Copy link
Copy Markdown
Member Author

Batch A revision — merge-blocking findings addressed

Pushed 7 commits (258c3fc8741adef71d) addressing all 9 findings from review. Each item below maps to the corresponding finding number.

1. Refuse adoption of identities carrying foreign configuration (084dbbd35)
canAdoptIdentity now refuses adoption when the identity has a non-nil ConfigurationSetName or any Policies — either means the identity is doing something e2a never asked for, and adoption would otherwise silently keep it (feedback routing to a foreign config set, or a cross-account send policy).

2+3. Require real key material and DKIM SUCCESS to adopt (af8e342c1)

  • canAdoptIdentity now takes an explicit haveKeyMaterial bool and requires it — a stored selector is not proof of key material (LoadSendingIdentityState can return one with a nil private key, e.g. mid domain-reclaim). Provider.Status widened to accept this; both worker.go call sites pass len(state.PrivateKey) > 0.
  • Added DkimAttributes.Status == SUCCESS as a required criterion — the existing checks (EXTERNAL origin + selector token) are both publicly-derivable OSS constants with zero entropy. Chose strict SUCCESS, no PENDING fallback (documented in canAdoptIdentity's doc comment): every domain this feature exists to unstrand was already at sending_status=verified before the regression, so the incident population is SUCCESS by construction. DNS-comparing hardening noted as a deferred follow-up, not implemented.

4. Lazy, non-fatal STS resolution (63c9ee61f)
NewSESProviderFromConfig no longer calls sts:GetCallerIdentity at construction. Account-id resolution is now sync.Once-lazy, triggered on first adoption attempt; a failure degrades to ErrIdentityNotOwned (exactly pre-adoption-PR behavior) instead of log.Fatalf-ing the whole server. Also: callerIdentity.Account == nil is now an error instead of silently producing an account-id-less ARN.

5. Classify permanent AWS errors as non-retryable (8a37206f7)
classifyAdoptionError maps AccessDeniedException (matched via smithy's APIError.ErrorCode() — it's unmodeled in the SES v2 SDK's generated types), BadRequestException, and NotFoundException into a wrapped ErrIdentityNotOwned; throttling/5xx/network errors propagate untouched for retry. Verified against the live prod IAM policy: ses:TagResource is allowed for e2a.dev's principal, so the catastrophic every-sweep-red variant isn't active in prod today, but a self-hoster on the design doc's narrower conditioned grant would hit it.

6. Stop forgetting the ledger on ownership failure (fa9eb7cc6)
Removed both ForgetSendingIdentityManaged calls from worker.go's ErrIdentityNotOwned handlers. The NOT EXISTS guard doesn't close the race the original commit claimed — it evaluates against the deleting transaction's own snapshot, so a concurrent domain-delete that commits first still passes the guard and orphans the SES identity. FinalizeSendingIdentityTombstone (drain-window guarded) is now the sole deletion path; ForgetSendingIdentityManaged's NOT EXISTS guard stays as defense-in-depth in the SQL.

7. Guard FinalizeSendingIdentityTombstone with the same predicate (0f9d1ad3b)
Added the identical NOT EXISTS (domains WHERE user_id IS NOT NULL) guard to FinalizeSendingIdentityTombstone. Concrete gap closed: a verified, live, owned domain with NULL dkim_selector/dkim_private_key hits the no-key branch, which calls this method under the reaper's finalizeDeletion=true — pre-fix, an aged row was deleted even though the domain is still live, stranding it exactly like the original incident via a different trigger.

8. Adoption observability — folded into 63c9ee61f (success log in adoptIdentity) and fa9eb7cc6 (ALERT-convention log on refusal for a live domain, matching reaper.go's style).

9. Doc corrections (41adef71d)

  • Corrected the "Steps 4, 6, 7 unaffected" claim — adoption itself writes the tag the IAM condition exists to gate, so the two controls are no longer fully independent; rewrote to say precisely what IAM still does vs. no longer independently catches.
  • Added "unless provably e2a's own (see canAdoptIdentity)" to the normative untagged-identity bullet.
  • Documented the domain-verification gate (live DNS probe of ownership TXT + apex MX) as the real bound on attacker-controlled input, in both the doc and canAdoptIdentity's comment.
  • Restated the shared-tag-value constraint inside the amendment: adoption makes violating it silent and escalates the blast radius (adopted identity becomes Deprovision-deletable).

Verification

  • go build ./... — clean
  • go vet ./... — clean except the known pre-existing findings in internal/agent/*_test.go (unrelated, confirmed identical on 258c3fc87)
  • go test ./internal/senderidentity/... ./internal/identity/...senderidentity fully green; identity has one pre-existing failure (TestInboundReviewApprovalAtomicallyAdvancesAuthenticatedEngagement, in review_test.go, unrelated to sender-identity) — confirmed to fail identically on 258c3fc87 before any of this branch's changes
  • go test ./... — three pre-existing failures outside this PR's diff scope (internal/agent: TestMarkSentUpdatesOutreachFromToRecipients, TestMarkSentIsNotDoubleCountedOnRedrive; internal/relay: TestInboundActivityRecording_AuthenticatedMailUpdatesCounters; plus the internal/identity one above) — all four are in packages/files this PR never touches (git diff 258c3fc87 --stat confirms zero changes to internal/agent or internal/relay), so they cannot be caused by this branch

New/updated tests

TestSESProvider_ProvisionRefusesForeignConfiguration, TestSESProvider_StatusRefusesAdoptionWithoutKeyMaterial, TestSESProvider_StatusRefusesAdoptionWhileDkimPending, TestAccountIDFromCallerIdentity, TestSESProvider_AdoptionDegradesWhenAccountIDUnavailable, TestClassifyAdoptionError, TestSESProvider_ProvisionAdoptionAccessDeniedRefusesNotRetries, TestReapWorker_NoKeyMaterialLiveDomainRetainsLedger, TestFinalizeSendingIdentityTombstoneRetainsLedgerForLiveOwnedDomain (DB-backed) — plus TestCanAdoptIdentity's truth table extended with the new criteria. Removed two tests (TestReconcileWorker_NotOwnedForgetFailureStillFires, TestSyncWorker_NotOwnedForgetFailureStillFires) that pinned the now-removed Forget-on-ownership-failure behavior.

🤖 Generated with Claude Code

Jace and others added 5 commits August 18, 2026 22:33
Provider.Status carries two caller-supplied judgement inputs beyond the
domain — expectedSelector and haveKeyMaterial (the latter added by the prior
commit) — that drive canAdoptIdentity's decision in the real SES provider.
FakeProvider.Status discarded both, recording only the domain, so a call
site that regressed to passing "", a stale selector, or the wrong boolean
would still pass every worker test: the compiler cannot catch a
wrong-but-same-typed argument, and adoption would silently stop firing in
production for exactly the domains this PR exists to rescue.

StatusCalls is now []StatusCall{Domain, Selector, HaveKey} instead of
[]string. Every existing caller only used len(...) or %v formatting, so this
is a mechanical, non-breaking change.

Adds TestReconcileWorker_StatusCallCarriesRealSelectorAndKeyMaterial,
exercising reconcileProviderIdentity's real call site and asserting it
passes the store's actual selector and an accurate key-material signal, both
with and without key material on file. Also strengthens
TestSyncWorker_AlreadyVerifiedNoOp with the same assertion for the other
call site (syncProviderIdentityWithInspection's providerStatus closure).
Verified both new assertions actually fail when the call site is mutated to
pass ("", false).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…guards

ForgetSendingIdentityManaged and FinalizeSendingIdentityTombstone both gate
their DELETE on "NOT EXISTS (domain still live and owned)". Only the retain
arm (domain live) had an explicitly-named, isolated test; the DELETE arm
(domain genuinely gone) was only exercised as a side effect of two other
tests (TestManagedSendingIdentityLedgerSurvivesDomainDelete's tail assertion,
and TestSendingIdentityTombstoneFinalizeRespectsAge's final "aged row" step,
which the prior commit had to start deleting the domain up front to isolate
the age gate from this guard).

Verified empirically: inverting both guards to EXISTS causes all four of
those existing tests to fail today, so the DELETE arm was NOT actually
uncovered — but that coverage was incidental to tests written for a
different purpose, and a future edit to either of those broader tests could
silently drop it without anyone noticing.

Adds TestForgetSendingIdentityManagedRemovesLedgerForGenuinelyDeletedDomain
and TestFinalizeSendingIdentityTombstoneRemovesLedgerForGenuinelyDeletedDomain
as explicit, minimal, DB-backed companions to the existing retain-arm tests
(TestForgetSendingIdentityManagedRetainsLedgerForLiveOwnedDomain /
TestFinalizeSendingIdentityTombstoneRetainsLedgerForLiveOwnedDomain), so the
DELETE arm has its own dedicated pin independent of any other test's setup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…convergence

TestReconcileWorker_NotOwnedRetainsLedgerForLiveDomain and
TestSyncWorker_NotOwnedRetainsLedgerForLiveDomain used to assert only that
the in-memory fakeStore's own `managed` map still had an entry — bookkeeping
that reverting the real store.go NOT EXISTS guard would leave completely
unaffected, since these worker-level tests never touch real SQL.

Both now add a phase 2: after the ownership problem resolves, drive the
reaper (the actual retry mechanism — a domain sitting `failed` from an
ownership issue is never revisited by another reconcile poll, only by the
hourly sweep's applied-vs-incarnation mismatch) through two sweeps and assert
the domain actually reaches `verified`. That's the consequence the incident
cared about: a retained row buys a retry that converges, not just a
surviving map entry.

Also strengthens TestReapWorker_GenuineDeleteStillFinalizesTombstone, whose
comment claimed to prove the teardown ran through
FinalizeSendingIdentityTombstone rather than ForgetSendingIdentityManaged,
but whose only assertion (the ledger map ending up empty) can't actually
distinguish the two — both simply delete the same map entry in the fake.
Gives fakeStore separate ForgetCalls/FinalizeTombstoneCalls recorders (it
previously only had a shared forgetErr injection point) and asserts on them
directly: Forget is never called, Finalize is called exactly once. The
existing prov.List() assertion already carried real, independent value
(proving Deprovision itself ran) and is unchanged; only the comment
overstated what the ledger-emptiness check alone proved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
syncProviderIdentityWithInspection's inspect block claimed the periodic
sweep "never MUTATES a healthy applied identity" and that both states it
inspects are "READ-ONLY". That was true of the DB record when written, but
providerStatus() calls provider.Status, which self-heals a removed
ownership tag via a TagResource write (canAdoptIdentity/adoptIdentity in
ses.go) whenever the identity is untagged but provably e2a's own — a
provider-side mutation this same inspect block can now trigger on an
otherwise "healthy" identity that merely lost its tag out-of-band.

Narrows the claim to the DB record specifically and adds a CAUTION note so a
future reader doesn't build on "this block never talks to SES with a write."

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adoption ARN was hardcoded to arn:aws:ses:..., which is wrong outside
the commercial partition — GovCloud is aws-us-gov, China is aws-cn — and
TagResource rejects an ARN built with the wrong partition as invalid,
permanently failing adoption there. STS GetCallerIdentity's Arn field
(arn:<partition>:sts::...) already carries the correct partition, so parse
it out instead.

Extends the existing lazy, non-fatal, sync.Once account-id resolution
(accountIDForAdoption, renamed identityForAdoption) to resolve the partition
alongside the account id from the same STS call — no extra round trip.
NewSESProvider (the explicit-account-id constructor; no STS call is ever
made on that path) defaults to "aws" since there's no ARN available to
derive it from; every production caller goes through
NewSESProviderFromConfig instead. A nil or unparseable STS Arn also degrades
to "aws" rather than failing resolution outright, since Account (not Arn) is
the field AWS actually guarantees on GetCallerIdentityOutput.

Also renames the misleading `ststypes` alias (which actually points at
aws-sdk-go-v2/service/sesv2/types, not STS) to `sestypes` throughout ses.go
and ses_test.go — it sat one letter apart from the real `sts` import this
PR's STS integration added, for two unrelated AWS services. Mechanical
rename, no behavior change.

Tests: TestIdentityFromCallerIdentity (renamed from
TestAccountIDFromCallerIdentity) adds cases for a commercial, GovCloud, and
China Arn, plus a nil/unparseable Arn falling back to "aws".
TestSESProvider_AdoptIdentityUsesResolvedPartitionInARN proves the resolved
partition reaches the actual TagResource call end-to-end, not just the pure
parsing function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jiashuoz

Copy link
Copy Markdown
Member Author

Batch B — review polish (5 commits, 41adef71dabb1faff5)

Follow-up to the batch A security/correctness pass, addressing the remaining findings from the two independent reviews.

Commit Finding addressed
3bb785e42 FakeProvider.Status now records the arguments it receives
8a2bb7971 DB-level tests pinning the DELETE arm of both NOT EXISTS guards
8c3ab1dcd Ownership-failure ledger tests now assert convergence, not fake bookkeeping
c4eae8b4b Corrected the stale "sweep is READ-ONLY" invariant comment
abb1faff5 ARN partition derived from the STS Arn instead of hardcoded arn:aws:

Why the first one mattered most: Provider.Status now carries two caller-supplied values (expectedSelector, haveKeyMaterial) that nothing verified — the fake discarded both. A call site passing "" or the wrong boolean would have left every worker test green while adoption silently stopped firing for exactly the domains this PR exists to rescue. Both are now recorded and asserted.

On the DELETE-arm tests: only the retain arm was covered, so an inverted NOT EXISTS predicate would have passed the entire suite. Both guards now have paired retain/remove coverage.

The ststypessestypes rename from the review was already resolved in batch A; no separate commit.

Verification

  • go build ./..., go vet ./internal/senderidentity/... ./internal/identity/... — clean
  • go test ./internal/senderidentity/... — green
  • All 15 sender-identity tests in internal/identity — green across two consecutive runs, including the 4 added here
  • Known pre-existing failures elsewhere in internal/identity were reproduced on unmodified main and are unrelated to this diff

Note for reviewers: internal/identity is unreliable against a shared local Postgres — main itself varied between 1 and 13+ failures across runs, with a different failure set each time. Several tests there use fixed fixture names and discard every setup error, so an upstream failure surfaces as a confusing nil-deref or "no rows" in an unrelated test. CI against a clean database is the arbiter. Worth a separate hygiene issue.

Jace added 5 commits August 19, 2026 00:33
…ct empty account id

Two independent narrow fixes from the batch C re-review:

- classifyAdoptionError mapped a TagResource NotFoundException to
  ErrIdentityNotOwned. That call only ever fires moments after a
  successful GetEmailIdentity fed canAdoptIdentity, so NotFound here
  means the identity vanished between the two calls (a delete raced
  adoption), not that it is foreign. Map it to ErrIdentityNotFound
  instead, which callers already treat as "repair/converge"
  (reconcileProviderIdentity's repairMissingIdentity branch). The
  previous mapping produced a wrong customer-visible sending_error and
  a spurious domain.sending_failed webhook.
- identityFromCallerIdentity guarded against a nil Account but not an
  empty-string one, which yields the exact malformed ARN
  (arn:aws:ses:<region>::identity/<domain>, missing the account id
  segment) the guard exists to prevent. Reject "" the same way.
…guard to require verified

The guard blocked Finalize whenever a domain row existed with a
non-NULL user_id, regardless of DNS-verification status. That
correctly protects the incident population (verified/live/owned) and
the no-key branch (also verified/live/owned), but also permanently
blocks a legitimate branch: delete followed by an immediate
re-register of the same domain lands a fresh, live, owned, but
UNVERIFIED row before the post-drain audit runs. The old
incarnation's ledger tombstone then survives forever and the hourly
reaper re-sweeps it pointlessly.

Tighten the NOT EXISTS predicate to require d.verified as well as
d.user_id IS NOT NULL: the protected populations (verified/live/owned,
including the no-key branch) stay protected, and the unverified
re-register case can finalize again.

Adds TestFinalizeSendingIdentityTombstoneRemovesLedgerForLiveUnverifiedDomain
and updates the existing retain-arm test to explicitly VerifyDomain so
it continues to isolate the now-narrower protected population.
…v1.7.8 regression

No migration re-populated ledger rows the pre-adoption v1.7.8
ErrIdentityNotOwned handler deleted via ForgetSendingIdentityManaged.
Migration 101's trigger only re-inserts on INSERT, a transition FROM
sending_status='none', or a verification_token change — none of which
a domain already sitting at `failed` (its state since the moment it
was stranded) hits again on its own. reaper.go skips any provider
identity whose domain row still exists, so a stranded domain is
invisible to BOTH reaper phases; only a customer-initiated POST
/domains/{d}/verify recovers one today.

Add migration 105, an idempotent re-run of 101's backfill scoped to
the stranded population (live, owned, sending_status='failed', no
ledger row). applied_incarnation is deliberately NULL: setting it to
the current token would make needsProvision=false, and the reaper's
non-forced inspect switch only acts on ledger status pending/verified
— a failed-status row would hit `default: return nil` and the repair
would buy nothing.

This mirrors a repair already performed by hand on one production
deployment.

Adds TestSenderIdentityStrandedDomainRepairMigrationBackfillsForgottenLedgerRows,
which models a stranded domain and an already-ledgered control domain
and proves the migration repairs the former while leaving the latter
untouched, applied twice to also confirm ON CONFLICT DO NOTHING makes
a manual re-run safe.
…diagnosable, guard non-production adoption

Five related findings from the batch C re-review, implemented together
since they all touch canAdoptIdentity/Provision/Status:

1. sync.Once cached the FIRST identityForAdoption result forever,
   success or failure. The cached ctx belongs to the first caller (a
   River job); jobs.go sets no JobTimeout so River's 1-minute default
   applies, and reapManagedIdentityPage's up-to-25-domain pages with
   several SES round trips each can land the deadline mid-STS-call on
   the largest post-upgrade sweep. A sync.Once then caches
   DeadlineExceeded FOREVER: every later adoption returns
   ErrIdentityNotOwned, every owned-but-untagged domain flips to
   failed, and a domain.sending_failed webhook fires per customer —
   recoverable only by a restart. Same failure shape this PR exists to
   fix, one layer down. Fixed: resolveIdentity now runs on
   context.WithoutCancel(ctx) with its own 10s timeout, and the cache
   is a mutex, not sync.Once — only success is cached permanently; a
   failure is cached for a 30s cooldown, then retried by the next
   caller (adoption is already ~hourly-rate-limited, so this isn't
   hammering STS).

2. worker.go's two ALERT log lines logged only a constant reason
   string, discarding the actual wrapped error (classifyAdoptionError's
   detail, or canAdoptIdentity's now-added refusal reason). A missing
   IAM grant, a wrong-partition ARN, DKIM still PENDING, a selector
   mismatch, and a genuinely foreign identity all logged identically.
   Fixed: canAdoptIdentity now returns (ok bool, reason string);
   Provision/Status wrap the reason into the returned ErrIdentityNotOwned;
   both ALERT lines now log %v of the real error alongside the constant
   summary. The customer-visible sending_error wording is unchanged.

4. Added a missing negative-arm test: TestSyncWorker_AlreadyVerifiedNoOp
   only ever exercised HaveKey=true at the healthy-recheck provider.Status
   call site in syncProviderIdentityWithInspection. Mutation-testing
   confirmed hardcoding `true` there leaves the whole suite green — the
   exact "tag an identity e2a cannot sign for, then let Deprovision
   delete it" path. New test
   TestSyncWorker_AlreadyVerifiedNoOpCarriesRealKeyMaterialSignal seeds
   no key material and asserts HaveKey:false; verified it actually
   catches the mutation before landing it.

5. Provider.Status(ctx, domain, expectedSelector string, haveKeyMaterial
   bool) replaced with Status(ctx, domain string, evidence
   AdoptionEvidence) — the two booleans existed only to feed one joint
   decision and had to be kept consistent by convention. AdoptionEvidence
   is built by one helper (adoptionEvidence in worker.go) shared by both
   provider.Status call sites.

10. Documented AND closed the reachability-bound gap: canAdoptIdentity's
    "no customer can aim adoption at a domain they don't control" bound
    depends on domain-verification's DNS probe actually running, but
    checkDomainRecords short-circuits to found for every domain when
    !production. Added SESProvider.refuseAdoption, set from
    NewSESProviderFromConfig's new production parameter (cfg.IsProduction()
    in main.go): when true, Provision/Status refuse to adopt any untagged
    identity outright, regardless of what canAdoptIdentity would otherwise
    conclude. Zero value (false) preserves every existing test's behavior.

11. The no-key branch in syncProviderIdentityWithInspection re-runs hourly
    forever for a live/owned/verified domain with NULL DKIM key material,
    with no log line at all — invisible to operators. Added an ALERT log
    matching the two ErrIdentityNotOwned branches' convention, noting it
    recurs hourly so it doesn't read as a fresh incident each sweep.

Also carries item 9 (already committed separately would duplicate;
folded here): corrected the doc overclaim that DKIM SUCCESS proves SES
matched "e2a's key" — it proves SES matched whatever key IT holds,
which for a foreign BYODKIM identity is that app's key, not e2a's.
Updated in both ses.go's canAdoptIdentity doc and the design doc.

Updates docs/design/sender-identity-mailfrom.md throughout to match:
the two-return canAdoptIdentity contract, AdoptionEvidence, the cache-
only-success account-id contract, the non-production adoption guard,
and migration 105.
…r.Status signature

Trailing cleanup from the AdoptionEvidence refactor — a test doc
comment still described the pre-refactor 4-arg provider.Status call.
@jiashuoz

Copy link
Copy Markdown
Member Author

Batch C revision (5 new commits, abb1faff5d637f5fa9)

Two independent Opus re-reviews (correctness + adversarial) of the full 12-commit diff confirmed the core security property holds and every prior finding is genuinely fixed. This batch addresses 11 operability/self-healing/doc-accuracy findings plus one real test hole from that pass. All re-verified against current code before fixing (a couple of line numbers had drifted).

1. sync.Once cached a transient STS failure forever. The cached ctx belonged to the first caller — a River job — and jobs.go sets no JobTimeout, so River's 1-minute default applies. reapManagedIdentityPage's up-to-25-domain pages with several SES round trips each could land that deadline mid-STS-call on the largest post-upgrade sweep, permanently caching DeadlineExceeded and flipping every later adoption to ErrIdentityNotOwned until a restart — the same failure shape this PR exists to fix. Fixed: identityForAdoption now runs STS on context.WithoutCancel(ctx) with its own 10s timeout, and the cache is a mutex — only success is cached permanently; a failure is cached for a 30s cooldown, then retried by the next caller.

2. Refusal reasons were discarded at the log line. canAdoptIdentity now returns (ok bool, reason string) instead of a bare bool; Provision/Status wrap the reason into the returned ErrIdentityNotOwned, and both worker.go ALERT lines now log %v of the real error alongside the constant summary — a missing IAM grant, a wrong-partition ARN, DKIM-still-PENDING, a selector mismatch, and a genuinely foreign identity are now distinguishable. Customer-visible sending_error wording unchanged.

3. New migration for domains stranded by v1.7.8. 105_sender_identity_managed_domains_repair_stranded.sql re-runs migration 101's backfill scoped to the stranded population (live, owned, sending_status='failed', no ledger row) — idempotent ON CONFLICT DO NOTHING, applied_incarnation deliberately NULL so the repaired row actually reconverges instead of reading as already-applied.

4. Missing negative-arm test. Mutation-testing confirmed hardcoding true at syncProviderIdentityWithInspection's healthy-recheck provider.Status call site left the whole suite green. New TestSyncWorker_AlreadyVerifiedNoOpCarriesRealKeyMaterialSignal seeds no key material and asserts HaveKey:false — verified it actually catches the mutation before landing it.

5. AdoptionEvidence struct replaces Provider.Status(ctx, domain, expectedSelector string, haveKeyMaterial bool)'s two independent booleans, built by one shared helper (adoptionEvidence in worker.go) at both call sites.

6. TagResource NotFoundException reclassified from ErrIdentityNotOwned to ErrIdentityNotFound — it means the identity vanished between the GET and the tag call (a delete raced adoption), not that it's foreign. Callers already treat ErrIdentityNotFound as repair/converge.

7. Empty-string AWS account id now rejected alongside nil, closing the same malformed-ARN gap from the other side.

8. FinalizeSendingIdentityTombstone's live-owner guard tightened to require d.verified, not just d.user_id IS NOT NULL — the old guard made Finalize a permanent no-op for a legitimate delete-then-immediate-re-register race (fresh unverified row), stranding the old incarnation's tombstone forever. Both arms now covered by DB tests.

9. Corrected an overclaim (code comment + design doc): DKIM SUCCESS proves SES matched whatever key it holds, not provably e2a's key — for a hypothetical foreign BYODKIM identity that's that app's key. The actual ownership work is done by the exact-selector match plus the reachability bound (see #10), not SUCCESS alone.

10. Reachability-bound caveat documented and closed. The bound ("adoption only ever attempted for a DNS-verified domain") holds only under env: productioncheckDomainRecords short-circuits DNS checks to always-found otherwise. Added SESProvider.refuseAdoption (from NewSESProviderFromConfig's new production parameter, cfg.IsProduction() in main.go): a non-production deployment now refuses adoption outright rather than silently trusting an unverified domain.

11. Silent unbounded retry now logs. The no-key branch (live/owned/verified domain, NULL DKIM key material) re-runs hourly forever with 2 SES calls + a status write and previously no log line at all. Added an ALERT log matching the other two branches' convention, noting it recurs hourly.

Verification

  • go build ./... — clean
  • go vet ./... — clean except pre-existing, unrelated findings in internal/agent/*_test.go (not touched by this PR, present on main)
  • go test ./internal/senderidentity/... — all pass (69 subtests)
  • go test ./internal/identity/ -run 'SendingIdentity|Tombstone|Forget|Managed' -count=1 — all pass

Did not run the full ./internal/identity/... suite per the known local-DB-contention flakiness. Nothing was found wrong in the batch — all 11 findings re-verified as accurate against current code and fixed as described.

🤖 Generated with Claude Code

@jiashuoz
jiashuoz merged commit 820ea86 into main Aug 19, 2026
32 checks passed
jiashuoz added a commit that referenced this pull request Aug 19, 2026
* chore: go mod tidy (smithy-go is a direct import)

github.com/aws/smithy-go is imported directly by internal/senderidentity/ses.go
(for smithy.APIError) but was still listed // indirect in go.mod. go mod tidy
moves it to the direct require block; no other changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(senderidentity): make fakestore_test.go gofmt-clean

Two doc comments (immediately preceding a func declaration) wrote SQL's
empty-string literal as two adjacent straight single quotes ('') Go's
doc-comment reformatter collapses that specific adjacent pair into a single
Unicode right-double-quote character, which is what gofmt -l was flagging.
Switch to double quotes (""), which the reformatter leaves alone, so the
comment still reads as the intended SQL literal instead of a mangled
character. Confirmed unrelated to main: the same gofmt run on main shows
several pre-existing non-clean files elsewhere in the repo that this PR does
not touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(senderidentity): use %w consistently when wrapping the account-id error

adoptIdentity's account-id-unavailable wrap used %w for ErrIdentityNotOwned
but %v for the underlying resolveIdentity error, making that error
unreachable via errors.Is/As — unlike classifyAdoptionError's deliberate
double-%w a few lines below, which exists specifically so both the sentinel
and the original error stay reachable. Match that idiom here too.

Confirmed no test or call site depends on the original error being
unreachable at this call site: TestSESProvider_AdoptionDegradesWhenAccountIDUnavailable
and TestSESProvider_AccountIDResolutionCachesOnlySuccess only assert
errors.Is(err, ErrIdentityNotOwned), never against the wrapped resolver
error, and both still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(senderidentity): fix DKIM-status gate overclaim in 3 adoption stubs

TestSESProvider_ProvisionRefusesForeignConfiguration (both subtests) and
TestSESProvider_StatusRefusesMismatchedSelector built their DkimAttributes
without Status: DkimStatusSuccess, so canAdoptIdentity's DKIM-SUCCESS gate
(checked before the selector match, and — for these three stubs — before
the foreign-configuration/selector guard the test names claim to cover) was
what actually refused adoption in each case; the guard the test name
advertises was never exercised. TestCanAdoptIdentity's truth table already
covers each guard directly, so this was an overclaim, not a coverage hole.

Add Status: sestypes.DkimStatusSuccess to all three stubs so each now fails
for the reason its name states. Verified by mutation: temporarily disabling
the foreign-configuration guard failed
TestSESProvider_ProvisionRefusesForeignConfiguration (both subtests), and
temporarily disabling the selector-match guard failed
TestSESProvider_StatusRefusesMismatchedSelector; both guards restored
afterwards and the full internal/senderidentity suite is green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(senderidentity): correct TestReapWorker_GenuineDeleteStillFinalizesTombstone's doc comment

The comment claimed the ForgetCalls/FinalizeTombstoneCalls assertion
disambiguates two live call sites (deletion vs. ownership-failure) inside
syncProviderIdentityWithInspection. ForgetSendingIdentityManaged has zero
production callers (both were removed by #908), so there is only one live
deletion path here — the ForgetCalls check is defense-in-depth, not evidence
of picking between two branches that both actually run. The
FinalizeTombstoneCalls assertion and the independent prov.List() evidence
still carry their original value; only the comment's framing changes, not
the test's assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(senderidentity): fix stale 'Forget' references in worker.go comments

Three (plus one analogous) comments illustrated 'fire before the error
check' rationale with call sites that no longer exist or no longer apply,
left over from #908 removing both ErrIdentityNotOwned handlers' calls to
ForgetSendingIdentityManaged:

- reconcileProviderIdentity's closing comment cited '(e.g. the ledger Forget
  after a not-owned failure)' as the example of a later store call that
  could fail — that call site is gone, and no later store call remains in
  this function's ownership-failure branch to serve as a replacement
  example, so the stale example is dropped and the general rule kept.
- The no-key branch's 'out before Forget' now precedes
  FinalizeSendingIdentityTombstone, not Forget.
- syncProviderIdentityWithInspection's provision-branch comment claimed
  'same rule as the two branches above' (the two ErrIdentityNotOwned
  branches) — those branches no longer have any store call following their
  out-assignment, so there's nothing left to draw the comparison to; the
  comparison is dropped and 'Forget/MarkApplied' corrected to the calls that
  actually follow (FinalizeSendingIdentityTombstone or
  MarkSendingIdentityApplied).
- The function's closing comment had the same stale '(e.g. the ledger
  Forget)' example; corrected the same way.

No behavior change - comments only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(identity,senderidentity): document ForgetSendingIdentityManaged as intentionally-retained dead code

After #908 removed both ErrIdentityNotOwned call sites in
internal/senderidentity/worker.go, ForgetSendingIdentityManaged has zero
non-test callers repo-wide. Its doc comment still claimed "Only the
ErrIdentityNotOwned handlers in internal/senderidentity/worker.go call
this" - a relationship that no longer holds.

Evaluated removing the method, its Store interface entry, the storeAdapter
shim, and the two fakes (folding its retain/delete DB test coverage into
FinalizeSendingIdentityTombstone's equivalents, which already exist:
TestFinalizeSendingIdentityTombstoneRetainsLedgerForLiveOwnedDomain and
TestFinalizeSendingIdentityTombstoneRemovesLedgerForGenuinelyDeletedDomain
in internal/identity/sender_identity_lock_test.go already pin the same
retain/delete invariant for Finalize, so the DB-test coverage question is
moot either way). Chose to keep it instead: removal would also require
rewriting several rationale comments in internal/senderidentity/worker.go's
two former call sites (which cite this method's guard by name while
explaining why they must never call it) and restructuring three
purpose-built regression tests in internal/senderidentity/worker_test.go
(TestReconcileWorker_NotOwnedRetainsLedgerForLiveDomain,
TestSyncWorker_NotOwnedRetainsLedgerForLiveDomain,
TestReapWorker_GenuineDeleteStillFinalizesTombstone) that poison a fake
implementation of this exact method and assert zero calls to it, existing
specifically to catch a reintroduced call. That's a larger, riskier change
than this method's own dead-code status warrants in a hygiene-only PR, and
risks silently weakening tests that were deliberately designed as
defense-in-depth against reintroducing the original incident.

Rewrote the doc comment on *Store.ForgetSendingIdentityManaged instead to
state plainly: it has no current callers, why it's kept anyway, and that
any future caller must not be an ownership-failure branch. Added a matching
one-line pointer on the Store interface entry in worker.go. No behavior
change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(design): fix drift in sender-identity-mailfrom.md

- accountIDFromCallerIdentity was already renamed to
  identityFromCallerIdentity repo-wide (no occurrences of the old name
  remain) - nothing to fix there, confirmed by grep.
- "deliberately out of scope for the BATCH A fix" used an internal
  review-batch label meaningless in a permanent doc; reworded to
  "deliberately out of scope for the adoption work above."
- The step-4 IAM-hardening paragraph narrated its own revision history ("An
  earlier draft of this amendment claimed... that framing is now only half
  true... the way the original doc implied... the way the original phrasing
  suggested"); rewritten to state the current position directly, same
  substance.
- Reconciled the numbered "Upgrading an existing installation" list with the
  amendment above it, which described automatic per-domain adoption but
  never fed back into the steps themselves:
  - Added a lead-in before step 1 noting steps 1-3 (the original manual
    export/review/tag pass) are no longer required for anything
    canAdoptIdentity adopts automatically, and are now only useful for
    pre-emptive tagging or exclusion before the IAM lockdown in step 4.
  - Step 5 previously said an untagged legacy identity at that point "fails
    closed... audit and tag it explicitly" - actively wrong now that
    automatic adoption already tags matching identities inline. Rewrote it
    to explain that a remaining untagged identity is now either genuinely
    foreign or simply hasn't had a poll cycle yet, and to point at the
    ALERT log line that distinguishes which.
- Added the ARN-partition derivation (arnPartition / identityFromCallerIdentity's
  partition handling) to the Verification section, next to the account-id
  resolution paragraph it belongs beside; it had unit coverage
  (TestIdentityFromCallerIdentity,
  TestSESProvider_AdoptIdentityUsesResolvedPartitionInARN) that the doc
  never mentioned.

No code changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jace <jace@team.tokencanopy.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant