feat(sending): deletion-resistant feedback provenance and subtype aggregation (B8) - #1013
Open
jiashuoz wants to merge 5 commits into
Open
feat(sending): deletion-resistant feedback provenance and subtype aggregation (B8)#1013jiashuoz wants to merge 5 commits into
jiashuoz wants to merge 5 commits into
Conversation
…letion Slice B8 of the sending abuse prevention plan. Provider feedback now counts even when the message, agent, or account it belongs to is gone. - delivery: a FeedbackProcessor seam the SNS consumer runs BEFORE any live-message lookup, in its own transaction, failing the notification if accounting fails so the provider retries. The parser retains complaintSubType and the echoed X-E2A-Provider-Attempt marker. - sendingpolicy: the module implements the seam. It correlates by SES id then attempt marker, matches recipients against the keyed HMACs recorded at authorization, dedupes by provider event id, derives the detector bucket from kind + subtypes (suppression-list subtypes excluded but repairing), moves per-recipient evidence monotonically with aggregate subtract/add on the correlation's shared/dedicated path under the account's current epoch, and repairs the account suppression while the account exists. VerifyKeyringCoverage fails startup when a retained row's key version is missing; GCFeedback and an hourly maintenance job honor the post-deletion horizon. - suppressionsync: the generation-bumping upsert and the pending-removal race contract Task 11 builds on; identity.AddSuppressionTx routes through it. - identity: account deletion stamps the post-deletion retention on the account's correlations and events instead of removing them. - main: wires the processor, the coverage gate, the retention, and the maintenance job through the composition root. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
Two reviews found the same blocker at the seam boundary: the accounting seam wrote the account suppression in its own transaction, so a live transaction that failed after that commit lost the suppression_added event and its lifecycle transition permanently on the SNS retry, and every feedback suppression lost its source_message_id and changed its reason shape on the public API. The seam now does accounting only and REPORTS the repairs an event proves. When a message survives, the consumer writes the suppression inside its own transaction exactly as before. Only when no message can own the row does the consumer apply the repair through the seam, and repair is limited to customer messages so a bounced approval notice cannot suppress the account owner's own address. Also from review: - the retention janitor reads the EFFECTIVE policy, so a database-source deployment cannot silently delete evidence at the config-file window; - the keyring gate skips a deployment with no keyring (it signs and matches nothing) and asks a bounded question instead of scanning; - migration 121 indexes the three feedback access paths that were on sequential scans (coverage gate, retention janitor, deletion stamp); - feedback recipients are addr-spec extracted before the HMAC match, so a DSN Final-Recipient with a display name still matches, and unmatched recipients are counted and logged; - suppression-list subtypes compare case-insensitively, like bounce type; - the correlation lookup prefers an unexpired row deterministically; - tests for the retry, the purged-message repair, the equal-rank provenance tie-break, and the repair guard on a deleted account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
The tie-break test asserted a Go timestamp against the value read back from timestamptz. macOS clocks are microsecond-granular so it passed locally; Linux clocks are nanosecond-granular so CI truncated and the comparison failed. Truncate before asserting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
…s once Round-2 re-review: no blockers, and both round-1 blockers proved closed by mutation. These are its should-fixes. - The purged-message repair now fires suppression_added. A row appearing in the customer's list with no event is the same state/notification desync the message-backed path exists to avoid, and the payload's message id is already documented as present only when known. - Address normalization moved into the parser, so the detector and the suppression list agree on one value. It also strips the DSN Final-Recipient address-type prefix. Previously a decorated recipient would credit the detector under the real address while suppressing a literal "Bob <bob@x.test>" the customer could never clear. - Tests for the two properties the reviewer showed were uncovered: the stored suppression reason and source (mutating the diagnostic passed before), and platform mail accounting without repairing (mutating the purpose guard passed before). Both now fail under those mutations. - RepairSuppressions takes FOR KEY SHARE on the account rather than a bare EXISTS, so a concurrent deletion is a no-op instead of a constraint violation; the unmatched-recipient log fires once per event, not per redelivery; the config-source policy read no longer opens a transaction; dead helper removed. - Migration 121 drops the recipients index: its only reader is the keyring gate's "version not in set" predicate, which no btree can serve, so it was write amplification with no reader. The comment now states what the gate actually costs and that CREATE INDEX takes SHARE. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
The coverage audit found the janitor at 0% and both production wiring lines untested — the two places where a silent failure looks like health. - The retention janitor DELETES evidence, and a review already caught it reading the wrong policy source. It now has tests: a database-source deployment cuts at the policy row's window and not the config file's, an expired correlation takes its recipients and events with it while a retained one survives, and the periodic reaches River's maintenance queue. Reverting the effective-policy read fails the first. - The delivery consumer's accounting seam and the janitor's registration were each one line in main.go with nothing asserting them. Dropping either leaves every test green while the feature is inert in production: the consumer still acks and still runs the lifecycle, so the detector simply never receives evidence. Both are now composed through the outbound root and pinned by a wiring test that fails when the root forgets them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
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.
Slice B8 of the sending abuse prevention plan (ops PR #320, Task 8): deletion-resistant feedback provenance and subtype aggregation. Passive evidence capture only; the detector (B9) is not in this PR and no control is enabled.
What changes
Feedback counts after deletion. The SNS consumer runs a new
delivery.FeedbackProcessorseam BEFORE it looks for a live message, in its own transaction; if accounting fails the notification is not acked and the provider retries. The seam (implemented by the sending-policy module) never readsmessages,agent_identities, orusers:X-E2A-Provider-Attemptmarker (now parsed; shape-checked like the message-id marker);delivered,terminal_other(transient/undetermined bounce),hard_bounce(permanent, including global-listSuppressed),complaint. Account/tenant suppression-list subtypes (bounce orcomplaintSubType) arenonebut still repair the local list;none < delivered < terminal_other < hard_bounce < complaint): subtracts the prior bucket from the epoch/day it was counted in and adds the new one to the account's currentoutcome_epoch+ ingestion UTC day on the correlation's immutable shared/dedicated path; equal rank is a zero delta with provider time then event id deciding stored provenance; lower evidence never regresses;Suppression upsert ownership →
internal/suppressionsync. The upsert advancessync_generationand clearsremoval_pending; a removal that observed an earlier generation deletes nothing.identity.AddSuppressionTxroutes through it (existing rows keep their first reason/source). The consumer uses the seam's Inserted verdict for thesuppression_addedevent so a second upsert cannot hide a genuine insert.Retention. Account deletion stamps the post-deletion horizon (policy
sending_feedback_post_account_retention_days, default 30) on the account's correlations and events. An hourlysending_feedback_maintenancejob removes expired provenance and daily outcome rows older than the detector window + 1 day.Keyring coverage is a startup gate. The server refuses to start if any unexpired recipient row was signed under a key version the keyring does not hold (rotation is superset-first).
Tests
sendingpolicy: bucket/rank table; purge message + agent then feedback by SES id and by attempt marker with HMAC match and a forged recipient rejected; monotonic evidence (delivered→complaint moves the unit, delayed delivery/suppression-subtype ignored, equal-rank zero delta, hard bounce kept across a suppression-list bounce, terminal_other denominator); shared vs dedicated aggregates incl. a HITL notification on the shared path; post-account-deletion provenance only; keyring rotation (superset matches, narrow keyring fails coverage and cannot match, expiry releases the version); GC.suppressionsync: upsert generation + stale-removal race.delivery: processor runs first and for uncorrelated events, processor error fails Process, Inserted verdict drives the event, parser retains subtypes and drops a malformed marker.identity: account deletion stamps retention on that account's rows only.outbound: the attempt header name is pinned equal across the adapter and the parser.Plan step 5 (
go test -p 1 -raceover delivery/sendingpolicy/suppressionsync) green locally.Review round 1 (correctness + adversarial, Opus) → second commit
Both reviewers found the same blocker: the seam wrote the suppression in its own transaction, so a live transaction that failed after that commit lost
suppression_addedand its lifecycle transition permanently on the SNS retry (the seam returned Duplicate with no suppressions, the store then reportedadded=false), and every feedback suppression lostsource_message_idand changedreasononGET /v1/account/suppressions.Fixed by giving ownership back to the live path. The seam does accounting only and reports
RepairNeeded; when a message survives, the consumer writes the suppression in its own transaction exactly as before (same fields, same event semantics, insert atomic with the announcement). Only when no message can own the row does the consumer callRepairSuppressions, and repair is limited to customer messages so a bounced approval notice cannot suppress the account owner's own address. A regression test drives the failure-then-retry and asserts exactly one event.Also applied: retention janitor reads the effective policy (a database-source deployment could otherwise delete evidence at the config window); the keyring gate skips a deployment with no keyring and asks a bounded question instead of scanning two unindexed tables at every boot; migration 121 indexes the three feedback access paths; feedback recipients are addr-spec extracted before the HMAC match (a DSN
Final-Recipientwith a display name previously failed silently) and unmatched recipients are counted and logged; suppression-list subtypes compare case-insensitively; the correlation lookup prefers an unexpired row deterministically; the account-deletion lock-order interaction is documented. New tests: retry, purged-message repair, equal-rank provenance tie-break (an earlier straggler must not overwrite), repair guard on a deleted account.Not changed, called out instead: the
suppressionsyncgeneration guard has no production remover yet (RemoveSuppressionis still an unconditional delete) — it is Task 11 groundwork, and the design addendum now says so.Review round 2 (mutation-tested re-review, Opus) → third commit
Verdict: no blockers. Both round-1 blockers proved closed by mutation (breaking the fix fails a named test in each case), and the equal-rank tie-break gap from round 1 is closed too. Its should-fixes are applied:
suppression_addedwith no message id (the schema documents that field as present only when known). Silently adding a row to the customer's suppression list was the one genuinely new customer-visible behavior in the slice.Bob <bob@x.test>the customer could never clear. The DSNFinal-Recipientaddress-type prefix is stripped too.reason/source(mutating the diagnostic to a constant passed the whole suite before) and platform mail accounting-but-not-repairing (mutating the purpose guard passed before). Both fail under those mutations now.RepairSuppressionstakesFOR KEY SHAREon the account instead of a bareEXISTS(a concurrent deletion was a constraint violation, not a no-op); the unmatched-recipient log fires once per event rather than per redelivery; the config-source policy read no longer opens a transaction; dead helper removed.enable_seqscan=off. It was write amplification on the hottest of these tables with no reader. The remaining three indexes are justified, and the comment now states the gate's real cost and that non-concurrentCREATE INDEXtakesSHARE.Coverage audit → fourth commit
A pass over the new code found two things with no coverage at all, both in the category where a failure is silent:
main.go. Dropping either leaves the whole suite green while the feature is inert in production, because the consumer still acks every notification and still runs the message lifecycle — the detector just never receives evidence. Both now compose through the outbound root and are pinned by a wiring test that fails when the root forgets them.Function coverage of the new code after this: decision logic (bucket derivation, ranking, subtype exclusion) 100%; the stateful paths 77–85%, with the remainder being database-failure branches; the janitor 67–100%, up from 0%.
Gate
B8's gate is ingestion observation, and it has to run on prod, not staging:
config.staging.yamldeliberately omitsdelivery_feedback:, so SES publishes no delivery/bounce/complaint events there and nothing would arrive to observe. The keyring is wired in both hosted environments (E2A_SENDING_FEEDBACK_HMAC_KEYSin the env assembler and both compose files), so recipient provenance is written on both today.After the release that carries this slice, confirm on prod that SES feedback for real sends lands in
sending_feedback_events/sending_feedback_recipientswith buckets set, thataccount_sending_outcomes_dailyaccumulates, that the hourly maintenance job runs, and that the unmatched-recipient log line stays quiet (a systematic normalization or key mismatch is exactly what it exists to surface). No activation, no detector.🤖 Generated with Claude Code
https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX