Relationships
↑ child of #2008#2011 feat(nurture): the nurture_send ledger and the dry-run projector, with the cold-start seal
Opened by keeb · 9/4/2026· Shipped 9/4/2026
Parent: the product-nurture epic. Depends on the catalog and the cohort reads.
Problem
This is where duplicates are prevented, and it must exist and be trusted before anything can send. It is also where the single most dangerous property of the whole epic lives:
A time-triggered sweep, run for the first time against an existing population, finds the entire backlog at once.
Every flow's first tick sees years of qualifying history. trial-ended-watch.ts
already met this and refuses to run until a backfill marker exists. Nurture
needs a stronger version, because it has six flows and no natural marker.
What ships
The nurture_send collection. One row per decision, written at
identification time, before any send:
_id: "<flow>:<occurrenceKey>:<operativeId>" // deterministic
flow, occurrenceKey, operativeId, username, email // email snapshotted
status: "candidate" | "sealed" | "suppressed" | "sent" | "failed" | "skipped"
channels: { email?: {...}, inbox?: {...} } // filled in Phase B/D
evidence: { ... } // why they qualified
decidedAt, armedAt, sealReason?, suppressionReason?The deterministic _id is the dedupe: a re-run of the projector against the
same qualification is a duplicate-key no-op, exactly like
publishSystemMessage's queue id. There is no "have we sent this?" query to get
wrong.
The cold-start seal. A flow has an armedAt instant. The projector's first
pass over a flow writes every already-qualifying operative with
status: "sealed", sealReason: "cold_start_backlog" — claimed forever,
never sendable. Only operatives who cross the threshold after armedAt can
become candidate. The seal is what makes it safe to develop this in
production against real data.
Atomic transition, no lease. A send claims a row with an updateOne
matching status: "candidate". Replicas racing the same tick produce exactly
one winner — the same discipline the welcome email and the trial-ended sweep
use, and the reason neither needs a leader lease.
Dry run by default. The projector runs on everyTick from
startBackgroundServices() (never import.meta.main — dead under
deno serve). With sending disabled it writes candidates and stops. That is
the entire deliverable of Phase A: a queue that fills correctly and drains
nowhere.
A per-tick cap on new candidates per flow, so a rule that turns out to be far too broad is bounded even before anyone looks at the panel.
Verification
tests/app/nurture_projector_test.ts— a fixture population, arm a flow, assert every pre-armedAtqualifier issealedand no row iscandidate; then advance the clock, cross one operative over the threshold, assert exactly onecandidate.- Re-run the projector twice against identical input; assert the second pass creates zero rows and modifies zero rows.
tests/infrastructure/nurture_send_document_test.ts— the_idderivation and its unique index, round-tripped through the driver's own BSON options (CI has no Mongo, and theundefined-vs-absent trap is real here).- On the local stack: run the projector against seeded data, confirm the
collection fills and
RESEND_API_KEYis never touched.
Out of scope
Any send. Any channel. The panel (A4) is what makes this observable.
Shipped
Click a lifecycle step above to view its details.