Skip to content

Commit a3fe9be

Browse files
committed
feat(growth): enrich install activations and report developer journeys
1 parent a350738 commit a3fe9be

21 files changed

Lines changed: 1572 additions & 57 deletions

apps/lifecycle/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ Deploy backend observation acceptance and bridge resolution with the rollout swi
1717

1818
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.
1919

20+
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.
21+
22+
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.
23+
2024
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.
2125

2226
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/campaign/send.spec.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
createUnsubscribeActionUrl,
3+
JobLeaseConflictError,
34
unsubscribeActionUrlValue,
45
type GrowthArtifact,
56
type GrowthJob,
@@ -392,6 +393,9 @@ function dependencies(
392393
return {
393394
now: () => NOW,
394395
readJobContext: vi.fn().mockResolvedValue(context()),
396+
readInstallRuntimeEnrichmentContext: vi
397+
.fn()
398+
.mockResolvedValue({ companyDomain: 'example.com' }),
395399
createUnsubscribeUrl: vi.fn(() => UNSUBSCRIBE),
396400
sendRecipient: vi.fn().mockResolvedValue({
397401
accepted: true,
@@ -641,6 +645,120 @@ describe('dispatchLifecycleAppOwnedJob', () => {
641645
expect(deps.completeJob).toHaveBeenCalledOnce();
642646
});
643647

648+
it('enriches an admitted install domain without inventing a form submission', async () => {
649+
const deps = dependencies({
650+
readJobContext: vi
651+
.fn()
652+
.mockResolvedValue(
653+
context({
654+
formSubmission: {},
655+
companyDomain: null,
656+
emailClassification: 'unknown',
657+
})
658+
),
659+
readInstallRuntimeEnrichmentContext: vi
660+
.fn()
661+
.mockResolvedValue({ companyDomain: 'neon.tech' }),
662+
});
663+
await expect(
664+
dispatchLifecycleAppOwnedJob(
665+
{} as SqlExecutor,
666+
job('enrich', { source: 'install_runtime' }),
667+
{},
668+
deps
669+
)
670+
).resolves.toBe('completed');
671+
expect(deps.fetchCompanyEvidence).toHaveBeenCalledWith(
672+
'neon.tech',
673+
expect.any(AbortSignal)
674+
);
675+
expect(deps.generateArtifact).toHaveBeenCalledWith(
676+
expect.objectContaining({
677+
formFacts: { source: 'install_runtime', companyDomain: 'neon.tech' },
678+
}),
679+
expect.any(AbortSignal)
680+
);
681+
expect(deps.readInstallRuntimeEnrichmentContext).toHaveBeenCalledTimes(2);
682+
expect(deps.sendRecipient).not.toHaveBeenCalled();
683+
});
684+
685+
it('cancels install enrichment if current evidence or contact eligibility is unavailable', async () => {
686+
const deps = dependencies({
687+
readInstallRuntimeEnrichmentContext: vi.fn().mockResolvedValue(null),
688+
});
689+
await expect(
690+
dispatchLifecycleAppOwnedJob(
691+
{} as SqlExecutor,
692+
job('enrich', { source: 'install_runtime' }),
693+
{},
694+
deps
695+
)
696+
).resolves.toBe('cancelled');
697+
expect(deps.fetchCompanyEvidence).not.toHaveBeenCalled();
698+
expect(deps.generateArtifact).not.toHaveBeenCalled();
699+
expect(deps.persistArtifact).not.toHaveBeenCalled();
700+
});
701+
702+
it('rechecks install eligibility after capture before making a model call', async () => {
703+
const deps = dependencies({
704+
readInstallRuntimeEnrichmentContext: vi
705+
.fn()
706+
.mockResolvedValueOnce({ companyDomain: 'neon.tech' })
707+
.mockResolvedValueOnce(null),
708+
});
709+
await expect(
710+
dispatchLifecycleAppOwnedJob(
711+
{} as SqlExecutor,
712+
job('enrich', { source: 'install_runtime' }),
713+
{},
714+
deps
715+
)
716+
).resolves.toBe('cancelled');
717+
expect(deps.fetchCompanyEvidence).toHaveBeenCalledOnce();
718+
expect(deps.generateArtifact).not.toHaveBeenCalled();
719+
expect(deps.persistArtifact).not.toHaveBeenCalled();
720+
});
721+
722+
it('abandons install enrichment when a stop has already revoked its lease', async () => {
723+
const deps = dependencies({
724+
readInstallRuntimeEnrichmentContext: vi
725+
.fn()
726+
.mockResolvedValueOnce({ companyDomain: 'neon.tech' })
727+
.mockResolvedValueOnce(null),
728+
cancelJob: vi.fn().mockRejectedValue(new JobLeaseConflictError(job().id)),
729+
});
730+
await expect(
731+
dispatchLifecycleAppOwnedJob(
732+
{} as SqlExecutor,
733+
job('enrich', { source: 'install_runtime' }),
734+
{},
735+
deps
736+
)
737+
).resolves.toBe('cancelled');
738+
expect(deps.cancelJob).toHaveBeenCalledOnce();
739+
expect(deps.deferJob).not.toHaveBeenCalled();
740+
expect(deps.failJob).not.toHaveBeenCalled();
741+
expect(deps.generateArtifact).not.toHaveBeenCalled();
742+
expect(deps.persistArtifact).not.toHaveBeenCalled();
743+
});
744+
745+
it('keeps ordinary database cancellation failures on the enrichment retry path', async () => {
746+
const deps = dependencies({
747+
readInstallRuntimeEnrichmentContext: vi.fn().mockResolvedValue(null),
748+
cancelJob: vi.fn().mockRejectedValue(new Error('database unavailable')),
749+
});
750+
await expect(
751+
dispatchLifecycleAppOwnedJob(
752+
{} as SqlExecutor,
753+
job('enrich', { source: 'install_runtime' }),
754+
{},
755+
deps
756+
)
757+
).resolves.toBe('deferred');
758+
expect(deps.deferJob).toHaveBeenCalledOnce();
759+
expect(deps.generateArtifact).not.toHaveBeenCalled();
760+
});
761+
644762
it('does not fetch company pages for the personal-email neutral path', async () => {
645763
const deps = dependencies({
646764
readJobContext: vi.fn().mockResolvedValue(

apps/lifecycle/src/campaign/send.ts

Lines changed: 93 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
createUnsubscribeActionUrl,
1010
deferLeasedJob,
1111
failLeasedJob,
12+
JobLeaseConflictError,
1213
loadGrowthTokenKeyring,
1314
markProviderAcceptanceUnknown,
1415
markInternalNotificationUnknown,
@@ -17,6 +18,7 @@ import {
1718
normalizeRecipientEmail,
1819
persistJobArtifact,
1920
readLifecycleJobContext,
21+
readInstallRuntimeEnrichmentContext,
2022
recordProviderAcceptance,
2123
RECIPIENT_EMAIL_SENDER,
2224
recomputeContactScore,
@@ -92,6 +94,7 @@ interface DeferLeasedJobInput extends LeasedTransitionInput {
9294
}
9395

9496
export interface LifecycleJobDependencies {
97+
readInstallRuntimeEnrichmentContext: typeof readInstallRuntimeEnrichmentContext;
9598
now: () => Date;
9699
readJobContext: (
97100
executor: SqlExecutor,
@@ -552,17 +555,35 @@ export async function dispatchLifecycleAppOwnedJob(
552555

553556
if (job.kind === 'enrich') {
554557
try {
558+
const installRuntime = job.payload['source'] === 'install_runtime';
559+
const installContext = installRuntime
560+
? await dependencies.readInstallRuntimeEnrichmentContext(executor, {
561+
jobId: job.id,
562+
leaseToken,
563+
now: dependencies.now(),
564+
})
565+
: null;
566+
if (installRuntime && !installContext) {
567+
await dependencies.cancelJob(executor, {
568+
jobId: job.id,
569+
leaseToken,
570+
now: dependencies.now(),
571+
errorCode: 'install_runtime_evidence_unavailable',
572+
});
573+
return 'cancelled';
574+
}
575+
const companyDomain = installRuntime
576+
? installContext?.companyDomain
577+
: context.companyDomain;
555578
const deterministicScore = await dependencies.readDeterministicScore(
556579
executor,
557580
context.contactId
558581
);
559582
signal.throwIfAborted();
560583
const companyPages =
561-
context.emailClassification !== 'personal' && context.companyDomain
562-
? await dependencies.fetchCompanyEvidence(
563-
context.companyDomain,
564-
signal
565-
)
584+
(installRuntime || context.emailClassification !== 'personal') &&
585+
companyDomain
586+
? await dependencies.fetchCompanyEvidence(companyDomain, signal)
566587
: [];
567588
signal.throwIfAborted();
568589
const paper = context.formSubmission['paper'];
@@ -571,40 +592,68 @@ export async function dispatchLifecycleAppOwnedJob(
571592
const timeline = context.formSubmission['timeline'];
572593
const researchInput = buildResearchInput({
573594
formFacts: {
574-
source: formSource(context.formSubmission['form_kind']),
575-
emailClassification: context.emailClassification,
576-
...(context.displayName ? { displayName: context.displayName } : {}),
577-
...(context.companyName ? { companyName: context.companyName } : {}),
578-
...(context.companyDomain
579-
? { companyDomain: context.companyDomain }
580-
: {}),
581-
...(paper === 'overview' ||
582-
paper === 'angular' ||
583-
paper === 'render' ||
584-
paper === 'chat'
585-
? { paper }
586-
: {}),
587-
...(pilotInterest === 'yes' ||
588-
pilotInterest === 'maybe' ||
589-
pilotInterest === 'no'
590-
? { pilotInterest }
591-
: {}),
592-
...(teamSize === '1-5' ||
593-
teamSize === '6-25' ||
594-
teamSize === '26-100' ||
595-
teamSize === '100+'
596-
? { teamSize }
597-
: {}),
598-
...(timeline === 'this_quarter' ||
599-
timeline === 'next_quarter' ||
600-
timeline === '6_plus_months' ||
601-
timeline === 'exploring'
602-
? { timeline }
603-
: {}),
595+
...(installRuntime
596+
? {
597+
source: 'install_runtime',
598+
emailClassification: 'unknown',
599+
companyDomain,
600+
}
601+
: {
602+
source: formSource(context.formSubmission['form_kind']),
603+
emailClassification: context.emailClassification,
604+
...(context.displayName
605+
? { displayName: context.displayName }
606+
: {}),
607+
...(context.companyName
608+
? { companyName: context.companyName }
609+
: {}),
610+
...(context.companyDomain
611+
? { companyDomain: context.companyDomain }
612+
: {}),
613+
...(paper === 'overview' ||
614+
paper === 'angular' ||
615+
paper === 'render' ||
616+
paper === 'chat'
617+
? { paper }
618+
: {}),
619+
...(pilotInterest === 'yes' ||
620+
pilotInterest === 'maybe' ||
621+
pilotInterest === 'no'
622+
? { pilotInterest }
623+
: {}),
624+
...(teamSize === '1-5' ||
625+
teamSize === '6-25' ||
626+
teamSize === '26-100' ||
627+
teamSize === '100+'
628+
? { teamSize }
629+
: {}),
630+
...(timeline === 'this_quarter' ||
631+
timeline === 'next_quarter' ||
632+
timeline === '6_plus_months' ||
633+
timeline === 'exploring'
634+
? { timeline }
635+
: {}),
636+
}),
604637
},
605638
deterministicScore,
606639
companyPages,
607640
});
641+
if (installRuntime) {
642+
const current = await dependencies.readInstallRuntimeEnrichmentContext(
643+
executor,
644+
{ jobId: job.id, leaseToken, now: dependencies.now() }
645+
);
646+
signal.throwIfAborted();
647+
if (!current || current.companyDomain !== companyDomain) {
648+
await dependencies.cancelJob(executor, {
649+
jobId: job.id,
650+
leaseToken,
651+
now: dependencies.now(),
652+
errorCode: 'install_runtime_evidence_unavailable',
653+
});
654+
return 'cancelled';
655+
}
656+
}
608657
const artifact = await dependencies.generateArtifact(
609658
researchInput,
610659
signal
@@ -629,6 +678,14 @@ export async function dispatchLifecycleAppOwnedJob(
629678
} catch (error) {
630679
signal.throwIfAborted();
631680
if (error instanceof DeterministicLifecycleJobError) throw error;
681+
if (
682+
job.payload['source'] === 'install_runtime' &&
683+
error instanceof JobLeaseConflictError
684+
) {
685+
// Stop/redaction or another worker already owns the durable state.
686+
// Cancel this dispatch without attempting another transition on its revoked lease.
687+
return 'cancelled';
688+
}
632689
if (job.attempts < 2) {
633690
const retryAt = dependencies.now();
634691
await dependencies.deferJob(executor, {
@@ -899,6 +956,7 @@ export function createDefaultLifecycleJobDependencies(
899956
return {
900957
now,
901958
readJobContext: readLifecycleJobContext,
959+
readInstallRuntimeEnrichmentContext,
902960
createUnsubscribeUrl: (input, key) =>
903961
createUnsubscribeActionUrl(input, key, mailRuntime().publicActionOrigin),
904962
sendRecipient: (executor, input, policy) => {

apps/lifecycle/src/enrichment/research-input.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,12 @@
11
import { z } from 'zod';
2+
import { isPersonalEmailDomain } from '../growth.js';
23

34
import {
45
CompanyPageEvidenceSchema,
56
DeterministicScoreReasonSchema,
67
type CompanyPageEvidence,
78
} from './schema.js';
89

9-
const PERSONAL_EMAIL_DOMAINS = new Set([
10-
'aol.com',
11-
'gmail.com',
12-
'googlemail.com',
13-
'hotmail.com',
14-
'icloud.com',
15-
'live.com',
16-
'me.com',
17-
'msn.com',
18-
'outlook.com',
19-
'proton.me',
20-
'protonmail.com',
21-
'yahoo.com',
22-
'ymail.com',
23-
]);
24-
2510
const DomainSchema = z
2611
.string()
2712
.min(3)
@@ -39,6 +24,7 @@ const FormFactsSchema = z
3924
'contact',
4025
'pricing',
4126
'project-claim',
27+
'install_runtime',
4228
]),
4329
emailClassification: z.enum(['work', 'personal', 'unknown']),
4430
displayName: z.string().min(1).max(120).optional(),
@@ -106,7 +92,7 @@ export function buildResearchInput(candidate: unknown): ResearchInput {
10692
const researchMode =
10793
emailClassification !== 'personal' &&
10894
domain &&
109-
!PERSONAL_EMAIL_DOMAINS.has(domain)
95+
!isPersonalEmailDomain(domain)
11096
? 'company'
11197
: 'neutral';
11298

libs/growth/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Internal growth operator reports
2+
3+
Run the existing CLI with the intended read-only database credentials:
4+
5+
```sh
6+
npm run growth:observability -- funnel --from 2026-09-01T00:00:00Z --to 2026-09-05T00:00:00Z
7+
npm run growth:observability -- journey --contact 11111111-1111-4111-8111-111111111111
8+
```
9+
10+
`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.
11+
12+
`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.
13+
14+
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.

0 commit comments

Comments
 (0)