Skip to content

feat(WS-26d-autolead): auto-lead from inbound email, OFF behind CRM_AUTO_LEAD (D5) - #403

Open
vjvarada wants to merge 4 commits into
mainfrom
ws-26d-autolead
Open

feat(WS-26d-autolead): auto-lead from inbound email, OFF behind CRM_AUTO_LEAD (D5)#403
vjvarada wants to merge 4 commits into
mainfrom
ws-26d-autolead

Conversation

@vjvarada

@vjvarada vjvarada commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What

WS-26d-autolead (D5 — the last demo-path slice, spec §9.0): auto-mint CRM leads from inbound email. Ships OFF behind CRM_AUTO_LEAD (new settings field, default False); the flip is §6 owner gate (b) and has not been touched. New routes/crm/auto_lead.py runs as the sixth try/except-isolated step in process_new_mail (last, after auto-archive — mail the user's own rules archived never becomes a lead), gated lexically outside the step (AST-pinned: with the flag off, zero calls, zero queries, zero imports of the step module). Migration 158: per-account cursor table (157 is left for open PR #399 — the header names it; the parallel-session collision trap, dodged unilaterally).

Why the design looks the way it does

This slice writes to a live-syncing CRM unattended — no confirmation card exists on a scheduler hook, so the safety boundary is the OFF default, the flip gate, and the candidate arithmetic. Three successive review gates each caught a different member of the same defect family, all measured against the fake DB, none speculative:

  1. Audit (NO-GO → spec PR docs(WS-26d-autolead): close audit blockers G1/G2/G3 — backfill discriminator, content ruling, real dedup #402): the hook is also fired after deep resyncs — built naively, connecting a second mailbox would mint a lead per unknown sender in a year of mail, each pushed to the live Zoho tenant within one 600s cycle, with no delete tool. Fix: activated_at anchor — mail received before auto-lead was active mints nothing, by construction.
  2. Review round 1 (P1): a flag OFF→ON round trip minted the whole OFF-window backlog (27/27 measured). Fix: dormancy re-anchoring.
  3. Delta review (P1 on the fix itself): the re-anchor ate the Monday-morning prospect — process_new_mail only fires when a sync persisted mail, so every >1h lull tripped dormancy and the triggering message was excluded forever. The guarding test had been green on a hand-seeded state the scheduler cannot produce. Fix: clamp — the anchor moves to max(last_run_at, now − 1h), never now, and the re-anchor cycle runs its own batch. Quiet-weekend mail mints; OFF-window/outage backlog stays out; the residual (the final hour before an ON flip mints, drained across capped cycles) is asserted as a deliberate bound, not left implicit.

Other rulings recorded in the spec's as-built block: watermark advances over the contiguous successful prefix only (a transient failure holds the cursor and retries — WARNING-stalls visibly — instead of silently dropping up to 200 leads; a poisoned activity write deliberately does not hold, since the lead exists and dedup would stall forever); the first activity is type='system' metadata-only (outside the Zoho push predicate, pinned against the actual statement text; subject + sender + ids in meta, body never); Sent-probe case-folding (a correspondent you emailed as Asha@… who replies as asha@… is not a lead); subdomain-aware colleague gate; in-batch dedup with the no-unique-index decision documented (the migration-148 lesson); name/subject clipping against attacker-controlled display names.

Chain evidence

  • Audit: NO-GO on three doc blockers → closed by spec PR docs(WS-26d-autolead): close audit blockers G1/G2/G3 — backfill discriminator, content ruling, real dedup #402 → build against the settled text.
  • Verify: PASS — blocks 592 / 24 / 421 reproduced in a clean worktree (75 cases in test_crm_auto_lead.py alone), full-suite collection clean, six mutations re-run independently; found the feat(WS-27o…t): recurrence · dependencies · cards · calendar · timeline · search — parity backlog closed #399 migration collision that prompted the 158 renumber.
  • Review: REQUEST-CHANGES (2 P1 + 4 P2) → repair → REQUEST-CHANGES (1 P1 the repair introduced) → repair → APPROVE, with the reviewer's own added regression guards green (deep-resync-after-lull, second-lull re-admission, 27-day OFF→ON — all still mint nothing).
  • Mutation testing: 15 mutants, zero survived — flag gate, both cursor predicates, watermark advance, both colleague gates, activity type, service-write path, in-batch dedup, dormancy re-anchor, clamp-vs-reset, early-return, stall log level, case-exact probe (with a precision check proving the mutant narrowed rather than broke), tie-stamp handling, quiet-cycle stamping.

For the owner, before ever flipping the flag (§6 gate (b))

  • First ON run only activates — leads start from mail arriving after that moment.
  • Flipping OFF is a stop, not a pause: the backlog does not accumulate; mail from the final hour before a re-flip does mint (flip at a quiet moment if that matters).
  • A repeating sync.auto_lead_stalled WARNING means the cursor is deliberately holding on a failure — leads are deferred, not lost.
  • Migration 158 applies on the next deploy either way; it creates one small cursor table and changes no behavior while the flag is OFF (regression-pinned).

Deferred (recorded): the fake's _ordered asserts the , id tiebreak as SQL text only (latent-only property); schema.generated.sql untouched (owner chore, needs live DB).

🤖 Generated with Claude Code

vjvarada and others added 4 commits August 8, 2026 03:22
…ind a flag that is still off

The last demo-path slice. With CRM_AUTO_LEAD off — which is how it ships and how
it stays until the owner flips it (work_plan.md §6 (b)) — this branch changes no
runtime behaviour at all. That is not a caveat, it is done-when 2: the flag is
read at the CALL SITE in process_new_mail, so the OFF state enters no CRM code
and issues no CRM query. A gate that lived inside the step would have satisfied a
careless test while opening a database session on every sync cycle of every
mailbox to discover it had nothing to do. An AST assertion pins the gate's
position, because the runtime sentinel cannot see a refactor.

THE SEAM IS ALSO REACHED BY DEEP RESYNCS, AND THAT IS THE WHOLE DESIGN.
process_new_mail is the one place scheduler, manual sync and webhook all funnel
through — which is why the ticket chose it — but resync_account runs a ~1-year
all-folder backfill and then fires the same hook, a newly connected mailbox's
first sync is deep by the same heuristic, and neither stamps rules_held_back_at.
A candidate query of "everything classified" would therefore mint a lead per
unknown sender across a year of mail the moment a second mailbox connects, each
born zoho_dirty and queued for the LIVE Zoho tenant within one 600s cycle
(D-CRM-9), with no confirmation card anywhere on a scheduler hook and no delete
tool to take them back.

So the step keeps a per-account two-timestamp cursor (migration 157,
crm_auto_lead_cursors). activated_at is stamped ONCE on the first ON-state run
and never advanced: `received_at > activated_at` is the backfill discriminator,
and it says mail that ARRIVED before auto-lead existed for this account mints
nothing no matter when a resync gets around to classifying it. processed_watermark
is the ordinary incremental cursor. Both apply together. The test that matters
seeds a year-old classified backlog and asserts zero leads; deleting the first
predicate turns it red.

DEDUP IS A SELECT GUARD PLUS IN-BATCH DE-DUPLICATION, NEVER ON CONFLICT.
crm_leads has no unique constraint on email — idx_crm_leads_email is a plain
index, only zoho_id is UNIQUE — so the shape the ticket originally prescribed
could not have fired at all. The cross-invocation race (two concurrent syncs of
one account reading the same watermark) is ACCEPTED and recorded: the cost is one
visible, hand-deletable duplicate, and the fix that suggests itself is a UNIQUE
index on a column where 1,516 imported rows may already carry duplicates —
a deploy-blocking constraint of exactly the shape migration 148 had to defuse.
The in-batch half is asserted as one unknown-sender probe per ADDRESS rather than
"one lead": the fake shares a dict, so "one lead" would stay green with the dedup
deleted while production, where those two sessions are genuinely concurrent, minted
two.

"NEVER A COLLEAGUE" IS TWO GATES BECAUSE IT IS TWO QUESTIONS. sender_scope fails
SAFE to "external", which is the wrong direction when the consequence is a lead
row for your own CFO in a live tenant, so it is necessary and not sufficient. The
second gate is the internal-domain list, normalised through resolve_org_domains —
and that is where it earns its place rather than restating the first: sender_scope's
own extra-domain arm only lstrip('@')s its input, the exact divergence runner.py
documents, so a colleague on a second company domain that somebody typed as an
address is external to gate 1 and internal to gate 2. Deleting gate 2 mints him a
lead, and a named test says so.

The first activity is metadata and never content: type='system' — deliberately
outside sync_zoho's `type IN ('note','task')` push predicate, so the subject and
sender stay inside the native CRM even though the lead itself is queued for the
tenant — with an EMPTY body and no snippet. The candidate SELECT does not read
body_text or snippet at all: the projection is the privacy boundary, and nothing
downstream can leak a body it was never handed. The exclusion is asserted against
push_activities' own statement text, not against the constant beside it.

The lead goes through records.create_record, never raw SQL: _resolve_status, the
owner_email default, validate_source and mark_dirty_on_insert all live only
there, and only the last of the four is visible in the row afterwards. lead_name
is left to compute_lead_name over a display name stripped BEFORE it is split.

Placement: routes/crm/auto_lead.py, not the email package. What it does is write
a CRM record — it owns the cursor table, it uses the CRM's own write path, its
flag is a CRM owner gate. It registers no routes and, like broker_handlers.py, is
not imported from routes/crm/__init__.py. It IMPORTS the automation package's
public identity primitives rather than copying them: D-CRM-4 declined to import
another package's private helper, and a third copy of "is this person a
colleague?" is the drift that rule exists to prevent.

_crm_fakes.py gained one reader — jsonb `@>` containment. Without it the "have we
ever emailed them" probe was invisible to the fake, which answered "yes" for
every message in Sent regardless of recipient, and the already-known-contact case
would have been a test of nothing. Same lesson as the four readers WS-26d-email
had to add.

Tests: tests/unit/test_crm_auto_lead.py, 52 cases, every done-when named. Seven
mutants run red and reverted: the flag check, the activated_at predicate, the
watermark advance, the internal-domain gate, type='system', the service write
path, and the in-batch dedup.

BUILT, NOT FLIPPED, NOT DEPLOYED. R4 sweep in this same change: crm_app.md status
header + the WS-26d-autolead ticket + §9.0's D5 row, work_plan.md's WS-26 row and
§6 gate (b) — whose "the settings field does not exist yet" note is now retired —
plus the gateway and infra AGENTS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on number somebody else had claimed

Diff-review repair round. Both P1s needed a cursor that had actually been
running to see, which is why the first cut passed its own seven mutants.

P1-1 — activated_at guarded the wrong thing. It stops a deep resync, and it does
nothing at all about a flag that was on, turned off for four weeks, and turned
back on: the cursor still carried day-1's anchor, so the first ON cycle minted
the entire OFF window in one batch. The reviewer measured 27 of 27, each born
zoho_dirty and each pushing unattended into the live tenant. The anchor now means
the CURRENT ON epoch: a gap in the step's own runs beyond REANCHOR_GAP_SECONDS
(3600, six scheduler periods) re-stamps the cursor, mints nothing from the gap,
and says so at WARNING with gap_seconds. Fail-closed both ways — an OFF window
and a real outage each skip their backlog, because a missed lead is
hand-creatable and visible in the mailbox and 27 unattended tenant pushes are
neither.

⚠️ DEVIATION, with its reason: dormancy is measured on a THIRD column,
last_run_at, not on processed_watermark as ruled. The watermark tracks MAIL, not
runs, and the literal predicate has two production failures. A mailbox that is
merely quiet over a weekend carries a 60-hour-old watermark while the step has
run faithfully every 600s — it would be re-anchored, and Monday's first message,
the one this whole feature exists to catch, falls before the new anchor and mints
nothing. Every Monday. And a poison head message holds the watermark still ON
PURPOSE under P1-2 below; a watermark-based test would re-anchor past it after an
hour and quietly undo the stall that was supposed to stay visible. last_run_at is
stamped on every cycle including empty and stalled ones, so "quiet" and "not
running" stay different facts. The ruling's constant, log key and behaviour on a
genuine OFF window are unchanged. Both named tests are in the file, including the
control that a quiet-but-running mailbox is never re-anchored.

P1-2 — a failure advanced the cursor over the work it lost. This step opens a
SECOND session per lead through create_record while holding the batch's own, so
pool exhaustion fails many candidates at once; the old code advanced past all of
them. Measured: 3 candidates, 3 errors, watermark advanced, three leads gone for
good. The watermark now advances over the contiguous PREFIX that actually wrote
its leads and stops at the first that raised. Later successes in the same batch
are simply re-considered next cycle, which is free — the third unknown-sender
step finds the lead they already created. A held cursor logs
sync.auto_lead_stalled at WARNING EVERY cycle, because a held cursor and a quiet
mailbox both create nothing and only the level can tell them apart; that trades
silent loss for a visible stall on a genuinely poison head message, deliberately,
fail-closed toward the CRM.

The counter-case matters as much: a failed first ACTIVITY does NOT hold the
cursor. The lead is already committed, so the message would be skipped on
re-consideration and the activity never retried — holding for it would stall
forever on work that cannot be redone. It is counted separately as
activity_errors, and `created` now increments the moment the lead commits rather
than after its activity: the row exists and will push either way, and a cycle
logging created=0 beside a queued lead sends whoever reads it looking in the
wrong place.

P2-4 — the Sent probe inherited @>'s case-exactness. The owner wrote to
Asha@AcmeRobotics.com; she replied from asha@acmerobotics.com and was minted as a
cold lead. This module now asks with EXISTS over jsonb_array_elements and
lower(). _maybe_block_cold is left alone on purpose: it is the email package's
predicate with its own blast radius. The fake learned to READ the new shape — and
the hard part was stripping it, since its inner lower(recipient->>'email') =
:address is exactly what _JSONB_LOWER_CMP matches, which would have filtered the
outer rows on a column they do not have.

P2-6 — cfo@mail.fracktal.in passed both colleague gates. Gate 2 is now suffix
aware, anchored on a leading dot so notfracktal.in is still a prospect.

P2-5 — the cap had no tiebreak. ORDER BY rules_processed_at, id; fetch cap+1; and
when the cap falls INSIDE a group sharing one timestamp, defer the whole group.
Only then — deferring the last group unconditionally would shrink every capped
batch by one message for nothing. Latent in production, where the rules runner
stamps one transaction per message.

Also: lead_name and the activity subject are clipped (120/500, with a marker).
Nothing upstream bounds a display name, and it becomes a column every list, board
card and Zoho push carries.

MIGRATION RENUMBERED 157 → 158. Open PR #399 claims 157, and two migrations
sharing a number replay in filename order against the wrong schema. The ladder
carries a deliberate reservation gap until #399 lands; the header names it, the
test finds the file by content, and the contiguity assertion is replaced by the
uniqueness one — which is the property that actually protects the ladder and the
only one this branch can hold.

The step's import moved inside the gated branch so the OFF state does not load
routes/crm on the mail path. The predicate stays above the gate on purpose:
auto_lead_enabled is the flag's ONE definition, and reading settings.crm_auto_lead
in the hook would make two places responsible for agreeing what the flag means.
The import-inside-try divergence from the five sibling steps is now recorded
rather than left to be rediscovered.

52 → 73 tests; 7 → 13 mutants red and reverted, one of them precision-checked
(reverting the Sent probe must not redden the lower-case already-emailed case, or
the mutant broke it rather than narrowing it). _crm_fakes gained fail_on(after=N),
because where in a batch a failure lands IS the property under test.

Still BUILT, NOT FLIPPED, NOT DEPLOYED. R4 in the same change: the ticket's cursor
paragraph rewritten to the epoch-anchor semantics, done-when 8 and 9 added, the
as-built block's deviation list grown to seven, plus work_plan's WS-26 row and
§6(b) — which now tells an owner that turning the flag off is a stop, not a pause
that accumulates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…amp, don't reset

My own P1, introduced by the previous round's fix, and it was worse than the bug
it fixed: instead of over-minting once after a four-week outage, it silently
dropped a lead every single night.

The premise I built that fix on was false. I assumed this step runs once per
scheduler period, so a stale last_run_at meant "the service was down". It does
not run per period: email_ingestion/scheduler.py:463-472 reads `synced` off the
sync result and fires the hook ONLY when a sync actually persisted mail. A
mailbox with no new mail does not run this step at all, so last_run_at freezes on
a quiet mailbox exactly like the watermark does — which means the cycle that
trips the dormancy test is ALWAYS the cycle carrying the message that woke it.

Measured consequence: no mail 22:00 to 07:30, a cold prospect writes at 07:30:50,
the sync persists it, the step runs for the first time in nine hours at 07:31:05,
gap > 3600 so it re-anchors everything to 07:31:05 and returns early — and the
triggering message's received_at is fifteen seconds the wrong side of the anchor
its own arrival created. Excluded forever. Every night, every weekend, on exactly
the message this feature exists to catch.

The fix is to clamp rather than reset, and to never discard the triggering batch:
activated_at moves forward to now - REANCHOR_GAP_SECONDS instead of to now, and
the cycle proceeds into the batch normally. The candidate WHERE then does the
work by itself — received_at > activated_at excludes the OFF window while
admitting everything from the last hour, which is where the waking message always
is. The watermark is deliberately untouched on re-anchor: OFF-window mail is
already excluded by the anchor whatever its rules_processed_at says, and moving
it would skip the triggering batch a second way.

Documented residual, now asserted deliberately rather than discovered later: mail
received in the final hour before the flag comes back on IS minted, up to one
capped batch of it. That is the price of never dropping the waking message, and
it is bounded. §6(b) tells an owner about it before they flip, with the advice to
flip at a quiet moment if the tail matters.

The guarding test was green on fiction — it seeded last_run_at = now - 300s
directly, a state the scheduler cannot produce, so it could not see any of this.
Tests now drive a movable clock and express the lull the way the scheduler does:
by NOT CALLING the step. The Monday-morning case runs an evening cycle, advances
nine and a half hours with no invocation at all, then delivers the 07:30 message
and asserts it is minted in the same cycle that re-anchors.

Also corrected the docstrings that carried the false premise — the constant's
comment claimed "six scheduler periods", which the wiring does not provide, and
the third-column rationale claimed the step "runs faithfully every 600s" on a
quiet mailbox, which it does not. last_run_at is still the right clock, but for a
narrower reason than I wrote: both it and the watermark freeze on a mailbox
receiving nothing, and what separates them is a cycle that ran and found no
candidates — which is also the state a deliberate stall holds the watermark in.

Migration hardening: CREATE TABLE IF NOT EXISTS is a no-op against a database
that already has the table, so it cannot add a column to one. A scratch DB that
applied this file while it was still the two-column version would keep the old
shape and fail every cycle. Added the guarded ADD COLUMN IF NOT EXISTS, a
COALESCE backfill (SET NOT NULL refuses otherwise), and SET NOT NULL — all
no-ops on a fresh database.

Keeping _JSONB_CONTAINS in _crm_fakes: it is what makes mutant k expressible, and
that mutant is the only thing standing between the case-folding Sent probe and a
silent revert to case-exact containment.

73 -> 75 tests; 13 -> 15 mutants red and reverted, the two new ones being the
reset-instead-of-clamp and the early-return that discarded the batch. R4 in the
same change: the cursor paragraph's re-anchor block, done-when 8, as-built
decision 6 (which now names its own earlier false premise rather than quietly
replacing it), work_plan's WS-26 row and the §6(b) owner note, both AGENTS.md.

Still BUILT, NOT FLIPPED, NOT DEPLOYED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n we reverted

Comments only — no executable line changed in this commit.

The header of 158_crm_auto_lead_cursor.sql was written for the reset-to-now
re-anchor and never updated when that became a clamp, so it contradicted the code
twice. It repeated the false premise the code and the spec now explicitly disown
— that the step "has run faithfully every 600s" on a quiet mailbox, which is why
re-anchoring one would drop Monday's first message — and it claimed all three
timestamps are re-stamped to now and the gap's backlog mints nothing. None of
that is true: only activated_at moves, it moves to now - GAP, processed_watermark
is deliberately left alone, and the final gap-width IS minted.

That matters more here than in most comments. This block is the canonical
description of crm_auto_lead_cursors — it is what the next person to touch
dormancy logic reads before they touch it, and a stale comment beside correct
code is worse than no comment, because it is evidence.

The rewritten header now describes the clamp: which column moves and which
deliberately does not, why clamping rather than resetting (the step runs only
when a sync PERSISTED mail, so the cycle that detects the gap always carries the
message that woke it), the measured failure the reset caused, and the accepted
residual stated as a residual.

Second fix, same understatement in four places: "up to one capped batch of it"
understates the residual. The cap defers rather than drops, so the tail drains
ACROSS cycles — 7 messages with a cap of 3 is 3 then 3 then 1, not 3 and the rest
lost. The bound is on TIME, not volume: one gap-width of mail, drained across
however many cycles the cap takes. Corrected in the migration header, the
_reanchor_if_dormant docstring and work_plan §6(b) as asked, and in two places
the review did not name but which carry the same sentence — crm_app.md §9's
residual paragraph, which is the canonical spec statement of it, and the
docstring of the test that asserts the bound. Leaving those two saying something
the other three now contradict is the same failure this commit is fixing.

⚠️ Two of the five files are .py, and both diffs are entirely inside docstrings —
`git diff` on them shows only comment text. No assertion, no logic, no test
behaviour changed, which the re-run confirms: 592 / 24 / 421 across the three
blocks, unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant