Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/lifecycle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ The service has two database boundaries:

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.

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.

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.

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.

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.

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.

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.
Expand Down
1 change: 1 addition & 0 deletions apps/lifecycle/src/app/dispatch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export async function workflow(
batchSize: configuredBatchSize(),
campaignEnabled: configuration.campaignEnabled,
campaignEnrollmentEnabled: configuration.campaignEnrollmentEnabled,
installRuntimeHelloEnabled: configuration.installRuntimeHelloEnabled,
campaignEnrollmentStartAt: configuration.campaignEnrollmentStartAt,
signal: context.signal,
});
Expand Down
160 changes: 159 additions & 1 deletion apps/lifecycle/src/campaign/send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,56 @@ function context(
}

describe('prepareCampaignMessage', () => {
it('prepares the install-runtime hello immediately without research', () => {
expect(
prepareCampaignMessage({
context: {
...context({ enrichmentArtifact: null }),
campaignEnrollmentReason: 'install_runtime',
},
job: job('send_step', { campaign_version: 'v1', step: 1 }),
now: new Date('2026-09-01T12:00:00.000Z'),
unsubscribeUrl: UNSUBSCRIBE,
})
).toMatchObject({ status: 'ready', subject: 'A practical place to start' });
});

it.each([1, 2, 3] as const)(
'keeps install-runtime step %i generic even when research is available',
(step) => {
const prepared = prepareCampaignMessage({
context: { ...context(), campaignEnrollmentReason: 'install_runtime' },
job: job('send_step', { campaign_version: 'v1', step }),
now: NOW,
unsubscribeUrl: UNSUBSCRIBE,
});
expect(prepared).toMatchObject({
status: 'ready',
subject: [
'A practical place to start',
'One debugging shortcut',
'One last architecture note',
][step - 1],
});
if (prepared.status !== 'ready') throw new Error('expected ready');
expect(prepared.text).toContain(unsubscribeActionUrlValue(UNSUBSCRIBE));
expect(prepared.text).toContain('\n\n—\nBrian\n');
if (step === 3)
expect(prepared.text).toContain('This is my last automated follow-up.');
}
);

it('rejects a fourth install-runtime sequence step', () => {
expect(() =>
prepareCampaignMessage({
context: { ...context(), campaignEnrollmentReason: 'install_runtime' },
job: job('send_step', { campaign_version: 'v1', step: 4 }),
now: NOW,
unsubscribeUrl: UNSUBSCRIBE,
})
).toThrow(DeterministicLifecycleJobError);
});

it('renders only a closed evidence-linked angle selection deterministically', () => {
const cited = artifact({
cited_signals: [
Expand Down Expand Up @@ -358,6 +408,93 @@ function dependencies(
}

describe('dispatchLifecycleAppOwnedJob', () => {
it('sends the install-runtime hello through the shared recipient boundary without research', async () => {
const deps = dependencies({
readJobContext: vi.fn().mockResolvedValue(
context({
campaignEnrollmentReason: 'install_runtime',
enrichmentArtifact: null,
})
),
});
const send = job('send_step', { campaign_version: 'v1', step: 1 });
await expect(
dispatchLifecycleAppOwnedJob({} as SqlExecutor, send, {}, deps)
).resolves.toBe('completed');
expect(deps.sendRecipient).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
jobId: send.id,
leaseToken: LEASE_TOKEN,
subject: 'A practical place to start',
unsubscribeUrl: UNSUBSCRIBE,
}),
deps.recipientPolicy
);
expect(deps.deferJob).not.toHaveBeenCalled();
expect(deps.fetchCompanyEvidence).not.toHaveBeenCalled();
expect(deps.generateArtifact).not.toHaveBeenCalled();
});

it.each([
'contact_stopped',
'contact_unapproved',
'contact_deleted',
] as const)(
'preserves the shared %s delivery stop for an install-runtime hello',
async (reason) => {
const deps = dependencies({
readJobContext: vi.fn().mockResolvedValue(
context({
campaignEnrollmentReason: 'install_runtime',
enrichmentArtifact: null,
})
),
sendRecipient: vi.fn().mockResolvedValue({ accepted: false, reason }),
});
await expect(
dispatchLifecycleAppOwnedJob(
{} as SqlExecutor,
job('send_step', { campaign_version: 'v1', step: 1 }),
{},
deps
)
).resolves.toBe('cancelled');
expect(deps.cancelJob).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ errorCode: reason })
);
expect(deps.generateArtifact).not.toHaveBeenCalled();
}
);

it.each(['campaign_disabled', 'delivery_disabled'] as const)(
'keeps an install-runtime hello deferred while %s',
async (reason) => {
const deps = dependencies({
readJobContext: vi.fn().mockResolvedValue(
context({
campaignEnrollmentReason: 'install_runtime',
enrichmentArtifact: null,
})
),
sendRecipient: vi.fn().mockResolvedValue({ accepted: false, reason }),
});
await expect(
dispatchLifecycleAppOwnedJob(
{} as SqlExecutor,
job('send_step', { campaign_version: 'v1', step: 1 }),
{},
deps
)
).resolves.toBe('deferred');
expect(deps.deferJob).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ errorCode: reason })
);
}
);

