diff --git a/emails/CATALOG.md b/emails/CATALOG.md
index fb2635c1c7..f96e5a897a 100644
--- a/emails/CATALOG.md
+++ b/emails/CATALOG.md
@@ -4,22 +4,23 @@ Generated from the email library with `bun run emails:catalog`. Edit the source
## Lifecycle flows
-These are the locally configured draft journeys. This document is not a live status check. Imports remain held; production enrollment is not deployed. Run `bun run emails:check-loops` to verify the actual Loops drafts.
+These are the locally configured journeys. This document is not a live status check. Imports remain held. Run `bun run emails:check-loops --structure-only --allow-live` to verify the remote workflow structure and status; review custom MJML content in the browser.
| Flow | Audience | Schedule after entry | Emails |
| --- | --- | --- | --- |
| [Independent free-user onboarding](https://app.loops.so/workflows/cmtvpih6201fp0jznodf8fpwz) | free | Day 0, Day 2, Day 5, Day 9 | 4 |
| [Customer onboarding](https://app.loops.so/workflows/cmtvpilm601f40jzwx3474cko) | customer | Day 0, Day 3, Day 7 | 3 |
+| [Free activation and Pro conversion v2](https://app.loops.so/workflows/cmtxlhkcw0dta0jzkd6an7xz0) | free | Day 0, Day 1, Day 3, Day 8, Day 10, Day 12 | 6 |
| [Teammate onboarding](https://app.loops.so/workflows/cmtvpm0ag01it0j37w02o6wav) | teammate | Day 0, Day 3 | 2 |
| [Former customer follow-up](https://app.loops.so/workflows/cmtvpm8zm01ii0j01cz7d15qr) | former | Day 14 | 1 |
-Completed signups reach Loops through Stripe; SSO uses a small direct fallback. Cap supplies targeting through a durable sync queue, without a separate marketing opt-in step. Existing opt-outs and suppressions take precedence. The integration is not deployed. Historical imports stay held; a new accepted invitation can start teammate help only.
+Completed signups reach Loops through Stripe; SSO uses a small direct fallback. Cap supplies targeting through a durable sync queue, without a separate marketing opt-in step. Existing opt-outs and suppressions take precedence. Historical imports stay held; a new accepted invitation can start teammate help only.
-Current draft journeys require global subscription, capConsent=subscribed, the exact audience, lifecycle enabled and onboarding eligible. capConsent is a legacy migration guard, not a separate consent-capture requirement for new signups. These filters continue to apply downstream. Free/former promotional flows additionally exclude teammates and require promotional eligibility.
+Journeys require global subscription, capConsent=subscribed, the exact audience, lifecycle enabled and onboarding eligible. capConsent is a legacy migration guard, not a separate consent-capture requirement for new signups. These filters continue to apply downstream. Free/former promotional flows additionally exclude teammates and require promotional eligibility.
Teammate history takes priority over paid/free classification. Ambiguous contacts receive no journey. [Audience classification and consent](../scripts/loops/README.md#audience-rules).
-An independent watchdog can block all four automatic journeys by adding an impossible subscription condition to their downstream guards. Recovery never resumes delivery automatically. This does not cancel manually scheduled campaigns; check sync health before every campaign send. See the [outage and resume procedure](../scripts/loops/README.md#outage-protection).
+The independent watchdog checks every registered journey and alerts when an active flow requires a manual pause. It can hold paused/draft journeys with an impossible subscription condition. Recovery never resumes delivery automatically. Check sync health before campaign sends. See the [outage and resume procedure](../scripts/loops/README.md#outage-protection) and [onboarding runtime](onboarding-runtime.md).
### Independent free-user onboarding
@@ -76,6 +77,53 @@ flowchart TD
| 3 | [One less thing to explain twice](../emails/marketing/customer-workflow.ts) | Journey guards |
| 7 | [How is Cap working for you?](../emails/marketing/customer-feedback.ts) | Journey guards |
+### Free activation and Pro conversion v2
+
+Entry: capLifecycleStage changes into free-v2. Re-entry is disabled. Delays below are relative to the previous step; day numbers are cumulative from entry.
+
+Downstream conditions: subscribed isTrue; capConsent equals subscribed; capAudience equals free; capTeammate isFalse; capPromotionalEligible isTrue; capLifecycleEnabled isTrue; capOnboardingEligible isTrue; capLifecycleStage equals free-v2.
+
+```mermaid
+flowchart TD
+ entry["Stage becomes free-v2"] --> guard["Consent and audience guards"]
+ guard --> email0["Day 0: welcome"]
+ email0 --> wait1["Wait 1 days"]
+ wait1 --> branch1{"capNeedsRecordingHelp is true?"}
+ branch1 -->|Yes| email1["Day 1: record"]
+ branch1 -->|No: skip| next1((Continue))
+ email1 --> next1
+ next1 --> wait2["Wait 2 days"]
+ wait2 --> branch2{"capReadyForPro is true?"}
+ branch2 -->|Yes| email2["Day 3: plans"]
+ branch2 -->|No: skip| next2((Continue))
+ email2 --> next2
+ next2 --> wait3["Wait 5 days"]
+ wait3 --> branch3{"capNeedsSharingHelp is true?"}
+ branch3 -->|Yes| email3["Day 8: share"]
+ branch3 -->|No: skip| next3((Continue))
+ email3 --> next3
+ next3 --> wait4["Wait 2 days"]
+ wait4 --> branch4{"capReadyForPro is true?"}
+ branch4 -->|Yes| email4["Day 10: ai"]
+ branch4 -->|No: skip| next4((Continue))
+ email4 --> next4
+ next4 --> wait5["Wait 2 days"]
+ wait5 --> branch5{"capNeedsRecordingHelp is true?"}
+ branch5 -->|Yes| email5["Day 12: help"]
+ branch5 -->|No: skip| next5((Continue))
+ email5 --> next5
+ next5 --> exit["End"]
+```
+
+| Day | Subject and source | Send condition |
+| --- | --- | --- |
+| 0 | [Your first Cap only needs 30 seconds](../emails/marketing/free-v2-welcome.ts) | Journey guards |
+| 1 | [One small thing to record today](../emails/marketing/free-v2-record.ts) | `capNeedsRecordingHelp=true` |
+| 3 | [Some explanations need more than five minutes](../emails/marketing/free-v2-plans.ts) | `capReadyForPro=true` |
+| 8 | [Put your recording to work](../emails/marketing/free-v2-share.ts) | `capNeedsSharingHelp=true` |
+| 10 | [Record the walkthrough. Skip the extra write-up.](../emails/marketing/free-v2-ai.ts) | `capReadyForPro=true` |
+| 12 | [Anything getting in the way?](../emails/marketing/free-v2-help.ts) | `capNeedsRecordingHelp=true` |
+
### Teammate onboarding
Entry: capLifecycleStage changes into teammate. Re-entry is disabled. Delays below are relative to the previous step; day numbers are cumulative from entry.
@@ -300,6 +348,142 @@ Just reply here. I read every reply, and hearing how people actually use Cap hel
Thanks again for backing us :)
+### free-v2-welcome
+
+Help a new independent free user record and share one useful explanation.
+
+**Subject:** Your first Cap only needs 30 seconds
+
+**Preview:** Record one thing, send the link, and you're off.
+
+**Variables:** `contact.capGreeting` (fallback: Hey,)
+
+**Edit:** [emails/marketing/free-v2-welcome.ts](../emails/marketing/free-v2-welcome.ts)
+
+{contact.capGreeting}
+
+Richie here, founder of Cap. Thanks for giving it a go :)
+
+For your first recording, pick something you'd normally explain in a long message. Open Cap, choose Instant Mode, and spend 30 seconds showing it on screen.
+
+Once it's ready, send the link to someone who needs that explanation. No polished presentation needed.
+
+Download Cap and make your first recording
+
+If you get stuck, reply here and I'll help.
+
+### free-v2-record
+
+Offer a small first cloud recording task when no completed video is visible.
+
+**Subject:** One small thing to record today
+
+**Preview:** Try explaining something you already know.
+
+**Variables:** `contact.capGreeting` (fallback: Hey,)
+
+**Edit:** [emails/marketing/free-v2-record.ts](../emails/marketing/free-v2-record.ts)
+
+{contact.capGreeting}
+
+If you're still finding your feet with Cap, try recording a quick walkthrough of something you already know: a setting, a page, or a problem you want to show someone.
+
+Choose Instant Mode, keep it short, then send the link when it's ready.
+
+Get started with your first recording
+
+If screen or microphone permissions are getting in the way, reply with what you're seeing and I'll help you sort it.
+
+### free-v2-plans
+
+Offer Pro to active free users through a specific cloud-sharing benefit.
+
+**Subject:** Some explanations need more than five minutes
+
+**Preview:** Share the full walkthrough with Cap Pro.
+
+**Variables:** `contact.capGreeting` (fallback: Hey,)
+
+**Edit:** [emails/marketing/free-v2-plans.ts](../emails/marketing/free-v2-plans.ts)
+
+{contact.capGreeting}
+
+Five minutes works for a quick question. A full walkthrough sometimes needs longer.
+
+Cap Pro removes the five-minute limit on cloud recordings and gives you unlimited shareable links, so you can send the whole explanation in one video.
+
+It also includes the desktop commercial license for work recordings.
+
+Pro is US$12 per user, billed monthly. Annual billing is also available.
+
+Upgrade to Cap Pro
+
+### free-v2-share
+
+Help an active user put a completed recording into a real conversation.
+
+**Subject:** Put your recording to work
+
+**Preview:** Send the link with one sentence about what you need.
+
+**Variables:** `contact.capGreeting` (fallback: Hey,)
+
+**Edit:** [emails/marketing/free-v2-share.ts](../emails/marketing/free-v2-share.ts)
+
+{contact.capGreeting}
+
+A useful way to share a Cap is to add one sentence telling the other person what you need from them.
+
+"Here's the bit I'm stuck on. Can you take a look?"
+
+Or: "Here's how to change that setting. Does that solve it?"
+
+Open your recordings
+
+Send the link wherever you're already having the conversation.
+
+### free-v2-ai
+
+Show active free users how Pro reduces the work around a recording.
+
+**Subject:** Record the walkthrough. Skip the extra write-up.
+
+**Preview:** Give people a summary and chapters alongside your video.
+
+**Variables:** `contact.capGreeting` (fallback: Hey,)
+
+**Edit:** [emails/marketing/free-v2-ai.ts](../emails/marketing/free-v2-ai.ts)
+
+{contact.capGreeting}
+
+A recording saves you typing everything out. Writing a summary afterwards can feel like doing the job twice.
+
+Cap Pro generates a title, summary, transcript and clickable chapters for your recordings. The person watching can get the context, then jump to the part they need.
+
+That's especially useful for walkthroughs people come back to later.
+
+See Cap Pro
+
+### free-v2-help
+
+Invite a reply from users who have not reached a completed cloud recording.
+
+**Subject:** Anything getting in the way?
+
+**Preview:** Reply and tell me where you're getting stuck.
+
+**Variables:** `contact.capGreeting` (fallback: Hey,)
+
+**Edit:** [emails/marketing/free-v2-help.ts](../emails/marketing/free-v2-help.ts)
+
+{contact.capGreeting}
+
+If you haven't found a useful way to fit Cap into your day yet, is anything getting in the way?
+
+Maybe you're not sure what to record, something isn't working, or it isn't quite what you expected.
+
+Reply and let me know. If I can help you get a useful first recording out of it, I'd like to.
+
### teammate-welcome
Help an invited teammate find their workspace without promoting upgrades.
diff --git a/emails/audiences.ts b/emails/audiences.ts
index 12381da937..c0e38d1274 100644
--- a/emails/audiences.ts
+++ b/emails/audiences.ts
@@ -13,6 +13,12 @@ export const contactProperties = {
capLifecycleStage: "string",
capHasVideo: "boolean",
capHasSharedVideo: "boolean",
+ capReadyForPro: "boolean",
+ capNeedsSharingHelp: "boolean",
+ capNeedsRecordingHelp: "boolean",
+ capFreeOnboardingVariant: "string",
+ capFreeOnboardingExperiment: "string",
+ capFreeOnboardingAssignedAt: "date",
capVerifiedAt: "date",
capImportedAt: "date",
capSignupAt: "date",
diff --git a/emails/delivery-safety.ts b/emails/delivery-safety.ts
index 10c2b37c39..8a3ed9c84a 100644
--- a/emails/delivery-safety.ts
+++ b/emails/delivery-safety.ts
@@ -7,6 +7,9 @@ export const workflowAudience = (journey: Journey) => ({
...audienceFilter(journey.audience, journey.promotional).conditions,
condition("capLifecycleEnabled", true),
condition("capOnboardingEligible", true),
+ ...(journey.key === "free-v2"
+ ? [condition("capLifecycleStage", "free-v2")]
+ : []),
],
});
diff --git a/emails/flows.ts b/emails/flows.ts
index 6fd19fd0a5..710e823d62 100644
--- a/emails/flows.ts
+++ b/emails/flows.ts
@@ -5,6 +5,12 @@ import { formerFeedback } from "./marketing/former-feedback";
import { freePlans } from "./marketing/free-plans";
import { freeRecord } from "./marketing/free-record";
import { freeShare } from "./marketing/free-share";
+import { freeV2Ai } from "./marketing/free-v2-ai";
+import { freeV2Help } from "./marketing/free-v2-help";
+import { freeV2Plans } from "./marketing/free-v2-plans";
+import { freeV2Record } from "./marketing/free-v2-record";
+import { freeV2Share } from "./marketing/free-v2-share";
+import { freeV2Welcome } from "./marketing/free-v2-welcome";
import { freeWelcome } from "./marketing/free-welcome";
import { teammateHandoff } from "./marketing/teammate-handoff";
import { teammateWelcome } from "./marketing/teammate-welcome";
@@ -42,6 +48,40 @@ export const journeys: Journey[] = [
{ ...customerFeedback, delayDays: 4 },
],
},
+ {
+ key: "free-v2",
+ name: "Cap | Free activation and Pro conversion v2",
+ audience: "free",
+ promotional: true,
+ messages: [
+ { ...freeV2Welcome, delayDays: 0 },
+ {
+ ...freeV2Record,
+ delayDays: 1,
+ onlyIf: { property: "capNeedsRecordingHelp", value: true },
+ },
+ {
+ ...freeV2Plans,
+ delayDays: 2,
+ onlyIf: { property: "capReadyForPro", value: true },
+ },
+ {
+ ...freeV2Share,
+ delayDays: 5,
+ onlyIf: { property: "capNeedsSharingHelp", value: true },
+ },
+ {
+ ...freeV2Ai,
+ delayDays: 2,
+ onlyIf: { property: "capReadyForPro", value: true },
+ },
+ {
+ ...freeV2Help,
+ delayDays: 2,
+ onlyIf: { property: "capNeedsRecordingHelp", value: true },
+ },
+ ],
+ },
{
key: "teammate",
name: "Cap | Teammate onboarding",
diff --git a/emails/marketing/free-v2-ai.ts b/emails/marketing/free-v2-ai.ts
new file mode 100644
index 0000000000..c75e1a8dca
--- /dev/null
+++ b/emails/marketing/free-v2-ai.ts
@@ -0,0 +1,18 @@
+import type { EmailDefinition } from "../types";
+
+export const freeV2Ai = {
+ id: "free-v2-ai",
+ key: "ai",
+ purpose:
+ "Show active free users how Pro reduces the work around a recording.",
+ subject: "Record the walkthrough. Skip the extra write-up.",
+ previewText: "Give people a summary and chapters alongside your video.",
+ variables: ["capGreeting"],
+ body: [
+ "{contact.capGreeting}",
+ "A recording saves you typing everything out. Writing a summary afterwards can feel like doing the job twice.",
+ "Cap Pro generates a title, summary, transcript and clickable chapters for your recordings. The person watching can get the context, then jump to the part they need.",
+ "That's especially useful for walkthroughs people come back to later.",
+ 'See Cap Pro',
+ ].join(""),
+} satisfies EmailDefinition;
diff --git a/emails/marketing/free-v2-help.ts b/emails/marketing/free-v2-help.ts
new file mode 100644
index 0000000000..bda06ad141
--- /dev/null
+++ b/emails/marketing/free-v2-help.ts
@@ -0,0 +1,17 @@
+import type { EmailDefinition } from "../types";
+
+export const freeV2Help = {
+ id: "free-v2-help",
+ key: "help",
+ purpose:
+ "Invite a reply from users who have not reached a completed cloud recording.",
+ subject: "Anything getting in the way?",
+ previewText: "Reply and tell me where you're getting stuck.",
+ variables: ["capGreeting"],
+ body: [
+ "{contact.capGreeting}",
+ "If you haven't found a useful way to fit Cap into your day yet, is anything getting in the way?",
+ "Maybe you're not sure what to record, something isn't working, or it isn't quite what you expected.",
+ "Reply and let me know. If I can help you get a useful first recording out of it, I'd like to.",
+ ].join(""),
+} satisfies EmailDefinition;
diff --git a/emails/marketing/free-v2-plans.ts b/emails/marketing/free-v2-plans.ts
new file mode 100644
index 0000000000..c3ca23de57
--- /dev/null
+++ b/emails/marketing/free-v2-plans.ts
@@ -0,0 +1,19 @@
+import type { EmailDefinition } from "../types";
+
+export const freeV2Plans = {
+ id: "free-v2-plans",
+ key: "plans",
+ purpose:
+ "Offer Pro to active free users through a specific cloud-sharing benefit.",
+ subject: "Some explanations need more than five minutes",
+ previewText: "Share the full walkthrough with Cap Pro.",
+ variables: ["capGreeting"],
+ body: [
+ "{contact.capGreeting}",
+ "Five minutes works for a quick question. A full walkthrough sometimes needs longer.",
+ "Cap Pro removes the five-minute limit on cloud recordings and gives you unlimited shareable links, so you can send the whole explanation in one video.",
+ "It also includes the desktop commercial license for work recordings.",
+ "Pro is US$12 per user, billed monthly. Annual billing is also available.",
+ 'Upgrade to Cap Pro',
+ ].join(""),
+} satisfies EmailDefinition;
diff --git a/emails/marketing/free-v2-record.ts b/emails/marketing/free-v2-record.ts
new file mode 100644
index 0000000000..0a304ba803
--- /dev/null
+++ b/emails/marketing/free-v2-record.ts
@@ -0,0 +1,18 @@
+import type { EmailDefinition } from "../types";
+
+export const freeV2Record = {
+ id: "free-v2-record",
+ key: "record",
+ purpose:
+ "Offer a small first cloud recording task when no completed video is visible.",
+ subject: "One small thing to record today",
+ previewText: "Try explaining something you already know.",
+ variables: ["capGreeting"],
+ body: [
+ "{contact.capGreeting}",
+ "If you're still finding your feet with Cap, try recording a quick walkthrough of something you already know: a setting, a page, or a problem you want to show someone.",
+ "Choose Instant Mode, keep it short, then send the link when it's ready.",
+ 'Get started with your first recording',
+ "If screen or microphone permissions are getting in the way, reply with what you're seeing and I'll help you sort it.",
+ ].join(""),
+} satisfies EmailDefinition;
diff --git a/emails/marketing/free-v2-share.ts b/emails/marketing/free-v2-share.ts
new file mode 100644
index 0000000000..fef7f28927
--- /dev/null
+++ b/emails/marketing/free-v2-share.ts
@@ -0,0 +1,19 @@
+import type { EmailDefinition } from "../types";
+
+export const freeV2Share = {
+ id: "free-v2-share",
+ key: "share",
+ purpose:
+ "Help an active user put a completed recording into a real conversation.",
+ subject: "Put your recording to work",
+ previewText: "Send the link with one sentence about what you need.",
+ variables: ["capGreeting"],
+ body: [
+ "{contact.capGreeting}",
+ "A useful way to share a Cap is to add one sentence telling the other person what you need from them.",
+ "\"Here's the bit I'm stuck on. Can you take a look?\"",
+ 'Or: "Here\'s how to change that setting. Does that solve it?"',
+ 'Open your recordings',
+ "Send the link wherever you're already having the conversation.",
+ ].join(""),
+} satisfies EmailDefinition;
diff --git a/emails/marketing/free-v2-welcome.ts b/emails/marketing/free-v2-welcome.ts
new file mode 100644
index 0000000000..6df0654126
--- /dev/null
+++ b/emails/marketing/free-v2-welcome.ts
@@ -0,0 +1,19 @@
+import type { EmailDefinition } from "../types";
+
+export const freeV2Welcome = {
+ id: "free-v2-welcome",
+ key: "welcome",
+ purpose:
+ "Help a new independent free user record and share one useful explanation.",
+ subject: "Your first Cap only needs 30 seconds",
+ previewText: "Record one thing, send the link, and you're off.",
+ variables: ["capGreeting"],
+ body: [
+ "{contact.capGreeting}",
+ "Richie here, founder of Cap. Thanks for giving it a go :)",
+ "For your first recording, pick something you'd normally explain in a long message. Open Cap, choose Instant Mode, and spend 30 seconds showing it on screen.",
+ "Once it's ready, send the link to someone who needs that explanation. No polished presentation needed.",
+ 'Download Cap and make your first recording',
+ "If you get stuck, reply here and I'll help.",
+ ].join(""),
+} satisfies EmailDefinition;
diff --git a/emails/onboarding-runtime.md b/emails/onboarding-runtime.md
new file mode 100644
index 0000000000..fa61e5d08b
--- /dev/null
+++ b/emails/onboarding-runtime.md
@@ -0,0 +1,33 @@
+# Free onboarding runtime
+
+The `free-v2` journey uses the templates and conditional delays in `flows.ts`. The existing `free` journey remains available. Assignment is disabled by default.
+
+## Enrollment
+
+`LOOPS_FREE_EXPERIMENT_ENABLED=true` enables new assignments for signups on or after `LOOPS_FREE_EXPERIMENT_AFTER`. Configure a future boundary only after verifying the deployed code, contact properties, workflow, and custom email uploads.
+
+A deterministic user-ID bucket assigns eligible independent free accounts equally between the two journeys. Loops stores the experiment ID, variant, and assignment timestamp together with the first lifecycle-stage update. Assignment survives later purchases and unsubscribes; subsequent updates do not replace it.
+
+Existing enrolled users, imports, customers, invitees, teammate history, incomplete signups, and unsubscribed accounts cannot enter. The `free-v2` downstream guard additionally requires `capLifecycleStage=free-v2`. Consent, list membership, and audience guards apply throughout delivery. Product and billing changes use the existing durable sync queue.
+
+## Activation signals
+
+Accounts created from September 12, 2026 UTC use the new readiness query in either journey. Older accounts retain their existing queries and profile fingerprints. This fixed signal boundary is independent of the enrollment switch.
+
+A completed cloud video requires positive duration, no screenshot flag or upload row, and a verified processing job or explicit published output/legacy completion marker with no processing job. Pending or failed uploads and processing suppress Pro and sharing follow-ups, including when an older video is complete.
+
+`capHasSharedVideo` uses `firstViewEmailSentAt` on a completed video as a proxy for a non-owner page visit. It does not prove playback, and notification preferences can make it undercount. Local Studio recordings are not observed. Imported or uploaded videos can establish cloud readiness.
+
+Follow-ups are suppressed for 24 hours after the latest synchronized application-notification signal. Profiles refresh hourly for new accounts, so this is not an atomic frequency lock across Loops and Resend; a refresh-to-delivery race remains possible. The configured journey allows at most three lifecycle messages in seven days, separated by at least 24 hours.
+
+Pro links preserve the organization-billing destination through the existing login `next` parameter. Opening a link does not initiate payment.
+
+## Verification and recovery
+
+Create a new custom-email workflow with `scripts/loops/provision.ts --new-journey free-v2`, using the required private receipt, verified team and mailing list. New-journey mode refuses unrelated existing workflows. Export with `bun run emails:export --output `, upload the archives in Loops, and review metadata, fallbacks, body, images, and footer. Store only nonsecret resource IDs in `resources.json`.
+
+Run `bun run emails:check-loops --structure-only --allow-live` to verify remote structure and state. Omit `--allow-live` when only drafts are expected. API checks cannot establish custom-MJML content parity; browser review remains necessary. Every registered journey is included in the independent watchdog.
+
+To stop new assignments, set `LOOPS_FREE_EXPERIMENT_ENABLED=false` and deploy. Existing assignments continue their journeys. Pause the corresponding Loops workflow when delivery itself must stop. Do not change the fixed activation boundary or reset assignment properties.
+
+Before reverting to code without variant-aware routing, pause both free workflows and reconcile enrolled contacts: older code would route treatment contacts back to `free`. Never bulk resubscribe, re-enroll, or clear imported holds during recovery.
diff --git a/emails/resources.json b/emails/resources.json
index 971e9e613e..0dbbdfce52 100644
--- a/emails/resources.json
+++ b/emails/resources.json
@@ -23,7 +23,8 @@
"Cap lifecycle header v5": "cmtwx1d1q0oys0jyw6yq91naz",
"Cap lifecycle signature v5": "cmtwx4qyk0odg0j03ibll1cad",
"Cap lifecycle header v6": "cmtwxickf0d570jzvtpz1dyyh",
- "Cap lifecycle signature v6": "cmtwxid5g17tp0jyc2mu1hgwj"
+ "Cap lifecycle signature v6": "cmtwxid5g17tp0jyc2mu1hgwj",
+ "free-v2": "cmtxlhkcw0dta0jzkd6an7xz0"
},
"operations": {
"free:guard": "n3",
@@ -48,6 +49,23 @@
"teammate:handoff:email": "n6",
"former:guard": "n3",
"former:feedback:timer": "n4",
- "former:feedback:email": "n5"
+ "former:feedback:email": "n5",
+ "free-v2:guard": "n3",
+ "free-v2:welcome:email": "n4",
+ "free-v2:record:timer": "n5",
+ "free-v2:record:branch": "n6",
+ "free-v2:record:email": "n9",
+ "free-v2:plans:timer": "n10",
+ "free-v2:plans:branch": "n11",
+ "free-v2:plans:email": "n14",
+ "free-v2:share:timer": "n15",
+ "free-v2:share:branch": "n16",
+ "free-v2:share:email": "n19",
+ "free-v2:ai:timer": "n20",
+ "free-v2:ai:branch": "n21",
+ "free-v2:ai:email": "n24",
+ "free-v2:help:timer": "n25",
+ "free-v2:help:filter": "n27",
+ "free-v2:help:email": "n29"
}
}
diff --git a/packages/database/loops/activation.ts b/packages/database/loops/activation.ts
new file mode 100644
index 0000000000..04c6423b58
--- /dev/null
+++ b/packages/database/loops/activation.ts
@@ -0,0 +1,23 @@
+export const activationQuery = `
+ SELECT
+ MAX(CASE WHEN u.video_id IS NOT NULL OR (j.video_id IS NOT NULL AND j.state <> 'verified') THEN 1 ELSE 0 END) AS hasPendingUpload,
+ MAX(CASE WHEN u.video_id IS NULL
+ AND v.duration > 0
+ AND (j.state = 'verified' OR (j.video_id IS NULL AND (
+ (JSON_TYPE(JSON_EXTRACT(v.source, '$.outputKey')) = 'STRING'
+ AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(v.source, '$.outputKey')), '') IS NOT NULL)
+ OR v.jobStatus = 'COMPLETE'
+ ))) THEN 1 ELSE 0 END) AS hasVideo,
+ MAX(CASE WHEN u.video_id IS NULL
+ AND v.duration > 0
+ AND (j.state = 'verified' OR (j.video_id IS NULL AND (
+ (JSON_TYPE(JSON_EXTRACT(v.source, '$.outputKey')) = 'STRING'
+ AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(v.source, '$.outputKey')), '') IS NOT NULL)
+ OR v.jobStatus = 'COMPLETE'
+ ))) AND v.firstViewEmailSentAt IS NOT NULL THEN 1 ELSE 0 END) AS hasSharedVideo,
+ MAX(GREATEST(DATE_ADD(v.createdAt, INTERVAL 5 MINUTE), COALESCE(v.firstViewEmailSentAt, v.createdAt))) AS lastActivationNotificationAt
+ FROM videos v
+ LEFT JOIN video_uploads u ON u.video_id = v.id
+ LEFT JOIN video_processing_jobs j ON j.video_id = v.id
+ WHERE v.ownerId = ? AND v.isScreenshot = 0
+`;
diff --git a/packages/database/loops/experiment.ts b/packages/database/loops/experiment.ts
new file mode 100644
index 0000000000..1733ab6e4e
--- /dev/null
+++ b/packages/database/loops/experiment.ts
@@ -0,0 +1,19 @@
+import { createHash } from "node:crypto";
+
+export const freeOnboardingExperiment = "free-pro-v2-2026-09";
+export const activationSignalsAfter = new Date("2026-09-12T00:00:00Z");
+
+export function freeOnboardingVariant(userId: string) {
+ return createHash("sha256")
+ .update(`${freeOnboardingExperiment}:${userId}`)
+ .digest()
+ .readUInt32BE(0) %
+ 2 ===
+ 0
+ ? "control"
+ : "pro-v2";
+}
+
+export function inFreeExperiment(signupAt: string, after?: Date) {
+ return Boolean(after && Date.parse(signupAt) >= after.getTime());
+}
diff --git a/packages/database/loops/lifecycle.ts b/packages/database/loops/lifecycle.ts
index 5e08a99975..01f8755662 100644
--- a/packages/database/loops/lifecycle.ts
+++ b/packages/database/loops/lifecycle.ts
@@ -1,3 +1,9 @@
+import {
+ activationSignalsAfter,
+ freeOnboardingExperiment,
+ freeOnboardingVariant,
+ inFreeExperiment,
+} from "./experiment";
import {
type ContactProfile,
type CustomerCopy,
@@ -12,6 +18,9 @@ export type LifecycleContact = RemoteContact & {
capConsent?: string;
capLifecycleEnabled?: boolean;
capSignupAt?: string;
+ capFreeOnboardingVariant?: string;
+ capFreeOnboardingExperiment?: string;
+ capFreeOnboardingAssignedAt?: string;
};
export type LoopsRuntimeConfig = {
@@ -20,6 +29,8 @@ export type LoopsRuntimeConfig = {
enrollmentEnabled: boolean;
allowedEmails: Set | null;
teammateJoinedAt?: string | null;
+ freeExperimentAfter?: Date;
+ freeExperimentEnrollmentEnabled?: boolean;
};
export function enrollmentWindow(signupAt: string, config: LoopsRuntimeConfig) {
@@ -35,6 +46,21 @@ export function enrollmentWindow(signupAt: string, config: LoopsRuntimeConfig) {
};
}
+export function activationFollowUps(input: LoopsProfileSource["input"]) {
+ const recentlyNotified = Boolean(
+ input.lastActivationNotificationAt &&
+ input.now.getTime() -
+ Date.parse(isoDate(input.lastActivationNotificationAt)) <
+ 24 * 60 * 60_000,
+ );
+ const ready = input.hasVideo && !input.hasPendingUpload && !recentlyNotified;
+ return {
+ capReadyForPro: ready,
+ capNeedsSharingHelp: ready && !input.hasSharedVideo,
+ capNeedsRecordingHelp: !input.hasVideo && !recentlyNotified,
+ };
+}
+
export function lifecycleUpdate(
source: LoopsProfileSource,
remote: LifecycleContact,
@@ -73,8 +99,40 @@ export function lifecycleUpdate(
capSourceGroup: _group,
...attributes
} = profile;
+ const assigned =
+ remote.capFreeOnboardingExperiment === freeOnboardingExperiment;
+ const canAssign =
+ config.freeExperimentEnrollmentEnabled === true &&
+ eligible &&
+ audience === "free" &&
+ !teammate &&
+ !remote.capFreeOnboardingExperiment &&
+ (!remote.capLifecycleStage || remote.capLifecycleStage === "idle") &&
+ inFreeExperiment(profile.capSignupAt, config.freeExperimentAfter);
+ const assignment = canAssign
+ ? {
+ capFreeOnboardingVariant: freeOnboardingVariant(profile.userId),
+ capFreeOnboardingExperiment: freeOnboardingExperiment,
+ capFreeOnboardingAssignedAt: source.input.now.toISOString(),
+ }
+ : {};
+ const variant = canAssign
+ ? assignment.capFreeOnboardingVariant
+ : assigned
+ ? remote.capFreeOnboardingVariant
+ : undefined;
+ if (
+ assigned &&
+ (!["control", "pro-v2"].includes(remote.capFreeOnboardingVariant ?? "") ||
+ !Number.isFinite(Date.parse(remote.capFreeOnboardingAssignedAt ?? "")))
+ )
+ throw new Error("invalid_free_experiment_assignment");
return {
...attributes,
+ ...(inFreeExperiment(profile.capSignupAt, activationSignalsAfter)
+ ? activationFollowUps(source.input)
+ : {}),
+ ...assignment,
capTeammate: teammate,
capAudience: audience,
capOrigin: teammate ? "teammate" : profile.capOrigin,
@@ -87,7 +145,11 @@ export function lifecycleUpdate(
subscribed && !teammate && profile.capPromotionalEligible,
capLifecycleEnabled: eligible,
capOnboardingEligible: eligible,
- capLifecycleStage: eligible ? audience : "idle",
+ capLifecycleStage: eligible
+ ? audience === "free" && variant === "pro-v2"
+ ? "free-v2"
+ : audience
+ : "idle",
};
}
diff --git a/packages/database/loops/profile.ts b/packages/database/loops/profile.ts
index 5adf8bbfb3..72600d2e97 100644
--- a/packages/database/loops/profile.ts
+++ b/packages/database/loops/profile.ts
@@ -42,6 +42,8 @@ export type ProfileInput = {
teammateLatch?: boolean;
hasVideo: boolean;
hasSharedVideo: boolean;
+ lastActivationNotificationAt?: string | null;
+ hasPendingUpload?: boolean;
now: Date;
};
diff --git a/packages/database/loops/sources.ts b/packages/database/loops/sources.ts
index b552ca56aa..9e886410ac 100644
--- a/packages/database/loops/sources.ts
+++ b/packages/database/loops/sources.ts
@@ -1,5 +1,13 @@
import type { Connection, RowDataPacket } from "mysql2/promise";
-import type { CapUser, License, Membership, ProfileInput } from "./profile";
+import { activationQuery } from "./activation";
+import { activationSignalsAfter, inFreeExperiment } from "./experiment";
+import {
+ type CapUser,
+ isoDate,
+ type License,
+ type Membership,
+ type ProfileInput,
+} from "./profile";
type RuntimeUser = CapUser & {
emailVerified: string | null;
@@ -44,16 +52,30 @@ export async function readLoopsProfile(
"SELECT status FROM organization_invites WHERE invitedEmail=? AND status IN ('pending','accepted')",
[user.email],
);
- const [video] = await rows<{ id: string }>(
- cap,
- "SELECT id FROM videos WHERE ownerId=? LIMIT 1",
- [userId],
- );
- const [shared] = await rows<{ id: string }>(
- cap,
- "SELECT id FROM videos WHERE ownerId=? AND public=1 LIMIT 1",
- [userId],
- );
+ let activation: {
+ hasVideo: number | boolean | null;
+ hasSharedVideo: number | boolean | null;
+ hasPendingUpload?: number | null;
+ lastActivationNotificationAt?: string | null;
+ };
+ if (inFreeExperiment(isoDate(user.created_at), activationSignalsAfter)) {
+ const [result] = await rows(cap, activationQuery, [
+ userId,
+ ]);
+ activation = result;
+ } else {
+ const [video] = await rows<{ id: string }>(
+ cap,
+ "SELECT id FROM videos WHERE ownerId=? LIMIT 1",
+ [userId],
+ );
+ const [shared] = await rows<{ id: string }>(
+ cap,
+ "SELECT id FROM videos WHERE ownerId=? AND public=1 LIMIT 1",
+ [userId],
+ );
+ activation = { hasVideo: Boolean(video), hasSharedVideo: Boolean(shared) };
+ }
const entitlement = await rows(
licenses,
"SELECT u.email,l.subscriptionActive,l.nextRenewalDate,'desktop' AS kind FROM commercialLicenses l JOIN user u ON u.id=l.userId WHERE u.email=? UNION ALL SELECT u.email,l.subscriptionActive,l.nextRenewalDate,'selfhosted' AS kind FROM selfHostedLicenses l JOIN user u ON u.id=l.userId WHERE u.email=?",
@@ -67,8 +89,10 @@ export async function readLoopsProfile(
licenses: entitlement,
invited: invites.length > 0,
sso: accounts.some((account) => account.provider === "workos"),
- hasVideo: Boolean(video),
- hasSharedVideo: Boolean(shared),
+ hasVideo: Boolean(activation?.hasVideo),
+ hasSharedVideo: Boolean(activation?.hasSharedVideo),
+ lastActivationNotificationAt: activation?.lastActivationNotificationAt,
+ hasPendingUpload: Boolean(activation?.hasPendingUpload),
now: new Date(),
},
signedUp: Boolean(user.emailVerified || accounts.length),
diff --git a/packages/database/loops/worker.ts b/packages/database/loops/worker.ts
index 0f8864ef90..541a0e52a9 100644
--- a/packages/database/loops/worker.ts
+++ b/packages/database/loops/worker.ts
@@ -11,7 +11,9 @@ import {
type LoopsClient,
LoopsRequestError,
} from "./client";
+import { activationSignalsAfter, inFreeExperiment } from "./experiment";
import {
+ activationFollowUps,
enrollmentWindow,
type LifecycleContact,
type LoopsRuntimeConfig,
@@ -176,11 +178,23 @@ export function loopsRuntimeConfig(env: NodeJS.ProcessEnv): LoopsRuntimeConfig {
);
if (allowedEmails && !allowedEmails.size)
throw new Error("Test mode requires LOOPS_TEST_EMAILS");
+ const freeExperimentAfter = env.LOOPS_FREE_EXPERIMENT_AFTER
+ ? new Date(env.LOOPS_FREE_EXPERIMENT_AFTER)
+ : undefined;
+ if (freeExperimentAfter && !Number.isFinite(freeExperimentAfter.getTime()))
+ throw new Error("Invalid free onboarding experiment date");
+ if (freeExperimentAfter && freeExperimentAfter < activationSignalsAfter)
+ throw new Error("Experiment date predates activation signal rollout");
+ if (env.LOOPS_FREE_EXPERIMENT_ENABLED === "true" && !freeExperimentAfter)
+ throw new Error("Enabled free experiment requires a start date");
return {
listId,
enrollmentAfter,
allowedEmails,
enrollmentEnabled: env.LOOPS_ENROLLMENT_ENABLED === "true",
+ freeExperimentAfter,
+ freeExperimentEnrollmentEnabled:
+ env.LOOPS_FREE_EXPERIMENT_ENABLED === "true",
};
}
@@ -270,6 +284,20 @@ export async function runLoopsSync(customerCopy: CustomerCopy) {
),
listId: config.listId,
teammateJoinedAt: job.teammateJoinedAt,
+ ...(inFreeExperiment(
+ localProfile.capSignupAt,
+ activationSignalsAfter,
+ )
+ ? {
+ freeExperiment: activationFollowUps(source.input),
+ freeExperimentEnrollmentEnabled:
+ config.freeExperimentEnrollmentEnabled &&
+ inFreeExperiment(
+ localProfile.capSignupAt,
+ config.freeExperimentAfter,
+ ),
+ }
+ : {}),
}),
)
.digest("hex");
diff --git a/scripts/emails/catalog.ts b/scripts/emails/catalog.ts
index 278f9e2b4d..e69fe52890 100644
--- a/scripts/emails/catalog.ts
+++ b/scripts/emails/catalog.ts
@@ -188,7 +188,7 @@ export const renderCatalog = () => {
"",
"## Lifecycle flows",
"",
- "These are the locally configured draft journeys. This document is not a live status check. Imports remain held; production enrollment is not deployed. Run `bun run emails:check-loops` to verify the actual Loops drafts.",
+ "These are the locally configured journeys. This document is not a live status check. Imports remain held. Run `bun run emails:check-loops --structure-only --allow-live` to verify the remote workflow structure and status; review custom MJML content in the browser.",
"",
"| Flow | Audience | Schedule after entry | Emails |",
"| --- | --- | --- | --- |",
@@ -201,13 +201,13 @@ export const renderCatalog = () => {
.join(", ")} | ${journey.messages.length} |`,
),
"",
- "Completed signups reach Loops through Stripe; SSO uses a small direct fallback. Cap supplies targeting through a durable sync queue, without a separate marketing opt-in step. Existing opt-outs and suppressions take precedence. The integration is not deployed. Historical imports stay held; a new accepted invitation can start teammate help only.",
+ "Completed signups reach Loops through Stripe; SSO uses a small direct fallback. Cap supplies targeting through a durable sync queue, without a separate marketing opt-in step. Existing opt-outs and suppressions take precedence. Historical imports stay held; a new accepted invitation can start teammate help only.",
"",
- "Current draft journeys require global subscription, capConsent=subscribed, the exact audience, lifecycle enabled and onboarding eligible. capConsent is a legacy migration guard, not a separate consent-capture requirement for new signups. These filters continue to apply downstream. Free/former promotional flows additionally exclude teammates and require promotional eligibility.",
+ "Journeys require global subscription, capConsent=subscribed, the exact audience, lifecycle enabled and onboarding eligible. capConsent is a legacy migration guard, not a separate consent-capture requirement for new signups. These filters continue to apply downstream. Free/former promotional flows additionally exclude teammates and require promotional eligibility.",
"",
"Teammate history takes priority over paid/free classification. Ambiguous contacts receive no journey. [Audience classification and consent](../scripts/loops/README.md#audience-rules).",
"",
- "An independent watchdog can block all four automatic journeys by adding an impossible subscription condition to their downstream guards. Recovery never resumes delivery automatically. This does not cancel manually scheduled campaigns; check sync health before every campaign send. See the [outage and resume procedure](../scripts/loops/README.md#outage-protection).",
+ "The independent watchdog checks every registered journey and alerts when an active flow requires a manual pause. It can hold paused/draft journeys with an impossible subscription condition. Recovery never resumes delivery automatically. Check sync health before campaign sends. See the [outage and resume procedure](../scripts/loops/README.md#outage-protection) and [onboarding runtime](onboarding-runtime.md).",
"",
];
for (const journey of journeys) {
diff --git a/scripts/loops/activation.test.ts b/scripts/loops/activation.test.ts
new file mode 100644
index 0000000000..cea6721633
--- /dev/null
+++ b/scripts/loops/activation.test.ts
@@ -0,0 +1,89 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import mysql, { type Connection, type RowDataPacket } from "mysql2/promise";
+import { activationQuery } from "../../packages/database/loops/activation";
+
+const url = process.env.LOOPS_ACTIVATION_READONLY_TEST_URL;
+
+describe.skipIf(!url)(
+ "activation SQL against synthetic rows without database writes",
+ () => {
+ let database: Connection;
+ beforeAll(async () => {
+ if (!url) throw new Error("Missing read-only test connection");
+ database = await mysql.createConnection({
+ uri: url,
+ dateStrings: true,
+ timezone: "Z",
+ });
+ });
+ afterAll(async () => {
+ await database?.end();
+ });
+
+ async function signals(
+ options: {
+ screenshot?: boolean;
+ upload?: boolean;
+ state?: string;
+ output?: string | null;
+ duration?: number;
+ viewed?: boolean;
+ otherOwner?: boolean;
+ legacyStatus?: string;
+ } = {},
+ ) {
+ const query = `WITH
+ videos AS (SELECT 'video' AS id, ? AS ownerId, ? AS isScreenshot,
+ ? AS duration, JSON_OBJECT('type', 'desktopMP4', 'outputKey', ?) AS source,
+ ? AS jobStatus, CAST('2026-09-12 01:00:00' AS DATETIME) AS createdAt,
+ CAST(? AS DATETIME) AS firstViewEmailSentAt),
+ video_uploads AS (SELECT 'video' AS video_id WHERE ?),
+ video_processing_jobs AS (SELECT 'video' AS video_id, ? AS state WHERE ?)
+ ${activationQuery}`;
+ const [rows] = await database.query(query, [
+ options.otherOwner ? "other" : "owner",
+ options.screenshot ? 1 : 0,
+ options.duration ?? 30,
+ options.output === undefined
+ ? "owner/video/result.mp4"
+ : options.output,
+ options.legacyStatus ?? null,
+ options.viewed ? "2026-09-12 02:00:00" : null,
+ options.upload ? 1 : 0,
+ options.state ?? null,
+ options.state ? 1 : 0,
+ "owner",
+ ]);
+ return rows[0];
+ }
+
+ for (const [name, options] of [
+ ["screenshot", { screenshot: true }],
+ ["another owner", { otherOwner: true }],
+ ["active upload", { upload: true }],
+ ["queued job with upload row already removed", { state: "queued" }],
+ ["failed processing", { state: "source-blocked" }],
+ ["empty output", { output: "" }],
+ ["null output", { output: null }],
+ ["zero duration", { duration: 0 }],
+ ] as const) {
+ test(`${name} is not activation`, async () => {
+ expect(Boolean((await signals(options)).hasVideo)).toBe(false);
+ });
+ }
+ test("published MP4 and verified processing count as completed videos", async () => {
+ expect((await signals()).hasVideo).toBe(1);
+ expect((await signals({ state: "verified" })).hasVideo).toBe(1);
+ expect(
+ (await signals({ output: null, legacyStatus: "COMPLETE" })).hasVideo,
+ ).toBe(1);
+ });
+ test("public-by-default is insufficient sharing evidence", async () => {
+ expect((await signals()).hasSharedVideo).toBe(0);
+ expect((await signals({ viewed: true })).hasSharedVideo).toBe(1);
+ expect(
+ (await signals({ viewed: true, upload: true })).hasSharedVideo,
+ ).toBe(0);
+ });
+ },
+);
diff --git a/scripts/loops/lifecycle.test.ts b/scripts/loops/lifecycle.test.ts
index 371dd80521..6cdc7c6a32 100644
--- a/scripts/loops/lifecycle.test.ts
+++ b/scripts/loops/lifecycle.test.ts
@@ -2,6 +2,11 @@ import { describe, expect, test } from "bun:test";
import { customerCopy } from "../../emails/customer-copy";
import type { LoopsClient } from "../../packages/database/loops/client";
import {
+ freeOnboardingExperiment,
+ freeOnboardingVariant,
+} from "../../packages/database/loops/experiment";
+import {
+ activationFollowUps,
enrollmentWindow,
type LifecycleContact,
type LoopsRuntimeConfig,
@@ -108,6 +113,155 @@ function remote(): LifecycleContact {
};
}
+describe("free conversion experiment", () => {
+ const experimentConfig = {
+ ...config,
+ freeExperimentAfter: new Date("2026-09-12T01:00:00Z"),
+ freeExperimentEnrollmentEnabled: true,
+ };
+ function freshSource() {
+ const source = fixture();
+ source.input.user.created_at = "2026-09-12T01:00:00Z";
+ source.input.now = new Date("2026-09-12T01:01:00Z");
+ return source;
+ }
+ test("both variants receive one immutable assignment at eligible entry", () => {
+ const variants = new Set();
+ for (let index = 0; index < 20; index++) {
+ const source = freshSource();
+ source.input.user.id = `new-user-${index}`;
+ const update = lifecycleUpdate(
+ source,
+ remote(),
+ experimentConfig,
+ customerCopy,
+ );
+ expect(update.capFreeOnboardingExperiment).toBe(freeOnboardingExperiment);
+ expect(update.capFreeOnboardingAssignedAt).toBe(
+ source.input.now.toISOString(),
+ );
+ expect(update.capFreeOnboardingVariant).toBe(
+ freeOnboardingVariant(source.input.user.id),
+ );
+ variants.add(update.capFreeOnboardingVariant ?? "");
+ expect(update.capLifecycleStage).toBe(
+ update.capFreeOnboardingVariant === "pro-v2" ? "free-v2" : "free",
+ );
+ const contact = { ...remote(), ...update };
+ source.input.now = new Date("2026-09-14T01:00:00Z");
+ const repeated = lifecycleUpdate(
+ source,
+ contact,
+ { ...experimentConfig, freeExperimentEnrollmentEnabled: false },
+ customerCopy,
+ );
+ expect(repeated.capLifecycleStage).toBe(update.capLifecycleStage);
+ expect(repeated).not.toHaveProperty("capFreeOnboardingAssignedAt");
+ }
+ expect([...variants].sort()).toEqual(["control", "pro-v2"]);
+ });
+ test("historical and already enrolled free contacts stay on their original flow", () => {
+ for (const [source, contact] of [
+ [fixture(), remote()],
+ [freshSource(), { ...remote(), capLifecycleStage: "free" }],
+ ] as const) {
+ const update = lifecycleUpdate(
+ source,
+ contact,
+ experimentConfig,
+ customerCopy,
+ );
+ expect(update.capLifecycleStage).toBe("free");
+ expect(update).not.toHaveProperty("capFreeOnboardingVariant");
+ }
+ });
+ test("opt-outs, imports, customers, pending invites and teammate history cannot enter the experiment", () => {
+ for (const kind of [
+ "unsubscribe",
+ "list",
+ "import",
+ "customer",
+ "invite",
+ "teammate",
+ "unsigned",
+ ] as const) {
+ const source = freshSource();
+ const contact = remote();
+ if (kind === "unsubscribe") contact.subscribed = false;
+ if (kind === "list") contact.mailingLists[config.listId] = false;
+ if (kind === "import") contact.capImportedAt = "2026-09-11T00:00:00Z";
+ if (kind === "customer")
+ source.input.user.stripeSubscriptionStatus = "active";
+ if (kind === "invite") source.pendingInvite = true;
+ if (kind === "teammate") contact.capTeammate = true;
+ if (kind === "unsigned") source.signedUp = false;
+ const update = lifecycleUpdate(
+ source,
+ contact,
+ experimentConfig,
+ customerCopy,
+ );
+ expect(update).not.toHaveProperty("capFreeOnboardingVariant");
+ expect(update.capLifecycleStage).not.toBe("free-v2");
+ }
+ });
+ test("purchase and unsubscribe exit sales without losing the recorded assignment", () => {
+ const source = freshSource();
+ const contact = {
+ ...remote(),
+ capFreeOnboardingExperiment: freeOnboardingExperiment,
+ capFreeOnboardingVariant: "pro-v2",
+ capFreeOnboardingAssignedAt: source.input.now.toISOString(),
+ capLifecycleStage: "free-v2",
+ };
+ source.input.user.stripeSubscriptionStatus = "active";
+ const paid = lifecycleUpdate(
+ source,
+ contact,
+ experimentConfig,
+ customerCopy,
+ );
+ expect(paid.capLifecycleStage).toBe("customer");
+ expect(paid.capPromotionalEligible).toBe(false);
+ expect(paid).not.toHaveProperty("capFreeOnboardingVariant");
+ contact.subscribed = false;
+ expect(
+ lifecycleUpdate(source, contact, experimentConfig, customerCopy)
+ .capLifecycleStage,
+ ).toBe("idle");
+ });
+ test("malformed assignments stop synchronization instead of silently changing arms", () => {
+ expect(() =>
+ lifecycleUpdate(
+ freshSource(),
+ {
+ ...remote(),
+ capFreeOnboardingExperiment: freeOnboardingExperiment,
+ capFreeOnboardingVariant: "unknown",
+ },
+ experimentConfig,
+ customerCopy,
+ ),
+ ).toThrow("invalid_free_experiment_assignment");
+ });
+ test("follow-ups wait 24 hours after application notification evidence and avoid pending uploads", () => {
+ const source = freshSource();
+ source.input.hasVideo = true;
+ source.input.lastActivationNotificationAt = "2026-09-11T01:01:01Z";
+ expect(activationFollowUps(source.input).capReadyForPro).toBe(false);
+ source.input.lastActivationNotificationAt = "2026-09-11T01:01:00Z";
+ expect(activationFollowUps(source.input)).toEqual({
+ capReadyForPro: true,
+ capNeedsSharingHelp: true,
+ capNeedsRecordingHelp: false,
+ });
+ source.input.hasSharedVideo = true;
+ expect(activationFollowUps(source.input).capNeedsSharingHelp).toBe(false);
+ source.input.hasPendingUpload = true;
+ expect(activationFollowUps(source.input).capReadyForPro).toBe(false);
+ });
+});
+
function fakeApi(initial: LifecycleContact | undefined) {
let contact = initial;
const calls: { path: string; method: string; body: unknown }[] = [];
diff --git a/scripts/loops/profile.test.ts b/scripts/loops/profile.test.ts
index 2b0a58ee07..dc2968c8af 100644
--- a/scripts/loops/profile.test.ts
+++ b/scripts/loops/profile.test.ts
@@ -291,7 +291,7 @@ test("verification timestamps do not churn the sync fingerprint", () => {
test("every journey requires subscription consent and promotional journeys exclude teammates", () => {
assert.equal(
journeys.reduce((count, journey) => count + journey.messages.length, 0),
- 10,
+ 16,
);
for (const journey of journeys) {
const filter = audienceFilter(journey.audience, journey.promotional);
diff --git a/scripts/loops/provision.ts b/scripts/loops/provision.ts
index 397e640bc6..807f327390 100644
--- a/scripts/loops/provision.ts
+++ b/scripts/loops/provision.ts
@@ -54,6 +54,7 @@ const { values } = parseArgs({
state: { type: "string" },
team: { type: "string" },
"mailing-list": { type: "string" },
+ "new-journey": { type: "string" },
},
});
@@ -63,7 +64,12 @@ if (!values.team)
throw new Error("Pass --team with the expected Loops team name");
if (values.apply && !values.state)
throw new Error("--apply requires a private --state file");
-if (values.apply && deliveryFormat === "mjml")
+const selectedJourneys = values["new-journey"]
+ ? journeys.filter((journey) => journey.key === values["new-journey"])
+ : journeys;
+if (!selectedJourneys.length) throw new Error("Unknown journey");
+const selectedCampaigns = values["new-journey"] ? [] : campaignTemplates;
+if (values.apply && deliveryFormat === "mjml" && !values["new-journey"])
throw new Error(
"Managed emails use custom MJML. Run emails:export and upload reviewed archives in the Loops editor; the API cannot edit this format.",
);
@@ -85,7 +91,10 @@ let receipt: Receipt = {
};
if (values.state) {
try {
- receipt = JSON.parse(await readFile(values.state, "utf8"));
+ receipt = {
+ ...receipt,
+ ...JSON.parse(await readFile(values.state, "utf8")),
+ };
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
throw error;
@@ -119,6 +128,20 @@ try {
);
const lists = await api.request("lists");
const workflows = await api.list("workflows");
+ if (values["new-journey"]) {
+ const match = named(workflows, selectedJourneys[0].name);
+ const existing = match
+ ? await api.request(`workflows/${match.id}`)
+ : undefined;
+ if (
+ existing &&
+ (receipt.resources[values["new-journey"]] !== existing.id ||
+ !existing.description.includes("created-for-mjml-upload"))
+ )
+ throw new Error(
+ "New-journey mode cannot edit an existing managed workflow",
+ );
+ }
const themes = await api.list("themes");
const sharedComponents = await api.list("components");
const segments = await api.list("audience-segments");
@@ -133,12 +156,12 @@ try {
JSON.stringify({
team: identity.teamName,
mode: values.apply ? "apply drafts" : "dry run",
- workflows: journeys.length,
- emails: journeys.reduce(
+ workflows: selectedJourneys.length,
+ emails: selectedJourneys.reduce(
(count, journey) => count + journey.messages.length,
0,
),
- campaignTemplates: campaignTemplates.length,
+ campaignTemplates: selectedCampaigns.length,
mailingListId:
listId ?? "Create Product updates and tips in the Loops dashboard",
}),
@@ -199,11 +222,21 @@ try {
await save();
return resource.id;
};
- const themeId = await ensure("themes", themes, theme, "theme");
+ const themeId = values["new-journey"]
+ ? receipt.resources.theme
+ : await ensure("themes", themes, theme, "theme");
+ assert(themeId, "New MJML drafts require the existing brand registry");
const componentIds: string[] = [];
for (const component of components) {
componentIds.push(
- await ensure("components", sharedComponents, component, component.name),
+ values["new-journey"]
+ ? receipt.resources[component.name]
+ : await ensure(
+ "components",
+ sharedComponents,
+ component,
+ component.name,
+ ),
);
}
const editEmail = async (
@@ -258,13 +291,13 @@ try {
);
};
- for (const journey of journeys) {
+ for (const journey of selectedJourneys) {
const existing = named(workflows, journey.name);
let workflow = existing
? await api.request(`workflows/${existing.id}`)
: await api.request("workflows", "POST", {
name: journey.name,
- description: `${programVersion}; managed by scripts/loops/provision.ts. Draft only. ${journey.messages.map((m) => m.delayDays).join(", ")} day relative delays. Imports never enroll.`,
+ description: `${programVersion}; ${values["new-journey"] ? "created-for-mjml-upload; " : ""}managed by scripts/loops/provision.ts. ${journey.messages.map((m) => m.delayDays).join(", ")} day relative delays. Imports never enroll.`,
mailingListId: listId,
});
if (workflow.status !== "Draft")
@@ -367,7 +400,23 @@ try {
);
}
let fromNodeId: string | undefined;
- if (message.onlyIf) {
+ if (message.onlyIf && message === journey.messages.at(-1)) {
+ const filter = await insert("AudienceFilter", `${operation}:filter`);
+ await update(
+ filter.id,
+ {
+ audienceFilter: {
+ match: "all",
+ conditions: [
+ condition(message.onlyIf.property, message.onlyIf.value),
+ ],
+ },
+ appliesDownstream: false,
+ },
+ `${operation}:filter-config`,
+ );
+ fromNodeId = filter.id;
+ } else if (message.onlyIf) {
const branch = await insert("BranchNode", `${operation}:branch`);
const children = workflow.nodes[branch.id].nextNodeIds;
if (children.length !== 2)
@@ -413,7 +462,7 @@ try {
);
}
- for (const template of campaignTemplates) {
+ for (const template of selectedCampaigns) {
const segmentName = `${template.name} audience`;
const segmentId = await ensure(
"audience-segments",
diff --git a/scripts/loops/verify.ts b/scripts/loops/verify.ts
index 8533ab9aba..a3dee025a6 100644
--- a/scripts/loops/verify.ts
+++ b/scripts/loops/verify.ts
@@ -57,6 +57,8 @@ const { values } = parseArgs({
"mailing-list": { type: "string", default: registry.resources.mailingList },
"structure-only": { type: "boolean", default: false },
"require-held": { type: "boolean", default: false },
+ "allow-live": { type: "boolean", default: false },
+ journey: { type: "string" },
},
});
assert(values.state && values.team && values["mailing-list"]);
@@ -145,10 +147,21 @@ const verifyEmail = async (
emails++;
};
-for (const journey of journeys) {
+const selectedJourneys = values.journey
+ ? journeys.filter((journey) => journey.key === values.journey)
+ : journeys;
+assert(selectedJourneys.length, "Unknown journey");
+const selectedCampaigns = values.journey ? [] : campaignTemplates;
+for (const journey of selectedJourneys) {
const id = receipt.resources[journey.key];
const workflow = await api.request(`workflows/${id}`);
- assert.equal(workflow.status, "Draft");
+ assert(
+ (values["allow-live"]
+ ? ["Draft", "Sending", "Paused", "PausedAndQueueing"]
+ : ["Draft"]
+ ).includes(workflow.status),
+ `Unexpected workflow status: ${workflow.status}`,
+ );
assert.equal(workflow.name, journey.name);
assert.equal(workflow.mailingListId, values["mailing-list"]);
const visited = new Set();
@@ -195,7 +208,19 @@ for (const journey of journeys) {
assert.equal(timer.unit, "d");
}
let skipped: string | undefined;
- if (message.onlyIf) {
+ if (message.onlyIf && message === journey.messages.at(-1)) {
+ const filterId = receipt.operations[`${operation}:filter`];
+ connect(filterId);
+ node(filterId, "AudienceFilter");
+ const filter = await api.request(
+ `workflows/${id}/nodes/${filterId}`,
+ );
+ assert.deepEqual(filter.audienceFilter, {
+ match: "all",
+ conditions: [condition(message.onlyIf.property, message.onlyIf.value)],
+ });
+ assert.equal(filter.appliesDownstream, false);
+ } else if (message.onlyIf) {
const branchId = receipt.operations[`${operation}:branch`];
connect(branchId);
const branch = node(branchId, "BranchNode");
@@ -236,14 +261,14 @@ for (const journey of journeys) {
console.log(
JSON.stringify({
workflow: journey.name,
- status: "Draft",
+ status: workflow.status,
graphVerified: true,
deliveryHeld: held,
}),
);
}
-for (const template of campaignTemplates) {
+for (const template of selectedCampaigns) {
const campaign = await api.request<{
status: string;
mailingListId: string;
@@ -267,13 +292,13 @@ for (const template of campaignTemplates) {
}
console.log(
JSON.stringify({
- workflows: journeys.length,
- campaigns: campaignTemplates.length,
+ workflows: selectedJourneys.length,
+ campaigns: selectedCampaigns.length,
apiEmailContentVerified: emails,
customEmailContentRequiresBrowserReview: customEmails,
guardianVerifiedEmails: emails,
deliveryHeldWorkflows: heldWorkflows,
- allDraft: true,
+ liveStatesAllowed: values["allow-live"],
}),
);
if (customEmails && !values["structure-only"])
diff --git a/scripts/loops/watchdog.test.ts b/scripts/loops/watchdog.test.ts
index 4350caf7c3..ca34b8b544 100644
--- a/scripts/loops/watchdog.test.ts
+++ b/scripts/loops/watchdog.test.ts
@@ -124,7 +124,7 @@ describe("independent delivery safety", () => {
apply: true,
});
expect(result[0].action).toBe("manual-pause-required");
- expect(provider.attemptedWrites).toHaveLength(4);
+ expect(provider.attemptedWrites).toHaveLength(safetyTargets.length);
}
});
@@ -159,7 +159,7 @@ describe("independent delivery safety", () => {
expect(recovered.every((item) => item.action === "already-held")).toBe(
true,
);
- expect(provider.writes).toHaveLength(4);
+ expect(provider.writes).toHaveLength(safetyTargets.length);
});
test("an unhealthy running journey requires manual pause without attempting forbidden edits", async () => {
@@ -278,7 +278,9 @@ describe("independent delivery safety", () => {
apply: true,
});
expect(result.filter((item) => item.action === "error")).toHaveLength(1);
- expect(result.filter((item) => item.action === "held")).toHaveLength(3);
+ expect(result.filter((item) => item.action === "held")).toHaveLength(
+ safetyTargets.length - 1,
+ );
});
test("audience drift closes delivery and cannot be silently overwritten on resume", async () => {