Skip to content

Commit 694003d

Browse files
authored
feat(growth): link install and development runtime to founder outreach (#1004)
* feat(growth): link install and development runtime evidence to founder outreach * test(growth): include observability CLI in CI scope coverage
1 parent 2f2baef commit 694003d

63 files changed

Lines changed: 6410 additions & 9 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/lifecycle/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ The service has two database boundaries:
99

1010
Neither variable falls back to the other. Preview and production must use different Neon resources for both boundaries. Configure no lifecycle secret with a `NEXT_PUBLIC_` prefix.
1111

12+
Install/runtime activation has a separate rollout switch: `GROWTH_INSTALL_RUNTIME_HELLO_ENABLED` defaults to `false` and accepts only exact `true` or `false`. Only when it and campaign enrollment are enabled does the existing lifecycle tick resolve linked activations before materializing the campaign cohort. Configure the same server-only `GROWTH_EMAIL_HMAC_ACTIVE_VERSION`, `GROWTH_EMAIL_HMAC_ACTIVE_SECRET`, and optional `GROWTH_EMAIL_HMAC_PREVIOUS_KEYS` used by collection; these keys are read lazily only for enabled activation processing. Existing form and claim enrollment needs no new HMAC configuration while the rollout switch is off. Announcement requests do not run this work or submit email.
13+
14+
Apply migrations 0004–0007 before deploying the backend: contact deletion and campaign authorization use the observation tables even while the activation switch is off. Verify a second migration run applies nothing. For databases with historical deletions, run `npm run growth:observability -- initialize-redactions --limit 100` with the matching collection HMAC keys, passing each returned `nextCursor` as `--cursor` until exhausted, before enabling identity collection or activation.
15+
16+
Deploy backend observation acceptance and bridge resolution with the rollout switch off. Verify the synthetic journey in preview with a controlled recipient and lifecycle's matching HMAC keys, then publish the matching collectors and enable production collection and activation gradually. Preserve the existing enrollment start timestamp, campaign, delivery, and cron controls.
17+
18+
A persisted `install_runtime` enrollment reason selects the existing generic founder sequence immediately, without waiting for an enrichment artifact. All three steps stay generic even if optional research later becomes available. Form and project-claim enrollments retain their existing behavior. The shared delivery authorization, reply/suppression stops, mailbox recovery guard, unsubscribe links, and once-per-contact three-step enrollment remain in force; install-derived eligibility does not verify identity or employment.
19+
1220
Recipient delivery also requires `GROWTH_PUBLIC_ACTION_ORIGIN`, a server-only bare HTTPS origin for the Website deployment that owns `/api/unsubscribe`. In preview, use a dedicated public custom-domain alias for the exact Website preview deployment while keeping generated preview URLs protected; the signed action token is the application-layer authorization. In production, use the canonical Website origin. Paths, query strings, fragments, credentials, and HTTP origins are rejected. The lifecycle service uses this value only to construct opaque, contact-bound unsubscribe action URLs; it never derives the origin from a request or hardcodes the production site.
1321

1422
Set `GROWTH_DATABASE_ENVIRONMENT` to exactly `preview`, `production`, or `test` in every process that handles verified Resend events. A verified webhook whose `environment` provider tag is missing or differs from that value is acknowledged without opening a growth transaction or changing delivery/suppression state.

apps/lifecycle/src/app/dispatch/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export async function workflow(
2525
batchSize: configuredBatchSize(),
2626
campaignEnabled: configuration.campaignEnabled,
2727
campaignEnrollmentEnabled: configuration.campaignEnrollmentEnabled,
28+
installRuntimeHelloEnabled: configuration.installRuntimeHelloEnabled,
2829
campaignEnrollmentStartAt: configuration.campaignEnrollmentStartAt,
2930
signal: context.signal,
3031
});

apps/lifecycle/src/campaign/send.spec.ts

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,56 @@ function context(
127127
}
128128

