# HeyLead Audit — August 2026

Audit run 2026-08-05 after a ~3.5 month development pause (last commit 2026-04-23).
Nine parallel read-only audits: Unipile layer, scheduler/rate limiting, AI/LLM layer,
MCP tool surface, database, runtime logs, test suite, security, and LLM landscape research.

Evidence sources: source tree, live SQLite DB (`~/.heylead/data/heylead.db`, 604 MB,
read-only queries), ~40 MB of runtime logs spanning 2026-07-14 → 2026-08-05, the
published PyPI sdist, and vendor deprecation documentation.

---

## Status of the immediate task

**Unipile credentials replaced.** `~/.heylead/config.json` now has
`unipile_api_url = https://api50.unipile.com:18066` and the new API key. Verified with a
read-only `GET /api/v1/accounts` — the token authenticates and returns
`{"items": [], "total_count": 0}`, i.e. a valid workspace with **no LinkedIn account
connected yet**. You still need to run `setup_profile` to link LinkedIn.

**Before you do that, read P0-2 below** — a caching bug means the reconnect will not take
effect until every HeyLead process is restarted.

---

## P0-1 — Live credentials published to PyPI in every release since February

Two credential files are tracked in git and bundled into every published sdist:

| File | Type | Scope |
|---|---|---|
| `.mcpregistry_github_token` | GitHub user-to-server OAuth token (`ghu_…`) | Acts as user D4umak |
| `.mcpregistry_registry_token` | EdDSA JWT, `iss: mcp-registry` | `publish` on `io.github.D4umak/*` |

Both were added in commit `6310f6d6` (2026-02-26) and never changed, so they ship in every
release from **v0.9.12 through v0.10.183** — roughly five months of packages.

The GitHub repo is **private**, so GitHub is not the leak vector. **PyPI is public and is.**
The audit downloaded the live `heylead-0.10.183.tar.gz` from PyPI and confirmed the files are
present at the tarball root with SHA-256 hashes byte-identical to the local copies. Anyone who
ran `pip download heylead` has them.

Root cause: `.gitignore` covers `.env`, `.env.*`, `*.enc`, `.heylead/` but not
`.mcpregistry_*` (verified: `git check-ignore` reports not-ignored). Hatchling's default sdist
includes all VCS-tracked files, so the gitignore gap became a packaging leak.

Both tokens are probably past expiry (the registry JWT's `exp` was 2026-02-25; `ghu_` tokens
are short-lived), which limits residual risk — but they were live when published, and
short-lived GitHub tokens can carry longer-lived installation grants.

**Remediation, in order:**

1. Revoke the GitHub OAuth grant (Settings → Applications → the MCP-registry app) and rotate
   any GitHub App installation tokens. Re-authenticate the MCP registry publisher.
2. Yank the affected PyPI releases, or at minimum cut a clean release and delete the old
   files. PyPI artifacts are widely mirrored, so rotation — not deletion — is the real fix.
3. `git rm --cached .mcpregistry_github_token .mcpregistry_registry_token`, add
   `/.mcpregistry_*` to `.gitignore`, and scrub history with git-filter-repo or BFG in case
   the repo is ever made public.
4. Add a packaging allowlist so future sdists cannot leak local files. This also fixes the
   related medium-severity finding that the sdist ships a third-party "Curated by Common Room"
   signals CSV, internal planning docs (`docs/EXPLICIT_IMPROVEMENT_PLAN.md`,
   `docs/QUALITY_AND_SATISFACTION_ASSESSMENT.md`), and `.cursorrules`.

**Related, lower severity:** `~/.heylead/config.json` is mode 644 (world-readable) and holds
the Unipile key, backend JWT, and all LLM API keys in cleartext. There is no `chmod` anywhere
in the codebase. Write the file atomically with mode `0o600` and set `~/.heylead` to `0o700`.
Separately, `src/heylead/crypto.py` derives its AES key from
`SHA-256(static_salt + hostname + os.getlogin())` — no secret input, so it is obfuscation, not
encryption. It has zero callers; delete it rather than leave a false sense of protection.

