diff --git a/apps/lifecycle/README.md b/apps/lifecycle/README.md index 71e664999..ce2bbe151 100644 --- a/apps/lifecycle/README.md +++ b/apps/lifecycle/README.md @@ -31,6 +31,23 @@ Keep `LIFECYCLE_CRON_ENABLED` unset or set to anything other than the exact valu Use [DOGFOOD.md](./DOGFOOD.md) for the provider-free setup, probe, and exact cleanup commands. The harness binds the growth target to a database-owned comment sentinel and binds each authenticated lifecycle health response to Vercel's `VERCEL_DEPLOYMENT_ID`; it also validates lifecycle origins in memory before making requests. +## Founder campaign schedule + +Campaign email uses `America/Los_Angeles` calendar dates. Enrollment schedules +the first email for 07:00 on the next weekday, even if enrollment happens before +07:00 that day. The second email is due three business days after actual provider +acceptance of the first; the third is due five business days after acceptance of +the second. Weekends are skipped; public holidays are not excluded in V1. +Each target date resolves its own Pacific offset, preserving 07:00 across DST. + +Due times are persisted in Growth jobs. The existing cron leases campaign sends +only Monday–Friday during 07:00–08:00 Pacific; final authorization and provider +submission recheck the window. Normal sends begin on the first successful cron +tick after 07:00. Retries can run within that hour; missed windows wait until the +next weekday morning. Stops, mailbox recovery and ambiguous provider acceptance +remain authoritative. Requested fulfillment and internal notifications do not +use this campaign window. Replayed acceptance cannot move later jobs earlier. + ## Company evidence capture Company enrichment uses Dawn. Set `GROWTH_DAWN_ENRICHMENT_ENABLED=false` to pause diff --git a/apps/lifecycle/src/campaign/send.spec.ts b/apps/lifecycle/src/campaign/send.spec.ts index 2baee8cb6..f79743eb3 100644 --- a/apps/lifecycle/src/campaign/send.spec.ts +++ b/apps/lifecycle/src/campaign/send.spec.ts @@ -500,7 +500,11 @@ describe('dispatchLifecycleAppOwnedJob', () => { } ); - it.each(['campaign_disabled', 'delivery_disabled'] as const)( + it.each([ + 'campaign_disabled', + 'delivery_disabled', + 'outside_send_window', + ] as const)( 'keeps an install-runtime hello deferred while %s', async (reason) => { const deps = dependencies({ diff --git a/apps/lifecycle/src/campaign/send.ts b/apps/lifecycle/src/campaign/send.ts index 16ee9f266..7b1ac7b5c 100644 --- a/apps/lifecycle/src/campaign/send.ts +++ b/apps/lifecycle/src/campaign/send.ts @@ -398,7 +398,8 @@ async function dispatchRecipient( if (result.reason === 'mailbox_recovery_required') return 'recovery_paused'; if ( result.reason === 'campaign_disabled' || - result.reason === 'delivery_disabled' + result.reason === 'delivery_disabled' || + result.reason === 'outside_send_window' ) { const now = dependencies.now(); await dependencies.deferJob(executor, { diff --git a/libs/growth/src/lib/campaign-schedule.spec.ts b/libs/growth/src/lib/campaign-schedule.spec.ts new file mode 100644 index 000000000..9b997dd36 --- /dev/null +++ b/libs/growth/src/lib/campaign-schedule.spec.ts @@ -0,0 +1,39 @@ +import { + businessMorningAfter, + isCampaignSendWindow, +} from './campaign-schedule.ts'; + +describe('Pacific campaign calendar', () => { + it.each([ + ['2026-09-07T12:00:00Z', 1, '2026-09-08T14:00:00.000Z'], + ['2026-09-11T18:00:00Z', 1, '2026-09-14T14:00:00.000Z'], + ['2026-09-12T18:00:00Z', 1, '2026-09-14T14:00:00.000Z'], + ['2026-09-13T18:00:00Z', 1, '2026-09-14T14:00:00.000Z'], + ['2026-09-08T14:01:00Z', 3, '2026-09-11T14:00:00.000Z'], + ['2026-09-11T14:01:00Z', 5, '2026-09-18T14:00:00.000Z'], + ['2026-03-06T15:01:00Z', 1, '2026-03-09T14:00:00.000Z'], + ['2026-10-30T14:01:00Z', 1, '2026-11-02T15:00:00.000Z'], + ['2026-12-31T15:01:00Z', 1, '2027-01-01T15:00:00.000Z'], + ['2026-09-08T01:00:00Z', 1, '2026-09-08T14:00:00.000Z'], + ])('schedules %s plus %s weekdays', (input, days, expected) => { + expect(businessMorningAfter(new Date(input), days).toISOString()).toBe( + expected + ); + }); + it.each([ + ['2026-09-08T13:59:59Z', false], + ['2026-09-08T14:00:00Z', true], + ['2026-09-08T14:59:59Z', true], + ['2026-09-08T15:00:00Z', false], + ['2026-09-12T14:00:00Z', false], + ['2026-11-02T15:00:00Z', true], + ])('checks the weekday morning send window %s', (input, expected) => { + expect(isCampaignSendWindow(new Date(input))).toBe(expected); + }); + it('rejects invalid dates and business-day offsets', () => { + expect(() => businessMorningAfter(new Date('invalid'), 1)).toThrow(); + for (const offset of [0, -1, 1.5, Infinity]) + expect(() => businessMorningAfter(new Date(), offset)).toThrow(); + expect(() => isCampaignSendWindow(new Date('invalid'))).toThrow(); + }); +}); diff --git a/libs/growth/src/lib/campaign-schedule.ts b/libs/growth/src/lib/campaign-schedule.ts new file mode 100644 index 000000000..b64d64030 --- /dev/null +++ b/libs/growth/src/lib/campaign-schedule.ts @@ -0,0 +1,54 @@ +export const CAMPAIGN_TIME_ZONE = 'America/Los_Angeles'; + +const calendar = new Intl.DateTimeFormat('en-US', { + timeZone: CAMPAIGN_TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hourCycle: 'h23', +}); + +function parts(date: Date) { + if (!Number.isFinite(date.getTime())) + throw new Error('Invalid campaign date'); + const values = Object.fromEntries( + calendar.formatToParts(date).map((p) => [p.type, p.value]) + ); + return { + year: Number(values['year']), + month: Number(values['month']), + day: Number(values['day']), + hour: Number(values['hour']), + }; +} + +/** Count local calendar weekdays, always excluding the anchor date. */ +export function businessMorningAfter(anchor: Date, businessDays: number): Date { + if ( + !Number.isSafeInteger(businessDays) || + businessDays < 1 || + businessDays > 366 + ) + throw new Error('Invalid business-day offset'); + const local = parts(anchor); + const day = new Date(Date.UTC(local.year, local.month - 1, local.day)); + let remaining = businessDays; + while (remaining > 0) { + day.setUTCDate(day.getUTCDate() + 1); + if (day.getUTCDay() !== 0 && day.getUTCDay() !== 6) remaining--; + } + // 15:00 UTC is 07:00 or 08:00 Pacific. Resolve the target date's own + // offset, not the anchor's, so crossing DST preserves the local hour. + day.setUTCHours(15); + day.setUTCHours(day.getUTCHours() + 7 - parts(day).hour); + return day; +} + +export function isCampaignSendWindow(now: Date): boolean { + const local = parts(now); + const weekday = new Date( + Date.UTC(local.year, local.month - 1, local.day) + ).getUTCDay(); + return weekday !== 0 && weekday !== 6 && local.hour === 7; +} diff --git a/libs/growth/src/lib/jobs.spec.ts b/libs/growth/src/lib/jobs.spec.ts index de3a5a2a2..dd3d5599d 100644 --- a/libs/growth/src/lib/jobs.spec.ts +++ b/libs/growth/src/lib/jobs.spec.ts @@ -75,7 +75,7 @@ function executorWith( }; } -const now = new Date('2026-09-01T12:00:00.000Z'); +const now = new Date('2026-09-01T14:00:00.000Z'); const leaseToken = '00000000-0000-4000-8000-000000000099'; function jobRow(overrides: TestRow = {}): TestRow { @@ -86,7 +86,7 @@ function jobRow(overrides: TestRow = {}): TestRow { project_id: null, status: 'leased', available_at: now, - lease_until: new Date('2026-09-01T12:05:00.000Z'), + lease_until: new Date('2026-09-01T14:05:00.000Z'), lease_token: leaseToken, attempts: 1, idempotency_key: 'campaign:v1:00000000-0000-4000-8000-000000000002:step:1', @@ -109,6 +109,48 @@ function jobRow(overrides: TestRow = {}): TestRow { } describe('campaign enrollment', () => { + it('rechecks the send window with a fresh clock after authorization locks', async () => { + const beforeClose = new Date('2026-09-01T14:59:59.000Z'); + const afterClose = new Date('2026-09-01T15:00:00.000Z'); + const harness = executorWith({ + 'acquire-google-reconcile-advisory-lock': () => ({ rows: [] }), + 'lock-contact-for-send': () => ({ + rows: [ + { + id: jobRow().contact_id, + email_normalized: 'reader@acme.com', + outreach_approved_at: now, + deleted_at: null, + latest_hard_stop_at: null, + campaign_approval_valid: true, + campaign_enrollment_valid: true, + }, + ], + }), + 'lock-job-for-send': () => ({ + rows: [jobRow({ lease_until: new Date('2026-09-01T15:01:00Z') })], + }), + 'read-google-mailbox-recovery-pause': () => ({ + rows: [{ paused: false }], + }), + }); + await expect( + authorizeLeasedJobForSubmission(harness.executor, { + jobId: String(jobRow().id), + leaseToken, + now: beforeClose, + currentTime: () => afterClose, + campaignEnabled: true, + deliveryEnabled: true, + }) + ).resolves.toMatchObject({ + authorized: false, + reason: 'outside_send_window', + }); + expect( + harness.calls.some((c) => c.marker === 'insert-final-send-authorization') + ).toBe(false); + }); it('does not touch the database when enrollment is disabled', async () => { const harness = executorWith({}); @@ -137,6 +179,7 @@ describe('campaign enrollment', () => { now, 25, CONTACT_HARD_STOP_REASONS, + new Date('2026-09-02T14:00:00.000Z'), ]); expect(sql).toMatch(/outreach_approved_at\s*>=\s*\$1/u); expect(sql).toMatch( @@ -214,7 +257,7 @@ describe('job leasing', () => { ['send_step', 'fulfill', 'enrich', 'notify'], now, 20, - new Date('2026-09-01T12:05:00.000Z'), + new Date('2026-09-01T14:05:00.000Z'), false, ]); expect(sql.match(/for update skip locked/gu)).toHaveLength(2); @@ -291,14 +334,14 @@ describe('job leasing', () => { jobRow().id, leaseToken, now, - new Date('2026-09-01T12:10:00.000Z'), + new Date('2026-09-01T14:10:00.000Z'), ]); expect(sql).toMatch(/status = 'leased'/u); expect(sql).toMatch(/lease_token = \$2::uuid/u); expect(sql).toMatch(/lease_until > \$3/u); expect(sql).toMatch(/lease_until\s*=\s*greatest\(lease_until, \$4\)/u); return { - rows: [jobRow({ lease_until: new Date('2026-09-01T12:10:00.000Z') })], + rows: [jobRow({ lease_until: new Date('2026-09-01T14:10:00.000Z') })], }; }, }); @@ -310,7 +353,7 @@ describe('job leasing', () => { leaseDurationMs: 10 * 60_000, }); - expect(renewed?.leaseUntil).toEqual(new Date('2026-09-01T12:10:00.000Z')); + expect(renewed?.leaseUntil).toEqual(new Date('2026-09-01T14:10:00.000Z')); }); it('claims an internal notification provider attempt at most once for a live lease', async () => { @@ -783,10 +826,10 @@ describe('final fulfillment authorization', () => { { id: fulfillJob.contact_id, email_normalized: 'reader@acme.com', - outreach_approved_at: new Date('2026-09-01T12:10:00.000Z'), + outreach_approved_at: new Date('2026-09-01T14:10:00.000Z'), deleted_at: null, latest_hard_stop_kind: 'complaint', - latest_hard_stop_at: new Date('2026-09-01T12:05:00.000Z'), + latest_hard_stop_at: new Date('2026-09-01T14:05:00.000Z'), mailbox_recovery_required: false, fulfillment_delivery_blocked: false, fulfillment_deletion_blocked: false, @@ -820,10 +863,10 @@ describe('final fulfillment authorization', () => { { id: fulfillJob.contact_id, email_normalized: 'reader@acme.com', - outreach_approved_at: new Date('2026-09-01T12:10:00.000Z'), + outreach_approved_at: new Date('2026-09-01T14:10:00.000Z'), deleted_at: null, latest_hard_stop_kind: 'deletion', - latest_hard_stop_at: new Date('2026-09-01T12:05:00.000Z'), + latest_hard_stop_at: new Date('2026-09-01T14:05:00.000Z'), mailbox_recovery_required: false, fulfillment_delivery_blocked: false, fulfillment_deletion_blocked: true, @@ -853,7 +896,7 @@ describe('final fulfillment authorization', () => { idempotency_key: 'campaign:v1:contact:step:1', payload: { campaign_version: 'v1', step: '1' }, }); - const stoppedAt = new Date('2026-09-01T12:03:00.000Z'); + const stoppedAt = new Date('2026-09-01T14:03:00.000Z'); const harness = executorWith({ 'acquire-google-reconcile-advisory-lock': () => ({ rows: [{}] }), 'lock-contact-for-send': () => ({ @@ -861,7 +904,7 @@ describe('final fulfillment authorization', () => { { id: sendJob.contact_id, email_normalized: 'reader@acme.com', - outreach_approved_at: new Date('2026-09-01T12:00:00.000Z'), + outreach_approved_at: new Date('2026-09-01T14:00:00.000Z'), deleted_at: null, latest_hard_stop_kind: 'unsubscribe', latest_hard_stop_at: stoppedAt, @@ -892,7 +935,7 @@ describe('final fulfillment authorization', () => { describe('leased transitions', () => { it('defers a live lease to one scheduler-owned retry time', async () => { - const availableAt = new Date('2026-09-01T12:01:00.000Z'); + const availableAt = new Date('2026-09-01T14:01:00.000Z'); const harness = executorWith({ 'defer-leased-job': (parameters, sql) => { expect(parameters).toEqual([ @@ -962,7 +1005,7 @@ describe('leased transitions', () => { }); it('records provider acceptance idempotently and anchors later cadence with greatest', async () => { - const acceptedAt = new Date('2026-09-01T12:02:00.000Z'); + const acceptedAt = new Date('2026-09-01T14:02:00.000Z'); const harness = executorWith({ 'discover-provider-acceptance-contact': (_parameters, sql) => { expect(sql).not.toMatch(/for update/u); @@ -984,7 +1027,7 @@ describe('leased transitions', () => { contact_id: jobRow().contact_id, project_id: null, kind: 'delivery.submission_authorized', - occurred_at: new Date('2026-09-01T12:01:00.000Z'), + occurred_at: new Date('2026-09-01T14:01:00.000Z'), data: { bounded_stop_race: true, lease_token: leaseToken }, }, ], @@ -1022,11 +1065,15 @@ describe('leased transitions', () => { }; }, 'anchor-campaign-cadence': (parameters, sql) => { - expect(parameters).toEqual([jobRow().contact_id, acceptedAt, 1]); + expect(parameters).toEqual([ + jobRow().contact_id, + 1, + new Date('2026-09-04T14:00:00.000Z'), + new Date('2026-09-11T14:00:00.000Z'), + ]); expect(sql).toMatch(/greatest/u); - expect(sql).toMatch(/interval '72 hours'/u); - expect(sql).toMatch(/interval '192 hours'/u); - expect(sql).toMatch(/interval '120 hours'/u); + expect(sql).toMatch(/\$3::timestamptz/u); + expect(sql).toMatch(/\$4::timestamptz/u); expect(sql).not.toMatch(/interval '\d+ days'/u); return { rows: [] }; }, @@ -1052,7 +1099,7 @@ describe('leased transitions', () => { }); it('upgrades only deletion-provisional unknown to known acceptance without resubmission', async () => { - const acceptedAt = new Date('2026-09-01T12:02:00.000Z'); + const acceptedAt = new Date('2026-09-01T14:02:00.000Z'); const interrupted = jobRow({ status: 'failed', lease_until: null, @@ -1076,7 +1123,7 @@ describe('leased transitions', () => { contact_id: interrupted.contact_id, project_id: null, kind: 'delivery.submission_authorized', - occurred_at: new Date('2026-09-01T12:01:00.000Z'), + occurred_at: new Date('2026-09-01T14:01:00.000Z'), data: { bounded_stop_race: true, lease_token: leaseToken }, }, ], @@ -1178,7 +1225,7 @@ describe('leased transitions', () => { contact_id: ordinaryUnknown.contact_id, project_id: null, kind: 'delivery.submission_authorized', - occurred_at: new Date('2026-09-01T12:01:00.000Z'), + occurred_at: new Date('2026-09-01T14:01:00.000Z'), data: { bounded_stop_race: true, lease_token: leaseToken }, }, ], @@ -1189,7 +1236,7 @@ describe('leased transitions', () => { recordProviderAcceptance(harness.executor, { jobId: String(ordinaryUnknown.id), leaseToken, - acceptedAt: new Date('2026-09-01T12:02:00.000Z'), + acceptedAt: new Date('2026-09-01T14:02:00.000Z'), providerEmailId: 'resend-email-ordinary', }) ).rejects.toBeInstanceOf(JobLeaseConflictError); @@ -1221,7 +1268,7 @@ describe('leased transitions', () => { ] as const)( 'does not move cadence when the same provider acceptance is replayed after delivery becomes %s', async (deliveryStatus) => { - const acceptedAt = new Date('2026-09-01T12:02:00.000Z'); + const acceptedAt = new Date('2026-09-01T14:02:00.000Z'); const harness = executorWith({ 'discover-provider-acceptance-contact': () => ({ rows: [{ contact_id: jobRow().contact_id }], @@ -1249,7 +1296,7 @@ describe('leased transitions', () => { contact_id: jobRow().contact_id, project_id: null, kind: 'delivery.submission_authorized', - occurred_at: new Date('2026-09-01T12:01:00.000Z'), + occurred_at: new Date('2026-09-01T14:01:00.000Z'), data: { bounded_stop_race: true, lease_token: leaseToken }, }, ], @@ -1292,7 +1339,7 @@ describe('leased transitions', () => { ); it('rejects a completed replay whose immutable acceptance envelope was forged', async () => { - const acceptedAt = new Date('2026-09-01T12:02:00.000Z'); + const acceptedAt = new Date('2026-09-01T14:02:00.000Z'); const harness = executorWith({ 'discover-provider-acceptance-contact': () => ({ rows: [{ contact_id: jobRow().contact_id }], @@ -1318,7 +1365,7 @@ describe('leased transitions', () => { contact_id: jobRow().contact_id, project_id: null, kind: 'delivery.submission_authorized', - occurred_at: new Date('2026-09-01T12:01:00.000Z'), + occurred_at: new Date('2026-09-01T14:01:00.000Z'), data: { bounded_stop_race: true, lease_token: leaseToken }, }, ], @@ -1354,7 +1401,7 @@ describe('leased transitions', () => { it.each(['not_submitted', 'unknown'] as const)( 'does not treat impossible completed/%s state as an accepted replay', async (deliveryStatus) => { - const acceptedAt = new Date('2026-09-01T12:02:00.000Z'); + const acceptedAt = new Date('2026-09-01T14:02:00.000Z'); const harness = executorWith({ 'discover-provider-acceptance-contact': () => ({ rows: [{ contact_id: jobRow().contact_id }], @@ -1382,7 +1429,7 @@ describe('leased transitions', () => { contact_id: jobRow().contact_id, project_id: null, kind: 'delivery.submission_authorized', - occurred_at: new Date('2026-09-01T12:01:00.000Z'), + occurred_at: new Date('2026-09-01T14:01:00.000Z'), data: { bounded_stop_race: true, lease_token: leaseToken }, }, ], @@ -1404,7 +1451,7 @@ describe('leased transitions', () => { ); it('rejects a contact delivery when the mandatory final authorization is missing', async () => { - const acceptedAt = new Date('2026-09-01T12:02:00.000Z'); + const acceptedAt = new Date('2026-09-01T14:02:00.000Z'); const harness = executorWith({ 'discover-provider-acceptance-contact': () => ({ rows: [{ contact_id: jobRow().contact_id }], @@ -1444,7 +1491,7 @@ describe('leased transitions', () => { recordProviderAcceptance(harness.executor, { jobId: String(jobRow().id), leaseToken, - acceptedAt: new Date('2026-09-01T12:02:00.000Z'), + acceptedAt: new Date('2026-09-01T14:02:00.000Z'), providerEmailId: 'resend-email-1', }) ).rejects.toThrow(/contact recipient/u); diff --git a/libs/growth/src/lib/jobs.ts b/libs/growth/src/lib/jobs.ts index 3118755bf..8a62423c9 100644 --- a/libs/growth/src/lib/jobs.ts +++ b/libs/growth/src/lib/jobs.ts @@ -3,6 +3,10 @@ import { CONTACT_HARD_STOP_REASONS } from './contacts.ts'; import { normalizeEmail } from './crypto.ts'; import type { GrowthArtifact, GrowthJob } from './models.ts'; import { privacyLock } from './observability/store.ts'; +import { + businessMorningAfter, + isCampaignSendWindow, +} from './campaign-schedule.ts'; import { installRuntimeEvidenceSql } from './observability/install-runtime-enrichment.ts'; const FULFILLMENT_ALLOWED_PRIOR_STOPS = new Set([ @@ -97,6 +101,7 @@ export type FinalSendAuthorization = | 'contact_unapproved' | 'campaign_disabled' | 'delivery_disabled' + | 'outside_send_window' | 'mailbox_recovery_required'; job: GrowthJob; }; @@ -399,7 +404,7 @@ export async function materializeCampaignEnrollment( select 'send_step', e.contact_id, 'pending', - $2, + $5, 'campaign:v1:' || e.contact_id::text || ':step:' || step::text, jsonb_build_object( 'campaign_version', 'v1', @@ -418,7 +423,13 @@ export async function materializeCampaignEnrollment( left join inserted_jobs j on j.contact_id = e.contact_id group by e.contact_id order by e.contact_id`, - [enrollmentStartAt, now, batchSize, CONTACT_HARD_STOP_REASONS] + [ + enrollmentStartAt, + now, + batchSize, + CONTACT_HARD_STOP_REASONS, + businessMorningAfter(now, 1), + ] ); return { enrolledContactIds: result.rows.map(({ contact_id }) => contact_id), @@ -467,8 +478,9 @@ export async function leaseDueJobs( from growth_activity submission_authorization where submission_authorization.kind = 'delivery.submission_authorized' - and submission_authorization.event_key like - 'job:' || interrupted.id::text || ':submission-authorized:%' + and submission_authorization.event_key = + 'job:' || interrupted.id::text || ':submission-authorized:' || interrupted.lease_token::text + and submission_authorization.data->>'lease_token' = interrupted.lease_token::text ) order by interrupted.lease_until, interrupted.id for update skip locked @@ -545,6 +557,8 @@ export async function leaseDueJobs( j.kind <> 'send_step' or ( j.payload->>'campaign_version' = 'v1' + and extract(isodow from $2::timestamptz at time zone 'America/Los_Angeles') between 1 and 5 + and extract(hour from $2::timestamptz at time zone 'America/Los_Angeles') = 7 and ( ( j.payload->>'step' = '1' @@ -748,11 +762,12 @@ export async function authorizeLeasedJobForSubmission( now: Date; campaignEnabled: boolean; deliveryEnabled: boolean; + currentTime?: () => Date; } ): Promise { const jobId = requiredText('jobId', input.jobId); const leaseToken = requiredText('leaseToken', input.leaseToken); - const now = validDate('now', input.now); + let now = validDate('now', input.now); if (typeof input.campaignEnabled !== 'boolean') { throw new Error('campaignEnabled must be a boolean'); } @@ -995,6 +1010,14 @@ export async function authorizeLeasedJobForSubmission( throw new JobLeaseConflictError(jobId); } + // Lock waits must not carry a pre-window-boundary timestamp into a send. + now = validDate('now', input.currentTime?.() ?? now); + if (job.leaseUntil === null || job.leaseUntil.getTime() <= now.getTime()) + throw new JobLeaseConflictError(jobId); + if (job.kind === 'send_step' && !isCampaignSendWindow(now)) { + return { authorized: false, reason: 'outside_send_window', job }; + } + const inserted = await transaction.execute<{ event_key: string }>( `/* growth:insert-final-send-authorization */ insert into growth_activity ( @@ -1679,12 +1702,12 @@ export async function recordProviderAcceptance( set available_at = greatest( later.available_at, case - when $3::integer = 1 and later.payload->>'step' = '2' - then $2::timestamptz + interval '72 hours' - when $3::integer = 1 and later.payload->>'step' = '3' - then $2::timestamptz + interval '192 hours' - when $3::integer = 2 and later.payload->>'step' = '3' - then $2::timestamptz + interval '120 hours' + when $2::integer = 1 and later.payload->>'step' = '2' + then $3::timestamptz + when $2::integer = 1 and later.payload->>'step' = '3' + then $4::timestamptz + when $2::integer = 2 and later.payload->>'step' = '3' + then $3::timestamptz else later.available_at end ) @@ -1693,10 +1716,15 @@ export async function recordProviderAcceptance( and later.payload->>'campaign_version' = 'v1' and later.status = 'pending' and ( - ($3::integer = 1 and later.payload->>'step' in ('2', '3')) - or ($3::integer = 2 and later.payload->>'step' = '3') + ($2::integer = 1 and later.payload->>'step' in ('2', '3')) + or ($2::integer = 2 and later.payload->>'step' = '3') )`, - [job.contactId, acceptedAt, step] + [ + job.contactId, + step, + businessMorningAfter(acceptedAt, step === 1 ? 3 : 5), + businessMorningAfter(acceptedAt, 8), + ] ); } diff --git a/libs/growth/src/lib/resend.spec.ts b/libs/growth/src/lib/resend.spec.ts index e3c59fd1b..39ce4990f 100644 --- a/libs/growth/src/lib/resend.spec.ts +++ b/libs/growth/src/lib/resend.spec.ts @@ -15,7 +15,7 @@ import { type RecipientResendClient, } from './resend.ts'; -const now = new Date('2026-09-01T12:00:00.000Z'); +const now = new Date('2026-09-01T14:00:00.000Z'); const jobId = '00000000-0000-4000-8000-000000000001'; const leaseToken = '00000000-0000-4000-8000-000000000099'; const contactId = '00000000-0000-4000-8000-000000000002'; @@ -54,7 +54,7 @@ function job(overrides: Partial = {}): GrowthJob { projectId: null, status: 'leased', availableAt: now, - leaseUntil: new Date('2026-09-01T12:05:00.000Z'), + leaseUntil: new Date('2026-09-01T14:05:00.000Z'), leaseToken, attempts: 1, idempotencyKey: 'campaign:v1:contact:step:1', @@ -515,9 +515,10 @@ describe('sendRecipientEmail', () => { it('records the post-submission observation time rather than the earlier authorization time', async () => { const test = harness(); - const submittedAt = new Date('2026-09-01T12:00:02.000Z'); + const submittedAt = new Date('2026-09-01T14:00:02.000Z'); test.dependencies.now = vi .fn() + .mockReturnValue(submittedAt) .mockReturnValueOnce(now) .mockReturnValueOnce(submittedAt); @@ -536,6 +537,7 @@ describe('sendRecipientEmail', () => { jobId, leaseToken, now, + currentTime: test.dependencies.now, } ); expect(test.recordProviderAcceptance).toHaveBeenCalledWith( @@ -545,6 +547,42 @@ describe('sendRecipientEmail', () => { }); it('does not submit when final authorization denies the recipient', async () => { + const test = harness(); + test.authorizeLeasedJobForSubmission.mockResolvedValueOnce({ + authorized: false, + reason: 'outside_send_window', + job: job(), + }); + await expect( + sendRecipientEmail( + test.database, + message, + productionPolicy(), + test.dependencies + ) + ).resolves.toEqual({ accepted: false, reason: 'outside_send_window' }); + expect(test.send).not.toHaveBeenCalled(); + }); + + it('rechecks the clock immediately before provider submission', async () => { + const test = harness(); + test.dependencies.now = vi + .fn() + .mockReturnValueOnce(new Date('2026-09-01T14:59:59Z')) + .mockReturnValue(new Date('2026-09-01T15:00:00Z')); + await expect( + sendRecipientEmail( + test.database, + message, + productionPolicy(), + test.dependencies + ) + ).resolves.toEqual({ accepted: false, reason: 'outside_send_window' }); + expect(test.send).not.toHaveBeenCalled(); + expect(test.markProviderAcceptanceUnknown).not.toHaveBeenCalled(); + }); + + it('does not submit when final authorization denies the recipient for a stop', async () => { const test = harness(); test.authorizeLeasedJobForSubmission.mockResolvedValueOnce({ authorized: false, diff --git a/libs/growth/src/lib/resend.ts b/libs/growth/src/lib/resend.ts index 9ccf487c8..9930ee767 100644 --- a/libs/growth/src/lib/resend.ts +++ b/libs/growth/src/lib/resend.ts @@ -1,5 +1,6 @@ import type { SqlExecutor } from './database.ts'; import type { ErrorResponse } from 'resend'; +import { isCampaignSendWindow } from './campaign-schedule.ts'; import { authorizeLeasedJobForSubmission, markProviderAcceptanceUnknown, @@ -147,6 +148,7 @@ export type RecipientSendResult = | 'contact_unapproved' | 'campaign_disabled' | 'delivery_disabled' + | 'outside_send_window' | 'mailbox_recovery_required' | 'provider_rejected' | 'provider_outcome_unknown'; @@ -381,6 +383,7 @@ export async function sendRecipientEmail( jobId, leaseToken, now: authorizedAt, + currentTime: dependencies.now, } ); if (!authorization.authorized) { @@ -428,6 +431,9 @@ export async function sendRecipientEmail( input.signal?.throwIfAborted(); let response: ResendResponse; + if (job.kind === 'send_step' && !isCampaignSendWindow(dependencies.now())) { + return { accepted: false, reason: 'outside_send_window' }; + } try { response = await dependencies.resend.emails.send( { diff --git a/libs/growth/src/lib/stops.spec.ts b/libs/growth/src/lib/stops.spec.ts index ee2693d88..49bb11203 100644 --- a/libs/growth/src/lib/stops.spec.ts +++ b/libs/growth/src/lib/stops.spec.ts @@ -76,7 +76,7 @@ function executorWith( } const contactId = '00000000-0000-4000-8000-000000000001'; -const now = new Date('2026-09-01T12:00:00.000Z'); +const now = new Date('2026-09-01T14:00:00.000Z'); const validCampaignProvenance = { campaign_approval_valid: true, campaign_enrollment_valid: true, @@ -176,7 +176,7 @@ describe('stopContact', () => { id: '00000000-0000-4000-8000-000000000011', status: 'leased', lease_token: '00000000-0000-4000-8000-000000000099', - lease_until: new Date('2026-09-01T12:05:00.000Z'), + lease_until: new Date('2026-09-01T14:05:00.000Z'), authorization_event_key: 'job:00000000-0000-4000-8000-000000000011:submission-authorized:00000000-0000-4000-8000-000000000099', authorization_contact_id: contactId, @@ -192,7 +192,7 @@ describe('stopContact', () => { id: '00000000-0000-4000-8000-000000000016', status: 'leased', lease_token: '00000000-0000-4000-8000-000000000097', - lease_until: new Date('2026-09-01T12:05:00.000Z'), + lease_until: new Date('2026-09-01T14:05:00.000Z'), }); const legacyScheduled = jobRow({ id: '00000000-0000-4000-8000-000000000012', @@ -212,7 +212,7 @@ describe('stopContact', () => { id: '00000000-0000-4000-8000-000000000015', status: 'leased', lease_token: '00000000-0000-4000-8000-000000000098', - lease_until: new Date('2026-09-01T12:05:00.000Z'), + lease_until: new Date('2026-09-01T14:05:00.000Z'), provider_email_id: 'resend-submitted-race', delivery_status: 'submitted', }); @@ -517,7 +517,7 @@ describe('stopContact', () => { const authorized = jobRow({ status: 'leased', lease_token: activeLeaseToken, - lease_until: new Date('2026-09-01T12:05:00.000Z'), + lease_until: new Date('2026-09-01T14:05:00.000Z'), authorization_event_key: `job:${ jobRow().id }:submission-authorized:${activeLeaseToken}`, @@ -931,7 +931,7 @@ describe('authorizeLeasedJobForSubmission', () => { jobRow({ status: 'leased', lease_token: activeLeaseToken, - lease_until: new Date('2026-09-01T12:05:00.000Z'), + lease_until: new Date('2026-09-01T14:05:00.000Z'), }), ], }), @@ -999,7 +999,7 @@ describe('authorizeLeasedJobForSubmission', () => { jobRow({ status: 'leased', lease_token: activeLeaseToken, - lease_until: new Date('2026-09-01T12:05:00.000Z'), + lease_until: new Date('2026-09-01T14:05:00.000Z'), }), ], }), @@ -1049,7 +1049,7 @@ describe('authorizeLeasedJobForSubmission', () => { jobRow({ status: 'leased', lease_token: activeLeaseToken, - lease_until: new Date('2026-09-01T12:05:00.000Z'), + lease_until: new Date('2026-09-01T14:05:00.000Z'), }), ], }), @@ -1107,7 +1107,7 @@ describe('authorizeLeasedJobForSubmission', () => { jobRow({ status: 'leased', lease_token: activeLeaseToken, - lease_until: new Date('2026-09-01T12:05:00.000Z'), + lease_until: new Date('2026-09-01T14:05:00.000Z'), }), ], }), @@ -1172,7 +1172,7 @@ describe('authorizeLeasedJobForSubmission', () => { jobRow({ status: 'leased', lease_token: activeLeaseToken, - lease_until: new Date('2026-09-01T12:05:00.000Z'), + lease_until: new Date('2026-09-01T14:05:00.000Z'), }), ], }), @@ -1207,7 +1207,7 @@ describe('authorizeLeasedJobForSubmission', () => { it.each([ ['changed contact', { contact_id: '00000000-0000-4000-8000-000000000777' }], - ['changed time', { occurred_at: new Date('2026-09-01T12:00:01.000Z') }], + ['changed time', { occurred_at: new Date('2026-09-01T14:00:01.000Z') }], [ 'changed data', { data: { bounded_stop_race: false, lease_token: 'forged' } }, @@ -1235,7 +1235,7 @@ describe('authorizeLeasedJobForSubmission', () => { jobRow({ status: 'leased', lease_token: activeLeaseToken, - lease_until: new Date('2026-09-01T12:05:00.000Z'), + lease_until: new Date('2026-09-01T14:05:00.000Z'), }), ], }), @@ -1274,7 +1274,7 @@ describe('authorizeLeasedJobForSubmission', () => { it('reconciles provider acceptance after a bounded stop race without erasing the ledger', async () => { const activeLeaseToken = '00000000-0000-4000-8000-000000000099'; - const acceptedAt = new Date('2026-09-01T12:00:01.000Z'); + const acceptedAt = new Date('2026-09-01T14:00:01.000Z'); const harness = executorWith({ 'discover-provider-acceptance-contact': (_parameters, sql) => { expect(sql).not.toMatch(/for update/u); @@ -1403,7 +1403,7 @@ describe('authorizeLeasedJobForSubmission', () => { recordProviderAcceptance(harness.executor, { jobId: String(jobRow().id), leaseToken: activeLeaseToken, - acceptedAt: new Date('2026-09-01T12:00:01.000Z'), + acceptedAt: new Date('2026-09-01T14:00:01.000Z'), providerEmailId: 'resend-forged-race', }) ).rejects.toThrow(/authorization event key conflict/u); diff --git a/libs/growth/test/install-runtime.integration.spec.ts b/libs/growth/test/install-runtime.integration.spec.ts index 499c08b99..979b185cf 100644 --- a/libs/growth/test/install-runtime.integration.spec.ts +++ b/libs/growth/test/install-runtime.integration.spec.ts @@ -104,7 +104,8 @@ describe('install-runtime founder activation', () => { return { install, runtime }; } it('resolves a runtime that arrived before install and enrolls without waiting for optional enrichment', async () => { - const now = new Date(); + const now = new Date('2026-03-06T18:00:00.000Z'); + const sendAt = new Date('2026-03-09T14:00:00.000Z'); const { install, runtime } = fixture(now); await acceptObservationBatch(db, 'runtime', runtime, { now, @@ -157,25 +158,45 @@ describe('install-runtime founder activation', () => { now, batchSize: 20, }); - const jobs = await db.execute<{ id: string }>( - "select id from growth_jobs where contact_id=$1 and kind='send_step' order by payload->>'step'", + const jobs = await db.execute<{ id: string; available_at: Date }>( + "select id, available_at from growth_jobs where contact_id=$1 and kind='send_step' order by payload->>'step'", [contact.id] ); expect(jobs.rows).toHaveLength(3); - const enrichment = await db.execute<{status: string; payload: Record}>( - "select status,payload from growth_jobs where contact_id=$1 and kind='enrich'", [contact.id] + expect( + jobs.rows.map((job) => new Date(job.available_at).toISOString()) + ).toEqual(Array(3).fill(sendAt.toISOString())); + const enrichment = await db.execute<{ + status: string; + payload: Record; + }>( + "select status,payload from growth_jobs where contact_id=$1 and kind='enrich'", + [contact.id] ); - expect(enrichment.rows).toEqual([expect.objectContaining({status: 'pending', payload: expect.objectContaining({source: 'install_runtime'})})]); + expect(enrichment.rows).toEqual([ + expect.objectContaining({ + status: 'pending', + payload: expect.objectContaining({ source: 'install_runtime' }), + }), + ]); expect( await readLifecycleJobContext(db, { jobId: jobs.rows[0].id }) ).toMatchObject({ campaignEnrollmentReason: 'install_runtime' }); - const leased = await leaseDueJobs(db, { + const immediate = await leaseDueJobs(db, { kinds: ['send_step'], now, batchSize: 20, leaseDurationMs: 30000, campaignEnabled: true, }); + expect(immediate.some((job) => job.contactId === contact.id)).toBe(false); + const leased = await leaseDueJobs(db, { + kinds: ['send_step'], + now: sendAt, + batchSize: 20, + leaseDurationMs: 30000, + campaignEnabled: true, + }); expect(leased.some((j) => j.id === jobs.rows[0].id)).toBe(true); const job = leased.find((j) => j.id === jobs.rows[0].id)!; const operationId = randomUUID(); @@ -183,13 +204,13 @@ describe('install-runtime founder activation', () => { await redactObservationEvidence( db, { email: install.events[0].identity!.gitEmail! }, - { operationId, now, keyring: evidenceKeys } + { operationId, now: sendAt, keyring: evidenceKeys } ); expect( await authorizeLeasedJobForSubmission(db, { jobId: job.id, leaseToken: job.leaseToken!, - now, + now: sendAt, campaignEnabled: true, deliveryEnabled: true, }) @@ -387,7 +408,8 @@ describe('install-runtime founder activation', () => { expect(result.enrolledContactIds).not.toContain(contact.id); }); it('waits for a source-redaction transaction before final send authorization', async () => { - const now = new Date(); + const now = new Date('2026-03-06T18:00:00.000Z'); + const sendAt = new Date('2026-03-09T14:00:00.000Z'); const { install, runtime } = fixture(now); await acceptObservationBatch(db, 'install', install, { now, @@ -418,7 +440,7 @@ describe('install-runtime founder activation', () => { const job = ( await leaseDueJobs(db, { kinds: ['send_step'], - now, + now: sendAt, batchSize: 100, leaseDurationMs: 30000, campaignEnabled: true, @@ -437,7 +459,7 @@ describe('install-runtime founder activation', () => { await gate; await tx.execute( 'update growth_observations set redacted_at=$2 where event_id=$1', - [install.events[0].eventId, now] + [install.events[0].eventId, sendAt] ); }); await locked; @@ -445,7 +467,7 @@ describe('install-runtime founder activation', () => { const authorization = authorizeLeasedJobForSubmission(db, { jobId: job.id, leaseToken: job.leaseToken!, - now, + now: sendAt, campaignEnabled: true, deliveryEnabled: true, }).finally(() => { diff --git a/libs/growth/test/jobs.integration.spec.ts b/libs/growth/test/jobs.integration.spec.ts index 2904d1c4b..ae455eed1 100644 --- a/libs/growth/test/jobs.integration.spec.ts +++ b/libs/growth/test/jobs.integration.spec.ts @@ -6,6 +6,7 @@ import { JobLeaseConflictError, authorizeLeasedJobForSubmission, createDatabaseExecutor, + deferLeasedJob, deleteContact, leaseDueJobs, markProviderAcceptanceUnknown, @@ -214,7 +215,8 @@ describeDatabase( ]); expect( jobs.rows.every( - ({ available_at }) => +new Date(available_at) === +enrollmentAt + ({ available_at }) => + new Date(available_at).toISOString() === '2097-09-02T14:00:00.000Z' ) ).toBe(true); }); @@ -317,8 +319,8 @@ describeDatabase( expect(importedJobs.rows).toEqual([{ count: '0' }]); }); - it('anchors fixed elapsed-hour cadence across DST and never compresses after pause', async () => { - const enrollmentAt = new Date('2026-03-07T19:00:00.000Z'); + it('anchors business dates across DST and never compresses after late acceptance or pause', async () => { + const enrollmentAt = new Date('2026-03-05T19:00:00.000Z'); const contactId = await createContact(enrollmentAt); await executor.transaction(async (transaction) => { await transaction.execute("set local time zone 'America/Los_Angeles'"); @@ -338,9 +340,23 @@ describeDatabase( batchSize: 10, }); + for (const now of [ + enrollmentAt, + new Date('2026-03-06T14:59:59.999Z'), + ]) { + expect( + await leaseDueJobs(sessionExecutor, { + kinds: ['send_step'], + now, + batchSize: 10, + leaseDurationMs: 60_000, + campaignEnabled: true, + }) + ).toEqual([]); + } const beforeAcceptance = await leaseDueJobs(sessionExecutor, { kinds: ['send_step'], - now: new Date(enrollmentAt.getTime() + 5 * 60_000), + now: new Date('2026-03-06T15:00:00.000Z'), batchSize: 10, leaseDurationMs: 2 * 60 * 60_000, campaignEnabled: true, @@ -351,14 +367,14 @@ describeDatabase( const step1 = beforeAcceptance[0]; if (!step1?.leaseToken) throw new Error('step 1 must have a lease token'); - const step1AcceptedAt = new Date('2026-03-07T20:00:00.000Z'); + const step1AcceptedAt = new Date('2026-03-06T15:30:00.000Z'); await expect( authorizeLeasedJobForSubmission(sessionExecutor, { campaignEnabled: true, deliveryEnabled: true, jobId: step1.id, leaseToken: step1.leaseToken, - now: new Date('2026-03-07T19:59:00.000Z'), + now: new Date('2026-03-06T15:29:00.000Z'), }) ).resolves.toMatchObject({ authorized: true }); await recordProviderAcceptance(sessionExecutor, { @@ -369,28 +385,26 @@ describeDatabase( }); const anchored = await transaction.execute<{ - elapsed_hours: number; + available_at: Date; step: string; }>( - `select payload->>'step' as step, - extract(epoch from (available_at - $2::timestamptz)) / 3600 - as elapsed_hours + `select payload->>'step' as step, available_at from growth_jobs where contact_id = $1 and payload->>'step' in ('2', '3') order by payload->>'step'`, - [contactId, step1AcceptedAt] + [contactId] ); expect( - anchored.rows.map(({ step, elapsed_hours }) => [ + anchored.rows.map(({ step, available_at }) => [ step, - Number(elapsed_hours), + new Date(available_at).toISOString(), ]) ).toEqual([ - ['2', 72], - ['3', 192], + ['2', '2026-03-11T14:00:00.000Z'], + ['3', '2026-03-18T14:00:00.000Z'], ]); - const step2DueAt = new Date('2026-03-10T20:00:00.000Z'); + const step2DueAt = new Date('2026-03-11T14:00:00.000Z'); const earlyStep2 = await leaseDueJobs(sessionExecutor, { kinds: ['send_step'], now: new Date(step2DueAt.getTime() - 1), @@ -401,7 +415,7 @@ describeDatabase( expect(earlyStep2).toEqual([]); const step2Lease = await leaseDueJobs(sessionExecutor, { kinds: ['send_step'], - now: step2DueAt, + now: new Date('2026-03-13T14:00:00.000Z'), batchSize: 10, leaseDurationMs: 2 * 60 * 60_000, campaignEnabled: true, @@ -410,14 +424,14 @@ describeDatabase( const step2 = step2Lease[0]; if (!step2?.leaseToken) throw new Error('step 2 must have a lease token'); - const step2AcceptedAt = new Date('2026-03-10T21:00:00.000Z'); + const step2AcceptedAt = new Date('2026-03-13T14:30:00.000Z'); await expect( authorizeLeasedJobForSubmission(sessionExecutor, { campaignEnabled: true, deliveryEnabled: true, jobId: step2.id, leaseToken: step2.leaseToken, - now: new Date('2026-03-10T20:59:00.000Z'), + now: new Date('2026-03-13T14:29:00.000Z'), }) ).resolves.toMatchObject({ authorized: true }); await recordProviderAcceptance(sessionExecutor, { @@ -427,16 +441,41 @@ describeDatabase( providerEmailId: `provider:${step2.id}`, }); - const step3Row = await transaction.execute<{ elapsed_hours: number }>( - `select extract(epoch from (available_at - $2::timestamptz)) / 3600 - as elapsed_hours + // Replayed step 1 acceptance must not undo step 2's later anchor. + await recordProviderAcceptance(sessionExecutor, { + jobId: step1.id, + leaseToken: step1.leaseToken, + acceptedAt: step1AcceptedAt, + providerEmailId: `provider:${step1.id}`, + }); + const step3Row = await transaction.execute<{ available_at: Date }>( + `select available_at from growth_jobs where idempotency_key = $1`, - [`campaign:v1:${contactId}:step:3`, step2AcceptedAt] + [`campaign:v1:${contactId}:step:3`] + ); + expect(new Date(step3Row.rows[0].available_at).toISOString()).toBe( + '2026-03-20T14:00:00.000Z' ); - expect(Number(step3Row.rows[0]?.elapsed_hours)).toBe(120); - const afterStep3Due = new Date('2026-03-16T00:00:00.000Z'); + for (const now of [ + new Date('2026-03-19T14:00:00.000Z'), + new Date('2026-03-20T15:00:00.000Z'), + new Date('2026-03-21T14:00:00.000Z'), + new Date('2026-03-22T14:00:00.000Z'), + ]) { + expect( + await leaseDueJobs(sessionExecutor, { + kinds: ['send_step'], + now, + batchSize: 10, + leaseDurationMs: 60_000, + campaignEnabled: true, + }) + ).toEqual([]); + } + + const afterStep3Due = new Date('2026-03-23T14:00:00.000Z'); const paused = await leaseDueJobs(sessionExecutor, { kinds: ['send_step', 'notify'], now: afterStep3Due, @@ -456,6 +495,156 @@ describeDatabase( }); }); + it('rechecks the closing send window and a stop before submitting a leased campaign job', async () => { + const enrollmentAt = new Date('2026-03-05T19:00:00.000Z'); + const contactId = await createContact(enrollmentAt); + await materializeCampaignEnrollment(executor, { + enrollmentEnabled: true, + enrollmentStartAt: enrollmentAt, + now: enrollmentAt, + batchSize: 10, + }); + const [job] = await leaseDueJobs(executor, { + kinds: ['send_step'], + now: new Date('2026-03-06T15:59:00.000Z'), + batchSize: 10, + leaseDurationMs: 120_000, + campaignEnabled: true, + }); + if (!job?.leaseToken) throw new Error('step 1 must be leased'); + await expect( + authorizeLeasedJobForSubmission(executor, { + campaignEnabled: true, + deliveryEnabled: true, + jobId: job.id, + leaseToken: job.leaseToken, + now: new Date('2026-03-06T15:59:59.999Z'), + currentTime: () => new Date('2026-03-06T16:00:00.000Z'), + }) + ).resolves.toMatchObject({ + authorized: false, + reason: 'outside_send_window', + }); + const authorizations = await executor.execute<{ count: string }>( + `select count(*)::text as count from growth_activity + where contact_id = $1 and kind = 'delivery.submission_authorized'`, + [contactId] + ); + expect(authorizations.rows).toEqual([{ count: '0' }]); + + const [resumed] = await leaseDueJobs(executor, { + kinds: ['send_step'], + now: new Date('2026-03-09T14:00:00.000Z'), + batchSize: 10, + leaseDurationMs: 60_000, + campaignEnabled: true, + }); + if (!resumed?.leaseToken) throw new Error('step 1 must be leased again'); + await executor.execute( + `insert into growth_activity (event_key, contact_id, kind, occurred_at, data) + values ($1, $2, 'unsubscribe', $3, '{}')`, + [ + `jobs-integration:stop:${contactId}`, + contactId, + new Date('2026-03-09T14:00:01.000Z'), + ] + ); + await expect( + authorizeLeasedJobForSubmission(executor, { + campaignEnabled: true, + deliveryEnabled: true, + jobId: resumed.id, + leaseToken: resumed.leaseToken!, + now: new Date('2026-03-09T14:00:02.000Z'), + }) + ).resolves.toMatchObject({ authorized: false }); + }); + + it('recovers an unauthorized later lease after safe deferral of an authorized campaign lease', async () => { + const enrollmentAt = new Date('2026-03-05T19:00:00.000Z'); + const contactId = await createContact(enrollmentAt); + await materializeCampaignEnrollment(executor, { + enrollmentEnabled: true, + enrollmentStartAt: enrollmentAt, + now: enrollmentAt, + batchSize: 10, + }); + const leaseAt = (now: Date) => + leaseDueJobs(executor, { + kinds: ['send_step'], + now, + batchSize: 10, + leaseDurationMs: 120_000, + campaignEnabled: true, + }); + const [friday] = await leaseAt(new Date('2026-03-06T15:59:00.000Z')); + if (!friday?.leaseToken) throw new Error('Friday lease required'); + await expect( + authorizeLeasedJobForSubmission(executor, { + campaignEnabled: true, + deliveryEnabled: true, + jobId: friday.id, + leaseToken: friday.leaseToken, + now: new Date('2026-03-06T15:59:30.000Z'), + }) + ).resolves.toMatchObject({ authorized: true }); + await deferLeasedJob(executor, { + jobId: friday.id, + leaseToken: friday.leaseToken, + now: new Date('2026-03-06T16:00:00.000Z'), + availableAt: new Date('2026-03-09T14:00:00.000Z'), + errorCode: 'outside_send_window', + }); + const [monday] = await leaseAt(new Date('2026-03-09T14:00:00.000Z')); + expect(monday?.id).toBe(friday.id); + expect(monday?.leaseToken).not.toBe(friday.leaseToken); + + // Monday's worker dies before authorization; Friday's event must not + // make this new lease an ambiguous provider submission. + const [tuesday] = await leaseAt(new Date('2026-03-10T14:00:00.000Z')); + expect(tuesday).toMatchObject({ + id: friday.id, + deliveryStatus: 'not_submitted', + }); + if (!tuesday?.leaseToken) + throw new Error('Tuesday recovery lease required'); + expect(tuesday.leaseToken).not.toBe(monday.leaseToken); + const audit = await executor.execute<{ count: string }>( + `select count(*)::text as count from growth_activity + where contact_id = $1 and kind = 'delivery.acceptance_unknown'`, + [contactId] + ); + expect(audit.rows).toEqual([{ count: '0' }]); + + // An interruption after authorization on the actual current lease + // still requires manual reconciliation instead of resubmission. + await expect( + authorizeLeasedJobForSubmission(executor, { + campaignEnabled: true, + deliveryEnabled: true, + jobId: tuesday.id, + leaseToken: tuesday.leaseToken, + now: new Date('2026-03-10T14:00:30.000Z'), + }) + ).resolves.toMatchObject({ authorized: true }); + expect(await leaseAt(new Date('2026-03-11T14:00:00.000Z'))).toEqual([]); + const state = await executor.execute<{ + status: string; + delivery_status: string; + last_error_code: string; + }>( + 'select status, delivery_status, last_error_code from growth_jobs where id = $1', + [friday.id] + ); + expect(state.rows).toEqual([ + { + status: 'failed', + delivery_status: 'unknown', + last_error_code: 'worker_interrupted_after_authorization', + }, + ]); + }); + it('gates non-campaign work independently and enforces tokened transitions and artifacts', async () => { const contactId = await createContact( new Date('2097-11-01T00:00:00.000Z')