Rules
The scoring formula, the policy gate, the state machine, and the capability
boundaries this system enforces — copied by hand from the real source (file names given
throughout) and re-checked against it, not re-derived here. This page renders no database
query at all; it's the same class of artifact as docs/policy-matrix.md, just in
one screen instead of eight files.
1. Lead scoring — a 0–100 formula, five sub-scores
Computed once per target by score_lead (app/tools/score_lead.py),
immediately after research. Every sub-score is a pure function of what the research stage
actually found — nothing here is guessed.
| Sub-score | Max | How it's computed |
|---|---|---|
| Company fit | 30 | Industry identified (+10) + estimated size identified (+10) + the research LLM's own confidence in the profile, scaled to 0–10. |
| Persona fit | 15 (spec allows 20; the "seniority sufficient" 5pts needs title parsing this phase doesn't have) | 15 if the target carries contact data (name/title from the CSV import), 0 otherwise — 0 is the common case, since Phase 1 has no contact-enrichment step. |
| Buying signals | 25 | Sum of each detected signal's strength (0.0–1.0) × its type's weight, capped at 25 — signal quality matters more than quantity. Weights: hiring for a related role (8), a product/ops change (7), a recent launch or expansion (5), workflow-complexity evidence (5). |
| Data completeness | 15 (10 reachable without contact data) | Readable website content (+5) + contact identified (+5) + verified email (+5, not implemented in Phase 1 — always 0, not guessed). |
| Evidence quality | 10 | At least one signal AND profile confidence ≥ 0.5 (+5, a proxy for "≥2 independent sources") + no detected contradiction (+5, unconditional in Phase 1 — there's no multi-source enrichment yet to contradict). |
The five caps sum to 100, so the formula can never exceed its own scale without an extra clamp — the caps are the clamp. In practice a contact-less target (the common case) tops out around 70; even a target with full contact data tops out around 95, because email verification isn't built in this phase. That ceiling is stated honestly here rather than rounded up.
Fit label — what the score routes to
| Score | Label | What happens next |
|---|---|---|
| ≥ 80 | strong_fit | Stays in the active pipeline, eligible to draft. |
| ≥ 60 | good_fit | Stays in the active pipeline, eligible to draft. |
| ≥ 40 | watchlist | Routed out — insufficient signal, re-checked later. |
| < 40 | not_target | Routed out — below the bar, dropped from the active pipeline. |
The LLM judge — a verdict, never a number
A second model (icp_judge) reviews the same evidence and may override the
label above — but only with a written justification, or its response is
rejected outright. Its output schema has no score field at all, so it structurally cannot
touch the number: the policy floor below always reads the deterministic score the formula
produced, never the judge's opinion. A judge that calls a 51-point target strong_fit
changes the target's routing label, but the policy decision on that target still records a
deny with rule P4 — the floor holds regardless of what the judge argues.
2. Policy gate — nine rules, one choke point
policy_check (app/policy.py) is the single place every action
that could produce an external effect or a state change gets checked. It never fails
silently — an action matching no rule below resolves to deny, not allow.
| Rule | What it enforces |
|---|---|
| P1 | No send without an operator approved decision — ever. |
| P2 | No send to any address present in suppressions. |
| P3 / P3a | Required fields must be present before drafting or sending (the Phase 1 variant excludes fields this phase has no enrichment to populate, e.g. verified email — never silently treated as passing). |
| P4 | Confidence floor: fit score < 60 → deny. Draft or reply-classification confidence < 0.7 → routed to a human, never auto-acted. |
| P5 | Reply classes legal, complaint, privacy,
risky always route to a human, at any confidence. |
| P6 | The kill switch dominates every other rule, unconditionally, while engaged. Read uncached, fail-closed — a missing or malformed switch file reads as engaged. De-escalating actions (reject, suppress, escalate) still work while engaged on purpose: the emergency stop must not also lock the operator out of the brakes. |
| P7 | No autonomous re-send: a rolling-window cap on drafts, plus a hard, separately-enforced cap of 2 follow-up drafts per reply thread, ever — counted directly from the state-transition history, never trusted from a prompt. |
| P8 | Scraped or inbound text carrying injection markers ("ignore previous", oversized base64 blobs, etc.) is flagged and routed to a human rather than drafted from. Inbound email is redacted before any model ever reads it. |
| P9 | A field enrichment conflict (two sources disagreeing on an email or title) routes to a human rather than silently picking one. |
Underneath the numbered rules: per-agent capability enforcement
Every core-table write passes through write_gate.commit(), which checks three
things before any SQL runs — a rejected write names both the agent and the action, so a
denial is observable, never silent:
| Check | Refuses when |
|---|---|
| Registered | No agent_registry row exists for the calling agent id. |
| Enabled | The agent's registry row has enabled = 0 — a per-agent kill switch. |
| Authorized | The requested action isn't in that agent's own allowed_actions list. |
This is what makes an agent structurally unable to take an action, rather than merely instructed against it: the research agent handles untrusted scraped text and would be refused any write outside its capability set even if its own prompt were subverted.
The natural-language agent's four boundaries
The TaskmasterAgent (python -m app.taskmaster_cli) can plan and dispatch the
whole pipeline from one sentence — but each of the following is something it cannot do,
enforced in code and pinned by a test that fails if the capability is ever added:
| Boundary | Enforced by |
|---|---|
| Cannot approve anything | Its registry row's allowed actions is the empty list — even a hypothetical write attributed to it is refused by the gate before any SQL runs. |
| Cannot send live email | No mail transport exists anywhere in this repository — an AST test walks every module and fails if one ever appears. |
| Cannot disable the kill switch | No tool it owns references the switch's writer; it may only read the switch. |
| Cannot exceed the batch cap | A natural-language batch over 15 targets is refused deterministically, in code, before any database or file I/O — the model can report the cap, never negotiate it. |
3. State machine — every valid move, nothing implicit
No component transitions a target directly — every change goes through
state_machine.transition(), validated against a fixed table
(app/state_machine.py). A transition not in this table is refused; there is no
LLM-decided or implicit state change anywhere in this system.
The 16 states
new → enriched / researched → scored →
drafted → awaiting_review → approved →
sent / dry_run_sent → replied → routed,
with watchlist, not_target, bounced,
suppressed, and failed as the exits along the way.
The path a target actually takes
| Stage | From → To | Trigger |
|---|---|---|
| Research & score | new → researched | Research succeeds |
researched → scored | Scoring succeeds | |
scored → drafted | Policy allows a draft (P3a + P4 pass) | |
| Routed away | scored → watchlist | Insufficient signal |
scored → not_target | Non-ICP or missing critical fields | |
| Review | drafted → awaiting_review | Draft complete |
awaiting_review → approved | Operator approves | |
awaiting_review → not_target | Operator rejects | |
awaiting_review → researched | Operator escalates for more research | |
| Send | approved → sent | Send gate passes, LIVE mode |
approved → dry_run_sent | Send gate passes, DRY_RUN mode | |
| Reply | sent / dry_run_sent → replied | An inbound (or simulated) reply is linked |
replied → routed | Classifier verdict + router decision | |
routed → drafted | A positive reply queues a real follow-up draft | |
| Terminal | routed → suppressed | Reply class is unsubscribe |
bounced → suppressed | Hard bounce, automatic | |
any state → suppressed / failed | An address lands in the suppression list, or an unrecoverable error — reachable from every state, not one hop from a fixed origin |