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):
EMAIL(col 35) — active primary email being used for outreachPHONE(col 36) — active primary phoneEMAIL_ALL(col 47) — ledger of ALL emails ever associated with this leadPHONE_ALL(col 48) — ledger of ALL phones ever associated with this lead
Ledger entry format (semicolon-separated entries; each entry pipe-delimited):
value|source|date|confidence;value|source|date|confidence;...
value: email address or phone digits-onlysource∈ {BV,OP,HS,SOS} — where this contact came fromdate:YYYY-MM-DDof capture, orlegacyfor pre-migration unknown-origin entriesconfidence∈ {high,med,low}
-
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:
daily-beenverified-enrichmentskill (Step 6 SoT write)bounce_handler.py(mark bounced + rotate)- Any future skill that touches EMAIL_ALL/PHONE_ALL
Key functions:
parse_ledger(raw)→list[LedgerEntry]serialize_ledger(entries)→ stringupsert_entry(entries, new)→ updated list (preserves bounced state, picks best metadata)mark_bounced(entries, value, date)→ bool (True if found+marked)compute_primary(entries)→ email/phone string (applies priority rule)additional_emails_string(entries, primary)→ semicolon list for HS fieldfind_rotation_candidate(entries)→(entry, decision)where decision ∈ {high_conf,low_conf,none}
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 position | HS field |
|---|---|
| primary email | email |
| all OTHER non-bounced emails | hs_additional_emails (semicolon-joined) |
| primary phone | phone (digits only) |
| second phone | mobilephone |
| third+ phones | NOT mapped today (HS doesn't have a native multi-phone field; SoT PHONE_ALL is canonical) |
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/:
pending_hs_updates.jsonl— HS proposals (existing); now includesauto_email_rotationtypepending_smartlead_actions.jsonl— SL queue (existing); now includesrotate_email_in_campaignactionpending_drafts.jsonl— operator approvals (existing); now includesapprove_email_rotationtypepending_bv_re_enrich.jsonl— NEW: high-priority BV re-enrichment queue. Read by daily BV run at the top of the tier batch.
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):
- HubSpot PATCH (skill Step 7 — needs CSRF + careful overwrite logic)
- SmartLead push (Step 7b — needs Chrome MCP CSV import)
- Heartbeat write (skill writes based on driver report)
Status semantics:
ok— captured ≥1 email or phone, ledger writtenno_match— entity-owner skipped OR no result card matched full namepartial— search succeeded but report didn't hydrate (BV doesn't have data — header was "Try Another Search")auth_required— BV redirected to login mid-flowerror— code-level failure (lookup, JSON parse, etc.)
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)
- 271 leads had EMAIL populated → migrated to EMAIL_ALL with
email|HS|legacy|lowtag - 109 leads had PHONE populated → migrated to PHONE_ALL with
phone|HS|legacy|lowtag - Backup at
Grand_County_STR_Engine_v3.5_filtered.bak.multi-contact-pre-20260503.xlsx - All legacy entries are tagged
confidence=lowso they DON'T trigger silent rotation. Operator must approve any rotation that involves only legacy entries (PWA queue).
Related memories
- Bounce handler skill — wiring of cmd_rotate
- BV enrichment skill — Steps 6 + 7 multi-contact write
- reference_smartlead_hs_email_logging.md — why HS additional_emails matters for SL BCC matching
- project_str_engine_current_state.md — SoT schema (Lead Details now 48 cols)