← Back to brief

reference email phone ledger

memory · reference_email_phone_ledger.md

Hardwired 2026-05-03 — multi-contact pivot. Replaces the old AltEmail:xxx / AltPhone:xxx fragments-in-lead_source_notes kludge.

Schema

SoT Lead Details columns (Grand_County_STR_Engine_v3.5_filtered.xlsx):

Ledger entry format (semicolon-separated entries; each entry pipe-delimited):


value|source|date|confidence;value|source|date|confidence;...


- high = BV-current (<30d) OR operator-entered (OP)
- med = BV-historical (>30d)
- low = legacy / unverified

Bounced suffix: bounced entries get :bounced-<date> appended to confidence:


old@deadcompany.com|HS|legacy|low:bounced-2026-04-22

Bounced entries STAY in the ledger forever (audit + dedup) — they just don't get chosen as primary.

Helper module — email_ledger.py

Single source of truth for ledger semantics. Used by:

Key functions:

CLI for ad-hoc inspection:

bash
python3 ~/Library/Application\ Support/SkyRun/email_ledger.py 'joe@yahoo.com|BV|2026-05-01|high;...'

Primary-email priority rule

When choosing which email is the active primary (writes to SoT EMAIL column + HS email field):
1. First non-bounced entry with confidence=high AND personal-class domain (gmail/yahoo/icloud/me/hotmail/aol/outlook)
2. Else: first non-bounced entry with confidence=high AND vanity-domain (e.g., firstname@firstname-lastname.com)
3. Else: first non-bounced entry with confidence=high AND professional domain (employer)
4. Else: first non-bounced entry of any confidence (med/low)
5. Else: empty (means all known emails bounced — surface in pending_bv_re_enrich.jsonl queue)

Identical rule for phones (just no domain classification).

Bounce auto-rotation

When smartlead-bounce-handler detects a HARD bounce:
1. Helper marks the bounced email in ledger (:bounced-<date> suffix) — done by cmd_ingest
2. Helper looks for rotation candidate via find_rotation_candidate() — done by new cmd_rotate
3. Decision tree:
- high_conf (BV-current<30d OR OP-entered, active) → SILENT rotation:
- SoT EMAIL overwritten with new primary
- Queue pending_hs_updates.jsonl of type auto_email_rotation (HS email + hs_additional_emails updated, contact note added)
- Queue pending_smartlead_actions.jsonl action rotate_email_in_campaign (operator triggers Chrome MCP swap; campaign keeps sending to new email)
- low_conf (med/low alt only) → QUEUE PWA approval:
- Append to pending_drafts.jsonl of type approve_email_rotation
- Operator decides via PWA — approve = swap, dismiss = leave as bounced + standard SL removal
- none (no alt at all) → QUEUE BV re-enrich:
- Append to pending_bv_re_enrich.jsonl with priority=high
- Next BV daily run picks it up early (re-injects at top of tier batch)

HS field routing

Per reference_smartlead_hs_email_logging.md, HS recognizes BOTH primary and additional emails when matching incoming SmartLead BCC engagements. So populating these properly is what makes SL email events log as Email engagements (not auto-contact dupes).

Ledger positionHS field
primary emailemail
all OTHER non-bounced emailshs_additional_emails (semicolon-joined)
primary phonephone (digits only)
second phonemobilephone
third+ phonesNOT mapped today (HS doesn't have a native multi-phone field; SoT PHONE_ALL is canonical)
Retired pattern: AltEmail:xxx | AltPhone:xxx fragments in lead_source_notes. The BV skill now scrubs these out during routine PATCH so legacy contacts get cleaned up over time.

Queue files

All in ~/Library/Application Support/SkyRun/pwa/data/:

BV driver — bv_driver.py (Item #75, hardwired 2026-05-03)

Single-process BV enrichment driver that collapses the per-lead workflow into ~1 tool call from the Claude session. Replaces the ~25-tool-call-per-lead pattern that was burning context and making 12-lead daily runs context-impossible.

bash

Enrich a single lead (test/ad-hoc)

python3 ~/Library/Application\ Support/SkyRun/bv_driver.py enrich --lead-id R306603

Drain the priority BV re-enrich queue (preferred for daily runs)

python3 ~/Library/Application\ Support/SkyRun/bv_driver.py drain-queue --limit 12

Custom batch

python3 ~/Library/Application\ Support/SkyRun/bv_driver.py batch --lead-ids R306603,R303662,...

What it does (single Python process, no Claude tool-call overhead):
1. chrome_bridge.ensure_tab — opens BV if not already open
2. Navigates to BV search URL with first/last/city/state from SoT
3. Auth-redirect detection — if BV bounced to login, returns auth_required cleanly
4. Result-card matching: requires BOTH first AND last name in the same card; prefers cards with city/state match (scoring)
5. click_real on the highest-scoring card's View button (full pointer/mouse sequence)
6. Force-reload after 3s (defeats BV SPA hydration bug)
7. 10s wait for hydration, then header check (first name in h1/h2)
8. Regex-extract phones + emails (filters BV-meta + onetrust noise)
9. Updates SoT EMAIL_ALL/PHONE_ALL ledgers via email_ledger.upsert_entry (preserves bounced state, picks max confidence)
10. Recomputes primary EMAIL/PHONE per priority rule
11. Drains queue entries on success
12. Writes /tmp/bv_driver_report_<TS>.json with full per-lead status

Runtime: ~14s/lead average. 16-lead drain = ~225s.

What it does NOT do (left to skill / downstream):

Status semantics:

How to inspect a lead's ledger ad-hoc

bash

Pull the EMAIL_ALL value for a lead and parse it

python3 -c " import openpyxl, sys sys.path.insert(0, '/Users/josephbowens/Library/Application Support/SkyRun') from email_ledger import parse_ledger, compute_primary, find_rotation_candidate wb = openpyxl.load_workbook('/Users/josephbowens/Desktop/SkyRun/Prospecting & Leads/STR Engine Archive/Grand_County_STR_Engine_v3.5_filtered.xlsx', read_only=True, data_only=True) ws = wb['Lead Details'] headers = [c.value for c in ws[3]] lid_col = headers.index('LEAD ID') email_all_col = headers.index('EMAIL_ALL') LEAD = 'R305925' # change this for row in ws.iter_rows(min_row=4, values_only=True): if row[lid_col] == LEAD: entries = parse_ledger(row[email_all_col]) print(f'{LEAD} ledger:') for e in entries: print(f' {e.value:<35} {e.source} {e.date} {e.confidence} {\"BOUNCED\" if e.is_bounced else \"\"}') print(f' primary: {compute_primary(entries)}') cand, dec = find_rotation_candidate(entries) print(f' rotation: {cand.value if cand else None} ({dec})') break "

Migration state (2026-05-03)

Related memories