it('fulfills the persisted form request through the recipient boundary', async () => {
const deps = dependencies();
const fulfill = job('fulfill', {
Expand Down Expand Up @@ -766,14 +903,35 @@ describe('loadLifecycleRuntimeConfiguration', () => {
});
});

it('defaults all three delivery switches off', () => {
it('defaults delivery and install-runtime activation switches off', () => {
expect(loadLifecycleRuntimeConfiguration({})).toMatchObject({
campaignEnrollmentEnabled: false,
campaignEnabled: false,
deliveryEnabled: false,
installRuntimeHelloEnabled: false,
});
});

it('enables install-runtime hello only with the exact configured boolean', () => {
expect(
loadLifecycleRuntimeConfiguration({
GROWTH_INSTALL_RUNTIME_HELLO_ENABLED: 'true',
})
).toMatchObject({ installRuntimeHelloEnabled: true });
expect(
loadLifecycleRuntimeConfiguration({
GROWTH_INSTALL_RUNTIME_HELLO_ENABLED: 'false',
})
).toMatchObject({ installRuntimeHelloEnabled: false });
for (const value of ['TRUE', '1', ' true ', '']) {
expect(() =>
loadLifecycleRuntimeConfiguration({
GROWTH_INSTALL_RUNTIME_HELLO_ENABLED: value,
})
).toThrow(/GROWTH_INSTALL_RUNTIME_HELLO_ENABLED/);
}
});

it('runs enrichment with every mail environment variable absent and delivery disabled', async () => {
const deps = createDefaultLifecycleJobDependencies({
CAMPAIGN_ENROLLMENT_ENABLED: 'false',
Expand Down
18 changes: 13 additions & 5 deletions apps/lifecycle/src/campaign/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export interface LifecycleJobContext {
emailClassification: 'work' | 'personal' | 'unknown';
formSubmission: Record<string, unknown>;
enrollmentAt: Date | null;
campaignEnrollmentReason?: 'install_runtime' | null;
enrichmentArtifact: GrowthArtifact | null;
}

Expand Down Expand Up @@ -174,6 +175,7 @@ export interface LifecycleJobDependencies {

export interface LifecycleRuntimeConfiguration {
campaignEnrollmentEnabled: boolean;
installRuntimeHelloEnabled: boolean;
campaignEnrollmentStartAt?: Date;
campaignEnabled: boolean;
deliveryEnabled: boolean;
Expand Down Expand Up @@ -264,11 +266,12 @@ export function prepareCampaignMessage(input: {
unsubscribeUrl: UnsubscribeActionUrl;
}): PreparedCampaignMessage {
const step = campaignStep(input.job);
const artifact = validArtifact(
input.context.enrichmentArtifact,
input.context.contactId
);
if (step === 1 && !artifact) {
const genericHello =
input.context.campaignEnrollmentReason === 'install_runtime';
const artifact = genericHello
? null
: validArtifact(input.context.enrichmentArtifact, input.context.contactId);
if (step === 1 && !artifact && !genericHello) {
if (!input.context.enrollmentAt) {
throw new DeterministicLifecycleJobError(
'Campaign enrollment timestamp is required'
Expand Down Expand Up @@ -662,6 +665,10 @@ export function loadLifecycleRuntimeConfiguration(
'CAMPAIGN_ENROLLMENT_ENABLED'
);
const campaignEnabled = exactBoolean(environment, 'CAMPAIGN_ENABLED');
const installRuntimeHelloEnabled = exactBoolean(
environment,
'GROWTH_INSTALL_RUNTIME_HELLO_ENABLED'
);
const deliveryEnabled = exactBoolean(environment, 'DELIVERY_ENABLED');
let campaignEnrollmentStartAt: Date | undefined;
if (campaignEnrollmentEnabled) {
Expand All @@ -682,6 +689,7 @@ export function loadLifecycleRuntimeConfiguration(
}
return {
campaignEnrollmentEnabled,
installRuntimeHelloEnabled,
...(campaignEnrollmentStartAt ? { campaignEnrollmentStartAt } : {}),
campaignEnabled,
deliveryEnabled,
Expand Down
79 changes: 79 additions & 0 deletions apps/lifecycle/src/dispatcher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import {
} from './campaign/send.js';

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

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

Expand Down Expand Up @@ -61,6 +64,13 @@ function dependencies(
dispatchLeasedJob: vi.fn().mockResolvedValue('completed'),
isRecoveryPaused: vi.fn().mockResolvedValue(false),
leaseDueJobs: vi.fn().mockResolvedValue([]),
loadEmailKeyring: vi.fn(() => EMAIL_KEYRING),
processInstallRuntimeActivations: vi.fn().mockResolvedValue({
approved: 0,
ineligible: 0,
conflicted: 0,
disabled: false,
}),
materializeCampaignEnrollment: vi.fn().mockResolvedValue({
enrolledContactIds: [],
createdJobs: 0,
Expand Down Expand Up @@ -280,6 +290,7 @@ describe('dispatchLifecycleJobs', () => {
batchSize: 10,
campaignEnabled: false,
campaignEnrollmentEnabled: true,
installRuntimeHelloEnabled: true,
campaignEnrollmentStartAt: start,
signal: new AbortController().signal,
},
Expand All @@ -295,13 +306,52 @@ describe('dispatchLifecycleJobs', () => {
batchSize: 10,
}
);
expect(deps.loadEmailKeyring).toHaveBeenCalledOnce();
expect(deps.processInstallRuntimeActivations).toHaveBeenCalledWith(
expect.anything(),
{ enabled: true, limit: 10, now: NOW, keyring: EMAIL_KEYRING }
);
expect(
vi.mocked(deps.processInstallRuntimeActivations).mock
.invocationCallOrder[0]
).toBeLessThan(
materializeCampaignEnrollment.mock.invocationCallOrder[0] ?? 0
);
expect(
materializeCampaignEnrollment.mock.invocationCallOrder[0]
).toBeLessThan(
leaseDueJobs.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
);
});

it.each([undefined, false])(
'keeps form and claim enrollment working without new keys when hello rollout is %s',
async (installRuntimeHelloEnabled) => {
const deps = dependencies({
loadEmailKeyring: vi.fn(() => {
throw new Error('new HMAC keys are not configured');
}),
});
await expect(
dispatchLifecycleJobs(
{
batchSize: 10,
campaignEnabled: true,
campaignEnrollmentEnabled: true,
installRuntimeHelloEnabled,
campaignEnrollmentStartAt: NOW,
signal: new AbortController().signal,
},
deps
)
).resolves.toMatchObject({ leased: 0 });
expect(deps.materializeCampaignEnrollment).toHaveBeenCalledOnce();
expect(deps.leaseDueJobs).toHaveBeenCalledOnce();
expect(deps.loadEmailKeyring).not.toHaveBeenCalled();
expect(deps.processInstallRuntimeActivations).not.toHaveBeenCalled();
}
);

it('does no enrollment work when enrollment is disabled', async () => {
const deps = dependencies();

Expand All @@ -310,12 +360,41 @@ describe('dispatchLifecycleJobs', () => {
batchSize: 10,
campaignEnabled: true,
campaignEnrollmentEnabled: false,
installRuntimeHelloEnabled: true,
signal: new AbortController().signal,
},
deps
);

expect(deps.materializeCampaignEnrollment).not.toHaveBeenCalled();
expect(deps.processInstallRuntimeActivations).not.toHaveBeenCalled();
expect(deps.loadEmailKeyring).not.toHaveBeenCalled();
});

it('stops before enrollment and leasing if activation processing is cancelled', async () => {
const controller = new AbortController();
const deps = dependencies({
processInstallRuntimeActivations: vi.fn().mockImplementation(async () => {
controller.abort(new Error('activation cancelled'));
return { approved: 0, ineligible: 0, conflicted: 0, disabled: false };
}),
});
await expect(
dispatchLifecycleJobs(
{
batchSize: 10,
campaignEnabled: true,
campaignEnrollmentEnabled: true,
installRuntimeHelloEnabled: true,
campaignEnrollmentStartAt: NOW,
signal: controller.signal,
},
deps
)
).rejects.toThrow('activation cancelled');
expect(deps.materializeCampaignEnrollment).not.toHaveBeenCalled();
expect(deps.leaseDueJobs).not.toHaveBeenCalled();
expect(deps.createDatabase().close).toHaveBeenCalledOnce();
});

it.each([0, 26, 1.5])(
Expand Down
Loading
Loading