Agent-operated CRM. Gmail = comms layer. Claude Code = strategist; internal Pydantic AI agent = tactical executor (routing, auto-reply, follow-up).
- Python 3.14. basedpyright strict. ruff. TDD per CLAUDE.md.
- PostgreSQL 18. psycopg, raw SQL via
psycopg.sql. no ORM. - IDs = UUIDv7 (
uuid.uuid7()via_new_id()). - Gmail scope =
gmail.modifyonly. no additional Gmail scopes. - Drive scope =
drive.readonlyonly. Read-only KB grounding. no write/modify. - Calendar scope =
calendar.events.readonlyonly. read-only event ingestion (§V.126). no event create/update from the app. - Auth = service account & domain-wide delegation per §V.37. Credential source in {file path (
google_application_credentialssetting orGOOGLE_APPLICATION_CREDENTIALSenv var), Application Default Credentials (e.g. GCE metadata server, Workload Identity, Cloud Run identity)}. Per-account impersonation: file creds ->credentials.with_subject(email); ADC creds ->service_account.Credentials(signer=iam.Signer(...), service_account_email=<sa>, subject=email, ...). no OAuth user login. - Email body = plain text only (control chars stripped). no HTML body persistence. no embeddings, no vector store, no ingestion pipeline.
- KB files =
.mdin Drive folder named inworkflow.instructions. no in-app PDF/Docs/HTML conversion. - ASCII-only project artifacts (code, docs, CLI output). Agent-generated email body content exempt.
- Tool pattern: typed sig, DI deps, dict return, error dicts on failure (no raise to agent).
- Harness-over-LLM: LLM turn = judgment only (classify, draft, pick terminal disposition); deterministic work (context fetch, scheduling, lifecycle, validation, bookkeeping) = harness code, never an agent tool round-trip. Every agent turn pre-fed w/ mechanically-loadable context (system holds key -> system loads data); post-decision side effects system-side; run answerable from DB state alone -> zero model calls. New tool / LLM turn admitted only for judgment the harness cannot encode.
- cli:
mailpilot <noun> <verb> [args]-> JSON on stdout, exit code 0 or 1, errors to stderr.- nouns:
account,company,contact,workflow,enrollment,task,email,meeting,activity,tag,note,template,db. - verbs:
list|search|view|stats|create|update|disable|enable|add|remove|set|merge|reply|send|start|stop|cancel|retry|run|sync|export|import|init|migrate|check. - top-level:
run(sync & task loop),status,config get|set, global--version|--debug|--completion|--skill. - envelope:
list|search|sync|export|import->{"<plural>": [...], "ok": true}(db export|importexception ->{"db": {...}, "ok": true}status object like otherdbverbs, §V.121);view|create|update|disable|enable|add|remove|merge|reply|send|start|stop|cancel|retry|init|migrate|check|stats->{"<singular>": {...}, "ok": true}(workflow statsexception ->{"workflow_stats": {...}, "ok": true}§V.132;workflow checkexception ->{"workflow_check": {...}, "ok": true}§V.134;task statsexception ->{"task_stats": {...}, "ok": true}§V.133; aggregate not entity row); err ->{"error": CODE, "message": TEXT, "ok": false}. Everyok:trueenvelope also carries top-levelrecord_count(int) = records displayed: array-bearing payload (list/search/sync/export/import) -> array len; single-object payload (single-entity verbs + aggregatestats/check+status) -> 1; err omitsrecord_count(§V.4). - email projection:
email view|list! projectroute_methodin {classified,thread_match,rfc_message_id_match,skipped_outside_window,skipped_no_workflows,skipped_predates_workflows,skipped_no_inbound_workflows} so operator audits routing decision from CLI w/o Logfire. - contact projection:
contact list|search! projecttitle+company_domain(company denorm per §V.5) so operator reads role + org from CLI w/o a separate company lookup; default list|search|view omitverification_meta(operator-only §V.144);contact view --include-metaprojectsverification_meta(null when unset);contact create|updateaccept--meta-json '<object>';contact create --stdinline schema ? optionalmetaobject (same validation). - company projection:
company list|searchlean row ! project {domain, name, has_profile, contact_count, tags[], disabled_reason} —tags= assigned tag names (empty ok; same shape asdb exportcompany.tags §V.121);disabled_reasonnull when enabled, value when disabled (disabled rows only via--include-disabled§V.114 on list; search returns disabled when matched);contact_count= child COUNT LEFT JOIN contact incl. disabled (§V.96). Result controls (§V.148/§V.115):--sort name|domain|created_at|contact_count(default name),--desc,--offset(default 0),--limit(default 500 on company list|search). Filters (list):--max-contacts N/--min-contacts N(inclusive, compose w/--has-profile);--status ready|needs_contacts|needs_profile|disabledpipeline Enum (§V.138); default-excludes disabled,--include-disabledopts in (--status disabledforces include). Opt-in profile:--fullembedsprofile.summary(null when no profile) — one list call for triage; default list never ships full profile/products/target_customers/sources.company view! projecttags[]+aliases[](sorted lowercased alias domains, empty ok) same shape as list + existing CompanyView fields (full profile, notes, disabled_reason) (§V.8/§V.142). template= read-only, code-defined (registry insrc/mailpilot/agent/templates.pyper §V.44). Verbs:list [--direction inbound|outbound],view NAME. no create/update/delete — new template = code change + PR.workflow importaccepts--file <path>:.tomlsingle-workflow catalog entry | directory (glob*.toml); TOML-only, no JSON, no stdin (§V.103); import enforcesname= kebab file-stem, globally unique; def fields{name, template, theme, goal, instructions, touches, touch_interval_days}import-only —workflow updatemutates non-def fields only (status, account binding), rename = rename file + re-import (§V.103). import envelope carries top-level intapplied+rejected;applied=0 ->import_failederror envelope (per-row rows inlined) + exit 1,applied>=1 -> ok:true w/ per-row errors inline (§V.103, §V.4).workflow export --account-email <addr> --out-dir <dir>writes one*.toml/workflow (def fields, name-sorted) + JSON status envelope on stdout (§V.103, §V.3).workflow check(§V.134) = read-only def-integrity check, mirrorsdb check: live 2-way SHA-256 over def fields{template, theme, goal, instructions, touches, touch_interval_days}, keyed by workflowname(eachworkflows/*.tomlread for itsnamefield, joined to rows by name); states {in_sync,out_of_sync,not_imported,orphaned};--filerepeatable (multi-source, last-def-wins on dupname); specific-file check scoped to passed names (orphanedsuppressed), dir check flags unaccounted roworphaned; report-onlyok:true, not a deploy gate; envelope{"workflow_check": {...}, "ok": true}(aggregate not a workflow row).workflow stats <workflow>(§V.132) = read-only per-campaign funnel, 1 workflow by entity ref (§V.107): enrolled, sent, bounced, replied, meeting_booked, contact_later, do_not_contact, active @ enrollment grain; envelope{"workflow_stats": {...}, "ok": true}(aggregate not a workflow row).task stats(§V.133) = read-only task-cadence aggregate, optional--workflow-id(§V.107) +--trigger(§V.26 taxonomy): per-status counts {pending, completed, failed, cancelled} +total,distinct_scheduled_days(bucketed by--bucket-tzIANA, default UTC), first/lastscheduled_at; envelope{"task_stats": {...}, "ok": true}(aggregate not a task row).task list+task statsshare--trigger(deterministic first-touch select viaenrollment_schedule§V.32, never readsdescription).- account-requiring cmds (
email send|reply,workflow create|export|import) take a single--account-email(polymorphic — email | UUID, §V.107);account syncmakes it optional (all accounts when omitted) + accepts--since <iso>to bound the initial full-INBOX backfill. keyed-entity verb targets + Scope/owner options use the natural key (account email, company domain, contact email, tag name, workflow name), UUID still accepted; single-entity verb target = positional<key>arg, never--<entity>-id(§V.107). - CLI API standard (§V.107, §V.115, §V.116, §V.141, §V.142, §V.143) — verb roles: Read {list, search, view, stats (read-only aggregate, §V.132 enrollment-grain | §V.133 task-grain)}; Mutate {create, add, remove, set, update, disable, enable, merge} (
create= new entity from own fields;add/remove= link/unlink to an owner (note remove= sole hard-delete surface §V.14 — single<note_id>or owner bulk w/--yes);set= replace owner's full link set (tag set§V.141);disable= soft-retire,enable= clear the soft-disable;merge= company-only absorb source into survivor §V.143; nodelete, no hard-delete §V.10/§V.114 exceptnote remove§V.14); Lifecycle {start, stop, cancel, retry, enrollmentrun} carry per-entity state guards (invalid_state, §V.109); Action {send, reply, sync} = external Gmail effect; Schema/catalog {init, migrate, check, export, import}.activity create->activity add(attaches event to a required owner).listfilter flags = six-family taxonomy (§V.115); error envelopeerrorcode = closed vocabulary — domain codes {not_found,invalid_state,missing_filter,already_exists,schema_migration_pending,schema_drift,import_failed, ...} around the §V.54 constraint/validation half, new code = spec change. - tag multi-owner + set (§V.141):
tag add --tag <name>accepts repeatable--company-domainor repeatable--contact-email(owner-kind XOR per call, ≥1 owner; not mixed kinds); undefined tag →not_found(never auto-create §V.116); N=1 →{"tag_assignment":{...},"ok":true}entity envelope; N>1 → results envelope §V.139 shape ({"results":[...],"ok":true,"record_count":N}), exit 0 iff zero error rows; already-linked multi row → status ok skip.tag set --company-domain|--contact-email <owner> --tags a,b,creplaces owner's full assignment set (empty--tagsclears); success company owner → company entity envelope w/ finaltags[]; undefined name in set →not_foundzero writes. db= schema provisioning/migration noun, off the connection hot path (§V.110). Verbs:init(provision empty DB — applyschema.sql+ stampschema_metadata; refuses ifaccountexists, no--force; idempotent no-op-with-message if current),migrate(apply pendingmigrations/NNN_*.sqlin order, each own txn, record inschema_migrations; no-op if none),check(emit report{recorded_hash, current_hash, applied, pending, verdict}; verdict=current ->ok:trueexit 0; pending|drift ->schema_migration_pending|schema_drifterror envelope w/ report inlined + exit 1 per §V.4/§V.109 — scriptable deploy gate),export(--file <path>writes one JSON snapshot bundle = tag vocabulary + company + contact to disk +{"db":{...}}status on stdout; read-only + drift-tolerant likecheck),import(--file <path>reads the bundle, restores tags -> companies -> contacts by natural key, per-row errors continue, dead-stops on drift|pending §V.109; same{"db":{...}}envelope). per §V.108-110, §V.121.meetingnoun (§V.125-126): rows ingested from Google Calendar by CalendarClient, NOT operator-created. verbslist,view,add(link an attendee contact per §V.115 link semantics),update(edit summary/status),cancel(status -> cancelled); nocreate(ingestion owns row creation), JSON envelope per §V.4.- stdin batch mutation (§V.139): selected Mutate verbs accept
--stdinNDJSON (1 JSON object/line); exclusive w/ positional single-entity target (not both); envelope always{"results":[{ref,status}|{ref,status:"error",error,message}], "ok":true, "record_count":N}full stream; exit 0 iff zero error rows, exit 1 if any error (still emit full results); MVPcompany disable --stdin+contact create --stdin. - company profile write (§V.140):
company update <domain|uuid>full-replace--profile-json|--profile-file <path>|--profile -(stdin) exclusive XOR; field-patch--summary/ multi--product/ multi--source/--timezone/--target-customersmerge into existing profile; keep--name; same CompanyProfile schema §V.72; success = company entity envelope. - company domain aliases + merge (§V.142, §V.143):
company create --domain <canonical> --name <name> [--alias <domain>](repeatable--alias); domain space shared — each lowercased string is eithercompany.domainorcompany_alias.domain, never both/two owners; polymorphic company refs + contact--company-domainresolve alias → canonical; create/seed of domain already canonical or alias →already_exists;company viewprojectsaliases[];company merge --from <domain|uuid> --into <domain|uuid> [--move-contacts]absorbs source into survivor (alias from.domain → into, soft-disable from w/merged:into <into.domain>, optional contact reassign); success envelope = survivor company entity; --skill recipes for alias + merge. - company tracker export + dry-run import (§V.145, §V.146):
company export→ NDJSON (1 company object/line) stable keys {domain, name, tags[], has_profile, contact_count, disabled_reason} + optionalprofilew/--full; filters compose w/ company list family (--tag/--no-tag/--status/--include-disabled/--has-profile/--min/max-contacts);--format jsonlMVP only;--out <path>→ write file + status envelope on stdout{"company_export":{path,format,record_count},"ok":true,"record_count":N}; without--outNDJSON body on stdout (stream exclusion from single-object envelope for tracker pipes).company import --from <path.jsonl> --dry-run→ report-only domain parity envelope{"company_import_diff":{missing_in_crm[],missing_profile[],zero_contacts[],disabled[],extra_in_crm[]},"ok":true,"record_count":N}(no apply writes MVP); CRM side scoped by same optional filters; notdb export|import(§V.121). - company/contact create upsert (§V.147):
company create/contact createaccept--upsert; natural-key hit w/o flag → existingduplicate_key(contact) /already_exists(company domain|alias); w/ flag → field-selective update (contact: title, email_confidence, company_domain if present; company: name if present; profile only if profile flags also passed §V.140 — bare upsert never wipes profile; new--alias? register missing only); success envelope = entity + top-level boolcreated(true=insert, false=update) + record_count=1;contact create --stdinline schema ? optionalupsert:truesame per-row semantics; --skill preferred agent path uses upsert. - disable reason-file (§V.149):
company disable+contact disableaccept--reason-file <path>XOR--reason(single-entity); empty file →validation_error; missing path →not_found; company--stdinexclusive w/ both reason sources. - note list + owner bulk remove (§V.14):
note list --company-domain|--contact-email= company/contact note surface (no full entity-view scrape);note remove <note_id>single hard-delete;note remove --company-domain|--contact-email <ref> --yesbulk-deletes all notes on owner one txn (owner-kind XOR,--yesrequired — missing →validation_error); owner missing →not_found; zero notes → ok no-op record_count=0; bulk success envelope{"notes_removed":{owner, removed_count, note_ids[]},"ok":true,"record_count":N}; operator-only (never agent tool); delete writes no activity. - enrollment tag-cohort dry-run (§V.150):
enrollment add --workflow-id <ref> --tag <name> --dry-run[optional--min-contacts N]; dry-run + company-tag path only MVP (no apply of tag cohort); single-contact path unchanged (--contact-emailrequired when no tag); tag w/o--dry-run→validation_error; dry-run w/o tag →validation_error; expand companies carrying tag (disabled excluded by default §V.114) → enabled contacts; drop already-enrolled for workflow + self-loop (§V.33) + disabled contacts; envelope{"enrollment_preview":{workflow, tag, count, contacts:[{email, company_domain}], excluded:{disabled_companies, already_enrolled, self_loop, disabled_contacts}},"ok":true,"record_count":count}; undefined tag →not_found; zero candidates → ok empty record_count=0; --skill recipes; help zero SPEC cites §V.111.
- nouns:
- agent tools (
src/mailpilot/agent/tools.py):send_email,reply_email,search_emails,read_email,list_enrollments,create_task,cancel_task,conclude_enrollment,disable_contact,list_drive_markdown,read_drive_markdown,search_drive_markdown,noop. all tool -> typed sig, dict return, err dict on failure. - pubsub (
src/mailpilot/pubsub.py): topicmailpilot-topic-dev, submailpilot-sub-dev(defaults; per-env override viaMAILPILOT_GOOGLE_PUBSUB_TOPIC/..._SUBSCRIPTION).setup_pubsub()idempotent.start_subscriber(settings, callback)streaming pull.make_notification_callback(queue, wakeup_event)decode -> enqueue ->wakeup_event.set().renew_watches()refresh @ T-24h. - config (
src/mailpilot/settings.py):llm_provider(defaultxai; in {anthropic,xai}; global switch for classifier + workflow agent; default-xai break — Anthropic-only hosts ! setllm_provider=anthropicor supplyxai_api_key),database_url,anthropic_api_key(envMAILPILOT_ANTHROPIC_API_KEY/ config only; bareANTHROPIC_API_KEYnot a mailpilot source),anthropic_model(defaultclaude-sonnet-5),anthropic_base_url(defaulthttps://api.anthropic.com; override -> Anthropic-compatible endpoint e.g.https://api.novita.ai/anthropic; not secret per §V.86),anthropic_thinking(defaultadaptive; in {unset,adaptive}; workflow agent only; Anthropic branch per §V.47),anthropic_effort(defaulthigh; closed enum {unset,low,medium,high,xhigh,max}; settings-reject invalid; workflow agent only;xhighOpus 4.7+ only),anthropic_max_tokens(default32768; int; workflow agent only; always-passed per §V.47),xai_api_key(envMAILPILOT_XAI_API_KEY/ config only; bareXAI_API_KEYnot a mailpilot source; secret per §V.86),xai_model(defaultgrok-4.5),xai_api_host(? hostname for gateway/proxy; not secret; omit -> SDK default),xai_reasoning_effort(defaultmedium; closed enum {low,medium,high}; nonone— Grok 4.5 always reasons; settings-reject invalid; workflow agent only),xai_max_tokens(default32768; int; workflow agent only; always-passed when provider=xai),google_application_credentials,google_pubsub_topic,google_pubsub_subscription,logfire_token,logfire_environmentin {development,production},run_interval(default 60s),max_concurrent_tasks(default 10). cwd.envauto-read @ load (MAILPILOT_*keys only); process env beats.env;.envbeats~/.mailpilot/config.json(precedence §V.85). Active-provider API key ! present @ model build; inactive-provider keys unchecked until selected. - module:
src/mailpilot/gmail.py->GmailClient;src/mailpilot/drive.py->DriveClient;src/mailpilot/calendar.py->CalendarClient(§V.126). Mirror shape. - entrypoint:
mailpilot = "mailpilot.cli:main".
V1: every CLI cmd loads settings first; DB + network init after (settings-first)
V2: cli.py module-level imports = click only; heavy deps lazy-import inside cmd fns
V3: stdout = strict JSON only (all flags, incl --debug); operator lifecycle + errors -> stderr; Logfire console exporter ! target stderr (ConsoleOptions output=sys.stderr), never stdout — output unset defaults stdout so console lines corrupt JSON envelope
V4: every cmd output ! match §I.cli envelope; ok:true carries top-level int record_count; error -> {"error","message","ok":false} + exit 1 — → .spec/check-extras.md §V4
V5: list/view rows carry parent denorm joined at fetch — → .spec/check-extras.md §V5
V7: EmailSummary projection includes gmail_thread_id, is_routed, route_method, recipients — → .spec/check-extras.md §V7
V8: contact/company/meeting views inline <=10 latest notes (_INLINE_NOTES_CAP); ContactView/CompanyView/MeetingView = base superset; CompanySummary + CompanyView ! carry tags[] (assigned names, empty ok; list/view same shape); CompanyView ! carry aliases[] (sorted lowercased alias domains, empty ok; view-only — list lean omits); ContactView ! project verification_meta by default (operator-only via contact view --include-meta §V.144); company list --full ? profile.summary only; field set test-tracked vs base (no silent Pydantic-strip drift); workflow-agent prompt pre-feed routes through load_contact_view/load_company_view (§V.135) — prompt + default CLI view byte-identical (meta opt-in = CLI-only, never agent path); → .spec/check-extras.md §V8
V10: tag soft-disable — disabled_reason TEXT NULL, reversible via tag enable; vocabulary-tag disable/enable write no activity — → .spec/check-extras.md §V10
V11: status payload = fixed envelope {version, schema, sync_loop, accounts, tasks, config, counts} — → .spec/check-extras.md §V11
V12: IDs minted client-side via new_id() -> UUIDv7; enrollment addressed by scalar id
V13: tag + note target = XOR — exactly one of {contact_id, company_id} set (schema CHECK)
V14: activity append-only — INSERT only; sole hard-delete = note remove operator-only (never agent tool) dual-mode XOR: (a) positional <note_id> → one note row; (b) owner scope --company-domain XOR --contact-email + required --yes → all notes on owner one txn; owner mode w/o --yes → validation_error; zero notes → ok no-op record_count=0; bulk envelope {"notes_removed":{owner, removed_count, note_ids[]},"ok":true,"record_count":N}; delete writes no activity (prior note_added trail intact); tag/note INSERT + activity row one txn — → .spec/check-extras.md §V14
V15: enrollment.status in {active, disabled}; outcomes on activity timeline via record_enrollment_outcome — → .spec/check-extras.md §V15
V16: race-safe create — UNIQUE-bearing create_X uses ON CONFLICT DO NOTHING -> None to race loser, exactly 1 row persists; bulk variants converge to shared ids; CLI surfaces duplicate_key envelope
V17: activity targets >= 1 of {contact_id, company_id}, both allowed (multi-target); list_activities enforces same
V18: schema drift = live structure diverged from schema.sql w/ no migration path (hash-mismatch primitive §V.19), distinct from pending (§V.108); tiered response per §V.109 — → .spec/check-extras.md §V18
V19: schema hash = sha256 over normalized schema.sql (strip -- comments, collapse whitespace)
V20: email.route_method NULL or in 7-value enum (schema CHECK, set per §I.cli); non-NULL -> is_routed=TRUE; NULL + is_routed=TRUE = pipeline ran, no match ("unrouted" = span-only label)
V21: background loops wake on events not timers — wakeup_event set by Pub/Sub notify + pg NOTIFY task_pending (INSERT + retry-UPDATE triggers); run_interval tick = fallback only
V22: <= 1 routing.route_email span lifecycle per email_id — is_routed gate; History-API re-delivery + repeat sync never trigger second route pass; → .spec/check-extras.md §V22
V23: task drain = bounded pool <= max_concurrent_tasks; atomic claim; each worker owns its connection + roots its own trace (trace_id 1:1 w/ agent.invoke) — → .spec/check-extras.md §V23
V24: main loop never blocks on task futures — reaper collects on later ticks + emits task.drain
V25: advisory locks 2-tier — coarse (workflow_id, contact_id) + task-scoped; acquired before agent.invoke span opens; contention -> reschedule w/o attempt_count bump — → .spec/check-extras.md §V25
V26: agent.invoke span trigger attr in {enrollment_run, enrollment_schedule, task, email, manual} = caller path
V27: routing pipeline order thread match -> RFC message-id match -> LLM classify, all account-scoped; every outcome marks is_routed=TRUE w/ distinct route_method — → .spec/check-extras.md §V27
V28: task.enrollment_id NOT NULL; workflow_id + contact_id denorm retained for filters; enrollment guaranteed @ route time via ensure_enrollment — ON CONFLICT once, enrollment_added activity on first insert only
V29: trigger email body inlined once under "New inbound email:"; excluded from email_history — no prompt duplicate
V30: prompt framing follows trigger — first-reach-out (enrollment_run + enrollment_schedule byte-identical) vs deferred-task vs inbound; inbound email present -> email framing wins; no synthesized task_description
V31: protocol deferred branch direction-aware — outbound task trigger -> terminal-outcome fragment; inbound -> reply-once fragment, templates bind neither conclude_enrollment nor create_task — → .spec/check-extras.md §V31
V32: enrollment_schedule = distinct trigger label (observability split from enrollment_run); --scheduled-at -> pending first-touch task (email_id NULL), idempotent, rejected for inbound workflows
V33: enrollment self-loop rejected — contact.email == workflow account email, case-insensitive
V34: every Drive call carries Shared-Drive flags — corpora="allDrives", supportsAllDrives=True, includeItemsFromAllDrives=True
V35: Drive KB isolation = per-account impersonation (DWD with_subject); account reads only files its identity can read; list/search filter mimeType text/markdown + trashed=false; content decoded UTF-8 errors=replace
V37: auth = service account + domain-wide delegation; file creds -> with_subject(email); ADC -> iam.Signer credentials w/ subject=email; no OAuth user login
V38: Drive tools registered sequential=True (shared httplib2.Http thread-unsafe); transport faults (HttpError, TimeoutError, OSError) → structured drive_unavailable dict, never bare raise; → .spec/check-extras.md §V38
V39: agent tool failure -> error dict {error, message}, never exception to agent; agent re-drafts via tool-error path
V40: protocol fragment naming tools ! name >= 2 distinct tools, never exactly 1
V41: KB grounding rules live in the workflow def instructions field (§V.103), never a code-defined template protocol fragment — → .spec/check-extras.md §V41
V42: email body format lint + SPEC_TABLE inbound pipe-table mandate — → .spec/check-extras.md §V42
V44: agent shape owned by code-defined template registry; workflow.template + type immutable post-create, type derived from template — → .spec/check-extras.md §V44
V45: protocol composed _BASE → [_SPEC_TABLE (inbound only) →] deferred branch → _MUST_SEND → _DECLINE → _NO_FABRICATION = tool-loop shape; compose-only touch runs per §V.136; deferred branch per §V.31 (direction+trigger); every fragment email-universal OR direction-scoped, never workflow-specific; agent-facing text carries zero SPEC citation; → .spec/check-extras.md §V45
V46: template name = -; prefix == direction field
V47: provider-aware model config — llm_provider dispatches _build_model (role in {classifier, workflow}); Anthropic branch: caching flags both call sites + thinking/effort/max_tokens workflow-only; xAI branch: XaiProvider+XaiModel, reasoning_effort+max_tokens workflow-only, no Anthropic cache flags; effort enums settings-validated; cache telemetry names Anthropic-only — → .spec/check-extras.md §V47
V48: provider transport timeout = 240s — Anthropic HTTP 240s (APITimeoutError + httpx.ReadTimeout terminal); xAI XaiProvider(timeout=240) hard-coded (no operator setting); xAI/gRPC timeouts terminal same spirit; mid-turn tool side-effects make retry unsafe
V49: bounded auto-retry — 4 attempts, execute_task per-task classification, manual retry = failed/cancelled only — retry matrix → .spec/check-extras.md §V49
V51: every logfire.exception site reachable from mailpilot run ! paired operator_event("error", source=..., message=...); contract test sweeps run-reachable modules; → .spec/check-extras.md §V51
V52: logfire.configure(environment=settings.logfire_environment) -> spans carry deployment_environment; cloud queries filter by env
V53: agent tool spans come from logfire.instrument_pydantic_ai() (gen_ai.tool.name attr); no logfire.span inside agent tools; agents carry explicit names mailpilot.classifier + mailpilot.workflow
V54: CLI mutation = logfire.span + operator_event; constraint code vocabulary; SystemExit absorbed at boundary — → .spec/check-extras.md §V54
V55: gen_ai.tool.call.result span attr exempt from Logfire scrubbing; all other attrs scrubbed; scrubbing contract test drives a real instrumented tool call, never a fabricated span
V62: release = make release major|minor|patch — local make check + clean-tree gate; uv version --bump; commit chore: release v<x.y.z> + tag v<x.y.z>; push main + tags only (no local gh release create); publish = .github/workflows/release.yml on push tags v* — ci.yml gate (workflow_call / make check) then publish job: tag==pyproject version + uv build + OIDC PyPI + gh release create --generate-notes (GH release + PyPI only after CI passes); dist name mailpilot-crm (PyPI mailpilot foreign-owned), module + CLI = mailpilot — → .spec/check-extras.md §V62
V67: persisted outbound in_reply_to + references_header mirror wire MIME headers exactly
V69: tick classifying >= 1 inbound -> next tick forces full sweep + wakeup_event set
V71: per-task reply-rejection counter (reply_rejection_scope) — format_check rejections cap 3; past cap bypasses; outside scope always enforces
V72: company.profile JSONB validated vs CompanyProfile — required {summary, products, target_customers, sources} non-empty; timezone optional, null on multi-zone; malformed -> validation_error
V73: skill-body-embedded Workflow snippet runnable as authored — (a) zero free vars; (b) args-as-collection guard; (c) prose-matches-dispatch; (d) saved-workflow byte-identical — recipe → .spec/check-extras.md §V73
V74: CSV ingestion uses RFC-4180 parser (csv module / csv.DictReader); redirect resolution = hop-agnostic curl -sL; scope .claude/skills/**; .claude/workflows/*.js excluded — recipe → .spec/check-extras.md §V74
V75: sync incremental via History API from gmail_history_id; 404 → full INBOX re-sync; first sync (last_synced_at NULL) → full INBOX; mid-batch 429|5xx → bounded backoff retry, NEVER dropped; checkpoint advances only past persisted messages; → .spec/check-extras.md §V75
V76: routing eligibility window — received_at older than 7 days, zero active workflows, or predates earliest active workflow -> is_routed=TRUE w/ matching skipped* route_method, no LLM call
V77: outbound email row persists only after Gmail accepts send; orphan recovery via get_email_by_gmail_message_id on conflict — → .spec/check-extras.md §V77
V78: outbound MIME headers (X-MailPilot-*) + thread_id threading via send path — → .spec/check-extras.md §V78
V79: send/reply guards + account soft-disable lifecycle — → .spec/check-extras.md §V79
V80: bounce/unsubscribe handling + contact disable; reason prefix in {bounced:, unsubscribed:} — → .spec/check-extras.md §V80
V81: tool-loop agent run ! call >= 1 tool; noop(reason) = explicit no-op escape; zero tool calls -> AgentDidNotUseToolsError; compose-only structured-output runs exempt (§V.136) — validated output IS the action
V82: agent email history scoped to (account_id, contact_id, workflow_id) — other workflows' mail excluded
V83: execute_task pre-flight cancels stale tasks (inactive workflow/contact/enrollment; terminal or replied-after touch) w/ zero LLM calls (complements §V.123) — → .spec/check-extras.md §V83
V84: pubsub notification callback acks unconditionally (decode error + missing emailAddress included); sets wakeup_event when supplied
V85: settings precedence kwargs > process MAILPILOT* env > cwd .env (MAILPILOT* keys only, pydantic-settings dotenv) > ~/.mailpilot/config.json > defaults; missing .env = no-op; config file auto-created on first load
V86: secret settings (anthropic_api_key, xai_api_key, logfire_token, database_url) redacted as '***' in telemetry; config.set event logs key + changed flag
V87: cross-account isolation — thread + RFC message-id lookups scoped to account_id; agent read_email cross-account -> None (prompt-injection guard)
V88: entity enums enforced by schema CHECK — workflow.template/type/status, enrollment.status, email.direction/status/route_method, task.status, activity.type; value sets authoritative in schema.sql
V89: singleton rows — schema_metadata id=1, sync_status id='singleton'
V90: natural-key UNIQUE constraints = canonical CLI identifiers; unknown key -> not_found — → .spec/check-extras.md §V90
V92: email render = Markdown -> HTML inline styles only, no stylesheet; THEMES = {blue, green, orange, purple, red, slate}; None/unknown theme -> blue fallback
V93: operator_event -> stderr single line "HH:MM:SS event=NAME k=v ..."; newlines collapsed to space; whitespace values double-quoted, inner quotes escaped
V94: CLI FK validation precedes mutation — referenced entity missing -> error envelope, no partial write
V95: contact lead-metadata flat cols: title TEXT, email_confidence INT; NULL = high risk; --max-email-confidence includes NULL — → .spec/check-extras.md §V95
V96: lead-contacts discover set + negative-verdict memoization on typed reason_code — → .spec/check-extras.md §V96
V97: lead-companies + lead-contacts run-summary completeness — batch gate capping below stale-count -> run summary carries deferred = stale-count - processed (rows left profile/contact-NULL for a follow-up run); all stale processed -> deferred 0 or omitted; bare created|enriched|seeded counts never the sole remainder signal
V98: lead-companies seed collision visibility — resolved-apex duplicate_key recorded in run-summary collapsed when incoming CSV display name diverges from the owning company's name; fires intra-batch AND onto a previously-seeded row; same-name re-seed stays silent existing; bare existing:N never the sole entity-merge signal
V99: every path .claude/skills/** cites resolves on disk; cited-but-absent = erroring recovery instruction — recipe → .spec/check-extras.md §V99
V100: skill-body progressive-disclosure — live procedure inline; rarely-loaded material + sibling shared prose in references/*.md; body >~500 lines -> extract — recipe → .spec/check-extras.md §V100
V101: skill-body obligation vocabulary — .claude/skills/** prose marks a hard requirement w/ an explicit word (MUST / required), never bare ! as must; backticked shell ([ ! -f ], !=) + SPEC.md/telegraph register exempt. Scope = .claude/skills/**/*.md prose, grep ! -> zero must-sense hits — recipe → .spec/check-extras.md §V101
V102: project-skill frontmatter hygiene — allowed-tools + argument-hint required; zero-body-use grant banned — → .spec/check-extras.md §V102
V103: workflow defs = workflows/*.toml, 1 file/workflow, pure TOML (stdlib tomllib); import enforces name = kebab file-stem, globally unique, def fields import-only; workflow import/export TOML-only (! JSON, ! stdin); workflows/ = gitignored symlink → kborovik/workflows @ /Users/kb/github/workflows (! submodule); → .spec/check-extras.md §V103
V104: reply-test reply-loop guard — outbound@lab5.ca has no active workflow (test precondition) — → .spec/check-extras.md §V104
V105: in-scope cases graded deterministically, false-PASS-at-worst; atomicity enforced test-time by allowlist — → .spec/check-extras.md §V105
V106: Drive search whitespace-tokenized OR-joined fullText predicates; never whole-phrase — → .spec/check-extras.md §V106
V107: CLI entity = natural key (§V.90) or UUID; polymorphic resolver: UUIDv7-shaped → id, else natural key; unknown key → not_found; single-entity target = positional <key> arg, never --<entity>-id; --account-email = sole account-requiring cmd option (polymorphic); → .spec/check-extras.md §V107
V108: migrations/NNN_*.sql forward-only (monotonic prefix, no down-migrations); db migrate each own txn + re-stamps schema_hash on success; schema.sql = canonical declarative full-schema; identity invariant: fresh db init == apply-all-migrations-from-zero (test-enforced); → .spec/check-extras.md §V108
V109: three-state schema verdict in {current, pending, drift} + tiered gate; run+mutations dead-stop on drift/pending — → .spec/check-extras.md §V109
V110: initialize_database = connect + verify, not provision; empty-DB auto-provision data-loss-free; populated DB never mutates as side-effect — → .spec/check-extras.md §V110
V111: CLI --help carries zero SPEC citation; guard walks command tree — → .spec/check-extras.md §V111
V112: lead-companies scoped enrich-scope; domain/URL arg enriches only rows seeded this run, never global backlog — → .spec/check-extras.md §V112
V113: Bouncer single GET /v1.1/email/verify?email= per contact; POST /email/verify/batch/sync absent — → .spec/check-extras.md §V113
V114: company soft-disable — disabled_reason TEXT NULL, uniform disable/enable verb pairing — → .spec/check-extras.md §V114
V115: list filter flag = six-family closed taxonomy {Scope, Enum, Range, Presence, Text-match, Lifecycle}; result-control set = {--limit (default 100 unless noun opts higher), --offset (default 0), --sort, --desc} not limit-alone; --direction = canonical direction axis; shared Click decorators composed fixed-order in cli.py/_filters.py; company list|search sort/limit policy → §V.148; → .spec/check-extras.md §V115
V116: tags = tag vocabulary table + tag_assignment link table; tag add errors not_found on undefined (never auto-creates); multi-owner tag add + tag set replace → §V.141; company|contact list --tag <name> / --no-tag <name> (repeatable) membership filters; company list|view ! project assigned tags as tags[] names (empty ok; list/view same shape); → .spec/check-extras.md §V116
V117: batch-gate option distinctness — fixed cap suppressed when stale-count <= cap — → .spec/check-extras.md §V117
V119: destructive DB op backs up first via mailpilot db export; failed export aborts drop (fail-closed) — → .spec/check-extras.md §V119
V120: tool-loop trigger run MUST leave a reply_email|send_email ToolReturnPart w/o error key OR a successful noop OR conclude_enrollment; send-obligated = inbound (email is not None); outbound first reach-out = compose-only touch run — send structural, harness sends the validated output (§V.136); _sent_reply walker + AgentCompletedWithoutReplyError guard; manual exempt; → .spec/check-extras.md §V120
V121: db snapshot bundle = {schema_version, exported_at, tags, companies, contacts} JSON; each company object ! carry aliases[] (sorted lowercased domains, empty ok; §V.142); db export/db import by natural key (never source-DB UUID); import restores aliases after companies; drift|pending dead-stops import; export→import round-trip field-identical (test-enforced); → .spec/check-extras.md §V121
V122: campaign-test Touch 1 delivery identified by rfc2822_message_id per scenario, not agent-written subject; send_touch1.py captures outbound_email_id + rfc2822_message_id per enrollment; inject_replies.py matches received Touch 1 by Message-ID (subject fallback only); single prospect inbound@lab5.ca, no per-sequence aliases — → .spec/check-extras.md §V122
V123: inbound reply routing bulk-cancels enrollment's pending future follow-up tasks (excluding first-touch enrollment_schedule trigger); cancel_enrollment_followup_tasks fires from 4 sites: routing.route_email + calendar booking + conclude_enrollment + cadence sequence exhaustion (§V.136); → .spec/check-extras.md §V123
V124: workflow.goal = observable enrollment success outcome; one field, two readers: conclude_enrollment disposition gate + classify.py semantic-match key; _DEFERRED_TASK_TASK fragment names the goal; record_enrollment_outcome is system-internal; → .spec/check-extras.md §V124
V125: meeting + meeting_attendee schema; status gates nothing; ingest owns row creation — → .spec/check-extras.md §V125
V126: CalendarClient mirrors GmailClient/DriveClient shape; _poll_account_calendar(connection, account) helper fires from run-interval tick + account sync; idempotent upsert on google_event_id; per-account errors isolated (logged, never raised); read-only; → .spec/check-extras.md §V126
V127: conclude_enrollment(disposition, note, reschedule_at) = sole agent terminal; disposition in {meeting_booked, do_not_contact, contact_later}; system side-effects per disposition; counts as send-obligation terminal (§V.120); record_enrollment_outcome NOT in agent tool set — system-internal conclusion sites: calendar booking (§V.128), cadence sequence exhaustion (§V.136); → .spec/check-extras.md §V127
V128: calendar booking concludes active outbound enrollments + cancels follow-ups, no agent turn — → .spec/check-extras.md §V128
V129: agent-supplied timestamps: grounded (current-date injected per run via @agent.instructions) + future-checked at boundary (create_task + conclude_enrollment.reschedule_at reject past timestamps); guard NOT in database.create_task; → .spec/check-extras.md §V129
V131: terminal inbound agent failure sends one _FALLBACK_ACKNOWLEDGEMENT fixed reply — gate: inbound email_id set AND reply-emitted contextvar unset; outbound first-touch stays silent; fallback-send failure logs error + marks task failed; → .spec/check-extras.md §V131
V132: workflow stats funnel — single SQL aggregate, 8 stages, enrollment grain, envelope {workflow_stats} — → .spec/check-extras.md §V132
V133: task stats aggregate — single SQL, task grain, envelope {task_stats}; filters --workflow-id (§V.107) + --trigger (§V.26); returns per-status {pending,completed,failed,cancelled}+total + distinct_scheduled_days + first/last scheduled_at; → .spec/check-extras.md §V133
V134: workflow check = read-only def-integrity check per §V.108 shape — SHA-256 over def fields {template,theme,goal,instructions,touches,touch_interval_days} keyed by name; states {in_sync,out_of_sync,not_imported,orphaned}; --file repeatable; specific-file check scoped to passed names (no orphaned), dir check flags orphaned; report-only envelope {"workflow_check"}; not a deploy gate; → .spec/check-extras.md §V134
V135: mechanical context pre-feed — invoke_workflow_agent pre-loads ContactView/CompanyView via shared loaders (§V.8); read_contact/read_company absent from every roster — → .spec/check-extras.md §V135
V136: system-owned touch cadence — def fields touches + touch_interval_days own the sequence; cadence engine owns schedule math + touch scheduled_at; touch runs = compose-only agent (output_type TouchMessage, zero tools), harness sends + schedules next touch; final touch -> system conclude contact_later — → .spec/check-extras.md §V136
V137: connect-fail operator UX — _connect_database maps OperationalError → SystemExit hint; expected fail → logfire.error + operator_event, zero console Traceback (closes §B.125) — → .spec/check-extras.md §V137
V138: company cohort pipeline status — company list --status Enum ∈ {ready, needs_contacts, needs_profile, disabled}; AND-composes w/ list filters; no company audit verb — → .spec/check-extras.md §V138
V139: stdin NDJSON batch mutation — selected Mutate verbs accept --stdin; results envelope + partial-success exit policy; MVP company disable --stdin + contact create --stdin — → .spec/check-extras.md §V139
V140: company profile write paths — company update full-replace XOR profile flags + field-patch merge; CompanyProfile validation; no partial write — → .spec/check-extras.md §V140
V141: multi-owner tag link + set-replace — tag add repeatable owner XOR; tag set full assignment replace; company list|view tags[] always — → .spec/check-extras.md §V141
V142: company domain aliases — company_alias shared domain space; polymorphic resolve; create --alias; view aliases[]; migration 011 — → .spec/check-extras.md §V142
V143: company merge into survivor — company merge --from/--into [--move-contacts]; alias + soft-disable source; enable blocked when domain is alias — → .spec/check-extras.md §V143
V144: contact operator-only verification meta — JSONB verification_meta; --meta-json / view --include-meta; never agent allowlist — → .spec/check-extras.md §V144
V145: company tracker export — company export NDJSON jsonl; list-family filters; --full profile; --out status envelope or stdout stream — → .spec/check-extras.md §V145
V146: company tracker dry-run import — company import --from jsonl --dry-run domain-parity buckets; no apply writes MVP — → .spec/check-extras.md §V146
V147: company/contact create upsert — --upsert field-selective update + top-level created flag; bare upsert never wipes profile — → .spec/check-extras.md §V147
V148: company list/search order + page — --sort/--desc/--offset; company default --limit 500; lean row fields pinned — → .spec/check-extras.md §V148
V149: disable reason-file — company|contact disable --reason-file XOR --reason; empty/missing/XOR validation — → .spec/check-extras.md §V149
V150: enrollment tag-cohort dry-run — enrollment add --tag --dry-run company-tag preview; disabled/enrolled/self-loop excluded — → .spec/check-extras.md §V150
id|status|task|cites
T181|x|impl §V.105(∆) — flip _is_brittle_inscope_token to allowlist; atomize qa-in-006 + qa-in-009 tokens|V105,B109
T182|x|impl §V.123(+) — cancel_enrollment_followup_tasks; call from routing.route_email on inbound match|V123,B110,V28,V32
T183|x|impl §V.78(∆) — expose optional thread_id on send_email agent tool; outbound thread-continuation|V78
T184|x|impl §V.124(+) + §V.103(∆) — rename workflow.objective → goal; migration 006; update classify.py + invoke.py + TOML files|V124,V103,V108
T185|x|impl §V.125(+) — meeting + meeting_attendee tables; migration 007; database.py CRUD|V125,V108
T186|x|impl §V.126(+) + §V.128(+) — CalendarClient; sync-loop poll + upsert meetings + booking-conclusion fan-out|V126,V128,V21
T187|x|impl §V.127(+) + §V.120(∆) — conclude_enrollment agent tool; drop record_enrollment_outcome from tool set|V127,V120,V15
T188|x|impl §V.125/§I — meeting CLI noun: list|view|add|update|cancel|V125,V126
T189|x|impl §V.126(∆) — _poll_account_calendar helper; wire into account sync per-account calendar pass|V126,V125,V128,V107
T190|x|impl §V.129(+) — date-grounding in @agent.instructions; past-date guard at create_task + conclude_enrollment.reschedule_at|V129,B111,V127,V32
T191|x|impl §V.8(∆) — list_meeting_attendees; MeetingView superset; list_meetings compact attendee summary|V8,B112,V125,V96
T192|x|impl §V.130(+) — anthropic_thinking + anthropic_effort settings; thread into _build_anthropic_model|V47
T193|x|impl §V.42(∆) + §V.45(∆) — extract _SPEC_TABLE fragment; inbound-templates-only composition|V42,V45,B114
T194|x|impl §V.130(∆) — anthropic_max_tokens setting; always-passed in _build_anthropic_model|V47,B115
T195|x|impl §V.131(+) — _FALLBACK_ACKNOWLEDGEMENT + reply-emitted flag; fallback send in _handle_agent_failure for inbound failures|V131,B116,V48,V49,V71
T196|x|impl §V.132 disposition persistence — record_enrollment_outcome disposition param writes detail.disposition; conclude_enrollment forwards disposition + booking-conclusion passes meeting_booked; JSONB key no migration|V132,V127,V128,V15
T197|x|impl §V.132 + §I — workflow stats funnel: single-SQL aggregation fn in database.py + CLI workflow stats verb + {"workflow_stats"} envelope|V132,V107,V4,V54
T198|x|impl §V.14(∆) + §I — delete_notes fn + CLI note remove --contact-email|--company-domain (clear an owner's notes); campaign-test setup clears prospect notes pre-Touch-1 + send_touch1 re-enables prospect between scenarios|V14,B118
T199|x|redesign §V.14(∆) — note remove <note_id> single-note hard-delete; delete_note(note_id) fn; campaign-test reset loops note list + remove-by-id via clear_contact_notes|V14
T200|x|impl §V.90(∆) — lowercase email natural key in create_contact/get_contact_by_email/create_or_get_contact_by_email/create_contacts_bulk/get_contacts_by_emails before match+insert; migration merges existing case-variant duplicate contacts onto canonical lowercase row|V90,B121
T201|x|impl §V.133(+) + §I — task stats single-SQL aggregate fn in database.py + CLI task stats verb + {"task_stats"} envelope; --trigger Enum filter on context->>'trigger' for task list + task stats; --bucket-tz day-bucketing for distinct_scheduled_days|V133,V107,V26,V32,V4,V115
T202|x|impl §V.90(∆) + §V.107(∆) — workflow natural key name global UNIQUE + kebab CHECK (migration 009); polymorphic resolver matches name case-insensitively|V90,V107,V108,V103
T203|x|impl §V.103(∆) — workflow import enforces name = kebab file-stem + global unique; def fields {name,template,theme,goal,instructions} import-only; workflow update restricted to non-def fields (status, account binding)|V103,V107,V90
T204|x|impl §V.134(+) + §I — workflow check verb: 2-way live SHA-256 over wording fields {template,theme,goal,instructions} keyed by workflow name (read each workflows/*.toml name field, join rows by name); states {in_sync,out_of_sync,not_imported,orphaned}; {"workflow_check"} envelope|V134,V103,V107,V4,V54
T205|x|impl §V.4(∆) + §I — top-level int record_count = records displayed on every ok:true envelope (array payload -> len; single-object -> 1); error omits; thread through cli.py output helper|V4,V3
T206|x|migrate pydantic-ai-slim[anthropic] v1→v2 — pin >=2.2.0,<3.0.0; instr-format v5 span/attr renames; scrub exemption re-keyed to gen_ai.tool.call.result|V38,V53,V55,V47
T207|x|impl §V.103(∆) + §I — workflow import zero-applied loud failure: applied/rejected envelope aggregates; applied=0 -> import_failed + exit 1|V103,V4,V3
T208|x|impl §V.31(∆) — _DEFERRED_TASK_INBOUND fragment + direction-aware build_protocol; inbound rosters exclude conclude_enrollment + create_task|V31,V45,V44
T209|x|impl §V.135(+) + §V.8(∆) + §V.45(∆) + §I — context pre-feed via shared view loaders; drop read_contact/read_company from all rosters|V135,V8,V45,V40
T210|x|impl §V.136(+) + §V.103(∆) + §V.134(∆) + §I — cadence def fields (migration 010) + cadence engine; strip cadence prose from workflow TOMLs|V136,V103,V134,V108,V127,V128
T211|x|impl §V.136 compose-only shape — trigger-keyed dispatch to output_type TouchMessage; harness send + next-touch schedule; retire DEFERRED_TASK_INITIAL|V136,V31,V45,V81,V120,V42,V44,V25
T212|x|impl §V.83(∆) — touch pre-flight guards: latest enrollment outcome terminal | inbound from contact after prior touch -> cancel task, zero LLM calls; then strip reply-self-guard prose from workflow TOMLs (workflows repo, contingent)|V83,V123,V136
T213|x|reconcile workflow TOMLs w/ T209-T211 landed code — strip retired-agent prose from workflows/*.toml; re-import; workflow check in_sync|V135,V136,V103
T214|x|impl §V.85(∆) — re-enable dotenv source for cwd .env (env_file=".env" + dotenv_settings in customise_sources); process env beats .env; .env beats config.json; TDD|V85
T215|x|impl §V.137 ordered connect-hint map + logfire.error (no console Traceback); extend tests/test_database_telemetry.py mocked OperationalError strings; stack #186 role/database split|V137,B125
T216|x|impl §V.47 provider-aware LLM — llm_provider default xai; xai* settings + _build_model dispatch; pydantic-ai-slim[anthropic,xai]; Anthropic path opt-in; effort enum reject tests; default model ids|V47,V48,V86
T217|x|impl §I company projection + §V.8(∆) + §V.116(∆) — CompanySummary/CompanyView tags[]; list --full profile.summary; disabled_reason always on list rows; tests + --skill docs (#188)|V8,V116,V114,V96,I.cli
T218|x|impl §V.138(+) + §I — company list --status pipeline Enum; SQL/filter rules; compose --tag/--min-max-contacts/--include-disabled; tests per bucket; --skill/help docs (#189)|V138,V115,V114,V96,V116,I.cli
T219|x|impl §V.139(+) + §I — company disable --stdin + contact create --stdin NDJSON batch; partial-success results envelope; exit policy; mixed ok/error tests; --skill recipes (#190)|V139,V4,V3,V16,V94,V107,I.cli
T220|x|impl §V.140(+) + §I — company update --profile-file/--profile - full-replace + field-patch flags (summary/product/source/timezone/target-customers); merge vs replace; validation_error no partial; tests + --skill (#191)|V140,V72,V4,V54,I.cli
T221|x|impl §V.141(+) + §V.116(∆) + §I — multi-owner tag add (repeatable --company-domain|--contact-email) + tag set replace; multi results envelope; tests multi-add + company view tags always; --skill docs (#192)|V141,V116,V8,V14,V4,V94,I.cli
T222|x|impl §V.142(+) + §V.143(+) + §I — company domain aliases + merge into survivor; migration 011 company_alias; resolve on view/contact; create --alias; merge --move-contacts; export aliases[]; tests + --skill (#193)|V142,V143,V8,V90,V107,V114,V121,V16,V4,V94,I.cli
T223|x|impl §V.144(+) + §V.8(∆) + §I — contact verification_meta JSONB; create|update --meta-json; view --include-meta; agent allowlist + context-builder tests; migration; --skill docs (#194)|V144,V8,V95,V135,V4,I.cli
T224|x|impl §V.145(+) + §I — company export jsonl; list-family filters; --full profile; --out status envelope or stdout stream; shape tests; --skill (#195)|V145,V138,V116,V114,V96,V3,V4,I.cli
T225|x|impl §V.146(+) + §I — company import --from jsonl --dry-run diff buckets; filter-scoped CRM; tests; --skill (#195)|V146,V145,V138,V4,I.cli
T226|x|impl §V.147(+) + §V.139(∆) + §I — company create --upsert + contact create --upsert; field-selective update; created flag; preserve non-upsert errors; stdin upsert line; tests create vs update + profile non-wipe; --skill agent recipe (#196)|V147,V139,V16,V142,V140,V90,V4,I.cli
T227|x|impl §V.148(+) + §V.115(∆) + §I — company list|search --sort/--desc/--offset; company default limit 500; pin search lean fields; tests; --skill defaults/sort keys (#197)|V148,V115,V8,V116,V114,V96,V3,V4,I.cli
T228|x|impl §V.149(+) + §I — company/contact disable --reason-file XOR --reason; tests empty/missing/XOR; --skill (#197)|V149,V114,V3,V4,I.cli
T229|x|impl §V.14(∆) + §I — note remove owner bulk (--company-domain|--contact-email + required --yes); single-id path unchanged; delete_notes fn; tests empty/missing-yes/XOR; --skill (#198)|V14,I.cli
T230|x|impl §V.150(+) + §I — enrollment add --tag --dry-run company-tag cohort preview; disabled companies out; exclude enrolled + self-loop; optional --min-contacts; envelope + tests; --skill (#198)|V150,V33,V114,V116,V107,V4,I.cli
id|date|cause|fix
B91|2026-06-16|mailpilot db --help renders SPEC cites via Click fn docstring + option help= strings|V111
B92|2026-06-17|seed_companies.py query_stale (company list --no-profile) emits global profile-NULL set; domain arg enriches backlog, not scoped rows; trips >10 batch gate|V112
B93|2026-06-17|contact-finder used Bouncer POST /email/verify/batch/sync (async, returns [] cold emails); agent read [] as status=unknown -> NULL|V113
B94|2026-06-17|ContactView omits title + email_confidence; **contact.model_dump() via Pydantic extra=ignore silently strips both -> contact view + read_contact return fewer fields than contact list|V8
B95|2026-06-18|lead-contacts re-dispatches a contact-finder every run for profiled companies that yield zero contacts (no row persists to memoize the empty verdict); company list --has-profile --max-contacts 0 accumulates, re-burning Hunter/TheOrg credits|V96
B96|2026-06-18|schema.sql column-ordering diverges from migration 002 ADD COLUMN order; fresh db init != migrate-from-zero schema; populated DB gets UndefinedColumn c.disabled_reason on company list|V108
B97|2026-06-18|db migrate never re-stamped schema_metadata.schema_hash; post-migration verdict sees recorded≠current + 0-pending -> phantom drift + dead-stop w/ no forward path|V108
B98|2026-06-20|batch-gate First 25 option not suppressed when stale-count <= 25; two options dispatch one identical batch; option set never gated on distinct-batch mapping|V117
B99|2026-06-20|output_error writes duplicate-key envelope to stderr per §V.3; seed_companies.py parses empty stdout -> JSONDecodeError; existing branch never fires|V3
B100|2026-06-21|company/contact/tag/enrollment disable had no enable mirror; company update never wired to clear disabled_reason; entities stuck disabled w/ no CLI recovery path|V114
B101|2026-06-22|negative-verdict memoization tagged zero-decision-maker only; all-already-seeded companies re-entered discover set + re-burned credits; prose-prefix reason match not a typed reason_code|V96,V116
B102|2026-06-22|in-scope grader false-FAILed a correct reply; multi-word column-header token rendered across two table cells; contiguous-substring match in _grade_inscope never hit|V105
B103|2026-06-22|agent drafted reply in <thinking> + emitted no reply_email tool call; §V.81 (>=1 tool) passed; no send-obligation guard for inbound; reply-less completion recorded as success|V120
B104|2026-06-22|db import dropped exported fields + forwarded source-DB UUID as company_id; FK resolved to no row in fresh DB -> ForeignKeyViolation; uncaught FK aborted whole contact batch|V121
B105|2026-06-23|batch-fetch per-sub-request 429s dropped silently to unused failed_ids; checkpoint advanced past unread messages -> permanent loss|V75
B106|2026-06-24|outbound first reach-out never sent; §V.120 send-guard gated inbound-only; outbound trigger bypassed guard; agent emitted email body as text w/o send_email call; run recorded completed|V120
B107|2026-06-24|output_error SystemExit(1) escaped logfire.span.__exit__; recorded as error-level exception span (exception.escaped=True) despite clean exit; test asserted child only, not parent span|V54
B108|2026-06-24|verify_delivery.py keyed delivery on agent-written subject; two sends sharing one subject collapsed to one key; second sequence counted missing; EmailSummary lacked recipients field|V7,V122
B109|2026-06-24|_is_brittle_inscope_token denylist matched Label (Qualifier) only; sentence-fragment tokens slipped through -> false-FAIL on restructured grounded replies|V105
B110|2026-06-25|inbound reply routing left pending follow-up tasks scheduled; firing guards gate on active-only, not terminal-outcome; replied prospect could receive later cold breakup|V123
B111|2026-06-26|agent-supplied scheduled_at past-dated; no current-date anchor in prompt + no future check at create_task; already-due task fires immediately|V129
B112|2026-06-26|get_meeting/list_meetings returned bare Meeting rows; no list_meeting_attendees + no MeetingView; operator could write + filter attendees but never read them|V8
B113|2026-06-26|gen_ai.usage.cache_read_input_tokens exists on neither span; real names = gen_ai.usage.cache_read.input_tokens (chat) + bare cache_read_input_tokens (rollup); false caching-off diagnosis|V47
B114|2026-06-26|outbound agent template carried inbound pipe-table mandate from shared _BASE; PR #165 claimed to drop it but only the duplicate-directive consolidation shipped; mandate absent from the diff|V42,V45
B115|2026-06-26|_build_anthropic_model omitted explicit max_tokens; default-active thinking exhausted provider-default output budget before reply text -> UnexpectedModelBehavior|V47
B116|2026-06-26|_handle_agent_failure terminal branch never composed a reply; inbound sender got silent NO_REPLY on any terminal failure class (UnexpectedModelBehavior, max-attempts, etc.)|V131
B117|2026-06-26|§V.105 + check-extras.md named score_replies.py + 3-shape allowlist; _is_brittle_inscope_token lives in test file with 4th shape; grep hit zero -> false /sdd:check violation|V105
B118|2026-06-27|prospect notes from prior runs (do-not-contact, wrong-person) poisoned read_contact grounding; setup cleared re-enable but not notes; agent concluded do_not_contact + skipped Touch 1|V14
B119|2026-06-27|V122 drift: campaign-test refactored to single-prospect multi-scenario architecture after T180; verify_delivery.py + inbound{N}@lab5.ca aliases removed; V122 text described the superseded alias-keyed design|V122
B120|2026-06-27|AI Engineering not_now branch named a two-option close (create_task OR conclude_enrollment contact_later); live agent took both — concluded contact_later AND created a follow-up task; contact_later already re-enrolls so prospect double-queued|V124
B121|2026-06-29|inbound reply From local-part cased unlike enrolled (CThorne@ vs cthorne@); sync sender→contact resolve keys off raw-cased address; case-sensitive contact.email UNIQUE misses canonical row + mints bare duplicate; email.sender independently lowercased so paths disagree|V90
B122|2026-07-01|pydantic-ai v2 instr-format 5 renames tool-return attr tool_response→gen_ai.tool.call.result; path-keyed scrub exemption silently dead; fabricated-span contract test asserted the hand-set old attr so stayed green|V55
B123|2026-07-01|workflow import emitted per-row rejections inside ok:true envelope + exit 0 @ zero rows applied; campaign-test setup gated on exit code -> silent no-op import, late name-lookup failure|V103
B124|2026-07-02|deferred-task fragments encode outbound lifecycle but compose direction-blind into inbound templates — TASK branch orders conclude_enrollment the workflow TOML forbids; INITIAL branch mislabels inbound reply as initial send|V31
B125|2026-07-20|_connect_database incomplete OperationalError→hint map + logfire.exception Traceback for expected connect fails (pg_hba/DNS hit generic database_url)|V137