Repository navigation
Optional pre-send compliance check with Precleared (off by default) - #8
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FQdvgNwwnTYBHUGD2dPwFh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FQdvgNwwnTYBHUGD2dPwFh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FQdvgNwwnTYBHUGD2dPwFh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FQdvgNwwnTYBHUGD2dPwFh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FQdvgNwwnTYBHUGD2dPwFh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FQdvgNwwnTYBHUGD2dPwFh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FQdvgNwwnTYBHUGD2dPwFh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FQdvgNwwnTYBHUGD2dPwFh
ethanplusai
left a comment
There was a problem hiding this comment.
Reviewed commit 616d6c96aa492cc0ce68012cb64ba082c0c86c20. Requesting changes for three reproduced issues in the enabled path.
1. [P1] Include decision inputs in the clearance cache key
harvey/integrations/precleared.py:94–98 keys cached clearance only by normalized email. However, the API decision also receives sender country, address source, and pitch. A configuration change can therefore reuse an approval for a different set of facts for up to 30 days.
Reproduction: obtain ok_to_email for an address with US / published / Delivery software. Construct a new client against the same state with GB / unknown / Unrelated offer and a mock that returns consent_needed. The second lookup returns the old approval and receipt; total upstream calls remain 1. Expected: changed inputs cause a fresh check and the new rejection is honored.
Please fingerprint the effective decision inputs alongside the address and invalidate legacy address-only entries when those inputs cannot be established.
2. [P2] Held cold-email backlog prevents replies from being fetched
harvey/agents/sender.py:440–456 fetches only budget * 3 due approved items (24 with the normal budget). When these are all cold emails and Precleared returns 429, they remain approved at the front of the queue. A later reply is outside the fetched page and never reaches the reply exemption, including on subsequent cycles.
Reproduction: queue 24 due approved sequence items, followed by one due approved reply for another prospect. Make Precleared return 429 and drain twice. Actual: zero captured sends; the reply stays queued. Expected: the reply sends once while cold email remains held. The reproduction uses one prospect's 24 items to keep setup minimal; the same pagination issue applies to a backlog spread across prospects.
Please fetch replies independently or paginate past held items with bounded work so unavailable compliance checks cannot starve replies.
3. [P2] Validate response structure before accessing fields
harvey/integrations/precleared.py:145–159 catches JSON decoding errors but assumes decoded JSON is an object. HTTP 200 with [] raises AttributeError: 'list' object has no attribute 'get' at line 151, aborting the drain before later replies are sent.
Reproduction: queue one cold email followed by a reply; return HTTP 200 with JSON [] from the mock. Actual: _drain_due() raises and the reply is not sent. Expected: malformed service data holds cold email as an unavailable check while the reply still sends. Please validate top-level and nested field shapes, including reasons, before using them.
Validation and runnable reproductions
python -m pytest -q tests/test_precleared.py tests/test_outbox_native.py: 24 passed locally on Python 3.12.
Save the following as tests/test_review_precleared_edges.py, then run python -m pytest -q tests/test_review_precleared_edges.py --tb=short. All three tests currently fail on the reviewed commit. They reuse this PR's fixtures; every Precleared request uses httpx.MockTransport and mail uses FakeProvider. No real emails or live Precleared requests were sent.
import httpx
import pytest
from tests.test_precleared import state, queue, mock_precleared, OK_BODY, CONSENT_BODY, with_check
from tests.test_outbox_native import seed_prospect, make_sender, FakeProvider
from harvey.integrations.precleared import PreclearedClient
@pytest.mark.asyncio
async def test_changed_context_requires_new_check(state):
calls = []
initial = PreclearedClient('US', address_source='published', pitch='Delivery software', transport=mock_precleared(calls, OK_BODY))
assert (await initial.clear('person@example.com', state)).allowed
updated = PreclearedClient('GB', address_source='unknown', pitch='Unrelated offer', transport=mock_precleared(calls, CONSENT_BODY))
result = await updated.clear('person@example.com', state)
assert not result.allowed, f'Stale approval reused; upstream calls={len(calls)}'
@pytest.mark.asyncio
async def test_quota_backlog_does_not_starve_reply(state):
pid = await seed_prospect(state)
for step in range(24):
await queue(state, pid, step=step+1)
reply_pid = await seed_prospect(state, email='reply@example.com', status='replied')
await queue(state, reply_pid, kind='reply', to='reply@example.com')
provider = FakeProvider()
sender = with_check(make_sender(state, provider), mock_precleared([], status=429))
await sender._drain_due()
await sender._drain_due()
assert [m['to'] for m in provider.sent] == ['reply@example.com']
@pytest.mark.asyncio
async def test_bad_response_shape_does_not_prevent_reply(state):
pid = await seed_prospect(state)
await queue(state, pid)
reply_pid = await seed_prospect(state, email='reply@example.com', status='replied')
await queue(state, reply_pid, kind='reply', to='reply@example.com')
provider = FakeProvider()
sender = with_check(make_sender(state, provider), mock_precleared([], body=[]))
await sender._drain_due()
assert [m['to'] for m in provider.sent] == ['reply@example.com']- Store a fingerprint of the decision inputs with each cached verdict; legacy address-only entries are never reused - When the check is on, fetch due replies separately so held cold email can't starve them - Treat responses that aren't a JSON object with a known verdict as an unavailable check; ignore optional fields with the wrong shape - Regression tests for all three, including the reviewer's reproductions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FQdvgNwwnTYBHUGD2dPwFh
|
Thanks for the thorough review and the runnable reproductions. They made these easy to pin down. All three are fixed, and your three tests are now in 1. Cache key (P1). Each cached verdict now stores a fingerprint of the facts it was decided on: sender country, address source, product description and 2. Replies starved by held cold email (P2). With the check enabled, 3. Response shape (P2). Only a JSON object with a known verdict counts as an answer. Anything else (a list, a string, a missing or unrecognised verdict) is treated as an unavailable check: cold email is held, nothing is cached, and the rest of the cycle carries on. Optional fields are checked as well: The full matrix passes on my fork with these changes (448 passed on Ubuntu 3.12, up from 440): https://github.com/gabrielaoimevbore-beep/harvey/actions/runs/38613074971 |
ethanplusai
left a comment
There was a problem hiding this comment.
Re-reviewed the fixes at 2458842. All three previously reported reproductions now pass: changed decision inputs invalidate cached clearance, held cold-email backlogs do not starve replies, and malformed service responses hold cold email without aborting reply processing. Local validation: 448 tests passed, 19 skipped; the focused run including the three regression reproductions passed all 35 tests. All six GitHub CI jobs passed. Tests used mocked service responses and fake mail providers; no real emails were sent. Approved.
Harvey's gate already does the important thing: checks in code, not just in the prompt. This adds one optional check to that step. Before the first cold email to a new address, Harvey can ask Precleared whether cold marketing email to that address is allowed under UK PECR, US CAN-SPAM, Canada's CASL, Germany's UWG or France's rules.
It suits Harvey because small local businesses are exactly where those rules split. A UK sole trader counts as an individual and needs consent, while a limited company doesn't. Germany wants prior consent even for business email. Canada depends on whether the address was published.
What changes
compliance.precleared.enabled: trueandsender_country: GBorUS.ok_to_email: sends as normal and logs the receipt id on theemail_sentaction.precleared: consent_needed: Sole traders count as individuals... (PECR reg. 22(2)) Do not send....Data sent: the recipient's address, the sender country, the address source (default
unknown, since Harvey builds most addresses from a pattern) andproduct.description, which is used for the relevance tests under CASL and the French rules.Cost: 10 checks a day without a key, 25 with a free key (
PRECLEARED_API_KEY), paid packs beyond that.Tests:
tests/test_precleared.pycovers: off by default, config validation, both kinds of verdict, one check per address, a blocked verdict remembered for later steps, the daily limit holding cold email while replies still go, network errors, and replies never checked. Every Precleared call goes to anhttpx.MockTransport, so there's no network in the tests. The full test matrix (Ubuntu, macOS and Windows on Python 3.11 and 3.12) passes on my fork: https://github.com/gabrielaoimevbore-beep/harvey/actions/runs/38592047181Disclosure: I built Precleared, and I wrote this with help from Claude. If you'd rather not have a named service in core, I'm happy to reshape it as a generic pre-send hook with Precleared as one implementation.
🤖 Generated with Claude Code
https://claude.ai/code/session_01FQdvgNwwnTYBHUGD2dPwFh