Board 2026-09-13 — Autonomous email SOP — FINAL MINUTES #151
Closed
opened 2026-09-13 23:05:15 -04:00 by logan
·
1 comment
No Branch/Tag Specified
Labels
Clear labels
area/ai-pipeline
area/auth
area/billing
area/correlation
area/discord
area/frontend
area/governance
area/growth
area/infra-deploy
area/legal
area/map
area/mqtt
area/node-ota
area/osint
area/recording-audio
area/sdr-radio
area/transcription
board
board:agenda
minutes:draft
minutes:final
role:ceo
role:cfo
role:cio
role:ciso
role:cmo
role:coo
role:cto
Scene/entity extraction, summarizer, AI providers, embeddings
AuthN/Z, roles, tenancy/org scoping, API keys, credentials
Plans, entitlements, AI spend accounting and caps
Incident correlation, linking, lifecycle, severity, corr_debug
Discord bot, radio bots, voice streams, bot tokens, webhooks
Web UI: Next.js app and the edge-node local dashboard
Board process, minutes, unattended runner, agent tooling
Go-to-market, outreach, cold email, discovery conversations
CI/CD, VM, registry, Firestore rules/indexes deploy, Terraform/Caddy
ToS, retention, licences/DAAs, CAN-SPAM, E&O, name suppression
Live map: pins, paths, tiles, legend, geocoding
Node-C2 MQTT, checkins, commands, node sweeper, dynsec
Node install, enrollment, OTA updater, image skew
External feeds: cameras, 511, ADS-B, AIS, outages, and their licences
Call capture, boundaries, PulseAudio, ring buffer, trimming, attribution
OP25 decode, SDR hardware/tuning, conventional, second SDR, mesh radio
STT, transcript correction, place verification, name accuracy
Board process: minutes and board-filed items
Needs board scoping at the next sitting; CEO removes the label once scoped
One exec pre-read, closed by CEO
CEO ruling; open until enacted
Filed by the CEO
Filed by the CFO
Filed by the CIO
Filed by the CISO
Filed by the CMO
Filed by the COO
Filed by the CTO
kind
behavior-bug
Wrong product behavior; the instrument reads it correctly
kind
measurement-bug
Corrupts the recording/instrument (debug data, evals, health checks), not behavior
priority
p0
Drop everything: prod broken, live exposure, or owner-ruled P0
priority
p1
Next up: blocks a gate, a customer, or core incident quality
priority
p2
Normal: real defect or planned work, schedule in turn
priority
p3
Low: minor, cosmetic, or someday
status
blocked
Waiting on an external party, spend, or another issue
status
in-progress
Branch or PR open against it
status
needs-decision
Waiting on an owner or board ruling before work
status
needs-info
Root cause or repro unknown; needs a reading first
type
bug
Something built is wrong: code, data, or a measurement
type
chore
Maintenance, refactor, tests, pinning, cleanup: no user-visible change
type
decision
A ruling to make or record: board minutes, owner calls, legal reviews
type
docs
Docs, runbooks, CLAUDE.md, skills, SOPs
type
feature
New capability or an extension of an existing one
type
security
Security, privacy, credential or tenant-isolation exposure
type
tracking
Tracker, checklist, initiative or punch-list spanning several changes
Milestone
No items
No Milestone
Projects
Clear projects
No projects
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: logan/server-26#151
Reference in New Issue
Block a user
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.
Closing per owner (2026-10-27): board minutes shouldn't be issues. The board will be rescoped after the app is done. The ratified SOP still lives in growth/AUTONOMOUS_EMAIL_SOP.md.