Repository navigation
Apply the shared-inbox limits to the path that actually sends - #48
Merged
Merged
Conversation
…ly sends #34 added address-keyed rate limits to the policy engine to stop one `support@` receiving a message per colleague. The gates are right. Only half the product passes them. `evaluatePolicy` takes `actionsToThisAddressThisWeek`, `hoursSinceLastActionToAddress` and `addressShared` as **optional** inputs, documented as "omitted when the caller could not resolve an address, in which case these gates simply do not fire". `apps/api/src/app.ts` — the human approval route — fills them in. `runAutopilot` never did. So the limits protected the path where a person is already looking at the message, and not the unattended one that sends at volume. Production shows the seam exactly. Every duplicate predates the fix except one: `info@thebarcelonafeeling.com` was written to at 14:03, #34 merged at 17:45, and autopilot wrote to it again at 18:39 — hours after the fix was live, through the half of the code the fix never reached. Optional inputs are why this was quiet. Omitting them is not a type error and not a runtime error; it is a gate that silently evaluates to "no opinion". The same shape will hide the next one, so the fields are now passed unconditionally rather than spread in behind a check — there is always an address by this point, since `pickEmailRecipient` returning nothing already skipped the row. Counted from `interactions.contact_address` — the mailbox a message reached — rather than from `actions`, which records who it was addressed to. For a shared inbox those are different questions and only the first can see fourteen colleagues arriving at one address. It also means a message sent by hand from the approval queue binds the automated path and vice versa: a mailbox does not care which half of the product wrote to it. Three tests, built from the production shape — two colleagues at one company, neither with a personal address, both inside their own per-person limits: - one message goes out, not two, and the one held back explains itself in terms of the address rather than the person - two people with their own addresses still get two messages, so this does not quietly become "one email per company" - an interaction recorded by the manual route stops the automated one 933 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ralyodio
added a commit
that referenced
this pull request
Aug 17, 2026
The address limits shipped in #48 work, and that is the problem. Production's queue holds 28 pending email cards that resolve to four shared inboxes — support@userlist.com alone stands in for 17 people — and all four were mailed within the last day. Every one of those cards showed as Ready, because `ready` only ever meant "an outbound action with a draft written". Clicking any of them answered "Only 12h since this address was last contacted; the cooldown is 72h", which reads as a broken queue when the person on the card has never been written to. The only way to learn that nothing was approvable was to click all twenty-eight. The queue now runs the two address gates ahead of time and says so on the card: which mailbox the message would reach, that it is the company's rather than the person's, and when the hold lifts. The Ready tab says how many of its cards can actually be sent. The gates are extracted into `evaluateAddressLimits` and the engine calls it too, so the preview and the refusal are the same code and cannot drift into naming different reasons for the same card. A test asserts they agree. This is still only a preview: approving re-runs the whole engine against current state, as it must, and that refusal remains the authority. Reading it costs two queries for the whole page, not four per card. The approvals page fetches up to 200 rows, so the per-card version would have been eight hundred sequential Turso round trips to render one screen. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio
added a commit
that referenced
this pull request
Aug 17, 2026
…selves (#50) Production holds 213 prospects and not one personal address. Every message therefore resolves to the company's shared inbox, the address limits added in #48 correctly refuse it, and the approvals queue reads as broken. The limits are not the problem. There is nowhere else to send. The obvious routes were tried and do not work. `/team`, `/about` and `/contact` were fetched for eight of these companies and every published address was a role mailbox — modern SaaS does not put staff addresses on the marketing site, so crawling deeper finds nothing. Commit metadata is worse: the addresses are public and the platforms publishing them specifically forbid using them for unsolicited mail. A licensed provider works and costs money per lookup, which is not this commit's decision to make; `PersonEnrichmentProvider` and the waterfall already exist for it and it drops in beside this. What is left is to learn the shape a company writes addresses in from one address already known to be right, and apply it to colleagues. The distinction this rests on is between deriving and guessing, and it is kept visible throughout: a candidate derived from a confirmed address at the domain carries its basis and ranks first, while a candidate built from priors alone is marked a guess and capped below any confidence that could be acted on. The safety property is that nothing here can send anything. Proposals live in `email_candidates`; the sender reads `social_identities`, which only `confirmCandidate` writes to, and only when a human has said yes. A derived address is a question put to the operator, never an answer the machine acts on. Confirming an address the operator simply knows is accepted even though nothing proposed it. That is deliberate and it is the most valuable input available: one confirmation at a company turns its colleagues from guesses into derivations, so the 17 people behind one shared inbox become 17 one-click decisions, each sharpening the next. Also fixes something nothing had noticed: `first_name` and `last_name` were null for 212 of 213 people, the whole name living in `display_name`. Splitting is what address derivation needs, so the stage records the parts as it goes. Dry-run against production, read-only: 195 people across 35 domains would be offered candidates. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 19, 2026
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.
#34 added address-keyed rate limits to stop one
support@receiving a message per colleague. The gates are right. Only half the product passes them.evaluatePolicytakesactionsToThisAddressThisWeek,hoursSinceLastActionToAddressandaddressSharedas optional inputs — documented as "omitted when the caller could not resolve an address, in which case these gates simply do not fire."apps/api/src/app.ts, the human approval route, fills them in.runAutopilotnever did.So the limits protected the path where somebody is already looking at the message, and not the unattended one that sends at volume.
The seam is visible in production
Every duplicate predates the fix except the last one. #34 merged at 17:45, and autopilot wrote to
info@thebarcelonafeeling.comagain at 18:39 — hours after the fix was live, through the half of the code it never reached.Why it was quiet
Omitting an optional input is not a type error and not a runtime error. It is a gate that silently evaluates to "no opinion". Nothing anywhere reports a limit that did not run.
The same shape will hide the next one, so the fields are now passed unconditionally rather than spread in behind a presence check. There is always an address by that point —
pickEmailRecipientreturning nothing has already skipped the row — so the conditional was only ever protecting against a case that cannot happen, at the cost of making absence look normal.Counting
From
interactions.contact_address— the mailbox a message actually reached — rather than fromactions, which records who it was addressed to. For a shared inbox those are different questions, and only the first can see fourteen colleagues arriving at one address.It also means a message sent by hand from the approval queue binds the automated path, and vice versa. A mailbox does not care which half of the product wrote to it.
Tests
Built from the production shape — two colleagues at one company, neither with a personal address, both comfortably inside their own per-person limits:
933 tests pass, format and typecheck clean.
Note on the existing damage
This stops it happening again; it does not un-send anything.
support@canny.iohas had 14 messages andsupport@featurebase.app4. Worth deciding separately whether those domains should be suppressed for a while.🤖 Generated with Claude Code