---

## P0-2 — The reconnect will not take effect until you restart every process

`get_account_id()` in `src/heylead/linkedin/unipile.py:4275` caches the stored account id in a
module global for the lifetime of the process, and sets `_cached_account_id_set = True` even
when the cached value is `None`. The only caller of `invalidate_account_id_cache()` is
`src/heylead/tools/switch_account.py:231`.

`setup_profile` (writes at `tools/setup_profile.py:128, 137, 150, 241`) and `unlink_account`
(`tools/unlink_account.py:43`) both write the setting to the database without invalidating the
cache. `server.py:1686` additionally warms the cache at startup.

So after you run `setup_profile` and successfully link LinkedIn, the new account id lands in
the database while every already-running tool call and the scheduler process keep using the
old, deleted id `i0vbaNkoQIu__uBR8ZABKA` — and keep getting 404s — until every HeyLead process
is restarted. On a fresh install the inverse happens: the startup warm pins `None`, so tools
report "not connected" even after a successful setup.

The fix is one line at each write site. Until it lands, **restart the MCP server after
`setup_profile`**.

**Other stale state the reconnect does not clean up:**

- `settings.email_account_id` is still `V3cMyUu5R2uCrhFXTAldFg` from the old workspace. No tool
  clears it — `setup_profile` only overwrites it if the backend returns a new one
  (`setup_profile.py:485`, guarded by `if email_acct`). Every email-channel send will target a
  dead account. Delete it manually.
- `connections`: all 4,568 rows carry the dead `account_id`. `_check_is_first_degree`
  (`scheduler/executors.py:165`) depends on this table, and 1,046 outreaches are already in
  `status='error'` with "Not a 1st-degree connection (pre-check failed)". Purge and re-sync.
- `engagements`: 2,941 rows tagged with the dead account id. The dedup unique index is
  `(post_id, account_id)`, so under a new account id HeyLead can re-comment and re-react on
  posts it already engaged.
- `premium_search_account_id` / `premium_search_detected_at` — re-detect after reconnect.
- `has_sales_navigator` — re-detect.

---

## P0-3 — Rate limiting is decorative for exactly the actions that cause bans

