Autonomous cold-email send and reply is authorized, fully unattended in both directions, effective on build completion. No approval gate exists anywhere in the design. Every control is a code-enforced gate that aborts, never a human who approves.
The owner's instruction to this sitting: "my involvement is to review the SOPs and then the agents carry it out, following the SOPs." Three drafts each ended with "needs a CEO ruling"; none of those questions goes back to him. They are all decided below.
The full SOP is section SOP of this issue and is written to growth/AUTONOMOUS_EMAIL_SOP.md. That file is the operative document; this issue is the ratification record.
Decisions
D1 — Reply autonomy ships. CISO's zero-data-access requirement is adopted as non-negotiable architecture, not as a trade-off to be relaxed under schedule pressure. (Owner: CTO to build, CISO to verify.) drb-email-sender/ and the reply-drafter get no credential for, no network route to, and no code path into Firestore, GCS, or any transcript/incident store. Enforced three ways: no GCP key in the service .env, no google-cloud-*/firebase-* in its requirements.txt, and a unit test asserting both. CISO finding #1 named the correct worst case — a bot answering a stranger by quoting real transcribed police radio tied to a real address and a real name. That is the largest unrecoverable downside available to this company and it gets an architectural block, not a prompt instruction. Given up: the reply-drafter can never sound informed about the product's actual output. It answers with directory facts and templates or it does not answer. Accepted.
D2 — 5-reply canary is a mandatory first phase, and it is not human review. (Owner: CTO.)
The first 5 autonomous replies send in real time with zero gate, exactly as the owner directed. The only difference is that after each one has already gone out, the owner gets one line via the existing drb-telegram-ops bot. At count 6 the canary ends automatically — no code change, no owner action, no re-ratification. This is still literally "fully autonomous"; it is observation, not approval.
CISO asked the owner to rule on this and explicitly said it was not his to assume. It is mine, and I am adopting it in the non-blocking form. A gate-blocked or hard-stopped message does not consume a canary slot. Given up: five Telegram lines of the owner's attention, once, ever.
D3 — A narrow LLM reply-draft step IS in scope. CTO's rule-based-ack-only recommendation is overruled; CTO's underlying risk is honored in full. (Owner: CTO.)
The owner asked for real replies, not a canned ack. But CTO draft #149 finding 5 is correct about the shape of the danger: an unattended script deciding when to invoke the full growth-ops agent, which has outbox-write access, is a bigger boundary change than the SMTP piece. What ships is not that.
CTO's stated risk
What is actually built
Agent with tools
No tools. One stateless HTTPS completion call.
Outbox write access
No filesystem access. Returns a string to poll.py.
Unbounded LLM judgment
Fixed versioned system prompt; output re-scanned by the same footer + forbidden-content gates as outbound.
New agent-invocation boundary
No Task/Agent spawn.growth-ops.md is unchanged.
Reads product internals for "context"
Blocked by D1.
Inputs are exactly three: the inbound body, the target's public directory row, the fixed prompt. The LLM gets no retry after a gate failure — a retry loop against a content gate is a gate-defeating loop. This resolves CTO's risk without reintroducing it. Given up: determinism. A rule-based ack is auditable line-by-line; this is not. Bought with the output gates and the thread-depth cap.
D4 — Kill switch is the flag file growth/outbox/.paused. CISO recommendation #5 (Firestore config/growth_autonomy) is overruled. (Owner: CIO.)
CISO's own recommendation contradicts CISO's own finding #1, and nobody caught it in draft. A kill switch living in the Firestore config collection requires the mailer to hold a GCP credential and open a live read path into the same project that holds calls and incidents — the exact blindness D1 makes non-negotiable. The flag file needs no credential, no network, and no dependency on the service being reachable.
CISO's requirements are adopted unchanged: checked before every individual send and reply (not once at process start), and fail-closed — an unreadable directory or a stat error counts as paused. Only the storage location moves.
D5 — The service is drb-email-sender/ at the Version 5C root, not growth/mailer/. (Owner: CIO.)
CIO #148 rec 2 over CTO #149 rec 1. Decisive reason neither draft states: growth-ops may write to anything under growth/. Putting credential-bearing code inside its own write scope reintroduces precisely the hazard its boundary #2 exists to prevent. Modeled on the working drb-telegram-ops/ precedent — own dir, own .gitignore for .env, own .env.example.
D6 — Relay is Brevo free tier on Brevo's default sending subdomain. (Owner: CIO.)
Both CIO and CTO routed this to the owner; it does not need him. $0/mo against the ≤$15/mo cap, 300 sends/day free against a 6/day cap. Default subdomain means zero SPF/DKIM/DMARC DNS work and no registrar task for the owner at launch. A custom sending domain is a separate later decision, and CIO rec 5's owner-DNS work item is not opened. Given up: deliverability. A Brevo shared subdomain lands in spam more often than an authenticated custom domain. At 33 hand-sourced B2B targets that is worth trading against blocking the whole channel on registrar work inside a <5h/week budget.
D7 — Inbound is IMAP poll. No webhook, no serverless function, no production infra. (Owner: CTO.)
CTO #149 finding 2 accepted in full: nothing is added to api.$DOMAIN, c2-core, Caddy, Server/infra/, Terraform, or Ansible. send.py and poll.py run from a local scheduled task. Brevo hosted inbound parse is permitted only as a drop-in alternative and only if it requires no prod-infra change.
D8 — Hard limits, all as code, all fail-closed. (Owner: CTO.)
6 sends/day with 20–90 min jitter (CISO #5, taking the cautious end of his 5–6). One cold email per business ever — no follow-up sequences. 2 autonomous replies per thread, then hard-stop and flag. Unsubscribe-family substring match → immediate permanent suppression and no reply at all, not even a confirmation. Hostile-family match → stop, flag, notify, never argue. Unknown sender → stop. Every classifier defaults to stop on an uncertain match.
D9 — Two gates neither draft specified, added because autonomy removes the human who was silently covering them. (Owner: CTO.)
Sourcing rule as a send-time check. The recipient must appear in the target list with a non-empty public source_url. Unsourced → skip. This is the line between a normal B2B cold email and a CAN-SPAM §7704(b) aggravated violation, and until now it was a drafting convention a human was trusted to have followed.
FROM_ORG_NAME fail-closed.growth-ops.md's footer still carries the literal placeholder [Company name TBD — do not invent one; ask the human sending it]. Under autonomy there is no human to ask. The name is read from drb-email-sender/.env; if it is unset, empty, or contains TBD or a bracket, the run aborts. The open company-name question becomes a gate the system cannot send through, rather than a question the owner has to answer before anything else can start.
D10 — AI disclosure is mandatory and fixed, not improvised. (Owner: CTO, in the versioned prompt.)
If asked whether it is a human or an AI, the reply says plainly that it is an automated assistant sending on the company's behalf. Claiming to be human is FTC Act §5 / NY GBL §349 exposure stacked on the CAN-SPAM footer duty already resolved on #147. CISO rec 7 adopted; the line lives in reply_v1.txt, not in per-reply judgment.
D11 — Opt-outs honored immediately, same run, not CAN-SPAM's 10-business-day floor (CISO rec 6). Trivial once automated, and it closes the window in which a bot could reply-argue with someone who already asked to stop.
D12 — growth-ops.md is not amended and gets no new capability. (Owner: CEO, no action.)
Its boundary #1 stands in substance: it drafts, it cannot send, it has no Bash and no network. What changed is who transmits — a separate deterministic service, not a tool grant. Boundary #2 (never reads a credential) is unchanged and is why D5 matters.
Not in scope (requires a new board minute)
Production-infra webhook; prod-VM deployment; any inbound receiver other than IMAP poll; any tool grant to growth-ops; any Task/Agent invocation from an unattended process; custom sending domain or DNS records; SMS/dialer/CRM; second cold email to any address; live-data demo, price, or account provisioning (Gates A, B, B3 — #43/#47 unchanged).
Nothing sends until F1–F4 are green. Nothing replies until F5 is green.
Owner action required
None. Every "needs a CEO ruling" item across the three drafts is decided above — relay (D6), credential home (D5), kill-switch mechanism (D4), scope split (D3), canary (D2), authorization itself (D1). The one thing the owner will eventually supply — the company name — is a one-line .env value that the D9 gate blocks all sending on until it exists, so it cannot be forgotten and does not block the build.
SOP
Autonomous Email SOP — send + reply, zero human in the loop
Status: RATIFIED — board minutes server-26#151, 2026-09-13.
Supersedes the human-transmits shape in growth/outbox/README.md and growth-ops.md boundary #1 (amended 2026-09-13 on server-26#147).
Owner ruling this SOP enacts: "my involvement is to review the SOPs and then
the agents carry it out, following the SOPs." Full autonomy both directions.
No approval gate exists anywhere in this document. Every control below is a code-enforced gate that aborts, never a human who approves.
0. Components and where they live
Component
Path
Holds credential?
LLM?
Firestore/GCS access
growth-ops agent
.claude/agents/growth-ops.md
No
Yes
No
Mailer service
drb-email-sender/ (Version 5C root)
Yes
No
No — architecturally blocked
Reply-drafter
drb-email-sender/app/reply_draft.py
No (mailer passes text only)
Yes, narrow
No — architecturally blocked
Outbox
growth/outbox/*.md
No
—
No
Why drb-email-sender/ and not growth/mailer/ (CTO's proposal):
growth-ops may write to anything under growth/. Putting credential-bearing
code inside its own write scope is the one shape that reintroduces the hazard
boundary #2 exists to prevent. drb-email-sender/ is outside growth-ops's
write scope and outside Server//Client/ git history. Modeled directly on
the working drb-telegram-ops/ precedent (own dir, own .gitignore, own .env.example).
Rule 0.1 — the reply-drafter is NOT the growth-ops agent. It is a single
stateless HTTPS call to an LLM API with a fixed system prompt, no tools, no
file access, no MCP, no Task/Agent invocation, no conversation memory. Nothing
in this SOP ever spawns growth-ops from an unattended process.
Rule 0.2 — zero production-data read path (non-negotiable, not a trade-off). drb-email-sender/ and everything it calls must have no credential for, no
network route to, and no code path into Firestore, GCS, the transcript store, calls, or incidents. Enforced by: (a) no GCP service-account key in drb-email-sender/.env; (b) no google-cloud-* or firebase-* package in drb-email-sender/requirements.txt; (c) a unit test that asserts both.
Rationale: the worst outcome available to this company is a bot answering a
stranger by quoting real transcribed police radio traffic tied to a real
address and a real name. Prompt text cannot be trusted to hold that line.
1. SEND FLOW
Trigger: scheduled run of drb-email-sender/app/send.py (local cron / Task
Scheduler on the owner's machine). Never the production VM, never docker compose, never CI in server-26/node-26.
[1.0] KILL SWITCH -> see §3. Checked before EVERY individual send, not once per run.
|
[1.1] Pick next file growth/outbox/<target-slug>.md (oldest mtime first, one at a time)
|
[1.2] GATE: daily rate cap
|
[1.3] GATE: dedupe ledger
|
[1.4] GATE: suppression list
|
[1.5] GATE: sourcing rule
|
[1.6] GATE: footer present and complete
|
[1.7] GATE: forbidden-content scan
|
[1.8] SEND via Brevo SMTP
|
[1.9] LOG + move file to growth/outbox/sent/
|
[1.10] Sleep jitter, loop to [1.0]
Gate definitions — all fail-closed
[1.2] Daily rate cap. Count entries in growth/outbox/send-ledger.jsonl
with sent_at inside the current local calendar day.
count >= 6 → STOP the run (not the file). Exit 0. Resume tomorrow.
Ledger unreadable/missing → ABORT run, log, do not send.
Also enforce a randomized 20–90 min sleep between sends within a day so the
33-target batch lands over ~6 business days, not as a burst.
Email already appears in send-ledger.jsonl with kind: "cold" → SKIP
file, move to growth/outbox/blocked/, log reason duplicate.
[1.4] Suppression list. Parse the table in growth/suppression-list.md, lowercase-compare the recipient address.
Present → SKIP file, move to growth/outbox/blocked/, log suppressed. Never send, never "confirm".
File missing or unparseable → ABORT run. Absence is not permission.
[1.5] Sourcing rule. The recipient address must appear in growth/westchester-target-list.md with a non-empty public source_url for
that row (the business's own website or Google listing).
Not found, or source_url empty → SKIP file, log unsourced_recipient.
Rationale: this is the line between a normal B2B cold email and a CAN-SPAM
§7704(b) aggravated violation. It must be checked at send time by code, not
assumed to have happened at draft time.
[1.6] Footer gate. The body must end with, verbatim:
---
{FROM_ORG_NAME}
1350 E. Main St. Ste. 24, Shrub Oak, NY 10588
Reply "unsubscribe" and you won't hear from us again.
FROM_ORG_NAME is read from drb-email-sender/.env. If unset, empty,
containing TBD, or containing [ or ] → ABORT run, log org_name_unset. This is how the unresolved company-name placeholder in growth-ops.md boundary #1 becomes a fail-closed gate instead of an open
question: nothing can send until the string is set once.
Footer missing, reordered, or the physical address altered → SKIP file,
log footer_invalid.
[1.7] Forbidden-content scan. Regex/pattern block over the full body.
Any hit → SKIP file, move to growth/outbox/blocked/, log the matching
rule. Never send on an ambiguous match.
any capitalized two-word token not in the target-list row for this recipient
BUSINESS_MODEL.md — names suppressed until E&O bound
[1.8] Send. Brevo free tier, Brevo's default sending subdomain (no
custom domain, therefore zero SPF/DKIM/DMARC DNS work for launch). SMTP creds
from drb-email-sender/.env only. On SMTP error: retry once after 60s, then ABORT run and log — never loop, never fall through to the next file.
[1.9] Log. Append one JSON line to growth/outbox/send-ledger.jsonl: {ts, kind:"cold", to, subject, message_id, thread_id, gates_passed[], result}.
Then move the source file to growth/outbox/sent/. Log write failure → ABORT run before sending anything further. An unlogged send is an
unenforceable dedupe.
2. REPLY FLOW
Trigger: scheduled run of drb-email-sender/app/poll.py, IMAP poll of the
reply mailbox (every 15 min). Ruling: IMAP poll is the mechanism. Brevo's
hosted inbound parse is permitted only as a drop-in alternative and only if it
requires no deployment to api.$DOMAIN or any Server/infra/ change.
[2.0] KILL SWITCH -> §3. Checked before EVERY individual reply.
|
[2.1] Fetch unread inbound, one message at a time
|
[2.2] HARD STOP: unsubscribe-family match? -> yes: suppress + end thread, NO reply
|
[2.3] HARD STOP: hostile/complaint match? -> yes: flag, NO reply, notify owner
|
[2.4] HARD STOP: thread depth >= 2? -> yes: flag for human, NO reply
|
[2.5] HARD STOP: sender not in send-ledger? -> yes: flag, NO reply
|
[2.6] LLM reply-draft (narrow, stateless, no tools, no Firestore)
|
[2.7] Re-run gates [1.6] footer, [1.7] forbidden-content on the GENERATED text
|
[2.8] SEND via Brevo SMTP
|
[2.9] LOG to reply-ledger.jsonl
|
[2.10] CANARY counter -> first 5: one-line owner notification. After 5: silent.
Hard stops — every one defaults to stop, never to send
[2.2] Unsubscribe family. Case-insensitive substring match (not exact,
not word-boundary) on the inbound body or subject: unsubscribe, stop, remove me, take me off, opt out, opt-out, do not email, don't email, no longer wish, delete my.
Match → append the address to growth/suppression-list.mdimmediately
(same run, not CAN-SPAM's 10-business-day floor), mark the thread closed,
and send nothing — not even a confirmation. A confirmation re-opens a
thread the person asked to end.
Suppression write failure → ABORT run. Never proceed to reply.
Match → write the thread to growth/inbox-needs-human/<thread-id>.md,
send nothing, notify the owner (one line, always — this is outside the
canary and never goes silent). Do not argue, do not apologize elaborately,
do not de-escalate autonomously. An LLM improvising a defense to a
threatened complaint is worse than silence.
[2.4] Thread-depth cap. Count outbound messages already sent on this thread_id in reply-ledger.jsonl, excluding the original cold email.
>= 2 → write to growth/inbox-needs-human/, send nothing, notify
owner (outside the canary, never silent). A prospect still replying after
two autonomous responses is in a live negotiation — the exact thing minutes #79 says only a phone/video call can carry.
[2.5] Unknown-sender check. The inbound From must match a recipient in send-ledger.jsonl.
No match → flag to growth/inbox-needs-human/, send nothing. Prevents the
reply-drafter being driven by an arbitrary stranger who emails the mailbox.
[2.6] The narrow LLM reply-draft call
This resolves CTO's stated risk (an unattended process invoking the full
growth-ops agent with outbox-write access) without reintroducing it. What is
built here has none of the properties that made that risky:
CTO's risk
This design
Agent with tools
No tools. One HTTPS completion call.
Outbox write access
No filesystem access. Returns a string to poll.py.
Unbounded judgment
Fixed system prompt, output re-gated by §1.6/§1.7.
Agent invocation boundary change
No Task/Agent spawn. No change to growth-ops.md.
Reads product internals for "context"
No Firestore/GCS/transcript path (Rule 0.2).
Inputs — exactly these three, nothing else:
The inbound message body (plain text, quoted history stripped).
The target's own row from growth/westchester-target-list.md
(business name, town, category — public directory facts only).
The fixed system prompt below.
Fixed system prompt (verbatim, versioned in drb-email-sender/app/prompts/reply_v1.txt — changes require a board minute):
You write one short reply to a business owner who responded to a cold email
about a public-safety radio monitoring product. You are an automated
assistant sending on the company's behalf.
Absolute rules:
- Never state, imply, estimate, or range any price. If asked what it costs,
say pricing is not set yet and the owner will follow up personally.
- Never offer a demo, a trial, a login, an account, or a free month.
- Never describe a real incident, transcript, talkgroup, unit number, radio
call, or any address other than the mailing address in the footer.
- Never use a person's name.
- If asked whether you are a human or an AI, say plainly that you are an
automated assistant sending on the company's behalf. Never claim to be
human.
- You have no access to any product data and must never claim otherwise.
- The only outcome you may propose is a short phone or video call with the
owner. That is the goal of every reply.
- Maximum 120 words before the footer. Plain text. No attachments, no links.
- End with the footer exactly as supplied to you, unchanged.
Output handling: the returned string is treated as untrusted text. It goes
straight into [2.7] and is re-scanned by the same [1.6] footer gate and [1.7]
forbidden-content gate used for outbound cold email. Any gate failure → send nothing, write the draft to growth/inbox-needs-human/ with the
failing rule named, notify owner (outside the canary). The LLM never gets a
second attempt — a retry loop against a content gate is a gate-defeating loop.
[2.8] Send. Same SMTP path as [1.8], threaded via In-Reply-To.
[2.9] Log. Append to growth/outbox/reply-ledger.jsonl: {ts, thread_id, to, inbound_excerpt, gates_passed[], model, reply_sent, canary_index, result}.
[2.10] Canary — observed, not gated
Ruling: mandatory first phase, non-blocking. This is not human review.
No reply ever waits on a human. Nothing is approved. The only difference
during the canary is that the owner is told.
Maintain autonomous_reply_count in growth/outbox/reply-ledger.jsonl (count of reply_sent: true).
Count 1–5: after the reply has already been sent, emit one line to the
owner via the existing drb-telegram-ops bot: DRB reply N/5 — <business> — replied, gates clean.
Count 6+: canary ends automatically. No code change, no owner action,
no re-ratification. Fully silent from then on.
A "clean" reply is one that passed all gates and sent. A gate-blocked or
hard-stopped message does not consume a canary slot — it produces its own
always-on notification per [2.3]/[2.4]/[2.7].
The canary counter never resets. It is a one-time observation window over
the classifiers' first real contact with live language, not a recurring mode.
Always-on notifications (never silenced by canary exit): hostile-family
match, thread-depth cap hit, generated-reply gate failure, kill switch
engaged/disengaged, run abort.
3. KILL SWITCH
Mechanism: flag file growth/outbox/.paused.
Checked immediately before every individual send ([1.0]) and every
individual reply ([2.0]) — not once at process start.
File exists (any contents, including empty) → log paused, exit 0. Send
nothing. No queue drain on resume beyond the normal daily cap.
Owner pauses: create the file. Owner resumes: delete it.
Fail-closed on the check itself: if the containing directory is
unreadable or the stat call errors, treat it as paused.
Overruling CISO recommendation #5 (Firestore config/growth_autonomy).
That mechanism contradicts CISO's own finding #1. A kill switch in the
Firestore config collection would require drb-email-sender/ to hold a GCP
credential and open a live read path into the same Firestore project that
holds calls and incidents — precisely the architectural blindness Rule 0.2
makes non-negotiable. A flag file needs no credential, no network, and no
dependency on the service being reachable. CISO's requirement (fail-closed,
checked per-action) is adopted in full; only the storage location changes.
Second independent brake: the daily cap at [1.2], enforced in drb-email-sender/'s own config, mirroring drb-telegram-ops's DAILY_BUDGET_USD pattern.
drb-email-sender/.env.example documents every key with no values, and
carries the drb-telegram-ops/.env.example warning line: treat as a
password.
No credential is ever placed in growth/. growth-ops can write that
whole tree; it must never be able to read or overwrite a secret.
growth-ops never reads any of these (its boundary #2 is unchanged) and
never triggers a send. It writes drafts to growth/outbox/ exactly as
today. This SOP changes who transmits, not what growth-ops may do.
The LLM reply-drafter's own context never contains a credential. It
receives three inputs ([2.6]) and nothing else. LLM_API_KEY is used by
the HTTP client wrapping the call, never placed in the prompt.
No GCP/Firebase credential of any kind in this service (Rule 0.2).
Relay: Brevo free tier, default sending subdomain. 300 sends/day
free — the 6/day cap is nowhere near it. $0/mo against the ≤$15/mo cap.
No custom sending domain, therefore no SPF/DKIM/DMARC DNS work and no
registrar task for the owner at launch. A custom domain is a separate,
later decision.
5. EXPLICITLY NOT IN SCOPE
Adopted from CTO draft #149 finding 2. Anything below requires a new board
minute before it is built.
No webhook on production infrastructure. Nothing is added to api.$DOMAIN, c2-core, Caddy, Server/infra/, Terraform, or Ansible.
Pre-revenue sales tooling does not touch the surface a paying customer's
product runs on.
No deployment to the production VM.send.py and poll.py run from a
local scheduled task on the owner's machine. Not docker compose, not
Gitea Actions in server-26/node-26, not the prod host.
No inbound webhook receiver at all in v1. IMAP poll only. No Cloudflare
Worker, no serverless function, no new hosting.
No tool grant to growth-ops. No Bash, no network, no SMTP. Its boundary #1 ("you cannot send anything, to anyone, ever") is unchanged in substance:
it still only drafts. drb-email-sender/ is a separate deterministic
service, not a capability handed to the agent.
No Task/Agent invocation from any unattended process. The reply-drafter
is a single stateless API call. If a future change wants real agent
judgment in the loop, that is a new board item.
No custom sending domain, no DNS records at launch.
No SMS, no dialer, no CRM. Those channels remain fully closed and still
escalate to the board (growth-ops.md).
No second cold email to any address. One cold email per business,
enforced by the [1.3] dedupe ledger. Follow-up sequences are not authorized.
No live-data demo, no price, no account provisioning — Gates A, B, B3
(#43, #47) are unchanged by this SOP.
6. Build order (CTO owns filing; CIO owns the credential scaffold)
drb-email-sender/ scaffold: .gitignore, .env.example, requirements.txt
(no google-cloud-*, no firebase-*), README. CIO.
Canary counter + notification via drb-telegram-ops. CTO.
Nothing sends until steps 1–4 are complete and green. Nothing replies until
1–7 are complete and green.
7. Amending this SOP
The fixed reply system prompt, the gate patterns, the daily cap, the
thread-depth cap, and Rule 0.2 are ratified content. Changing any of them
requires a board minute in logan/server-26, not a commit. Tuning a regex to
catch a phrasing it missed is a bug fix and does not.
## Ruling
Autonomous cold-email **send and reply** is authorized, fully unattended in both directions, effective on build completion. No approval gate exists anywhere in the design. Every control is a code-enforced gate that **aborts**, never a human who approves.
The owner's instruction to this sitting: *"my involvement is to review the SOPs and then the agents carry it out, following the SOPs."* Three drafts each ended with "needs a CEO ruling"; none of those questions goes back to him. They are all decided below.
The full SOP is section **SOP** of this issue and is written to `growth/AUTONOMOUS_EMAIL_SOP.md`. That file is the operative document; this issue is the ratification record.
## Decisions
**D1 — Reply autonomy ships. CISO's zero-data-access requirement is adopted as non-negotiable architecture, not as a trade-off to be relaxed under schedule pressure.** (Owner: CTO to build, CISO to verify.)
`drb-email-sender/` and the reply-drafter get **no** credential for, no network route to, and no code path into Firestore, GCS, or any transcript/incident store. Enforced three ways: no GCP key in the service `.env`, no `google-cloud-*`/`firebase-*` in its `requirements.txt`, and a unit test asserting both. CISO finding #1 named the correct worst case — a bot answering a stranger by quoting real transcribed police radio tied to a real address and a real name. That is the largest unrecoverable downside available to this company and it gets an architectural block, not a prompt instruction.
*Given up:* the reply-drafter can never sound informed about the product's actual output. It answers with directory facts and templates or it does not answer. Accepted.
**D2 — 5-reply canary is a mandatory first phase, and it is not human review.** (Owner: CTO.)
The first 5 autonomous replies send in real time with zero gate, exactly as the owner directed. The only difference is that after each one has already gone out, the owner gets one line via the existing `drb-telegram-ops` bot. At count 6 the canary ends **automatically** — no code change, no owner action, no re-ratification. This is still literally "fully autonomous"; it is observation, not approval.
CISO asked the owner to rule on this and explicitly said it was not his to assume. It is mine, and I am adopting it in the non-blocking form. A gate-blocked or hard-stopped message does not consume a canary slot.
*Given up:* five Telegram lines of the owner's attention, once, ever.
**D3 — A narrow LLM reply-draft step IS in scope. CTO's rule-based-ack-only recommendation is overruled; CTO's underlying risk is honored in full.** (Owner: CTO.)
The owner asked for real replies, not a canned ack. But CTO draft #149 finding 5 is correct about the *shape* of the danger: an unattended script deciding when to invoke the full growth-ops agent, which has outbox-write access, is a bigger boundary change than the SMTP piece. What ships is not that.
| CTO's stated risk | What is actually built |
|---|---|
| Agent with tools | **No tools.** One stateless HTTPS completion call. |
| Outbox write access | **No filesystem access.** Returns a string to `poll.py`. |
| Unbounded LLM judgment | Fixed versioned system prompt; output re-scanned by the same footer + forbidden-content gates as outbound. |
| New agent-invocation boundary | **No Task/Agent spawn.** `growth-ops.md` is unchanged. |
| Reads product internals for "context" | Blocked by D1. |
Inputs are exactly three: the inbound body, the target's public directory row, the fixed prompt. The LLM gets no retry after a gate failure — a retry loop against a content gate is a gate-defeating loop. This resolves CTO's risk without reintroducing it.
*Given up:* determinism. A rule-based ack is auditable line-by-line; this is not. Bought with the output gates and the thread-depth cap.
**D4 — Kill switch is the flag file `growth/outbox/.paused`. CISO recommendation #5 (Firestore `config/growth_autonomy`) is overruled.** (Owner: CIO.)
CISO's own recommendation contradicts CISO's own finding #1, and nobody caught it in draft. A kill switch living in the Firestore `config` collection requires the mailer to hold a GCP credential and open a live read path into the same project that holds `calls` and `incidents` — the exact blindness D1 makes non-negotiable. The flag file needs no credential, no network, and no dependency on the service being reachable.
CISO's *requirements* are adopted unchanged: checked before **every individual** send and reply (not once at process start), and **fail-closed** — an unreadable directory or a stat error counts as paused. Only the storage location moves.
**D5 — The service is `drb-email-sender/` at the Version 5C root, not `growth/mailer/`.** (Owner: CIO.)
CIO #148 rec 2 over CTO #149 rec 1. Decisive reason neither draft states: growth-ops may write to *anything* under `growth/`. Putting credential-bearing code inside its own write scope reintroduces precisely the hazard its boundary #2 exists to prevent. Modeled on the working `drb-telegram-ops/` precedent — own dir, own `.gitignore` for `.env`, own `.env.example`.
**D6 — Relay is Brevo free tier on Brevo's default sending subdomain.** (Owner: CIO.)
Both CIO and CTO routed this to the owner; it does not need him. $0/mo against the ≤$15/mo cap, 300 sends/day free against a 6/day cap. Default subdomain means **zero SPF/DKIM/DMARC DNS work and no registrar task for the owner at launch**. A custom sending domain is a separate later decision, and CIO rec 5's owner-DNS work item is **not** opened.
*Given up:* deliverability. A Brevo shared subdomain lands in spam more often than an authenticated custom domain. At 33 hand-sourced B2B targets that is worth trading against blocking the whole channel on registrar work inside a <5h/week budget.
**D7 — Inbound is IMAP poll. No webhook, no serverless function, no production infra.** (Owner: CTO.)
CTO #149 finding 2 accepted in full: nothing is added to `api.$DOMAIN`, `c2-core`, Caddy, `Server/infra/`, Terraform, or Ansible. `send.py` and `poll.py` run from a local scheduled task. Brevo hosted inbound parse is permitted only as a drop-in alternative and only if it requires no prod-infra change.
**D8 — Hard limits, all as code, all fail-closed.** (Owner: CTO.)
6 sends/day with 20–90 min jitter (CISO #5, taking the cautious end of his 5–6). One cold email per business ever — no follow-up sequences. 2 autonomous replies per thread, then hard-stop and flag. Unsubscribe-family substring match → immediate permanent suppression and **no reply at all**, not even a confirmation. Hostile-family match → stop, flag, notify, never argue. Unknown sender → stop. Every classifier defaults to **stop** on an uncertain match.
**D9 — Two gates neither draft specified, added because autonomy removes the human who was silently covering them.** (Owner: CTO.)
- **Sourcing rule as a send-time check.** The recipient must appear in the target list with a non-empty public `source_url`. Unsourced → skip. This is the line between a normal B2B cold email and a CAN-SPAM §7704(b) aggravated violation, and until now it was a drafting convention a human was trusted to have followed.
- **`FROM_ORG_NAME` fail-closed.** `growth-ops.md`'s footer still carries the literal placeholder `[Company name TBD — do not invent one; ask the human sending it]`. Under autonomy there is no human to ask. The name is read from `drb-email-sender/.env`; if it is unset, empty, or contains `TBD` or a bracket, **the run aborts**. The open company-name question becomes a gate the system cannot send through, rather than a question the owner has to answer before anything else can start.
**D10 — AI disclosure is mandatory and fixed, not improvised.** (Owner: CTO, in the versioned prompt.)
If asked whether it is a human or an AI, the reply says plainly that it is an automated assistant sending on the company's behalf. Claiming to be human is FTC Act §5 / NY GBL §349 exposure stacked on the CAN-SPAM footer duty already resolved on #147. CISO rec 7 adopted; the line lives in `reply_v1.txt`, not in per-reply judgment.
**D11 — Opt-outs honored immediately**, same run, not CAN-SPAM's 10-business-day floor (CISO rec 6). Trivial once automated, and it closes the window in which a bot could reply-argue with someone who already asked to stop.
**D12 — `growth-ops.md` is not amended and gets no new capability.** (Owner: CEO, no action.)
Its boundary #1 stands in substance: it drafts, it cannot send, it has no Bash and no network. What changed is *who transmits* — a separate deterministic service, not a tool grant. Boundary #2 (never reads a credential) is unchanged and is why D5 matters.
## Not in scope (requires a new board minute)
Production-infra webhook; prod-VM deployment; any inbound receiver other than IMAP poll; any tool grant to growth-ops; any Task/Agent invocation from an unattended process; custom sending domain or DNS records; SMS/dialer/CRM; second cold email to any address; live-data demo, price, or account provisioning (Gates A, B, B3 — #43/#47 unchanged).
## Drafts consolidated
- #148 — CIO draft (credential home, kill switch, relay, deliverability)
- #149 — CTO draft (service shape, inbound mechanism, reply-scope split)
- #150 — CISO draft (zero-data-access, pre-send asserts, reply classifiers, canary)
## Follow-up issues to file
| # | Work | Files it |
|---|---|---|
| F1 | `drb-email-sender/` scaffold — `.gitignore`, `.env.example`, `requirements.txt` with the Rule 0.2 exclusions, README | CIO |
| F2 | Ledgers + `growth/outbox/{sent,blocked}/` + `growth/inbox-needs-human/` | CTO |
| F3 | Send gates [1.2]–[1.7] + unit tests, incl. the Rule 0.2 dependency-assertion test — lands **before** any send capability | CTO |
| F4 | `send.py` + kill switch §3 | CTO |
| F5 | Reply hard stops [2.2]–[2.5] + phrasing tests; `reply_draft.py` + `reply_v1.txt` + output re-gating; canary counter | CTO |
Nothing sends until F1–F4 are green. Nothing replies until F5 is green.
## Owner action required
**None.** Every "needs a CEO ruling" item across the three drafts is decided above — relay (D6), credential home (D5), kill-switch mechanism (D4), scope split (D3), canary (D2), authorization itself (D1). The one thing the owner will eventually supply — the company name — is a one-line `.env` value that the D9 gate blocks all sending on until it exists, so it cannot be forgotten and does not block the build.
---
# SOP
# Autonomous Email SOP — send + reply, zero human in the loop
**Status: RATIFIED — board minutes server-26#151, 2026-09-13.**
Supersedes the human-transmits shape in `growth/outbox/README.md` and
`growth-ops.md` boundary #1 (amended 2026-09-13 on server-26#147).
Owner ruling this SOP enacts: *"my involvement is to review the SOPs and then
the agents carry it out, following the SOPs."* Full autonomy both directions.
No approval gate exists anywhere in this document. Every control below is a
**code-enforced gate that aborts**, never a human who approves.
---
## 0. Components and where they live
| Component | Path | Holds credential? | LLM? | Firestore/GCS access |
|---|---|---|---|---|
| `growth-ops` agent | `.claude/agents/growth-ops.md` | **No** | Yes | No |
| Mailer service | `drb-email-sender/` (Version 5C root) | **Yes** | No | **No — architecturally blocked** |
| Reply-drafter | `drb-email-sender/app/reply_draft.py` | No (mailer passes text only) | Yes, narrow | **No — architecturally blocked** |
| Outbox | `growth/outbox/*.md` | No | — | No |
**Why `drb-email-sender/` and not `growth/mailer/` (CTO's proposal):**
growth-ops may write to *anything* under `growth/`. Putting credential-bearing
code inside its own write scope is the one shape that reintroduces the hazard
boundary #2 exists to prevent. `drb-email-sender/` is outside growth-ops's
write scope and outside `Server/`/`Client/` git history. Modeled directly on
the working `drb-telegram-ops/` precedent (own dir, own `.gitignore`, own
`.env.example`).
**Rule 0.1 — the reply-drafter is NOT the growth-ops agent.** It is a single
stateless HTTPS call to an LLM API with a fixed system prompt, no tools, no
file access, no MCP, no Task/Agent invocation, no conversation memory. Nothing
in this SOP ever spawns growth-ops from an unattended process.
**Rule 0.2 — zero production-data read path (non-negotiable, not a trade-off).**
`drb-email-sender/` and everything it calls must have no credential for, no
network route to, and no code path into Firestore, GCS, the transcript store,
`calls`, or `incidents`. Enforced by: (a) no GCP service-account key in
`drb-email-sender/.env`; (b) no `google-cloud-*` or `firebase-*` package in
`drb-email-sender/requirements.txt`; (c) a unit test that asserts both.
Rationale: the worst outcome available to this company is a bot answering a
stranger by quoting real transcribed police radio traffic tied to a real
address and a real name. Prompt text cannot be trusted to hold that line.
---
## 1. SEND FLOW
Trigger: scheduled run of `drb-email-sender/app/send.py` (local cron / Task
Scheduler on the owner's machine). Never the production VM, never
`docker compose`, never CI in `server-26`/`node-26`.
```
[1.0] KILL SWITCH -> see §3. Checked before EVERY individual send, not once per run.
|
[1.1] Pick next file growth/outbox/<target-slug>.md (oldest mtime first, one at a time)
|
[1.2] GATE: daily rate cap
|
[1.3] GATE: dedupe ledger
|
[1.4] GATE: suppression list
|
[1.5] GATE: sourcing rule
|
[1.6] GATE: footer present and complete
|
[1.7] GATE: forbidden-content scan
|
[1.8] SEND via Brevo SMTP
|
[1.9] LOG + move file to growth/outbox/sent/
|
[1.10] Sleep jitter, loop to [1.0]
```
### Gate definitions — all fail-closed
**[1.2] Daily rate cap.** Count entries in `growth/outbox/send-ledger.jsonl`
with `sent_at` inside the current local calendar day.
- `count >= 6` → **STOP the run** (not the file). Exit 0. Resume tomorrow.
- Ledger unreadable/missing → **ABORT run**, log, do not send.
Also enforce a randomized 20–90 min sleep between sends within a day so the
33-target batch lands over ~6 business days, not as a burst.
**[1.3] Dedupe ledger.** Key = lowercased recipient email.
- Email already appears in `send-ledger.jsonl` with `kind: "cold"` → **SKIP
file**, move to `growth/outbox/blocked/`, log reason `duplicate`.
**[1.4] Suppression list.** Parse the table in
`growth/suppression-list.md`, lowercase-compare the recipient address.
- Present → **SKIP file**, move to `growth/outbox/blocked/`, log
`suppressed`. Never send, never "confirm".
- File missing or unparseable → **ABORT run**. Absence is not permission.
**[1.5] Sourcing rule.** The recipient address must appear in
`growth/westchester-target-list.md` with a non-empty public `source_url` for
that row (the business's own website or Google listing).
- Not found, or `source_url` empty → **SKIP file**, log
`unsourced_recipient`.
Rationale: this is the line between a normal B2B cold email and a CAN-SPAM
§7704(b) aggravated violation. It must be checked at send time by code, not
assumed to have happened at draft time.
**[1.6] Footer gate.** The body must end with, verbatim:
```
---
{FROM_ORG_NAME}
1350 E. Main St. Ste. 24, Shrub Oak, NY 10588
Reply "unsubscribe" and you won't hear from us again.
```
- `FROM_ORG_NAME` is read from `drb-email-sender/.env`. If unset, empty,
containing `TBD`, or containing `[` or `]` → **ABORT run**, log
`org_name_unset`. This is how the unresolved company-name placeholder in
`growth-ops.md` boundary #1 becomes a fail-closed gate instead of an open
question: nothing can send until the string is set once.
- Footer missing, reordered, or the physical address altered → **SKIP file**,
log `footer_invalid`.
**[1.7] Forbidden-content scan.** Regex/pattern block over the full body.
Any hit → **SKIP file**, move to `growth/outbox/blocked/`, log the matching
rule. Never send on an ambiguous match.
| Rule | Pattern (case-insensitive) | Why |
|---|---|---|
| `price` | `\$\s?\d`, `\bper month\b`, `\bper node\b`, `\bpricing is\b`, `\b\d+\s?(usd\|dollars)\b` | Gate A blocks publishing any price |
| `live_data` | `\btalkgroup\b`, `\bincident #\b`, `\bunit \d`, `\b10-\d\d\b`, a street address that is not the footer address | Gate B3 (#43) not shipped |
| `demo_offer` | `\bdemo\b`, `\bfree (month\|trial)\b`, `\btrial\b`, `\blog ?in\b`, `\baccount\b` | Gate B blocks provisioning |
| `person_name` | any capitalized two-word token not in the target-list row for this recipient | BUSINESS_MODEL.md — names suppressed until E&O bound |
**[1.8] Send.** Brevo free tier, Brevo's **default sending subdomain** (no
custom domain, therefore zero SPF/DKIM/DMARC DNS work for launch). SMTP creds
from `drb-email-sender/.env` only. On SMTP error: retry once after 60s, then
**ABORT run** and log — never loop, never fall through to the next file.
**[1.9] Log.** Append one JSON line to `growth/outbox/send-ledger.jsonl`:
`{ts, kind:"cold", to, subject, message_id, thread_id, gates_passed[], result}`.
Then move the source file to `growth/outbox/sent/`. Log write failure →
**ABORT run** before sending anything further. An unlogged send is an
unenforceable dedupe.
---
## 2. REPLY FLOW
Trigger: scheduled run of `drb-email-sender/app/poll.py`, IMAP poll of the
reply mailbox (every 15 min). **Ruling: IMAP poll is the mechanism.** Brevo's
hosted inbound parse is permitted only as a drop-in alternative and only if it
requires no deployment to `api.$DOMAIN` or any `Server/infra/` change.
```
[2.0] KILL SWITCH -> §3. Checked before EVERY individual reply.
|
[2.1] Fetch unread inbound, one message at a time
|
[2.2] HARD STOP: unsubscribe-family match? -> yes: suppress + end thread, NO reply
|
[2.3] HARD STOP: hostile/complaint match? -> yes: flag, NO reply, notify owner
|
[2.4] HARD STOP: thread depth >= 2? -> yes: flag for human, NO reply
|
[2.5] HARD STOP: sender not in send-ledger? -> yes: flag, NO reply
|
[2.6] LLM reply-draft (narrow, stateless, no tools, no Firestore)
|
[2.7] Re-run gates [1.6] footer, [1.7] forbidden-content on the GENERATED text
|
[2.8] SEND via Brevo SMTP
|
[2.9] LOG to reply-ledger.jsonl
|
[2.10] CANARY counter -> first 5: one-line owner notification. After 5: silent.
```
### Hard stops — every one defaults to *stop*, never to *send*
**[2.2] Unsubscribe family.** Case-insensitive **substring** match (not exact,
not word-boundary) on the inbound body or subject:
`unsubscribe`, `stop`, `remove me`, `take me off`, `opt out`, `opt-out`,
`do not email`, `don't email`, `no longer wish`, `delete my`.
- Match → append the address to `growth/suppression-list.md` **immediately**
(same run, not CAN-SPAM's 10-business-day floor), mark the thread closed,
and send **nothing** — not even a confirmation. A confirmation re-opens a
thread the person asked to end.
- Suppression write failure → **ABORT run**. Never proceed to reply.
**[2.3] Hostile / complaint family.** Case-insensitive substring:
`lawyer`, `attorney`, `sue`, `lawsuit`, `BBB`, `FTC`, `attorney general`,
`complaint`, `harassment`, `harassing`, `spam`, `report you`, `cease and
desist`, `GDPR`, `CCPA`.
- Match → write the thread to `growth/inbox-needs-human/<thread-id>.md`,
send **nothing**, notify the owner (one line, always — this is outside the
canary and never goes silent). Do not argue, do not apologize elaborately,
do not de-escalate autonomously. An LLM improvising a defense to a
threatened complaint is worse than silence.
**[2.4] Thread-depth cap.** Count outbound messages already sent on this
`thread_id` in `reply-ledger.jsonl`, excluding the original cold email.
- `>= 2` → write to `growth/inbox-needs-human/`, send **nothing**, notify
owner (outside the canary, never silent). A prospect still replying after
two autonomous responses is in a live negotiation — the exact thing minutes
#79 says only a phone/video call can carry.
**[2.5] Unknown-sender check.** The inbound `From` must match a recipient in
`send-ledger.jsonl`.
- No match → flag to `growth/inbox-needs-human/`, send nothing. Prevents the
reply-drafter being driven by an arbitrary stranger who emails the mailbox.
### [2.6] The narrow LLM reply-draft call
This resolves CTO's stated risk (an unattended process invoking the **full**
growth-ops agent with outbox-write access) without reintroducing it. What is
built here has none of the properties that made that risky:
| CTO's risk | This design |
|---|---|
| Agent with tools | **No tools.** One HTTPS completion call. |
| Outbox write access | **No filesystem access.** Returns a string to `poll.py`. |
| Unbounded judgment | Fixed system prompt, output re-gated by §1.6/§1.7. |
| Agent invocation boundary change | No Task/Agent spawn. No change to `growth-ops.md`. |
| Reads product internals for "context" | **No Firestore/GCS/transcript path (Rule 0.2).** |
**Inputs — exactly these three, nothing else:**
1. The inbound message body (plain text, quoted history stripped).
2. The target's own row from `growth/westchester-target-list.md`
(business name, town, category — public directory facts only).
3. The fixed system prompt below.
**Fixed system prompt (verbatim, versioned in
`drb-email-sender/app/prompts/reply_v1.txt` — changes require a board minute):**
```
You write one short reply to a business owner who responded to a cold email
about a public-safety radio monitoring product. You are an automated
assistant sending on the company's behalf.
Absolute rules:
- Never state, imply, estimate, or range any price. If asked what it costs,
say pricing is not set yet and the owner will follow up personally.
- Never offer a demo, a trial, a login, an account, or a free month.
- Never describe a real incident, transcript, talkgroup, unit number, radio
call, or any address other than the mailing address in the footer.
- Never use a person's name.
- If asked whether you are a human or an AI, say plainly that you are an
automated assistant sending on the company's behalf. Never claim to be
human.
- You have no access to any product data and must never claim otherwise.
- The only outcome you may propose is a short phone or video call with the
owner. That is the goal of every reply.
- Maximum 120 words before the footer. Plain text. No attachments, no links.
- End with the footer exactly as supplied to you, unchanged.
```
**Output handling:** the returned string is treated as untrusted text. It goes
straight into [2.7] and is re-scanned by the same [1.6] footer gate and [1.7]
forbidden-content gate used for outbound cold email. Any gate failure →
**send nothing**, write the draft to `growth/inbox-needs-human/` with the
failing rule named, notify owner (outside the canary). The LLM never gets a
second attempt — a retry loop against a content gate is a gate-defeating loop.
**[2.8] Send.** Same SMTP path as [1.8], threaded via `In-Reply-To`.
**[2.9] Log.** Append to `growth/outbox/reply-ledger.jsonl`:
`{ts, thread_id, to, inbound_excerpt, gates_passed[], model, reply_sent,
canary_index, result}`.
### [2.10] Canary — observed, not gated
**Ruling: mandatory first phase, non-blocking. This is not human review.**
No reply ever waits on a human. Nothing is approved. The only difference
during the canary is that the owner is *told*.
- Maintain `autonomous_reply_count` in
`growth/outbox/reply-ledger.jsonl` (count of `reply_sent: true`).
- **Count 1–5:** after the reply has already been sent, emit one line to the
owner via the existing `drb-telegram-ops` bot:
`DRB reply N/5 — <business> — replied, gates clean.`
- **Count 6+:** canary ends **automatically**. No code change, no owner action,
no re-ratification. Fully silent from then on.
- A "clean" reply is one that passed all gates and sent. A gate-blocked or
hard-stopped message does **not** consume a canary slot — it produces its own
always-on notification per [2.3]/[2.4]/[2.7].
- The canary counter never resets. It is a one-time observation window over
the classifiers' first real contact with live language, not a recurring mode.
**Always-on notifications (never silenced by canary exit):** hostile-family
match, thread-depth cap hit, generated-reply gate failure, kill switch
engaged/disengaged, run abort.
---
## 3. KILL SWITCH
**Mechanism: flag file `growth/outbox/.paused`.**
- Checked immediately before **every individual send** ([1.0]) and **every
individual reply** ([2.0]) — not once at process start.
- File exists (any contents, including empty) → log `paused`, exit 0. Send
nothing. No queue drain on resume beyond the normal daily cap.
- Owner pauses: create the file. Owner resumes: delete it.
- **Fail-closed on the check itself:** if the containing directory is
unreadable or the stat call errors, treat it as paused.
**Overruling CISO recommendation #5 (Firestore `config/growth_autonomy`).**
That mechanism contradicts CISO's own finding #1. A kill switch in the
Firestore `config` collection would require `drb-email-sender/` to hold a GCP
credential and open a live read path into the same Firestore project that
holds `calls` and `incidents` — precisely the architectural blindness Rule 0.2
makes non-negotiable. A flag file needs no credential, no network, and no
dependency on the service being reachable. CISO's *requirement* (fail-closed,
checked per-action) is adopted in full; only the storage location changes.
**Second independent brake:** the daily cap at [1.2], enforced in
`drb-email-sender/`'s own config, mirroring `drb-telegram-ops`'s
`DAILY_BUDGET_USD` pattern.
---
## 4. CREDENTIALS
| Item | Location | Read by |
|---|---|---|
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_API_KEY` | `drb-email-sender/.env` | `send.py`, `poll.py` only |
| `IMAP_HOST` / `IMAP_USER` / `IMAP_PASSWORD` | `drb-email-sender/.env` | `poll.py` only |
| `LLM_API_KEY` (reply-drafter) | `drb-email-sender/.env` | `reply_draft.py` only |
| `FROM_ORG_NAME`, `FROM_ADDRESS` | `drb-email-sender/.env` | `send.py`, `poll.py` |
**Rules:**
1. `drb-email-sender/.gitignore` contains `.env` — copy `drb-telegram-ops/.gitignore`.
2. `drb-email-sender/.env.example` documents every key with no values, and
carries the `drb-telegram-ops/.env.example` warning line: *treat as a
password.*
3. **No credential is ever placed in `growth/`.** growth-ops can write that
whole tree; it must never be able to read or overwrite a secret.
4. **growth-ops never reads any of these** (its boundary #2 is unchanged) and
never triggers a send. It writes drafts to `growth/outbox/` exactly as
today. This SOP changes who *transmits*, not what growth-ops may do.
5. **The LLM reply-drafter's own context never contains a credential.** It
receives three inputs ([2.6]) and nothing else. `LLM_API_KEY` is used by
the HTTP client wrapping the call, never placed in the prompt.
6. **No GCP/Firebase credential of any kind in this service** (Rule 0.2).
7. Relay: **Brevo free tier, default sending subdomain.** 300 sends/day
free — the 6/day cap is nowhere near it. $0/mo against the ≤$15/mo cap.
No custom sending domain, therefore **no SPF/DKIM/DMARC DNS work** and no
registrar task for the owner at launch. A custom domain is a separate,
later decision.
---
## 5. EXPLICITLY NOT IN SCOPE
Adopted from CTO draft #149 finding 2. Anything below requires a new board
minute before it is built.
1. **No webhook on production infrastructure.** Nothing is added to
`api.$DOMAIN`, `c2-core`, Caddy, `Server/infra/`, Terraform, or Ansible.
Pre-revenue sales tooling does not touch the surface a paying customer's
product runs on.
2. **No deployment to the production VM.** `send.py` and `poll.py` run from a
local scheduled task on the owner's machine. Not `docker compose`, not
Gitea Actions in `server-26`/`node-26`, not the prod host.
3. **No inbound webhook receiver at all in v1.** IMAP poll only. No Cloudflare
Worker, no serverless function, no new hosting.
4. **No tool grant to growth-ops.** No Bash, no network, no SMTP. Its boundary
#1 ("you cannot send anything, to anyone, ever") is unchanged in substance:
it still only drafts. `drb-email-sender/` is a separate deterministic
service, not a capability handed to the agent.
5. **No Task/Agent invocation from any unattended process.** The reply-drafter
is a single stateless API call. If a future change wants real agent
judgment in the loop, that is a new board item.
6. **No custom sending domain, no DNS records** at launch.
7. **No SMS, no dialer, no CRM.** Those channels remain fully closed and still
escalate to the board (`growth-ops.md`).
8. **No second cold email to any address.** One cold email per business,
enforced by the [1.3] dedupe ledger. Follow-up sequences are not authorized.
9. **No live-data demo, no price, no account provisioning** — Gates A, B, B3
(#43, #47) are unchanged by this SOP.
---
## 6. Build order (CTO owns filing; CIO owns the credential scaffold)
1. `drb-email-sender/` scaffold: `.gitignore`, `.env.example`, `requirements.txt`
(no `google-cloud-*`, no `firebase-*`), README. **CIO.**
2. Ledgers + `growth/outbox/{sent,blocked}/`, `growth/inbox-needs-human/`. **CTO.**
3. Gates [1.2]–[1.7] with unit tests, including the Rule 0.2 dependency
assertion test. Gates land **before** any send capability. **CTO.**
4. `send.py` + kill switch §3. **CTO.**
5. Hard stops [2.2]–[2.5] with unit tests over real-world phrasings. **CTO.**
6. `reply_draft.py` + prompt `reply_v1.txt` + output re-gating [2.7]. **CTO.**
7. Canary counter + notification via `drb-telegram-ops`. **CTO.**
Nothing sends until steps 1–4 are complete and green. Nothing replies until
1–7 are complete and green.
---
## 7. Amending this SOP
The fixed reply system prompt, the gate patterns, the daily cap, the
thread-depth cap, and Rule 0.2 are ratified content. Changing any of them
requires a board minute in `logan/server-26`, not a commit. Tuning a regex to
catch a phrasing it missed is a bug fix and does not.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Ruling
Autonomous cold-email send and reply is authorized, fully unattended in both directions, effective on build completion. No approval gate exists anywhere in the design. Every control is a code-enforced gate that aborts, never a human who approves.
The owner's instruction to this sitting: "my involvement is to review the SOPs and then the agents carry it out, following the SOPs." Three drafts each ended with "needs a CEO ruling"; none of those questions goes back to him. They are all decided below.
The full SOP is section SOP of this issue and is written to
growth/AUTONOMOUS_EMAIL_SOP.md. That file is the operative document; this issue is the ratification record.Decisions
D1 — Reply autonomy ships. CISO's zero-data-access requirement is adopted as non-negotiable architecture, not as a trade-off to be relaxed under schedule pressure. (Owner: CTO to build, CISO to verify.)
drb-email-sender/and the reply-drafter get no credential for, no network route to, and no code path into Firestore, GCS, or any transcript/incident store. Enforced three ways: no GCP key in the service.env, nogoogle-cloud-*/firebase-*in itsrequirements.txt, and a unit test asserting both. CISO finding #1 named the correct worst case — a bot answering a stranger by quoting real transcribed police radio tied to a real address and a real name. That is the largest unrecoverable downside available to this company and it gets an architectural block, not a prompt instruction.Given up: the reply-drafter can never sound informed about the product's actual output. It answers with directory facts and templates or it does not answer. Accepted.
D2 — 5-reply canary is a mandatory first phase, and it is not human review. (Owner: CTO.)
The first 5 autonomous replies send in real time with zero gate, exactly as the owner directed. The only difference is that after each one has already gone out, the owner gets one line via the existing
drb-telegram-opsbot. At count 6 the canary ends automatically — no code change, no owner action, no re-ratification. This is still literally "fully autonomous"; it is observation, not approval.CISO asked the owner to rule on this and explicitly said it was not his to assume. It is mine, and I am adopting it in the non-blocking form. A gate-blocked or hard-stopped message does not consume a canary slot.
Given up: five Telegram lines of the owner's attention, once, ever.
D3 — A narrow LLM reply-draft step IS in scope. CTO's rule-based-ack-only recommendation is overruled; CTO's underlying risk is honored in full. (Owner: CTO.)
The owner asked for real replies, not a canned ack. But CTO draft #149 finding 5 is correct about the shape of the danger: an unattended script deciding when to invoke the full growth-ops agent, which has outbox-write access, is a bigger boundary change than the SMTP piece. What ships is not that.
poll.py.growth-ops.mdis unchanged.Inputs are exactly three: the inbound body, the target's public directory row, the fixed prompt. The LLM gets no retry after a gate failure — a retry loop against a content gate is a gate-defeating loop. This resolves CTO's risk without reintroducing it.
Given up: determinism. A rule-based ack is auditable line-by-line; this is not. Bought with the output gates and the thread-depth cap.
D4 — Kill switch is the flag file
growth/outbox/.paused. CISO recommendation #5 (Firestoreconfig/growth_autonomy) is overruled. (Owner: CIO.)CISO's own recommendation contradicts CISO's own finding #1, and nobody caught it in draft. A kill switch living in the Firestore
configcollection requires the mailer to hold a GCP credential and open a live read path into the same project that holdscallsandincidents— the exact blindness D1 makes non-negotiable. The flag file needs no credential, no network, and no dependency on the service being reachable.CISO's requirements are adopted unchanged: checked before every individual send and reply (not once at process start), and fail-closed — an unreadable directory or a stat error counts as paused. Only the storage location moves.
D5 — The service is
drb-email-sender/at the Version 5C root, notgrowth/mailer/. (Owner: CIO.)CIO #148 rec 2 over CTO #149 rec 1. Decisive reason neither draft states: growth-ops may write to anything under
growth/. Putting credential-bearing code inside its own write scope reintroduces precisely the hazard its boundary #2 exists to prevent. Modeled on the workingdrb-telegram-ops/precedent — own dir, own.gitignorefor.env, own.env.example.D6 — Relay is Brevo free tier on Brevo's default sending subdomain. (Owner: CIO.)
Both CIO and CTO routed this to the owner; it does not need him. $0/mo against the ≤$15/mo cap, 300 sends/day free against a 6/day cap. Default subdomain means zero SPF/DKIM/DMARC DNS work and no registrar task for the owner at launch. A custom sending domain is a separate later decision, and CIO rec 5's owner-DNS work item is not opened.
Given up: deliverability. A Brevo shared subdomain lands in spam more often than an authenticated custom domain. At 33 hand-sourced B2B targets that is worth trading against blocking the whole channel on registrar work inside a <5h/week budget.
D7 — Inbound is IMAP poll. No webhook, no serverless function, no production infra. (Owner: CTO.)
CTO #149 finding 2 accepted in full: nothing is added to
api.$DOMAIN,c2-core, Caddy,Server/infra/, Terraform, or Ansible.send.pyandpoll.pyrun from a local scheduled task. Brevo hosted inbound parse is permitted only as a drop-in alternative and only if it requires no prod-infra change.D8 — Hard limits, all as code, all fail-closed. (Owner: CTO.)
6 sends/day with 20–90 min jitter (CISO #5, taking the cautious end of his 5–6). One cold email per business ever — no follow-up sequences. 2 autonomous replies per thread, then hard-stop and flag. Unsubscribe-family substring match → immediate permanent suppression and no reply at all, not even a confirmation. Hostile-family match → stop, flag, notify, never argue. Unknown sender → stop. Every classifier defaults to stop on an uncertain match.
D9 — Two gates neither draft specified, added because autonomy removes the human who was silently covering them. (Owner: CTO.)
source_url. Unsourced → skip. This is the line between a normal B2B cold email and a CAN-SPAM §7704(b) aggravated violation, and until now it was a drafting convention a human was trusted to have followed.FROM_ORG_NAMEfail-closed.growth-ops.md's footer still carries the literal placeholder[Company name TBD — do not invent one; ask the human sending it]. Under autonomy there is no human to ask. The name is read fromdrb-email-sender/.env; if it is unset, empty, or containsTBDor a bracket, the run aborts. The open company-name question becomes a gate the system cannot send through, rather than a question the owner has to answer before anything else can start.D10 — AI disclosure is mandatory and fixed, not improvised. (Owner: CTO, in the versioned prompt.)
If asked whether it is a human or an AI, the reply says plainly that it is an automated assistant sending on the company's behalf. Claiming to be human is FTC Act §5 / NY GBL §349 exposure stacked on the CAN-SPAM footer duty already resolved on #147. CISO rec 7 adopted; the line lives in
reply_v1.txt, not in per-reply judgment.D11 — Opt-outs honored immediately, same run, not CAN-SPAM's 10-business-day floor (CISO rec 6). Trivial once automated, and it closes the window in which a bot could reply-argue with someone who already asked to stop.
D12 —
growth-ops.mdis not amended and gets no new capability. (Owner: CEO, no action.)Its boundary #1 stands in substance: it drafts, it cannot send, it has no Bash and no network. What changed is who transmits — a separate deterministic service, not a tool grant. Boundary #2 (never reads a credential) is unchanged and is why D5 matters.
Not in scope (requires a new board minute)
Production-infra webhook; prod-VM deployment; any inbound receiver other than IMAP poll; any tool grant to growth-ops; any Task/Agent invocation from an unattended process; custom sending domain or DNS records; SMS/dialer/CRM; second cold email to any address; live-data demo, price, or account provisioning (Gates A, B, B3 — #43/#47 unchanged).
Drafts consolidated
Follow-up issues to file
drb-email-sender/scaffold —.gitignore,.env.example,requirements.txtwith the Rule 0.2 exclusions, READMEgrowth/outbox/{sent,blocked}/+growth/inbox-needs-human/send.py+ kill switch §3reply_draft.py+reply_v1.txt+ output re-gating; canary counterNothing sends until F1–F4 are green. Nothing replies until F5 is green.
Owner action required
None. Every "needs a CEO ruling" item across the three drafts is decided above — relay (D6), credential home (D5), kill-switch mechanism (D4), scope split (D3), canary (D2), authorization itself (D1). The one thing the owner will eventually supply — the company name — is a one-line
.envvalue that the D9 gate blocks all sending on until it exists, so it cannot be forgotten and does not block the build.SOP
Autonomous Email SOP — send + reply, zero human in the loop
Status: RATIFIED — board minutes server-26#151, 2026-09-13.
Supersedes the human-transmits shape in
growth/outbox/README.mdandgrowth-ops.mdboundary #1 (amended 2026-09-13 on server-26#147).Owner ruling this SOP enacts: "my involvement is to review the SOPs and then
the agents carry it out, following the SOPs." Full autonomy both directions.
No approval gate exists anywhere in this document. Every control below is a
code-enforced gate that aborts, never a human who approves.
0. Components and where they live
growth-opsagent.claude/agents/growth-ops.mddrb-email-sender/(Version 5C root)drb-email-sender/app/reply_draft.pygrowth/outbox/*.mdWhy
drb-email-sender/and notgrowth/mailer/(CTO's proposal):growth-ops may write to anything under
growth/. Putting credential-bearingcode inside its own write scope is the one shape that reintroduces the hazard
boundary #2 exists to prevent.
drb-email-sender/is outside growth-ops'swrite scope and outside
Server//Client/git history. Modeled directly onthe working
drb-telegram-ops/precedent (own dir, own.gitignore, own.env.example).Rule 0.1 — the reply-drafter is NOT the growth-ops agent. It is a single
stateless HTTPS call to an LLM API with a fixed system prompt, no tools, no
file access, no MCP, no Task/Agent invocation, no conversation memory. Nothing
in this SOP ever spawns growth-ops from an unattended process.
Rule 0.2 — zero production-data read path (non-negotiable, not a trade-off).
drb-email-sender/and everything it calls must have no credential for, nonetwork route to, and no code path into Firestore, GCS, the transcript store,
calls, orincidents. Enforced by: (a) no GCP service-account key indrb-email-sender/.env; (b) nogoogle-cloud-*orfirebase-*package indrb-email-sender/requirements.txt; (c) a unit test that asserts both.Rationale: the worst outcome available to this company is a bot answering a
stranger by quoting real transcribed police radio traffic tied to a real
address and a real name. Prompt text cannot be trusted to hold that line.
1. SEND FLOW
Trigger: scheduled run of
drb-email-sender/app/send.py(local cron / TaskScheduler on the owner's machine). Never the production VM, never
docker compose, never CI inserver-26/node-26.Gate definitions — all fail-closed
[1.2] Daily rate cap. Count entries in
growth/outbox/send-ledger.jsonlwith
sent_atinside the current local calendar day.count >= 6→ STOP the run (not the file). Exit 0. Resume tomorrow.Also enforce a randomized 20–90 min sleep between sends within a day so the
33-target batch lands over ~6 business days, not as a burst.
[1.3] Dedupe ledger. Key = lowercased recipient email.
send-ledger.jsonlwithkind: "cold"→ SKIPfile, move to
growth/outbox/blocked/, log reasonduplicate.[1.4] Suppression list. Parse the table in
growth/suppression-list.md, lowercase-compare the recipient address.growth/outbox/blocked/, logsuppressed. Never send, never "confirm".[1.5] Sourcing rule. The recipient address must appear in
growth/westchester-target-list.mdwith a non-empty publicsource_urlforthat row (the business's own website or Google listing).
source_urlempty → SKIP file, logunsourced_recipient.Rationale: this is the line between a normal B2B cold email and a CAN-SPAM
§7704(b) aggravated violation. It must be checked at send time by code, not
assumed to have happened at draft time.
[1.6] Footer gate. The body must end with, verbatim:
FROM_ORG_NAMEis read fromdrb-email-sender/.env. If unset, empty,containing
TBD, or containing[or]→ ABORT run, logorg_name_unset. This is how the unresolved company-name placeholder ingrowth-ops.mdboundary #1 becomes a fail-closed gate instead of an openquestion: nothing can send until the string is set once.
log
footer_invalid.[1.7] Forbidden-content scan. Regex/pattern block over the full body.
Any hit → SKIP file, move to
growth/outbox/blocked/, log the matchingrule. Never send on an ambiguous match.
price\$\s?\d,\bper month\b,\bper node\b,\bpricing is\b,\b\d+\s?(usd|dollars)\blive_data\btalkgroup\b,\bincident #\b,\bunit \d,\b10-\d\d\b, a street address that is not the footer addressdemo_offer\bdemo\b,\bfree (month|trial)\b,\btrial\b,\blog ?in\b,\baccount\bperson_name[1.8] Send. Brevo free tier, Brevo's default sending subdomain (no
custom domain, therefore zero SPF/DKIM/DMARC DNS work for launch). SMTP creds
from
drb-email-sender/.envonly. On SMTP error: retry once after 60s, thenABORT run and log — never loop, never fall through to the next file.
[1.9] Log. Append one JSON line to
growth/outbox/send-ledger.jsonl:{ts, kind:"cold", to, subject, message_id, thread_id, gates_passed[], result}.Then move the source file to
growth/outbox/sent/. Log write failure →ABORT run before sending anything further. An unlogged send is an
unenforceable dedupe.
2. REPLY FLOW
Trigger: scheduled run of
drb-email-sender/app/poll.py, IMAP poll of thereply mailbox (every 15 min). Ruling: IMAP poll is the mechanism. Brevo's
hosted inbound parse is permitted only as a drop-in alternative and only if it
requires no deployment to
api.$DOMAINor anyServer/infra/change.Hard stops — every one defaults to stop, never to send
[2.2] Unsubscribe family. Case-insensitive substring match (not exact,
not word-boundary) on the inbound body or subject:
unsubscribe,stop,remove me,take me off,opt out,opt-out,do not email,don't email,no longer wish,delete my.growth/suppression-list.mdimmediately(same run, not CAN-SPAM's 10-business-day floor), mark the thread closed,
and send nothing — not even a confirmation. A confirmation re-opens a
thread the person asked to end.
[2.3] Hostile / complaint family. Case-insensitive substring:
lawyer,attorney,sue,lawsuit,BBB,FTC,attorney general,complaint,harassment,harassing,spam,report you,cease and desist,GDPR,CCPA.growth/inbox-needs-human/<thread-id>.md,send nothing, notify the owner (one line, always — this is outside the
canary and never goes silent). Do not argue, do not apologize elaborately,
do not de-escalate autonomously. An LLM improvising a defense to a
threatened complaint is worse than silence.
[2.4] Thread-depth cap. Count outbound messages already sent on this
thread_idinreply-ledger.jsonl, excluding the original cold email.>= 2→ write togrowth/inbox-needs-human/, send nothing, notifyowner (outside the canary, never silent). A prospect still replying after
two autonomous responses is in a live negotiation — the exact thing minutes
#79 says only a phone/video call can carry.
[2.5] Unknown-sender check. The inbound
Frommust match a recipient insend-ledger.jsonl.growth/inbox-needs-human/, send nothing. Prevents thereply-drafter being driven by an arbitrary stranger who emails the mailbox.
[2.6] The narrow LLM reply-draft call
This resolves CTO's stated risk (an unattended process invoking the full
growth-ops agent with outbox-write access) without reintroducing it. What is
built here has none of the properties that made that risky:
poll.py.growth-ops.md.Inputs — exactly these three, nothing else:
growth/westchester-target-list.md(business name, town, category — public directory facts only).
Fixed system prompt (verbatim, versioned in
drb-email-sender/app/prompts/reply_v1.txt— changes require a board minute):Output handling: the returned string is treated as untrusted text. It goes
straight into [2.7] and is re-scanned by the same [1.6] footer gate and [1.7]
forbidden-content gate used for outbound cold email. Any gate failure →
send nothing, write the draft to
growth/inbox-needs-human/with thefailing rule named, notify owner (outside the canary). The LLM never gets a
second attempt — a retry loop against a content gate is a gate-defeating loop.
[2.8] Send. Same SMTP path as [1.8], threaded via
In-Reply-To.[2.9] Log. Append to
growth/outbox/reply-ledger.jsonl:{ts, thread_id, to, inbound_excerpt, gates_passed[], model, reply_sent, canary_index, result}.[2.10] Canary — observed, not gated
Ruling: mandatory first phase, non-blocking. This is not human review.
No reply ever waits on a human. Nothing is approved. The only difference
during the canary is that the owner is told.
autonomous_reply_countingrowth/outbox/reply-ledger.jsonl(count ofreply_sent: true).owner via the existing
drb-telegram-opsbot:DRB reply N/5 — <business> — replied, gates clean.no re-ratification. Fully silent from then on.
hard-stopped message does not consume a canary slot — it produces its own
always-on notification per [2.3]/[2.4]/[2.7].
the classifiers' first real contact with live language, not a recurring mode.
Always-on notifications (never silenced by canary exit): hostile-family
match, thread-depth cap hit, generated-reply gate failure, kill switch
engaged/disengaged, run abort.
3. KILL SWITCH
Mechanism: flag file
growth/outbox/.paused.individual reply ([2.0]) — not once at process start.
paused, exit 0. Sendnothing. No queue drain on resume beyond the normal daily cap.
unreadable or the stat call errors, treat it as paused.
Overruling CISO recommendation #5 (Firestore
config/growth_autonomy).That mechanism contradicts CISO's own finding #1. A kill switch in the
Firestore
configcollection would requiredrb-email-sender/to hold a GCPcredential and open a live read path into the same Firestore project that
holds
callsandincidents— precisely the architectural blindness Rule 0.2makes non-negotiable. A flag file needs no credential, no network, and no
dependency on the service being reachable. CISO's requirement (fail-closed,
checked per-action) is adopted in full; only the storage location changes.
Second independent brake: the daily cap at [1.2], enforced in
drb-email-sender/'s own config, mirroringdrb-telegram-ops'sDAILY_BUDGET_USDpattern.4. CREDENTIALS
SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_API_KEYdrb-email-sender/.envsend.py,poll.pyonlyIMAP_HOST/IMAP_USER/IMAP_PASSWORDdrb-email-sender/.envpoll.pyonlyLLM_API_KEY(reply-drafter)drb-email-sender/.envreply_draft.pyonlyFROM_ORG_NAME,FROM_ADDRESSdrb-email-sender/.envsend.py,poll.pyRules:
drb-email-sender/.gitignorecontains.env— copydrb-telegram-ops/.gitignore.drb-email-sender/.env.exampledocuments every key with no values, andcarries the
drb-telegram-ops/.env.examplewarning line: treat as apassword.
growth/. growth-ops can write thatwhole tree; it must never be able to read or overwrite a secret.
never triggers a send. It writes drafts to
growth/outbox/exactly astoday. This SOP changes who transmits, not what growth-ops may do.
receives three inputs ([2.6]) and nothing else.
LLM_API_KEYis used bythe HTTP client wrapping the call, never placed in the prompt.
free — the 6/day cap is nowhere near it. $0/mo against the ≤$15/mo cap.
No custom sending domain, therefore no SPF/DKIM/DMARC DNS work and no
registrar task for the owner at launch. A custom domain is a separate,
later decision.
5. EXPLICITLY NOT IN SCOPE
Adopted from CTO draft #149 finding 2. Anything below requires a new board
minute before it is built.
api.$DOMAIN,c2-core, Caddy,Server/infra/, Terraform, or Ansible.Pre-revenue sales tooling does not touch the surface a paying customer's
product runs on.
send.pyandpoll.pyrun from alocal scheduled task on the owner's machine. Not
docker compose, notGitea Actions in
server-26/node-26, not the prod host.Worker, no serverless function, no new hosting.
#1 ("you cannot send anything, to anyone, ever") is unchanged in substance:
it still only drafts.
drb-email-sender/is a separate deterministicservice, not a capability handed to the agent.
is a single stateless API call. If a future change wants real agent
judgment in the loop, that is a new board item.
escalate to the board (
growth-ops.md).enforced by the [1.3] dedupe ledger. Follow-up sequences are not authorized.
(#43, #47) are unchanged by this SOP.
6. Build order (CTO owns filing; CIO owns the credential scaffold)
drb-email-sender/scaffold:.gitignore,.env.example,requirements.txt(no
google-cloud-*, nofirebase-*), README. CIO.growth/outbox/{sent,blocked}/,growth/inbox-needs-human/. CTO.assertion test. Gates land before any send capability. CTO.
send.py+ kill switch §3. CTO.reply_draft.py+ promptreply_v1.txt+ output re-gating [2.7]. CTO.drb-telegram-ops. CTO.Nothing sends until steps 1–4 are complete and green. Nothing replies until
1–7 are complete and green.
7. Amending this SOP
The fixed reply system prompt, the gate patterns, the daily cap, the
thread-depth cap, and Rule 0.2 are ratified content. Changing any of them
requires a board minute in
logan/server-26, not a commit. Tuning a regex tocatch a phrasing it missed is a bug fix and does not.