Skip to content

email-security: a message that joins a campaign late, and the EMAIL_VERDICT that says so - #391

Open
maximelb wants to merge 2 commits into
masterfrom
retrojoin-late-attribution
Open

email-security: a message that joins a campaign late, and the EMAIL_VERDICT that says so#391
maximelb wants to merge 2 commits into
masterfrom
retrojoin-late-attribution

Conversation

@maximelb

@maximelb maximelb commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

HOLD — merge with the prod release

This documents behaviour that is not in production yet (go-mailsec#139 + legion_mailsec#95, deployed to the experimental datacenter first). Do not merge until that ships to prod.

Why

Campaign clustering runs while a message is being ingested, so two copies of one attack that arrive in the same instant each look for a campaign-mate before the other has been written down. Both are stored attributed to nothing, and a campaign that forms around a third copy later leaves one of them out — so a campaign-wide quarantine misses it and the campaign's member_count under-reports the spread.

A bounded retro-join pass now re-asks that question for 24 hours after delivery, and reports the answer as an EMAIL_VERDICT carrying campaign_joined_late: true.

Both facts are customer-visible and neither was documented: a customer whose campaign grew after they swept it needs to know why, and a customer writing a D&R rule needs the field to gate on.

What changed

campaigns.md — a new "Messages that join after the fact" section, placed straight after "A campaign takes two messages" because it is the same subject continued: what happens to the message that had nobody to pair with yet. It states the two numbers a customer can observe (24 hours from delivery, re-asked at most every ten minutes), what they will see change (campaign_id, cluster_reason, member_count, and that a sweep from that point reaches the message), and the one case that is never re-asked — a message with no usable cluster keys, because no answer could change.

automation.md — a new "A message that joins a campaign late" subsection under the existing EMAIL_VERDICT reference, plus two rows in that reference's field table (campaign_joined_late, cluster_reason).

The subsection exists because the revision block on this event needs explaining or it will be misread. It is a restatement, not a decision:

  • revision/seq is the message's current revision sequence — 0 when the engine's verdict has never been overridden, which is the usual case — so a consumer already de-duplicating on (msg_uuid, seq) reads this as a decision it holds, now carrying a campaign. Without saying so, that consumer would either drop the event or treat it as a new decision, and both are wrong.
  • revision/decided_at is when the join happened, because that is the only thing the event reports as new. The original decision's clock is unchanged in the store and in the event that carried it.
  • an overridden message restates its analyst's or agent's decision verbatim, attribution included.

It also states why this is a flag and not a new event type: a rule already written against EMAIL_VERDICT keeps working and simply starts seeing the campaign, which is the one fact a campaign-wide response needs. A new type would leave every one of those rules blind to it.

A copy-pasteable D&R rule is included, and a tip saying why a customer would act on it at all — this event is how a response that has already run learns it missed something.

Anchors

Manually checked, both directions:

  • campaigns.md -> automation.md#a-message-that-joins-a-campaign-late — the heading exists (### A message that joins a campaign late, automation.md:60).
  • the new campaign_joined_late table row -> #a-message-that-joins-a-campaign-late — same-page anchor, same heading.
  • No existing anchor was renamed or moved; both changes are insertions.

Scope

Nothing else was corrected. The body-similarity section is already current (it predates this branch), and the pre-existing text about clustering, sweeps and member_count is unchanged.

…vent that says so

Clustering runs during ingest, so two copies of one attack that arrive together
can each be stored attributed to nothing. A retro-join pass re-asks the question
for 24 hours, and an EMAIL_VERDICT with campaign_joined_late: true reports the
answer so a response that has already run can learn it missed something.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
lcbill
lcbill previously approved these changes Sep 8, 2026
@maximelb
maximelb marked this pull request as ready for review September 8, 2026 11:42
@maximelb

maximelb commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

/lc-review

…t, and decided_at has two branches

Review found both claims too broad: the queue is not "every recently-delivered
ungrouped message" (most mail satisfies the two-key rule and would swamp it), and
revision/decided_at is the join's clock only on an unrevised message.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@maximelb

maximelb commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

/lc-review

2 similar comments
@maximelb

maximelb commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

/lc-review

@maximelb

maximelb commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

/lc-review

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.

2 participants