Skip to main content
← Back to list
01Issue
FeatureShippedSwamp ClubPublic
Assigneeskeeb

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-armedAt qualifier is sealed and no row is candidate; then advance the clock, cross one operative over the threshold, assert exactly one candidate.
  • 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 _id derivation and its unique index, round-tripped through the driver's own BSON options (CI has no Mongo, and the undefined-vs-absent trap is real here).
  • On the local stack: run the projector against seeded data, confirm the collection fills and RESEND_API_KEY is never touched.

Out of scope

Any send. Any channel. The panel (A4) is what makes this observable.

02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 5 MOREREVIEW+ 4 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

9/4/2026, 11:09:21 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
keeb assigned keeb9/4/2026, 8:04:12 PM
keeb linked parent of #20089/4/2026, 7:41:05 PM

Sign in to post a ripple.