#2008 Epic: Product nurture — lifecycle identification, dedupe-safe delivery, and operator control (email, inbox, CLI)
Opened by keeb · 9/4/2026
Six lifecycle moments deserve a message and currently get nothing. This epic builds the machinery to find the right operatives, decide once whether to contact them, and deliver on three channels — Resend email, the swamp-club inbox, and a waiting-notifications hint in the CLI.
It spans two repos: swamp-club (identification, ledger, admin control,
email, inbox, API) and swamp (the CLI hint). Sub-issues are tagged by
source.
The flows
From the whiteboard, with the segment name each one is really about:
| # | Trigger | Segment | Message |
|---|---|---|---|
| 1 | Signup | — | Welcome, split by whether the CLI drove the signup |
| 2 | Signed up ≥30d ago, never ran the CLI | ARRIVED, NEVER RAN | Re-engage: install / next steps |
| 3 | Sustained model usage, zero workflow run |
BUILDING PRODUCT FEATURES | Promote workflows + shareability |
| 4 | Published an extension | GROW CHAMPION | Celebrate, invite them to campaign for it |
| 5 | Was active, nothing for 7 days | DRIFTED | "Your quest is waiting" |
| 6 | A run failed, then nothing | NEEDS HELP | "Need help?" |
Flow 1 already ships. sendWelcomeEmail (#1168) sends welcome-cli or
welcome-web off the sc_cli_origin marker, once, behind an atomic claim. It
is in scope only as the pattern to copy and as the thing flows 2–6 must not
duplicate.
Order of work, and why it is this order
The ask was explicit: identification first, and prove we will not send duplicates, before a single message goes out. That is also the only order that is safe here, for one reason:
A time-triggered sweep, run for the first time against an existing population, finds the entire backlog at once and mails all of it.
trial-ended-watch.ts already hit this and solved it by refusing to run until
a backfill marker exists. Nurture faces it six times over — every flow's first
tick sees years of qualifying history. So:
- Phase A builds identification and writes candidate rows that are visibly
marked as never-to-be-sent. Nothing leaves the building.
/admin/nurtureis the verification surface: you look at real names, real evidence, and real counts, and only then decide the rule is right. - Phase B builds the delivery spine — suppression, an outbound transport that reports its outcome, the templates — and arms exactly one flow (extension published: event-triggered, no backlog, and a message nobody minds receiving).
- Phase C arms the remaining flows one at a time. Each is its own issue because each has its own backlog size and its own false-positive story.
- Phase D adds the inbox channel on the same decision record.
- Phase E adds the CLI hint.
Non-negotiables
One decision, many channels. A nurture_send row is written at
identification time with a deterministic _id of
<flow>:<occurrenceKey>:<operativeId>. The unique index is the dedupe. Email
and inbox are two deliveries of one row, never two producers — otherwise a
Resend retry duplicates the inbox message.
Absent is not unknown. An operative with no bound device has unknown
activity, not zero: someone who runs the CLI without swamp auth login is
indistinguishable from someone who never installed it. Mailing them "you never
got started" is a lie the recipient can see. Unresolvable operatives are
skipped with a reason, never mailed — the #1589 rule, applied to people.
Devices are not people. ~11.6× more distinct_ids than humans. Every
qualification is at the account grain, resolved device→owner through
identity_map. collective:<id> grains are excluded outright — a team daemon
is a credential, not a person (#1679).
Identification is a batch job and must never touch a request path. It may
scan swamp.events for things cli_daily cannot answer (failures — cli_daily
is result_status = 'success' only). That is fine at a nightly cadence and
catastrophic on a page render (#1317, #1586). A static guard should pin it.
Every candidate carries its evidence. The numbers that made someone qualify are stored on the row and rendered in the panel. Without that, the panel is a list; with it, it is a diagnosis, and a wrong rule is visible before it mails anybody.
Known traps found during investigation
ResendMailer.sendswallows failures. A non-2xx is logged and the method returnsvoid. You cannot record send status through it — B2.- Resend suppression does not cover
POST /emails.newsletter_subscriber.unsubscribedAtis mirrored from Resend broadcasts. A direct nurture send bypasses it entirely — B1. - There is no per-operative product-email preference. Only the newsletter list has one. Nurture needs its own, with a footer link — B1.
- "Broadcast messages in the app" ≠
BroadcastMessage. In this codebase a broadcast goes to everyone. Nurture is targeted:sendSystemMessage({audience: {type: "users"}})— D1. buildInboxViewis too heavy to poll. It reads lab issues, broadcasts and colours tiers (a score read). The CLI needs a dedicated count endpoint, and that count must match what the inbox actually shows or the hint lies — E1.- One human can hold several operatives.
disableImplicitLinkingmints a new account when a social email does not match. Per-operative dedupe will still mail the same person twice, so the frequency cap is keyed on the address, the way the Attio person grain is (#1665) — B1. - An unnamed operative must hold, not send. SSO accounts are named milliseconds-to-minutes after creation; the announce path already learned this. Any copy rendering a username holds rather than going out blank.
Definition of done
All six flows armed in production; every send visible in /admin/nurture with
its evidence, channel outcomes, and suppression reasons; the CLI showing a
waiting-notifications line; and a documented, verified answer to "why did this
person get this message, and can they get it twice?" — no.
Open
No activity in this phase yet.
Sign in to post a ripple.