The daily caps added in commits `9456ff4` ("halve daily caps to prevent LinkedIn automation
detection") and `5944d2b` ("add proactive daily caps to prevent friendly bans") do not
actually count anything for invitations or follow-ups.

**Invitations (`DAILY_CAP_INVITATIONS = 12`) — three independent counting failures:**

1. Invites are logged as `log_action("invitation_sent", result="success")`
   (`tools/generate_send.py:1145`), but the cap patterns are
   `["engagement_invite", "outreach_status_change"]` (`linkedin/rate_limiter.py:36`).
   `invitation_sent` is not in the list.
2. `outreach_status_change` rows are written with `result=<new_status>` — `"invited"`,
   `"connected"` (`db/queries.py:435`) — while `_get_daily_action_counts` counts only rows
   where `result == "success"` (`rate_limiter.py:108`). Always zero. `engagement_invite` rows
   are never created by any code path.
3. The total-budget fallback `_total_visible_actions` calls the **synchronous**
   `get_rate_limit_today()` on the event-loop thread (`rate_limiter.py:150`). `get_db()`
   deliberately raises `RuntimeError` there (`db/schema.py:592`), and a bare
   `except Exception: pass` swallows it — so `rate_limits.sent`, the only place invites are
   actually tracked, never contributes to the 100/day total either.

Net effect: `check_daily_cap("invite")` returns `0/12` on every call, forever.

**Follow-ups (`DAILY_CAP_DMS = 12`):** sends are logged as `followup_sent`, which appears in
no cap pattern and not in `_total_visible_actions`. Compounding this,
`constants.py:59` ships `FREE_MAX_FOLLOWUPS = 9999` with a `TODO(release)` to restore 2 — and
the live install is `tier=free`. `_is_followup_due` (`scheduler/planner.py:1623`) returns
`days_since >= 1` forever on the free tier. The only surviving guard is `_check_recent_messages`
(2 messages per prospect per 24h, `executors.py:117`).

With 149 outreaches currently in follow-up-eligible states across 4 campaigns, a reconnect
arms roughly 300 DMs/day — past the intended 12/day cap and the 100/day total, reproducing the
volume pattern that caused the original restriction.

**Stale-invite withdrawal has no cap and no pacing.** `_execute_withdraw_stale_invites`
(`executors.py:1394`) loops over every invitation older than 21 days with no
`check_daily_cap("withdraw")` (contrast `rate_limiter.py:378`, where the single-withdraw helper
does check `DAILY_CAP_WITHDRAWALS = 15`) and no inter-call delay. Months of disconnection have
built a backlog; the first hourly job after reconnect fires the whole thing back-to-back.

**Four stale auto-replies are queued to fire immediately.** Four outreaches
(`15679df4…`, `1a13f93a…`, `7a602042…`, `bd1d4ad4…`) have been re-planned every ~35 minutes
since 2026-07-20 — 1,798 plan events, 865 jobs. The prospect message being answered is from
**2026-04-13**. `get_auto_reply_candidates` has a 30-minute job cooldown but no maximum message
age, and the planner re-plans whenever no pending job exists. On reconnect these send
four-month-late replies.

**Working hours are not enforced at all.** `services/timezone_resolver.py:128`
(`is_business_hours`, `seconds_until_business_hours`) has no callers in the scheduler path.
`tools/edit_campaign.py` accepts `send_in_business_hours` and `active_days`, but they are only
displayed, never checked. The strategist's `_is_right_timing`
(`services/communication_strategist.py:510`) compares against UTC despite documentation
claiming prospect-local time. Live config is 0–24, all 7 days. The account acts at 3 AM local
time — a strong automation signal.

**Do not reconnect LinkedIn until at least the invite/follow-up counting, the withdrawal
pacing, and the auto-reply age ceiling are fixed.**

---

## P0-4 — `mode='copilot'` is a guard-bypass switch, and no approval path exists

The copilot/approval system was amputated, not removed. What remains functions as a way to
turn safety checks off.

In `tools/reply_to_prospect.py`, the chat-scoped dedup (`:237`), local dedup (`:314`),
negative-sentiment skip (`:385`), decline-keyword skip (`:405`), vendor-pitch skip (`:425`),
and reverse-pitch detection (`:438`) are **all** gated on `mode == "autopilot"`. Then at
`:576`: `if False:  # copilot mode removed` … `else: # Autopilot — send immediately`.

The same shape appears in `tools/generate_send.py:352, 391, 835` (warm-up requirement,
rate-limit early exit, and the **validation-failure block**) and
`tools/send_followup.py:547`, `tools/engage_prospect.py:587`.

The bypass is documented as a feature. `tools/send_message.py:33` tells the model
`mode: 'autopilot' (sends automatically) or 'copilot' (you review first)`, and error messages
actively recommend it — `generate_send.py:848`, `send_followup.py:559`,
`reply_to_prospect.py:558` all say "Try: (mode='copilot') to review and edit manually."

So a model that follows the docstring when a user asks to review first will send a message
that **failed the five-stage validation pipeline**, or duplicates an existing reply, or replies
to someone who said "not interested" — instantly, with the guards off.

Meanwhile `create_campaign.py:133` overwrites the `mode` parameter with `"autopilot"`
unconditionally and `:914` silently enables both the local scheduler and 24/7 cloud
scheduling. `tools/approve_outreach.py` is 960 lines of unregistered dead code.

Either restore a real two-step flow (`generate` returns a `draft_id` and full text; a separate
`send(draft_id)` executes) or delete `mode` entirely and make every guard unconditional. The
current state is the worst of both.

---

## P1 — Features that have been silently dead for months

**Four scheduler subsystems have never run.** `scheduler/engine.py:969, 978, 989, 1016` call
`create_scheduler_job(None, None, JOB_X)` against the signature
`create_scheduler_job(campaign_id, job_type, scheduled_at, ...)` (`db/queries.py:2913`), so
`job_type` receives `None`, violating a `NOT NULL` constraint. The exception is swallowed at
`logger.debug`. The correct 3-argument form is used at `engine.py:933` for `daily_strategy`,
which does work.

Confirmed in the live DB: **zero rows have ever existed** for `verify_actions`,
`sync_connections`, `partner_reminder`, or `daily_digest`. Confirmed in the logs: 1,758
swallowed `NOT NULL constraint failed: scheduler_jobs.job_type` errors between 2026-07-14 and
minutes before this audit. Confirmed downstream: **0 of 4,594 outreaches have ever been
verified** (`verified_at` is null for all).

`verify_actions` is the safety net that confirms sends actually landed. `sync_connections` is
what keeps the 1st-degree cache fresh — and its absence is a plausible cause of the 1,046
outreaches killed in April with "Not a 1st-degree connection".

**Daily communication strategist crashes on every run.**
`services/communication_strategist.py:549` calls `get_setting("voice_signature", {})`
synchronously on the event loop inside `_build_campaign_context`. Twenty `Sync DB call on event
loop thread` errors from 2026-07-20 through 2026-08-05; the feature has never completed in the
observed window. One-line fix: `await run_db(get_setting, ...)`.

**Four features are dead on import.** `llm_router` and `call_llm` do not exist anywhere in the
repo. Broken importers: `ai/email_generator.py:12` (kills the email paths in
`generate_send.py:1262` and `send_followup.py:916`), `tools/create_post.py:64` (whole tool
fails), `tools/engage_prospect.py:1378` (comment-thread replies always return "Failed to
generate reply"), `services/communication_strategist.py:270`.

**Four prompt templates crash before any LLM call.** `prompts/comment_main.json`,
`voice_analysis.json`, `prospect_analysis.json`, and `followup_reasoning.json` contain literal
JSON examples with unescaped braces; `render_prompt` (`ai/prompt_loader.py:90`) uses
`content.format_map(...)` and raises `ValueError: Invalid format specifier`. Verified by
executing the repo's own `render_prompt` against all 16 templates. Because `has_prompt()` only
checks file existence, the broken path is always chosen over the working legacy inline prompts.
This breaks voice analysis, prospect analysis, follow-ups, and comments in bring-your-own-key
mode — which strongly suggests only the backend-proxy path has been exercised for months.

**Brand strategy has been dead since March.** `settings.brand_actions_completed` holds 41
entries, 39 of them `Failed: 'str' object has no attribute 'get'` — `tools/brand_strategy.py:413`
calling `.get()` on settings that were double-JSON-encoded strings at the time. Worse,
`services/brand_service.py:124` sets `status="completed"` regardless of failure, and a retry
loop burned the same actions every ~8 minutes. The entire brand plan was consumed by failures.

**Invite circuit breaker is off by one and self-resetting.** `MAX_INVITE_ATTEMPTS = 3`, but the
guard at `tools/generate_send.py:1062` increments then checks `attempts > MAX`, and its
`except Exception: attempts = 1` fallback silently rearms unlimited retries. Live data shows 5
outreaches at 7 attempts, 17 at 6, 24 at 5, 70 at 4.

---

## P1 — LLM stack: two of three model IDs are retired

| Configured model | Status as of 2026-08-05 |
|---|---|
| `gemini-2.0-flash` (primary) | **Retired 2026-06-01** — API calls fail |
| `claude-sonnet-4-20250514` (fallback) | **Retired 2026-06-15** — API calls fail |
| `gpt-4o-mini` (fallback) | Alive but two generations old |

If the local-key path had been exercised since June, the primary and first fallback would have
been returning hard errors and **every call would have landed on `gpt-4o-mini`** — the weakest
model in the stack. Combined with the `llm_router` import failures and the broken templates,
the practical conclusion is that the bring-your-own-key path has not run in months.

Model IDs live in two places and will drift: `constants.py:163-165` and duplicated literals at
`services/calendar_booker.py:525, 532, 539`.

**Recommended replacements** (prices per million tokens; batch APIs give 50% off on all three
providers, and HeyLead is explicitly latency-tolerant):

| Task | Primary | Cheap alternative |
|---|---|---|
| Outreach message generation | `claude-sonnet-5` ($3/$15, intro $2/$10 through 2026-08-31) | `gemini-3.6-flash` ($1.50/$7.50) |
| Reply sentiment classification | `gemini-3.5-flash-lite` ($0.30/$2.50) | `gpt-5.6-luna` ($0.20/$1.20) |
| ICP / persona generation (RAG) | `claude-sonnet-5` | `gemini-3.6-flash` |
| Comment writing | `claude-haiku-4-5` ($1/$5) | `gemini-3.5-flash-lite` |
| Conversation replies | `claude-sonnet-5` | `claude-haiku-4-5` |
| Fallback chain | `gemini-3.5-flash-lite` → `claude-haiku-4-5` → `gpt-5.6-luna` | — |

Note that migrating the Claude path has breaking changes: non-default `temperature`/`top_p`/
`top_k` now return 400 on current models, assistant-message prefills return 400, and adaptive
thinking is on by default (budget `max_tokens` for thinking plus response).

**The bigger win is structural.** There are no structured outputs anywhere —
`ai/llm.py` sends plain-text requests with no Gemini `responseSchema`, no OpenAI
`response_format`, no Anthropic tool-forcing, despite ~15 JSON-consuming call sites. The entire
`ai/json_repair.py` module, the regex cleanup hacks, the 8k-token second-pass LLM repair in
`icp_generator_v2.py:514`, and the five-call shorten loop in `ai/length_fixer.py` exist to
compensate for a problem that native JSON-schema enforcement eliminates. Adopting structured
outputs would let you delete most of that machinery.

Related quality issues in the same layer:

- **JSON parse failure sends raw LLM output to the prospect.** `ai/message_generator.py:302`
  uses `fallback={"message": raw, ...}`; same at `reply_generator.py:652` and
  `comment_generator.py:247`. A truncated response means the prospect receives a message
  starting `{"reasoning": "I chose the pain-first angle...`.
- **Empty and safety-blocked responses are treated as success**, so the fallback chain never
  engages when it matters most (`ai/llm.py:126, 164, 200`). `finishReason` is never inspected,
  so silent truncation flows straight into the previous bug.
- **No placeholder guard.** Nothing checks generated messages for `[Name]`, `[Company]`, or
  unfilled braces before sending. `SafeDict` (`prompt_loader.py:31`) leaves `{var}` literals in
  the *prompt* when a key is missing, actively teaching the model that brace placeholders are
  normal.
- **Gemini API key is in the request URL** (`ai/llm.py:109`), so it lands in logs and in
  user-facing error messages on any 4xx/5xx. Use the `x-goog-api-key` header.
- **Prompt injection is unmitigated.** Prospect reply text is interpolated raw into prompts
  (`reply_generator.py:111` and five sibling templates) and the result is auto-sent. The five
  validators check voice match, length, repetition, and bot-tells — none check for injected
  instructions or attacker URLs. Blast radius is bounded (recipient comes from the campaign
  record, not the reply), but content can be steered.
- **Sentiment misclassification drives wrong automated actions.** `classify_fast()`
  (`ai/sentiment.py:302`) runs before the LLM and its keyword lists are ambiguous: "we're good"
  → negative (so "We're good to go for Tuesday!" triggers a graceful close);
  "unfortunately" → negative; "free next week" as a substring → positive (so "I'm not free next
  week" marks a hot lead); "no thanks" → permanent opt-out.
- **The auto-booking browser agent will visit any URL in a reply.**
  `ai/sentiment.py:153` matches generic paths like `/booking`, `/schedule`, `/meet/` on **any
  domain**; `tools/check_replies.py:700` then invokes `book_meeting()`, whose fallback
  navigates there with an LLM browser agent and fills in your name and email. No allowlist, no
  confirmation.
- Em dashes hard-fail validation (`ai/message_validator.py:103`) and the blocklists ban common
  words including the substring "ai" — rules that encode gemini-2.0-flash's tics and now fire
  constantly on modern models, each costing an extra fix round trip.

---

## P2 — Database and operations

- **A full 576 MB backup runs on every process start.** `db/schema.py:649` calls
  `create_backup("pre-migration")` from `_run_migrations`, which runs on the first `get_db()` in
  every process, with no check for whether a migration will actually run. `~/.heylead/backups/`
  holds 2.8 GB — five copies, three of them created within 40 seconds on Aug 5. `MAX_BACKUPS=5`
  means startup churn rotates out any deliberate backups within days.
- **`actions_log` is 1.25M rows, ~380 MB of the 604 MB database, with no retention.** 87% is
  scheduler skip telemetry: `skip_followup` 501,607, `skip_dm` 190,730, `skip_invite` 169,290,
  `skip_auto_reply` 109,757. Currently 5,000–8,000 rows/day are written while the product does
  zero real work. Only `scheduler_events` has a retention policy.
- **89 async call sites still reach sync DB code on the event loop.** Several fail 100% of the
  time: `tools/account.py:59` (`account(action='get_search_account')`),
  `tools/partner_followup.py:84` (every `partner()` action), and
  `tools/approve_outreach.py:144, 188, 297, 452, 613, 824` (every approval path).
- **No explicit transactions anywhere** — every helper is autocommit-per-statement, so
  read-modify-write races exist between the scheduler process and MCP tool processes.
  `get_rate_limit_today` and `get_monthly_usage` both do SELECT-then-INSERT against a UNIQUE
  constraint and will raise uncaught `IntegrityError` at day/month rollover. The live DB
  already has 48 duplicate `(outreach_id, role, text)` message groups.
- **FK orphans:** `PRAGMA foreign_key_check` reports 132 `actions_log`→`outreaches`, 18
  `messages`→`outreaches`, 10 `prospect_daily_plans`→`outreaches`, 7
  `engagements`→`outreaches`. No `ON DELETE CASCADE` anywhere.
- **Contradictory states:** 265 outreaches where `followup_count` disagrees with the actual
  message count; 14 in `replied`/`hot_lead` with zero SDR messages ever sent.
- **The tick timeout is shorter than the job timeout.** `engine.py:585` wraps `_tick()` in
  `asyncio.wait_for(..., timeout=55)`, but per-job timeouts are 120s (300s for slow jobs,
  `engine.py:1241`). Any tick exceeding 55s is cancelled mid-flight, potentially after a
  LinkedIn call was accepted, leaving jobs stuck in `running`. The 300s slow-job budget is
  unreachable by design. This is consistent with the "job duration +68%" anomaly chased in
  commit `1ff8b26`, and with 23 logged tick timeouts.
- **Pending jobs older than 30 minutes are killed, not retried.** `db/queries.py:3108` marks
  them `failed` with `retry_count=0`; the retry path only covers jobs that failed during
  execution. All 73 failed jobs in the live DB are `stale_pending_recovered` — machine-asleep
  batches.

---

## P2 — Unipile client layer

The layer is defensive per-request but is two ~4,400-line near-duplicate god classes
(`unipile.py` and `backend_client.py`) with no unit tests, and they have already drifted:

- **Retries wrap non-idempotent sends.** `_retry_request` (`unipile.py:255`) retries
  `send_message`, `send_new_message`, `send_inmail`, `send_post_comment`, and
  `reply_to_comment` up to 3× on 502/503/504 and on `httpx.TimeoutException` — a read timeout
  can occur after LinkedIn accepted the message, so the prospect gets 2–3 copies.
  `backend_client.py:339` additionally retries after a **429**, the opposite of the direct
  client's policy and precisely the pattern anti-automation heuristics look for.
- **Multipart retries re-send an exhausted file handle** (`unipile.py:279` with
  `send_voice_message:1710`), uploading zero-byte audio that "succeeds".
- **Chat existence is treated as proof of connection.** `find_chat_for_user`
  (`unipile.py:1426`) has a `POST /chats` write fallback inside a *lookup*, and
  `check_existing_relation` sets `connected=True` whenever `has_chat`. Chats also exist with
  InMail senders, open-profile members, and group participants. This skips invitations,
  inflates acceptance stats, and is a plausible contributor to the "engagement success 87→57%"
  anomaly.
- **`get_relations` pagination is fragile and feeds a destructive prune.** Cursor is appended
  un-encoded (`unipile.py:2188`), only `data.get("cursor")` is checked (unlike every other
  paginator in the file), any mid-pagination exception discards all accumulated pages and
  returns `[]`, and there is no inter-page delay across up to 200 requests. The consumer
  (`services/connection_sync.py:120`) then deletes local rows not in the "fresh" set.
- **`BackendClient.get_profile` returns raw Unipile JSON while `UnipileClient.get_profile`
  returns the normalized dict**, so company/title/location enrichment silently no-ops in
  backend mode.
- **Direct-mode searches swallow all non-auth errors** (`return [], None`) while backend mode
  raises — so a 500 during campaign refill is indistinguishable from "ICP exhausted".

---

## P2 — Test suite

511 tests collected, **473 pass, 38 fail**, no errors, ~15–21s. Tests are properly isolated
from `~/.heylead` (verified empirically by redirecting `HOME`), and none hit real networks.

All 38 failures are test-side defects, not production bugs:

- 33 in `tests/test_profile_history.py` — sync tests using the deprecated
  `asyncio.get_event_loop().run_until_complete(...)`, which raises on Python ≥3.12 once
  pytest-asyncio tears down the loop. Passes 42/42 in isolation. One mechanical fix clears 33
  of 38.
- 3 in `tests/test_voice_sprint4.py` — not updated after `_check_is_first_degree` was added to
  the executor. Notably, that guard itself has **zero direct tests**.
- 2 in `tests/test_calendar_booker.py` — hard-coded dates in March 2026 that are now past. This
  also exposes a real smell: the `_pick_best_slot` fallback (`calendar_booker.py:160`) happily
  returns a slot that is in the past, on a weekend, and outside business hours.

The coverage center of gravity is misplaced. Peripheral features (voice memos, profile history,
one collector) are heavily tested while the actual send path is essentially untested:
`linkedin/unipile.py` (4,295 lines) is under 5% covered, `scheduler/engine.py` (1,815 lines)
has zero tests, and so do the emergency stop, the leader election, the approval gate, and the
entire LLM output validation pipeline.

Packaging note: test dependencies are declared nowhere, and `heylead[all]` is unresolvable on
Python 3.10 because `browser-use>=0.12.0` requires ≥3.11 while `requires-python` says ≥3.10.

---

## Suggested order of work

1. **Today, independent of everything else:** revoke and rotate the two leaked tokens; yank or
   replace the affected PyPI releases; add the gitignore entry and a packaging allowlist.
2. **Before reconnecting LinkedIn:** fix the account-id cache invalidation (P0-2), the invite
   and follow-up counting (P0-3), withdrawal capping and pacing, and the auto-reply age
   ceiling. Restore `FREE_MAX_FOLLOWUPS = 2`. Clear the stale `email_account_id` and purge the
   dead-account `connections` and `engagements` rows.
3. **Then reconnect** and verify with a single campaign at low volume before re-enabling
   always-on scheduling.
4. **Then correctness:** the four `create_scheduler_job` argument-order bugs, the
   communication-strategist sync-DB call, the missing `llm_router`, the four broken prompt
   templates, and the `mode='copilot'` guard bypass.
5. **Then modernization:** current model IDs, structured outputs (which lets you delete
   `json_repair.py`, most of `length_fixer.py`, and the fix/regenerate loop), batch APIs for
   background jobs, prompt caching, and a pass to strip prompt scaffolding written for
   gemini-2.0-flash.
6. **Then hygiene:** `actions_log` retention, the startup backup gate, the 38 test failures,
   and unit tests for the send path.
