feat(account): defer immediate erase for accounts that recently sent externally - #1059
Merged
Merged
Conversation
A permanent account erase (DELETE /v1/account?permanent=true, or the restore interstitial's erase now) of an account that sent to an external recipient within trash.recent_sender_erase_defer_days (default 14, 0 disables) now leaves the account in the trash and returns a trash receipt with erase_deferred, purge_after and a message. Late provider feedback keeps landing on the sending controls and aggregates until the janitor purges at the end of the window. The paused-account erase_held refusal is unchanged and checked first. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Regenerated the TS and Python bases, documented the deferred erase on both client wrappers, and added a shared contract scenario run by the Go, TS and Python runners against a new seeded disposable account. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
The CLI prints that permanent erasure was deferred and when purge happens. The settings page and restore interstitial tell the user the account stays in the trash until purge_after and can still be restored. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
…enders Permanently deleting the agents (or trashed messages) that emailed external recipients removed the message_recipients evidence the account erase check reads, so an account could purge its sends and then erase itself immediately. The same window now applies one level down: a permanent agent delete moves the agent to (or keeps it in) the agent trash, and a permanent delete of an externally sent trashed message leaves it in the message trash. Both receipts gain erase_deferred, purge_after and message; window 0 disables everywhere and the paused account erase_held refusal still comes first. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Regenerated both SDK bases, documented the deferral on the TS and Python agent/message delete wrappers, gave the Python agents.delete the permanent flag the TS SDK already had, and added a re-runnable shared contract scenario on a new seeded account. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
delete_agent describes the deferral; the dashboard trash views explain a deferred Delete forever and keep the row restorable. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Review follow-ups: - exempt provider test domains (trash.erase_defer_exempt_domains, default simulator.amazonses.com), the shared agent domain by name, and system/internal account classes, so the prober and e2e cleanups no longer pile deferred rows into the trash; - parse the recipient domain after the LAST @, so a quoted local part cannot pose as an internal domain; - decide the account deferral inside purgeAccount's claim transaction, under the user row lock; - count provider-accepted but unsettled sends and sends claimed inside the window, classified by their own recipient lists; - refuse an explicit defer window longer than either trash window and lower the unset default to the shortest window; - bound the lookup with index range scans (retry-lag lower bound plus a partial expression index for scheduled/held sends, migration 125); - cancel a deferred agent's pending scheduled sends. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
The prober's message sweep counts erase_deferred purges apart instead of booking them as purged, and the e2e harness reports deferred permanent deletes separately without retrying them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
- MCP delete_agent/delete_domain and the deleteDomain operation explain that a deferred agent keeps its domain blocked until purge_after (SDKs regenerated). - Tests for surviving mutants: provider evidence alone defers, a stale owner-mailbox proof counts as external, account trash disabled erases immediately, and each trash window is validated and lowered on its own with the exact field named. - Config.EraseDeferExemptDomainList appends the shared domain (tested) and cmd/e2a uses it. - EraseDeferRetryLag is exported and pinned against the send worker's longest hold horizon by a test in outboundsend. - Explain why a failed deferral check defers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW
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.
Why
Account deletion (v1.11.0) defaults to a 30-day trash. While an account is in the trash, its
account_sending_controlsrow and its bounce/complaint aggregates survive and keep updating from late SES feedback.DELETE /v1/account?confirm=DELETE&permanent=trueskips that window and purges at once.Abusive senders can exploit this: send a burst, then erase the account immediately. Complaints arrive hours to days after a send, so they land after the account is gone and count against nothing. Until now the only thing that refused an immediate erase was an active sending pause (
409 erase_held).Contract (additive)
A permanent erase of an account that sent to an external recipient within the last N days is deferred. The account goes to the same trash the default delete uses, and the janitor purges it at the normal end of the account trash window. From the caller's side this is a success, not an error:
{ "deleted": true, "mode": "trash", "erase_deferred": true, "purge_after": "2026-10-28T12:00:00Z", "message": "This account emailed external recipients recently, so it is kept in the trash until purge_after ...", "user_deleted": false, "messages_deleted": 0, "agents_deleted": 2 }DeleteUserDataResult:erase_deferred,message.modeandpurge_afteralready exist.oasdiffreports no breaking changes.domainsrows with no owner).409 erase_held, which is checked first.trashnotice. The purge notice follows from the janitor.deleted_at) still works (tested).Data source:
message_recipients. The send path writes one row per normalized envelope recipient exactly when the provider or relay accepts the message, so drafts, holds, refusals and queued mail never count. The query goes through the account's agents andidx_messages_agent_created, andEXISTSstops at the first hit. The send time is the latest ofcreated_at,provider_accepted_at,reviewed_atandscheduled_at, so a scheduled or review-held message that was submitted recently still counts. No new table. Alternatives rejected:usage_eventshas no recipient addresses (it can't tell external sends from agent-to-agent ones) and isn't written for non-standard account classes. Thesending_feedback_*ledger stores recipients only as keyed digests.Config
trash.recent_sender_erase_defer_days(default14,0disables; envE2A_TRASH_RECENT_SENDER_ERASE_DEFER_DAYS; negative values are rejected at startup). It has no effect when account trash is disabled (account_retention_days: 0). It is documented inconfig.example.yaml.Symmetric client changes
permanentparam descriptions plus the new response fields. Both SDK bases regenerated withmake generate-sdk(OAG v7.16.0).client.account.delete()and Pythonclient.account.delete()docstrings describe the deferred case.e2a account delete --permanentprints "Permanent erasure deferred: …" and the purge date.--jsonis unchanged (raw receipt).delete_agent; see below).docs/api.md,docs/data-handling.md, and a newdocs/design/account-soft-deletion.md. Code already cited that file, but it was never committed. For now it holds only a short pointer plus the "Deferred erase for recent senders" section, which now includes the agent and message levels.Test evidence
internal/identity(DB): external send 1 day ago → trashed,erase_deferred,purge_after == deleted_at + retention, control row, aggregates and messages intact, a second erase is deferred again with zero counts, restore works. Last external send 15 days ago → purged. Sends only to own agents, the verified owner mailbox, or another shared-domain agent → purged. An unverified owner mailbox counts as external. A message created 20 days ago but accepted 1 hour ago → deferred. Window 0 → purged. Paused →ErrEraseHeldand the account is untouched. Backdate plus janitor → purged.internal/agent: a deferred erase notifies billing withtrashat/account-stateand never calls the cancel hook.internal/httpapi: 200 deferred receipt shape. The interstitial keeps the restricted cookie.internal/config: default, zero, negative and env override.purge_after = deleted_at + trash retention, recipient evidence intact). A second delete is deferred again, restore works, and a later account erase is still deferred.erase_heldand the agent is not trashed.purge_after, and an internal-only message is purged.permanentflag. The web trash-view tests cover the deferral notice.systemandinternalclasses are never deferred.failedwith the deferred-purge detail, and a later restore doesn't re-arm it.TestPurgeMessage_AllowsStaleOrphanedSendClaim,TestDeleteAgent_AllowsStaleOrphanedSendClaim) now run with the deferral disabled. Their stale claim to an external address is now evidence by design.permanent_agent_and_message_delete_deferred_for_recent_senderscenario on a new seeded account (E2A_TEST_DEFERRED_PURGE_API_KEY). It passes on the Go, TS and Python runners.account_delete_permanent_deferred_for_recent_senderscenario on a new seeded disposable account (E2A_TEST_DISPOSABLE_DEFERRED_ERASE_API_KEY, which CI picks up through the env file). It passes on the Go, TS and Python runners against a live contract server.go build ./...,go veton the touched non-test packages,go test -tags integrationfor identity/agent/httpapi/apiserver/config/janitor/auth/cmd/tests/contract,make spec-check,make openapi-compat-check(no breaking changes),make generate-sdkplus the generator unit tests, SDK/CLI vitest, TS/Python contract, pytest plus mypy, web jest (1096 passed) plus lint, and the repo text integrity check.origin/maincheckout, so this PR doesn't cause them:TestInboundReviewApprovalAtomicallyAdvancesAuthenticatedEngagement(identity)TestMarkSentIsNotDoubleCountedOnRedriveandTestMarkSentUpdatesOutreachFromToRecipients(agent; the latter is flaky on both trees)internal/e2etests (outreach, webhooks, threading, eval runner)Agent and message level (closes the evidence-removal bypass)
The account check reads
message_recipients, which cascades frommessages. Before this change an account could permanently delete the agents (or sent messages) that emailed externally and then erase itself immediately. Every on-demand path that purges sent mail now follows the same rule, using the same predicate scoped to one agent or one message:DELETE /v1/account?permanent=true, restore interstitial "erase now"mode: trash,erase_deferred,purge_after,messageDELETE /v1/agents/{email}?confirm=DELETE&permanent=true(live or trashed agent)trash.retention_days)erase_deferred,purge_after,message,messages_deleted: 0DELETE …/messages/{id}?permanent=true&confirm=DELETE(trashed message)trash.retention_days)erase_deferred,purge_after,message409 erase_heldstill comes first. Read-only accounts cannot reach these writes.delete_agentgoes through the same/v1operation. No path purges sent mail immediately without a trash.DeleteAgent,DeleteAgentIncarnation,PurgeMessage) returnidentity.ErrPurgeDeferredwhen a purge is deferred, so no internal caller can silently skip the rule.DeleteAgentResultandDeleteMessageResult, plus updated operation and param docs. oasdiff reports no breaking changes. Both SDKs regenerated.agents.deletenow takes thepermanentflag the TS SDK already had.delete_agentdescription is updated.delete_messageis soft-only, so it is unchanged.Review hardening (third round)
success@simulator.amazonses.comevery tick, and the e2e harness permanently deletes throwaway agents that did the same. Both would have piled deferred rows into the trash until the storage cap blocked the probe. Now exempt:trash.erase_defer_exempt_domains(defaultsimulator.amazonses.com; envE2A_TRASH_ERASE_DEFER_EXEMPT_DOMAINS);shared_domain, matched by name even when an account owns itsdomainsrow;systemandinternalaccount classes.erase_deferredpurges asdeferred, never aspurged. The e2e harness reports deferred permanent deletes in a newdeferredlist, untracks them, and doesn't retry.@, so"x@<shared>@y"@victim.exampleis external.purgeAccount's claim transaction, under the userFOR NO KEY UPDATElock, next to the pause re-check.recent_sender_erase_defer_dayslarger thanretention_daysoraccount_retention_daysis refused at startup. When unset, the effective value is 14, lowered to the shortest trash window, so existing short-trash configs still start.idx_messages_agent_created, with a 14-day retry-lag lower bound;idx_messages_agent_delayed_outboundon(agent_id, GREATEST(scheduled_at, reviewed_at)), added by migration 125 (CREATE INDEX CONCURRENTLY, no-transaction).email.failed, so a restore can't re-arm them.FinalizeScheduledCancellationTxnow takes the detail string.deleted_at.origin/main(migrations 123/124) is merged into the branch. This was a normal merge, with no history rewrite.Final review batch
delete_agentanddelete_domaindescriptions, and thedeleteDomainoperation description in the spec, now say that a deferred agent keeps its domain blocked (domain_has_agents) untilpurge_after. Both SDKs are regenerated.failedrow;Config.EraseDeferExemptDomainList()appends the shared domain (it now feedscmd/e2a).identity.EraseDeferRetryLag(14 days) is now exported. The send worker derives its longest hold deadline from the constantoutboundsend.PolicyBudgetHoldHorizon(7 days) and never reads the runtime policy'sbudget_hold_max_days. A test inoutboundsendfails if that horizon ever outgrows the lag minus a day.Remaining limit
The janitor's time-based purge is intentionally not deferred; config validation now keeps the defer window within both trash windows. Recipient lists on unsettled messages are compared verbatim, so display-name forms count as external (the conservative direction).
Ops note
The hosted privacy page (ops repo,
legal/) needs a matching line: permanent deletion of an account, inbox or sent message that recently emailed external recipients is completed at the end of the trash window rather than immediately.🤖 Generated with Claude Code
https://claude.ai/code/session_018tVLxUHk3fqQuq8C3wqyHW