129129
describe('prepareCampaignMessage', () => {
130+
it('prepares the install-runtime hello immediately without research', () => {
131+
expect(
132+
prepareCampaignMessage({
133+
context: {
134+
...context({ enrichmentArtifact: null }),
135+
campaignEnrollmentReason: 'install_runtime',
136+
},
137+
job: job('send_step', { campaign_version: 'v1', step: 1 }),
138+
now: new Date('2026-09-01T12:00:00.000Z'),
139+
unsubscribeUrl: UNSUBSCRIBE,
140+
})
141+
).toMatchObject({ status: 'ready', subject: 'A practical place to start' });
142+
});
143+
144+
it.each([1, 2, 3] as const)(
145+
'keeps install-runtime step %i generic even when research is available',
146+
(step) => {
147+
const prepared = prepareCampaignMessage({
148+
context: { ...context(), campaignEnrollmentReason: 'install_runtime' },
149+
job: job('send_step', { campaign_version: 'v1', step }),
150+
now: NOW,
151+
unsubscribeUrl: UNSUBSCRIBE,
152+
});
153+
expect(prepared).toMatchObject({
154+
status: 'ready',
155+
subject: [
156+
'A practical place to start',
157+
'One debugging shortcut',
158+
'One last architecture note',
159+
][step - 1],
160+
});
161+
if (prepared.status !== 'ready') throw new Error('expected ready');
162+
expect(prepared.text).toContain(unsubscribeActionUrlValue(UNSUBSCRIBE));
163+
expect(prepared.text).toContain('\n\n—\nBrian\n');
164+
if (step === 3)
165+
expect(prepared.text).toContain('This is my last automated follow-up.');
166+
}
167+
);
168+
169+
it('rejects a fourth install-runtime sequence step', () => {
170+
expect(() =>
171+
prepareCampaignMessage({
172+
context: { ...context(), campaignEnrollmentReason: 'install_runtime' },
173+
job: job('send_step', { campaign_version: 'v1', step: 4 }),
174+
now: NOW,
175+
unsubscribeUrl: UNSUBSCRIBE,
176+
})
177+
).toThrow(DeterministicLifecycleJobError);
178+
});
179+
130180
it('renders only a closed evidence-linked angle selection deterministically', () => {
131181
const cited = artifact({
132182
cited_signals: [
@@ -358,6 +408,93 @@ function dependencies(
358408
}
359409

360410
describe('dispatchLifecycleAppOwnedJob', () => {
411+
it('sends the install-runtime hello through the shared recipient boundary without research', async () => {
412+
const deps = dependencies({
413+
readJobContext: vi.fn().mockResolvedValue(
414+
context({
415+
campaignEnrollmentReason: 'install_runtime',
416+
enrichmentArtifact: null,
417+
})
418+
),
419+
});
420+
const send = job('send_step', { campaign_version: 'v1', step: 1 });
421+
await expect(
422+
dispatchLifecycleAppOwnedJob({} as SqlExecutor, send, {}, deps)
423+
).resolves.toBe('completed');
424+
expect(deps.sendRecipient).toHaveBeenCalledWith(
425+
expect.anything(),
426+
expect.objectContaining({
427+
jobId: send.id,
428+
leaseToken: LEASE_TOKEN,
429+
subject: 'A practical place to start',
430+
unsubscribeUrl: UNSUBSCRIBE,
431+
}),
432+
deps.recipientPolicy
433+
);
434+
expect(deps.deferJob).not.toHaveBeenCalled();
435+
expect(deps.fetchCompanyEvidence).not.toHaveBeenCalled();
436+
expect(deps.generateArtifact).not.toHaveBeenCalled();
437+
});
438+
439+
it.each([
440+
'contact_stopped',
441+
'contact_unapproved',
442+
'contact_deleted',
443+
] as const)(
444+
'preserves the shared %s delivery stop for an install-runtime hello',
445+
async (reason) => {
446+
const deps = dependencies({
447+
readJobContext: vi.fn().mockResolvedValue(
448+
context({
449+
campaignEnrollmentReason: 'install_runtime',
450+
enrichmentArtifact: null,
451+
})
452+
),
453+
sendRecipient: vi.fn().mockResolvedValue({ accepted: false, reason }),
454+
});
455+
await expect(
456+
dispatchLifecycleAppOwnedJob(
457+
{} as SqlExecutor,
458+
job('send_step', { campaign_version: 'v1', step: 1 }),
459+
{},
460+
deps
461+
)
462+
).resolves.toBe('cancelled');
463+
expect(deps.cancelJob).toHaveBeenCalledWith(
464+
expect.anything(),
465+
expect.objectContaining({ errorCode: reason })
466+
);
467+
expect(deps.generateArtifact).not.toHaveBeenCalled();
468+
}
469+
);
470+
471+
it.each(['campaign_disabled', 'delivery_disabled'] as const)(
472+
'keeps an install-runtime hello deferred while %s',
473+
async (reason) => {
474+
const deps = dependencies({
475+
readJobContext: vi.fn().mockResolvedValue(
476+
context({
477+
campaignEnrollmentReason: 'install_runtime',
478+
enrichmentArtifact: null,
479+
})
480+
),
481+
sendRecipient: vi.fn().mockResolvedValue({ accepted: false, reason }),
482+
});
483+
await expect(
484+
dispatchLifecycleAppOwnedJob(
485+
{} as SqlExecutor,
486+
job('send_step', { campaign_version: 'v1', step: 1 }),
487+
{},
488+
deps
489+
)
490+
).resolves.toBe('deferred');
491+
expect(deps.deferJob).toHaveBeenCalledWith(
492+
expect.anything(),
493+
expect.objectContaining({ errorCode: reason })
494+
);
495+
}
496+
);
497+
361498
it('fulfills the persisted form request through the recipient boundary', async () => {
362499
const deps = dependencies();
363500
const fulfill = job('fulfill', {
@@ -766,14 +903,35 @@ describe('loadLifecycleRuntimeConfiguration', () => {
766903
});
767904
});
768905

769-
it('defaults all three delivery switches off', () => {
906+
it('defaults delivery and install-runtime activation switches off', () => {
770907
expect(loadLifecycleRuntimeConfiguration({})).toMatchObject({
771908
campaignEnrollmentEnabled: false,
772909
campaignEnabled: false,
773910
deliveryEnabled: false,
911+
installRuntimeHelloEnabled: false,
774912
});
775913
});
776914

915+
it('enables install-runtime hello only with the exact configured boolean', () => {
916+
expect(
917+
loadLifecycleRuntimeConfiguration({
918+
GROWTH_INSTALL_RUNTIME_HELLO_ENABLED: 'true',
919+
})
920+
).toMatchObject({ installRuntimeHelloEnabled: true });
921+
expect(
922+
loadLifecycleRuntimeConfiguration({
923+
GROWTH_INSTALL_RUNTIME_HELLO_ENABLED: 'false',
924+
})
925+
).toMatchObject({ installRuntimeHelloEnabled: false });
926+
for (const value of ['TRUE', '1', ' true ', '']) {
927+
expect(() =>
928+
loadLifecycleRuntimeConfiguration({
929+
GROWTH_INSTALL_RUNTIME_HELLO_ENABLED: value,
930+
})
931+
).toThrow(/GROWTH_INSTALL_RUNTIME_HELLO_ENABLED/);
932+
}
933+
});
934+
777935
it('runs enrichment with every mail environment variable absent and delivery disabled', async () => {
778936
const deps = createDefaultLifecycleJobDependencies({
779937
CAMPAIGN_ENROLLMENT_ENABLED: 'false',

apps/lifecycle/src/campaign/send.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export interface LifecycleJobContext {
7676
emailClassification: 'work' | 'personal' | 'unknown';
7777
formSubmission: Record<string, unknown>;
7878
enrollmentAt: Date | null;
79+
campaignEnrollmentReason?: 'install_runtime' | null;
7980
enrichmentArtifact: GrowthArtifact | null;
8081
}
8182

@@ -174,6 +175,7 @@ export interface LifecycleJobDependencies {
174175

175176
export interface LifecycleRuntimeConfiguration {
176177
campaignEnrollmentEnabled: boolean;
178+
installRuntimeHelloEnabled: boolean;
177179
campaignEnrollmentStartAt?: Date;
178180
campaignEnabled: boolean;
179181
deliveryEnabled: boolean;
@@ -264,11 +266,12 @@ export function prepareCampaignMessage(input: {
264266
unsubscribeUrl: UnsubscribeActionUrl;
265267
}): PreparedCampaignMessage {
266268
const step = campaignStep(input.job);
267-
const artifact = validArtifact(
268-
input.context.enrichmentArtifact,
269-
input.context.contactId
270-
);
271-
if (step === 1 && !artifact) {
269+
const genericHello =
270+
input.context.campaignEnrollmentReason === 'install_runtime';
271+
const artifact = genericHello
272+
? null
273+
: validArtifact(input.context.enrichmentArtifact, input.context.contactId);
274+
if (step === 1 && !artifact && !genericHello) {
272275
if (!input.context.enrollmentAt) {
273276
throw new DeterministicLifecycleJobError(
274277
'Campaign enrollment timestamp is required'
@@ -662,6 +665,10 @@ export function loadLifecycleRuntimeConfiguration(
662665
'CAMPAIGN_ENROLLMENT_ENABLED'
663666
);
664667
const campaignEnabled = exactBoolean(environment, 'CAMPAIGN_ENABLED');
668+
const installRuntimeHelloEnabled = exactBoolean(
669+
environment,
670+
'GROWTH_INSTALL_RUNTIME_HELLO_ENABLED'
671+
);
665672
const deliveryEnabled = exactBoolean(environment, 'DELIVERY_ENABLED');
666673
let campaignEnrollmentStartAt: Date | undefined;
667674
if (campaignEnrollmentEnabled) {
@@ -682,6 +689,7 @@ export function loadLifecycleRuntimeConfiguration(
682689
}
683690
return {
684691
campaignEnrollmentEnabled,
692+
installRuntimeHelloEnabled,
685693
...(campaignEnrollmentStartAt ? { campaignEnrollmentStartAt } : {}),
686694
campaignEnabled,
687695
deliveryEnabled,

apps/lifecycle/src/dispatcher.spec.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import {
2121
} from './campaign/send.js';
2222

2323
const NOW = new Date('2026-09-01T12:00:00.000Z');
24+
const EMAIL_KEYRING = {
25+
active: { version: 1, secret: 'dispatcher-email-test-secret-material' },
26+
};
2427

2528
afterEach(() => vi.useRealTimers());
2629

@@ -61,6 +64,13 @@ function dependencies(
6164
dispatchLeasedJob: vi.fn().mockResolvedValue('completed'),
6265
isRecoveryPaused: vi.fn().mockResolvedValue(false),
6366
leaseDueJobs: vi.fn().mockResolvedValue([]),
67+
loadEmailKeyring: vi.fn(() => EMAIL_KEYRING),
68+
processInstallRuntimeActivations: vi.fn().mockResolvedValue({
69+
approved: 0,
70+
ineligible: 0,
71+
conflicted: 0,
72+
disabled: false,
73+
}),
6474
materializeCampaignEnrollment: vi.fn().mockResolvedValue({
6575
enrolledContactIds: [],
6676
createdJobs: 0,
@@ -280,6 +290,7 @@ describe('dispatchLifecycleJobs', () => {
280290
batchSize: 10,
281291
campaignEnabled: false,
282292
campaignEnrollmentEnabled: true,
293+
installRuntimeHelloEnabled: true,
283294
campaignEnrollmentStartAt: start,
284295
signal: new AbortController().signal,
285296
},
@@ -295,13 +306,52 @@ describe('dispatchLifecycleJobs', () => {
295306
batchSize: 10,
296307
}
297308
);
309+
expect(deps.loadEmailKeyring).toHaveBeenCalledOnce();
310+
expect(deps.processInstallRuntimeActivations).toHaveBeenCalledWith(
311+
expect.anything(),
312+
{ enabled: true, limit: 10, now: NOW, keyring: EMAIL_KEYRING }
313+
);
314+
expect(
315+
vi.mocked(deps.processInstallRuntimeActivations).mock
316+
.invocationCallOrder[0]
317+
).toBeLessThan(
318+
materializeCampaignEnrollment.mock.invocationCallOrder[0] ?? 0
319+
);
298320
expect(
299321
materializeCampaignEnrollment.mock.invocationCallOrder[0]
300322
).toBeLessThan(
301323
leaseDueJobs.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
302324
);
303325
});
304326

327+
it.each([undefined, false])(
328+
'keeps form and claim enrollment working without new keys when hello rollout is %s',
329+
async (installRuntimeHelloEnabled) => {
330+
const deps = dependencies({
331+
loadEmailKeyring: vi.fn(() => {
332+
throw new Error('new HMAC keys are not configured');
333+
}),
334+
});
335+
await expect(
336+
dispatchLifecycleJobs(
337+
{
338+
batchSize: 10,
339+
campaignEnabled: true,
340+
campaignEnrollmentEnabled: true,
341+
installRuntimeHelloEnabled,
342+
campaignEnrollmentStartAt: NOW,
343+
signal: new AbortController().signal,
344+
},
345+
deps
346+
)
347+
).resolves.toMatchObject({ leased: 0 });
348+
expect(deps.materializeCampaignEnrollment).toHaveBeenCalledOnce();
349+
expect(deps.leaseDueJobs).toHaveBeenCalledOnce();
350+
expect(deps.loadEmailKeyring).not.toHaveBeenCalled();
351+
expect(deps.processInstallRuntimeActivations).not.toHaveBeenCalled();
352+
}
353+
);
354+
305355
it('does no enrollment work when enrollment is disabled', async () => {
306356
const deps = dependencies();
307357

@@ -310,12 +360,41 @@ describe('dispatchLifecycleJobs', () => {
310360
batchSize: 10,
311361
campaignEnabled: true,
312362
campaignEnrollmentEnabled: false,
363+
installRuntimeHelloEnabled: true,
313364
signal: new AbortController().signal,
314365
},
315366
deps
316367
);
317368

318369
expect(deps.materializeCampaignEnrollment).not.toHaveBeenCalled();
370+
expect(deps.processInstallRuntimeActivations).not.toHaveBeenCalled();
371+
expect(deps.loadEmailKeyring).not.toHaveBeenCalled();
372+
});
373+
374+
it('stops before enrollment and leasing if activation processing is cancelled', async () => {
375+
const controller = new AbortController();
376+
const deps = dependencies({
377+
processInstallRuntimeActivations: vi.fn().mockImplementation(async () => {
378+
controller.abort(new Error('activation cancelled'));
379+
return { approved: 0, ineligible: 0, conflicted: 0, disabled: false };
380+
}),
381+
});
382+
await expect(
383+
dispatchLifecycleJobs(
384+
{
385+
batchSize: 10,
386+
campaignEnabled: true,
387+
campaignEnrollmentEnabled: true,
388+
installRuntimeHelloEnabled: true,
389+
campaignEnrollmentStartAt: NOW,
390+
signal: controller.signal,
391+
},
392+
deps
393+
)
394+
).rejects.toThrow('activation cancelled');
395+
expect(deps.materializeCampaignEnrollment).not.toHaveBeenCalled();
396+
expect(deps.leaseDueJobs).not.toHaveBeenCalled();
397+
expect(deps.createDatabase().close).toHaveBeenCalledOnce();
319398
});
320399

321400
it.each([0, 26, 1.5])(

0 commit comments

Comments
 (0)