Board 2026-09-13 — Autonomous email SOP — FINAL MINUTES #151

Open
opened 2026-09-13 23:05:15 -04:00 by logan · 0 comments
Owner

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.

## 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.
logan added the boardminutes:finalrole:ceo labels 2026-09-13 23:05:15 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#151