Skip to content

feat(senderidentity): let the reaper reclaim expired fixture orphans - #973

Merged
jiashuoz merged 1 commit into
mainfrom
feat/reaper-orphan-reclaim
Aug 30, 2026
Merged

feat(senderidentity): let the reaper reclaim expired fixture orphans#973
jiashuoz merged 1 commit into
mainfrom
feat/reaper-orphan-reclaim

Conversation

@jiashuoz

Copy link
Copy Markdown
Member

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 reapProviderOrphanPage can 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.

# Guard
1 The pre-existing orphan precondition: not ledgered and no domain row (unchanged)
2 Provider reports not verified for sending — an identity that can send today is never deleted
3 e2a-managed is e2a's ownership anchor
4 e2a-env equals this deployment (prod and staging share one AWS account)
5 e2a-purpose is fixture — customer, unrecognized, and missing are all refusals
6 e2a-expires parses and is in the past
7 e2a-created parses 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 reclaimable
8 The name sits at or under a configured reclaim zone, matched on a label boundary — customer domains are never under the test zone, so even total failure of guards 3-7 cannot reach one. Empty zone list means reclaim nothing
9 Per-invocation cap (default 5) — a systematic mistake costs a handful and a loud log, not the account
10 reap_orphans config flag, default off: the full decision still runs and logs WOULD DELETE / the refusal reason, so the operator can observe for days before arming

Deletion goes through the existing provider.Deprovision, which independently verifies ownership and returns ErrIdentityNotOwned — defense in depth if the tag logic is ever wrong. On staging, AWS IAM additionally denies DeleteEmailIdentity outside *.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, the eviltrymnexa.com near-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; -race clean; build, vet, gofmt clean.

Two judgement calls beyond the spec: reclaim_min_age: 0s is 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 flips reap_orphans — three separate deliberate acts.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ScEpytuD7GvaXEssvJ2W32

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
@jiashuoz
jiashuoz merged commit b853f81 into main Aug 30, 2026
29 checks passed
@jiashuoz
jiashuoz deleted the feat/reaper-orphan-reclaim branch August 30, 2026 07:06
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