feat(senderidentity): let the reaper reclaim expired fixture orphans - #973
Merged
Conversation
The reaper's orphan phase — a provider identity with no ledger row and no
domain row — only logged an ALERT, so identities leaked by crashed e2e runs
accumulated until a human swept them by hand. This gives that phase delete
authority under a policy that fails closed at every step.
Safety model. Deletion requires ALL of:
1. the existing orphan precondition (not ledgered AND no domain row);
2. the e2a-managed ownership tag;
3. an e2a-env tag naming THIS deployment, which must itself be named;
4. e2a-purpose == fixture;
5. an e2a-expires stamp that parses as RFC3339 and is in the past;
6. an e2a-created stamp that parses as RFC3339 and clears a configured age
floor (default 7d) INDEPENDENTLY of the expiry, so clock skew or a bad
TTL cannot make something instantly reclaimable;
7. a name at or under a configured reclaim zone, matched on a label
boundary (evilzone.test never matches zone zone.test) — customer domains
are never under the test zone, and an EMPTY zone list reclaims nothing;
8. the provider reporting the identity NOT verified for sending;
9. a per-job deletion cap (default 5);
10. an arming flag, default OFF.
Anything missing, unparseable, or unrecognized is a refusal with a logged
reason, never a default-allow. That is what makes tags.go's deliberate
fail-OPEN tagging safe: a dropped tag costs an identity a human must classify,
never one deleted by mistake.
All policy lives in orphanReclaimable, a pure function exhaustively table-
tested over every refusal path individually. The provider never decides: it
only reports facts, via one InspectIdentity (a single GetEmailIdentity, so
tags and the sending bit can never be read from two different moments), paid
only for orphan candidates. An unconfigured policy is refused before that call,
so self-hosts keep paying exactly what the alert-only audit paid.
Deletion goes through the existing Deprovision, never a new delete path: it
re-reads the ownership tag itself and returns ErrIdentityNotOwned, so a bug in
the tag reasoning above is not sufficient on its own to destroy an identity.
With the flag off the whole decision still runs and logs WOULD DELETE (or the
refusal reason) without touching the provider — the observe-only mode an
operator runs for days before arming. No new metric: logs only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScEpytuD7GvaXEssvJ2W32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #972 (identity classification tags). Crashed e2e runs leak fixture domains and their SES identities, and nothing reclaims them — 8 accumulated over weeks and had to be swept by hand. The reaper already detects them every hour and can only log
manual review required, because a missing ledger row is ambiguous: leaked fixture, or a stale/restored database hiding a live customer domain.This resolves that ambiguity from the identity's own tags, so the orphan branch of
reapProviderOrphanPagecan delete. Nothing else in the reaper changes.Safety model
Deletion requires every one of these; any missing, unparseable, or unrecognized value refuses and logs the reason. There is no fail-open path.
e2a-managedis e2a's ownership anchore2a-envequals this deployment (prod and staging share one AWS account)e2a-purposeisfixture— customer, unrecognized, and missing are all refusalse2a-expiresparses and is in the paste2a-createdparses and the identity clears an absolute age floor, independent of the TTL, so clock skew or a mis-set TTL can't make something instantly reclaimablereap_orphansconfig flag, default off: the full decision still runs and logsWOULD DELETE/ the refusal reason, so the operator can observe for days before armingDeletion goes through the existing
provider.Deprovision, which independently verifies ownership and returnsErrIdentityNotOwned— defense in depth if the tag logic is ever wrong. On staging, AWS IAM additionally deniesDeleteEmailIdentityoutside*.staging.trymnexa.com.All policy lives in a pure
orphanReclaimable(audit, cfg, now) (bool, reason); the provider reports facts and never decides.Tests
TestOrphanReclaimable— 26 subtests, one per refusal path (wrong env, purpose customer/missing/unrecognized, expires missing/unparseable/future, created missing/unparseable/future-dated, under min age, outside zones, theeviltrymnexa.comnear-miss, empty zone list, unnamed deployment, verified-for-sending, foreign ownership tag, no tags at all) plus the accept case.TestReapWorkerOrphanReclaim— 11 subtests including disarmed mode mutating nothing, cap enforcement, untagged legacy orphans never deleted, an unusable policy never even calling the provider, and the managed-ledger phase unchanged. 37 subtests green;-raceclean; build, vet, gofmt clean.Two judgement calls beyond the spec:
reclaim_min_age: 0sis treated as an unconfigured policy (refuse) rather than "no floor", closing the one fail-open reading; and the cap deliberately does not apply in observe-only mode, so a dry run shows every candidate rather than a truncated preview.Inert until ops config sets
deployment_name,reclaim_zones, and flipsreap_orphans— three separate deliberate acts.🤖 Generated with Claude Code
https://claude.ai/code/session_01ScEpytuD7GvaXEssvJ2W32