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
4 changes: 4 additions & 0 deletions apps/lifecycle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ Deploy backend observation acceptance and bridge resolution with the rollout swi

All three campaign steps are founder session offers and send without waiting for an enrichment artifact; a cited research angle only selects an angle-flavored version of the same offer. A persisted `install_runtime` enrollment reason keeps all three steps 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.

An eligible install/runtime link also queues at most one optional company-enrichment job per contact when the admitted install email has a valid non-personal domain. It uses the existing enrichment worker, capture provider and artifact schema. Its payload contains observation references and an explicit `install_runtime` source; it does not invent a form submission or verified company association. The worker rechecks contact approval, stops, lease, and linked evidence before capture and again before model execution. Persistence checks these controls again, and evidence redaction cancels affected work and removes retained artifacts. A skipped or failed enrichment job does not delay the generic hello sequence.

Use the [growth operator reports](../../libs/growth/README.md) for the bounded funnel and contact journey. Collection and activation can precede fact projection; the report exposes pending observation work. Projection currently runs through the existing operator command rather than the lifecycle tick.

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
118 changes: 118 additions & 0 deletions apps/lifecycle/src/campaign/send.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
createUnsubscribeActionUrl,
JobLeaseConflictError,
unsubscribeActionUrlValue,
type GrowthArtifact,
type GrowthJob,
Expand Down Expand Up @@ -392,6 +393,9 @@ function dependencies(
return {
now: () => NOW,
readJobContext: vi.fn().mockResolvedValue(context()),
readInstallRuntimeEnrichmentContext: vi
.fn()
.mockResolvedValue({ companyDomain: 'example.com' }),
createUnsubscribeUrl: vi.fn(() => UNSUBSCRIBE),
sendRecipient: vi.fn().mockResolvedValue({
accepted: true,
Expand Down Expand Up @@ -641,6 +645,120 @@ describe('dispatchLifecycleAppOwnedJob', () => {
expect(deps.completeJob).toHaveBeenCalledOnce();
});

it('enriches an admitted install domain without inventing a form submission', async () => {
const deps = dependencies({
readJobContext: vi
.fn()
.mockResolvedValue(
context({
formSubmission: {},
companyDomain: null,
emailClassification: 'unknown',
})
),
readInstallRuntimeEnrichmentContext: vi
.fn()
.mockResolvedValue({ companyDomain: 'neon.tech' }),
});
await expect(
dispatchLifecycleAppOwnedJob(
{} as SqlExecutor,
job('enrich', { source: 'install_runtime' }),
{},
deps
)
).resolves.toBe('completed');
expect(deps.fetchCompanyEvidence).toHaveBeenCalledWith(
'neon.tech',
expect.any(AbortSignal)
);
expect(deps.generateArtifact).toHaveBeenCalledWith(
expect.objectContaining({
formFacts: { source: 'install_runtime', companyDomain: 'neon.tech' },
}),
expect.any(AbortSignal)
);
expect(deps.readInstallRuntimeEnrichmentContext).toHaveBeenCalledTimes(2);
expect(deps.sendRecipient).not.toHaveBeenCalled();
});

it('cancels install enrichment if current evidence or contact eligibility is unavailable', async () => {
const deps = dependencies({
readInstallRuntimeEnrichmentContext: vi.fn().mockResolvedValue(null),
});
await expect(
dispatchLifecycleAppOwnedJob(
{} as SqlExecutor,
job('enrich', { source: 'install_runtime' }),
{},
deps
)
).resolves.toBe('cancelled');
expect(deps.fetchCompanyEvidence).not.toHaveBeenCalled();
expect(deps.generateArtifact).not.toHaveBeenCalled();
expect(deps.persistArtifact).not.toHaveBeenCalled();
});

it('rechecks install eligibility after capture before making a model call', async () => {
const deps = dependencies({
readInstallRuntimeEnrichmentContext: vi
.fn()
.mockResolvedValueOnce({ companyDomain: 'neon.tech' })
.mockResolvedValueOnce(null),
});
await expect(
dispatchLifecycleAppOwnedJob(
{} as SqlExecutor,
job('enrich', { source: 'install_runtime' }),
{},
deps
)
).resolves.toBe('cancelled');
expect(deps.fetchCompanyEvidence).toHaveBeenCalledOnce();
expect(deps.generateArtifact).not.toHaveBeenCalled();
expect(deps.persistArtifact).not.toHaveBeenCalled();
});

it('abandons install enrichment when a stop has already revoked its lease', async () => {
const deps = dependencies({
readInstallRuntimeEnrichmentContext: vi
.fn()
.mockResolvedValueOnce({ companyDomain: 'neon.tech' })
.mockResolvedValueOnce(null),
cancelJob: vi.fn().mockRejectedValue(new JobLeaseConflictError(job().id)),
});
await expect(
dispatchLifecycleAppOwnedJob(
{} as SqlExecutor,
job('enrich', { source: 'install_runtime' }),
{},
deps
)
).resolves.toBe('cancelled');
expect(deps.cancelJob).toHaveBeenCalledOnce();
expect(deps.deferJob).not.toHaveBeenCalled();
expect(deps.failJob).not.toHaveBeenCalled();
expect(deps.generateArtifact).not.toHaveBeenCalled();
expect(deps.persistArtifact).not.toHaveBeenCalled();
});

it('keeps ordinary database cancellation failures on the enrichment retry path', async () => {
const deps = dependencies({
readInstallRuntimeEnrichmentContext: vi.fn().mockResolvedValue(null),
cancelJob: vi.fn().mockRejectedValue(new Error('database unavailable')),
});
await expect(
dispatchLifecycleAppOwnedJob(
{} as SqlExecutor,
job('enrich', { source: 'install_runtime' }),
{},
deps
)
).resolves.toBe('deferred');
expect(deps.deferJob).toHaveBeenCalledOnce();
expect(deps.generateArtifact).not.toHaveBeenCalled();
});

it('does not fetch company pages for the personal-email neutral path', async () => {
const deps = dependencies({
readJobContext: vi.fn().mockResolvedValue(
Expand Down
128 changes: 93 additions & 35 deletions apps/lifecycle/src/campaign/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createUnsubscribeActionUrl,
deferLeasedJob,
failLeasedJob,
JobLeaseConflictError,
loadGrowthTokenKeyring,
markProviderAcceptanceUnknown,
markInternalNotificationUnknown,
Expand All @@ -17,6 +18,7 @@ import {
normalizeRecipientEmail,
persistJobArtifact,
readLifecycleJobContext,
readInstallRuntimeEnrichmentContext,
recordProviderAcceptance,
RECIPIENT_EMAIL_SENDER,
recomputeContactScore,
Expand Down Expand Up @@ -92,6 +94,7 @@ interface DeferLeasedJobInput extends LeasedTransitionInput {
}

export interface LifecycleJobDependencies {
readInstallRuntimeEnrichmentContext: typeof readInstallRuntimeEnrichmentContext;
now: () => Date;
readJobContext: (
executor: SqlExecutor,
Expand Down Expand Up @@ -552,17 +555,35 @@ export async function dispatchLifecycleAppOwnedJob(

if (job.kind === 'enrich') {
try {
const installRuntime = job.payload['source'] === 'install_runtime';
const installContext = installRuntime
? await dependencies.readInstallRuntimeEnrichmentContext(executor, {
jobId: job.id,
leaseToken,
now: dependencies.now(),
})
: null;
if (installRuntime && !installContext) {
await dependencies.cancelJob(executor, {
jobId: job.id,
leaseToken,
now: dependencies.now(),
errorCode: 'install_runtime_evidence_unavailable',
});
return 'cancelled';
}
const companyDomain = installRuntime
? installContext?.companyDomain
: context.companyDomain;
const deterministicScore = await dependencies.readDeterministicScore(
executor,
context.contactId
);
signal.throwIfAborted();
const companyPages =
context.emailClassification !== 'personal' && context.companyDomain
? await dependencies.fetchCompanyEvidence(
context.companyDomain,
signal
)
(installRuntime || context.emailClassification !== 'personal') &&
companyDomain
? await dependencies.fetchCompanyEvidence(companyDomain, signal)
: [];
signal.throwIfAborted();
const paper = context.formSubmission['paper'];
Expand All @@ -571,40 +592,68 @@ export async function dispatchLifecycleAppOwnedJob(
const timeline = context.formSubmission['timeline'];
const researchInput = buildResearchInput({
formFacts: {
source: formSource(context.formSubmission['form_kind']),
emailClassification: context.emailClassification,
...(context.displayName ? { displayName: context.displayName } : {}),
...(context.companyName ? { companyName: context.companyName } : {}),
...(context.companyDomain
? { companyDomain: context.companyDomain }
: {}),
...(paper === 'overview' ||
paper === 'angular' ||
paper === 'render' ||
paper === 'chat'
? { paper }
: {}),
...(pilotInterest === 'yes' ||
pilotInterest === 'maybe' ||
pilotInterest === 'no'
? { pilotInterest }
: {}),
...(teamSize === '1-5' ||
teamSize === '6-25' ||
teamSize === '26-100' ||
teamSize === '100+'
? { teamSize }
: {}),
...(timeline === 'this_quarter' ||
timeline === 'next_quarter' ||
timeline === '6_plus_months' ||
timeline === 'exploring'
? { timeline }
: {}),
...(installRuntime
? {
source: 'install_runtime',
emailClassification: 'unknown',
companyDomain,
}
: {
source: formSource(context.formSubmission['form_kind']),
emailClassification: context.emailClassification,
...(context.displayName
? { displayName: context.displayName }
: {}),
...(context.companyName
? { companyName: context.companyName }
: {}),
...(context.companyDomain
? { companyDomain: context.companyDomain }
: {}),
...(paper === 'overview' ||
paper === 'angular' ||
paper === 'render' ||
paper === 'chat'
? { paper }
: {}),
...(pilotInterest === 'yes' ||
pilotInterest === 'maybe' ||
pilotInterest === 'no'
? { pilotInterest }
: {}),
...(teamSize === '1-5' ||
teamSize === '6-25' ||
teamSize === '26-100' ||
teamSize === '100+'
? { teamSize }
: {}),
...(timeline === 'this_quarter' ||
timeline === 'next_quarter' ||
timeline === '6_plus_months' ||
timeline === 'exploring'
? { timeline }
: {}),
}),
},
deterministicScore,
companyPages,
});
if (installRuntime) {
const current = await dependencies.readInstallRuntimeEnrichmentContext(
executor,
{ jobId: job.id, leaseToken, now: dependencies.now() }
);
signal.throwIfAborted();
if (!current || current.companyDomain !== companyDomain) {
await dependencies.cancelJob(executor, {
jobId: job.id,
leaseToken,
now: dependencies.now(),
errorCode: 'install_runtime_evidence_unavailable',
});
return 'cancelled';
}
}
const artifact = await dependencies.generateArtifact(
researchInput,
signal
Expand All @@ -629,6 +678,14 @@ export async function dispatchLifecycleAppOwnedJob(
} catch (error) {
signal.throwIfAborted();
if (error instanceof DeterministicLifecycleJobError) throw error;
if (
job.payload['source'] === 'install_runtime' &&
error instanceof JobLeaseConflictError
) {
// Stop/redaction or another worker already owns the durable state.
// Cancel this dispatch without attempting another transition on its revoked lease.
return 'cancelled';
}
if (job.attempts < 2) {
const retryAt = dependencies.now();
await dependencies.deferJob(executor, {
Expand Down Expand Up @@ -899,6 +956,7 @@ export function createDefaultLifecycleJobDependencies(
return {
now,
readJobContext: readLifecycleJobContext,
readInstallRuntimeEnrichmentContext,
createUnsubscribeUrl: (input, key) =>
createUnsubscribeActionUrl(input, key, mailRuntime().publicActionOrigin),
sendRecipient: (executor, input, policy) => {
Expand Down
20 changes: 3 additions & 17 deletions apps/lifecycle/src/enrichment/research-input.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,12 @@
import { z } from 'zod';
import { isPersonalEmailDomain } from '../growth.js';

import {
CompanyPageEvidenceSchema,
DeterministicScoreReasonSchema,
type CompanyPageEvidence,
} from './schema.js';

const PERSONAL_EMAIL_DOMAINS = new Set([
'aol.com',
'gmail.com',
'googlemail.com',
'hotmail.com',
'icloud.com',
'live.com',
'me.com',
'msn.com',
'outlook.com',
'proton.me',
'protonmail.com',
'yahoo.com',
'ymail.com',
]);

const DomainSchema = z
.string()
.min(3)
Expand All @@ -39,6 +24,7 @@ const FormFactsSchema = z
'contact',
'pricing',
'project-claim',
'install_runtime',
]),
emailClassification: z.enum(['work', 'personal', 'unknown']),
displayName: z.string().min(1).max(120).optional(),
Expand Down Expand Up @@ -106,7 +92,7 @@ export function buildResearchInput(candidate: unknown): ResearchInput {
const researchMode =
emailClassification !== 'personal' &&
domain &&
!PERSONAL_EMAIL_DOMAINS.has(domain)
!isPersonalEmailDomain(domain)
? 'company'
: 'neutral';

Expand Down
14 changes: 14 additions & 0 deletions libs/growth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Internal growth operator reports

Run the existing CLI with the intended read-only database credentials:

```sh
npm run growth:observability -- funnel --from 2026-09-01T00:00:00Z --to 2026-09-05T00:00:00Z
npm run growth:observability -- journey --contact 11111111-1111-4111-8111-111111111111
```

`funnel` requires a positive UTC date range of at most 31 days, with an exclusive end. Independent observation/subject counts are grouped by source, kind, and install environment. Processing status reflects the current state of observations received in that window. Activation decisions use their evaluation timestamps. The linked cohort consists only of distinct contacts in those persisted decisions; its campaign outcomes include all recorded history as of the read and its authorization/stops reflect current control state (approval prerequisites, not full campaign eligibility). These counts are not a sequential conversion rate or anonymous website attribution.

`journey` accepts one opaque contact UUID. It shows the latest 50 directly linked observations, activation decisions, jobs, and activity records, and the latest enrichment artifact's company profile and up to three source references. Each section declares its limit and truncation; missing records return `no_evidence`, an unknown contact returns `not_found`, and a deleted contact returns `redacted` with control state only. Missing profile fields mean unavailable. Profiles are candidate-domain research, not verified employment.

Both commands are read-only and need neither the processing switch nor the email HMAC keyring. Output excludes plaintext contact/install identity, full job payloads, activity data, email drafts, and raw artifact contents. Only explicit job provenance fields are shown. Source links omit query strings and fragments; unsafe or identity-bearing links are unavailable. Persisted provider results may lag delivery; ingress rejection details require service logs. Reads are not a transaction snapshot.
Loading
Loading