@cowliss/cli 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -82,30 +82,30 @@ const TOPUP_PRESETS_MICROS = [
82
82
  2e8
83
83
  ];
84
84
  /**
85
- * Fixed consent purposes for the prototype. Consent is a per-purpose map on
86
- * the profile, checked at send-step execution time.
87
- *
88
- * The two names describe what the recipient agreed to, not the pipe it
89
- * arrives on: "emails I did not ask for individually" and "my data leaving
90
- * for somewhere else". Naming them after the channel (`email`, `webhook`)
91
- * said nothing a recipient could consent to, and transactional mail already
92
- * bypasses the email purpose, so it was only ever marketing consent.
93
- */
94
- const CONSENT_PURPOSES = ["emailMarketing", "dataProcessing"];
95
- /**
96
85
  * The marketing purpose by name, since it is the one every gate, the
97
86
  * unsubscribe route, and the developer's own toggle all reach for.
98
87
  */
99
- const EMAIL_MARKETING = "emailMarketing";
88
+ const MARKETING = "marketing";
89
+ /**
90
+ * A legal journey `purpose` that is not a consent purpose: the send gates
91
+ * pass it unconditionally, no profile's map stores it and no editor renders
92
+ * it. What protects the channel itself (suppression, the SES gates, the
93
+ * quota) still applies.
94
+ */
95
+ const TRANSACTIONAL = "transactional";
96
+ /**
97
+ * The purposes a project may not declare, which are the same two that sit
98
+ * outside the `marketing` umbrella: `marketing` is the umbrella itself and
99
+ * `transactional` is the case consent does not govern. Everything a project
100
+ * declares is a marketing-mail category and so sits under it.
101
+ */
102
+ const RESERVED_PURPOSES = [MARKETING, TRANSACTIONAL];
100
103
  /**
101
104
  * What a purpose means when the profile's map does not answer it, matching
102
- * the `profiles.consent` column default: marketing is asked for, everything
103
- * else about the data the developer already sends is granted.
105
+ * the `profiles.consent` column default: marketing is asked for, never
106
+ * assumed.
104
107
  */
105
- const CONSENT_PURPOSE_DEFAULTS = {
106
- emailMarketing: false,
107
- dataProcessing: true
108
- };
108
+ const CONSENT_PURPOSE_DEFAULTS = { marketing: false };
109
109
  /**
110
110
  * The `defaults` argument `consentGranted` takes, built from an org's
111
111
  * purpose rows. Every surface that renders or gates a purpose reads those
@@ -3341,6 +3341,10 @@ const deliveryKindEnum = pgEnum("delivery_kind", [
3341
3341
  * `skipped_suppressed` is the recipient's address sitting in the
3342
3342
  * suppression mirror, which SES would have bounced off its own
3343
3343
  * suppression list anyway.
3344
+ * `skipped_unknown_purpose` is the version naming a purpose the
3345
+ * organization does not have, which is a broken manifest rather than a
3346
+ * consent decision: the `error` column names the purpose, and the fix is
3347
+ * another push (ADR 0017).
3344
3348
  * - `would_*` are dry-run outcomes: terminal, never reach SES, and
3345
3349
  * excluded from feedback, quotas, and reconciliation.
3346
3350
  *
@@ -3357,6 +3361,7 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
3357
3361
  "skipped_paused",
3358
3362
  "skipped_suppressed",
3359
3363
  "skipped_quota",
3364
+ "skipped_unknown_purpose",
3360
3365
  "skipped_frequency_cap",
3361
3366
  "skipped_consent",
3362
3367
  "skipped_domain",
@@ -3366,6 +3371,7 @@ const deliveryStatusEnum = pgEnum("delivery_status", [
3366
3371
  "would_skip_paused",
3367
3372
  "would_skip_suppressed",
3368
3373
  "would_skip_quota",
3374
+ "would_skip_unknown_purpose",
3369
3375
  "would_skip_frequency_cap",
3370
3376
  "would_skip_consent",
3371
3377
  "would_skip_domain",
@@ -3551,7 +3557,7 @@ const executions$1 = pgTable("executions", {
3551
3557
  })
3552
3558
  }, (table) => [
3553
3559
  index("executions_org_id_version_id_status_idx").on(table.orgId, table.versionId, table.status),
3554
- uniqueIndex("executions_org_id_workflow_id_unique").on(table.orgId, table.workflowId),
3560
+ index("executions_org_id_workflow_id_status_idx").on(table.orgId, table.workflowId, table.status),
3555
3561
  index("executions_org_id_started_at_idx").on(table.orgId, table.startedAt)
3556
3562
  ]);
3557
3563
  const selectExecutionSchema = createSelectSchema(executions$1);
@@ -3619,11 +3625,11 @@ const idempotencyKeys = pgTable("idempotency_keys", {
3619
3625
  * creating one when none is known.
3620
3626
  *
3621
3627
  * traits is the merged trait bag (RFC 7386 key-level merge on write).
3622
- * consent is the fixed-purpose consent map from the spec. Marketing starts
3623
- * denied and data processing granted: nobody is subscribed by the act of
3624
- * being ingested, while the processing the product runs on is the basis the
3625
- * profile exists under at all. It is written by the identify path (a caller
3626
- * passing `consent`), the consent editor, and the automatic revocations.
3628
+ * consent is the per-purpose consent map. Marketing starts denied: nobody
3629
+ * is subscribed by the act of being ingested. `transactional` is absent by
3630
+ * design (ADR 0017): it is not a purpose anyone may withhold, so it is not
3631
+ * stored. The map is written by the identify path (a caller passing
3632
+ * `consent`), the consent editor, and the automatic revocations.
3627
3633
  *
3628
3634
  * `mergedInto` is the merge pointer (spec: Identity). Null on a live
3629
3635
  * profile; on a profile a merge folded away it names the survivor, whose
@@ -3641,10 +3647,7 @@ const profiles = pgTable("profiles", {
3641
3647
  appId: text("app_id").notNull(),
3642
3648
  sourceId: text("source_id").notNull(),
3643
3649
  traits: jsonb("traits").$type().notNull().default({}),
3644
- consent: jsonb("consent").$type().notNull().default({
3645
- emailMarketing: false,
3646
- dataProcessing: true
3647
- }),
3650
+ consent: jsonb("consent").$type().notNull().default({ marketing: false }),
3648
3651
  mergedInto: text("merged_into"),
3649
3652
  createdAt: createdAt(),
3650
3653
  updatedAt: updatedAt()
@@ -3745,6 +3748,18 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
3745
3748
  //#endregion
3746
3749
  //#region ../../packages/db/src/schema/journeys.ts
3747
3750
  /**
3751
+ * The one operational state a manager sets (ADR 0018): `on` (recipients
3752
+ * enroll and executions run), `off` (nothing enrolls, executions in flight
3753
+ * finish) or `paused` (nothing enrolls, executions in flight hold before
3754
+ * their next step). Three values rather than two booleans: a second flag
3755
+ * beside the first would spell two combinations that mean nothing.
3756
+ */
3757
+ const journeyStatusEnum = pgEnum("journey_status", [
3758
+ "on",
3759
+ "off",
3760
+ "paused"
3761
+ ]);
3762
+ /**
3748
3763
  * Derived journey rows: one per (org, key), upserted from the manifest a
3749
3764
  * push carried. Nothing here is authored through the API, which is why
3750
3765
  * there is no id of its own: the key is the name the author gave the file,
@@ -3757,9 +3772,9 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
3757
3772
  * what makes a key another project owns a refused push rather than a silent
3758
3773
  * overwrite. Removing a journey is an explicit delete (ADR 0009).
3759
3774
  *
3760
- * Whether a journey fires is one flag and one only: `enabled`, which a
3775
+ * Whether a journey fires is one field and one only: `status`, which a
3761
3776
  * push never writes, so deploying code never turns anything on or off. The
3762
- * author has no second gate of their own (ADR 0011).
3777
+ * author has no second gate of their own (ADR 0011, ADR 0018).
3763
3778
  *
3764
3779
  * The descriptive columns are what its latest ready version reported, so a
3765
3780
  * failed compile leaves both the code and its description as they were.
@@ -3782,12 +3797,18 @@ const journeys$1 = pgTable("journeys", {
3782
3797
  * the fixed pair.
3783
3798
  */
3784
3799
  purpose: text("purpose").notNull(),
3800
+ /**
3801
+ * The author's own sentence about what the journey does, from the
3802
+ * manifest. Null when they wrote none.
3803
+ */
3804
+ description: text("description"),
3785
3805
  spine: jsonb("spine").$type().notNull(),
3786
3806
  /**
3787
3807
  * The one gate on the journey: off until `cow enable` or a manager's
3788
- * toggle, and never touched by a push (ADR 0011, amended by ADR 0013).
3808
+ * control, and never touched by a push (ADR 0011, amended by ADR 0013
3809
+ * and ADR 0018).
3789
3810
  */
3790
- enabled: boolean("enabled").notNull().default(false),
3811
+ status: journeyStatusEnum("status").notNull().default("off"),
3791
3812
  createdAt: createdAt(),
3792
3813
  updatedAt: updatedAt()
3793
3814
  }, (table) => [primaryKey({ columns: [table.orgId, table.key] })]);
@@ -3814,17 +3835,16 @@ const ingestionPolicyEnum = pgEnum("ingestion_policy", ["permissive", "strict"])
3814
3835
  * switch, the allowance override, and Clerk's cached profile.
3815
3836
  *
3816
3837
  * `slug` and `name` are the exception to "only what Cowliss owns": they are
3817
- * Clerk's, cached here by the API's org-sync middleware because the worker
3818
- * has no Clerk client and still has to build the shared fallback sender
3819
- * `<org-slug>@<shared domain>` with the org's name as the from-name.
3838
+ * Clerk's, cached here by the API's org-sync middleware, and they are what a
3839
+ * template test-send composes its from-address out of (`orgFromAddress`).
3820
3840
  */
3821
3841
  const orgSettings = pgTable("org_settings", {
3822
3842
  orgId: text("org_id").primaryKey(),
3823
3843
  /**
3824
3844
  * The Clerk org's slug and display name, synced by the API on request.
3825
- * Null until a member of the org has hit the dashboard API once; the
3826
- * shared fallback sender falls back to an orgId-derived local part and the
3827
- * tool name until then.
3845
+ * Null until a member of the org has hit the dashboard API once, when
3846
+ * `orgFromAddress` falls back to an orgId-derived local part and the tool
3847
+ * name.
3828
3848
  */
3829
3849
  slug: text("slug"),
3830
3850
  name: text("name"),
@@ -4038,9 +4058,17 @@ const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
4038
4058
  //#endregion
4039
4059
  //#region ../../packages/db/src/schema/segments.ts
4040
4060
  /**
4041
- * Segments: dashboard-managed audience definitions. Membership is computed,
4042
- * never hand-maintained: the engine in packages/segments recomputes on every
4043
- * write and materializes the result into segment_members.
4061
+ * Segments: membership rules over profiles. Membership is computed, never
4062
+ * hand-maintained: the engine in packages/segments recomputes on every write
4063
+ * and materializes the result into segment_members.
4064
+ *
4065
+ * A row is either standalone (built in the dashboard, `journey_key` null) or
4066
+ * owned by one journey whose trigger inlined its definition (ADR 0016). An
4067
+ * owned row is named after its journey's key, is written only by the push,
4068
+ * and goes when the journey does — which is what the composite foreign key
4069
+ * says: it points at `journeys(org_id, key)` and cascades. A null
4070
+ * `journey_key` satisfies it vacuously (MATCH SIMPLE), so standalone rows
4071
+ * are unconstrained.
4044
4072
  *
4045
4073
  * Timestamps use millisecond precision, same rationale as apps: JS Dates
4046
4074
  * carry ms only and cursor pagination compares createdAt for equality.
@@ -4051,9 +4079,19 @@ const segments$1 = pgTable("segments", {
4051
4079
  name: text("name").notNull(),
4052
4080
  description: text("description"),
4053
4081
  definition: jsonb("definition").$type().notNull(),
4082
+ /** The journey that owns this row, or null for a standalone segment. */
4083
+ journeyKey: text("journey_key"),
4054
4084
  createdAt: createdAt(),
4055
4085
  updatedAt: updatedAt()
4056
- }, (table) => [index("segments_org_id_idx").on(table.orgId), uniqueIndex("segments_org_id_name_unique").on(table.orgId, table.name)]);
4086
+ }, (table) => [
4087
+ index("segments_org_id_idx").on(table.orgId),
4088
+ uniqueIndex("segments_org_id_name_unique").on(table.orgId, table.name),
4089
+ foreignKey({
4090
+ columns: [table.orgId, table.journeyKey],
4091
+ foreignColumns: [journeys$1.orgId, journeys$1.key],
4092
+ name: "segments_journey_fk"
4093
+ }).onDelete("cascade")
4094
+ ]);
4057
4095
  /**
4058
4096
  * Materialized membership: one row per (segment, profile) currently in the
4059
4097
  * segment. Deleting a segment drops its members with it, and erasing a
@@ -4089,10 +4127,16 @@ const insertSegmentSchema = createInsertSchema(segments$1);
4089
4127
  * emits no domain events, so the mirror refreshes by pulling
4090
4128
  * `GetEmailIdentity` on a schedule and on the manual re-check button.
4091
4129
  *
4092
- * The unique index on `domain` is GLOBAL, not per org: it is the
4093
- * anti-squatting rule from the spec. Once one org claims a domain string, no
4094
- * other org can claim it, and the cross-org parent/child guard (a query, not
4095
- * a constraint) covers the subdomain half of the same rule.
4130
+ * A domain is claimed when it VERIFIES, not when it is added (ADR 0021).
4131
+ * Any number of orgs may hold the same unverified string; the partial unique
4132
+ * below is what makes at most one of them verified, and so the owner. The
4133
+ * cross-org parent/child guard (a query, not a constraint) refuses a claim
4134
+ * under or over a name another org already verified.
4135
+ *
4136
+ * SES keys ONE identity by the domain name, so its DKIM records, and the
4137
+ * SUCCESS it reports for them, are shared by every org holding the name.
4138
+ * They therefore prove that SOMEBODY controls the DNS, never which org, and
4139
+ * `challenge_token` is the part that is per org: the claim needs both.
4096
4140
  */
4097
4141
  /**
4098
4142
  * SES's identity status vocabulary, lower-cased like every other enum here.
@@ -4100,13 +4144,19 @@ const insertSegmentSchema = createInsertSchema(segments$1);
4100
4144
  * all report the same five words. `temporary_failure` is SES's retryable
4101
4145
  * state, which is neither verified nor a dead end, so it is kept distinct
4102
4146
  * from `failed`.
4147
+ *
4148
+ * `blocked` is the one value SES never says: the DNS checks out, but another
4149
+ * organization has already verified this name or a parent or child of it, so
4150
+ * the claim cannot be granted here (ADR 0021). It is a terminal-looking
4151
+ * state that clears itself, because every refresh re-asks the question.
4103
4152
  */
4104
4153
  const senderDomainStatusEnum = pgEnum("sender_domain_status", [
4105
4154
  "not_started",
4106
4155
  "pending",
4107
4156
  "success",
4108
4157
  "failed",
4109
- "temporary_failure"
4158
+ "temporary_failure",
4159
+ "blocked"
4110
4160
  ]);
4111
4161
  const senderDomains = pgTable("sender_domains", {
4112
4162
  id: text("id").primaryKey(),
@@ -4120,12 +4170,25 @@ const senderDomains = pgTable("sender_domains", {
4120
4170
  spfStatus: text("spf_status"),
4121
4171
  /**
4122
4172
  * The per-domain click-tracking toggle, Cowliss's own setting: the send
4123
- * picks SES's tracked configuration set when it is on. Off by default
4124
- * and always off for the shared fallback domain (which has no row here
4125
- * at all): one setting cannot serve every org sharing it.
4173
+ * picks SES's tracked configuration set when it is on. Off by default:
4174
+ * link rewriting is a thing an org opts into.
4126
4175
  */
4127
4176
  clickTracking: boolean("click_tracking").notNull().default(false),
4128
4177
  dnsRecords: jsonb("dns_records").$type().notNull().default([]),
4178
+ /**
4179
+ * This org's own proof of control, published as a TXT record under the
4180
+ * domain. Random per row and never re-issued, so two orgs holding the
4181
+ * same name publish different values and only the one that controls the
4182
+ * DNS can publish its own. Verified once, at the moment the claim is
4183
+ * granted; a live sender never loses its domain to one failed lookup.
4184
+ */
4185
+ challengeToken: text("challenge_token").notNull().default(sql`replace(gen_random_uuid()::text, '-', '')`),
4186
+ /** When this org's own TXT record was last seen. Null until it is. */
4187
+ challengeVerifiedAt: timestamp("challenge_verified_at", {
4188
+ withTimezone: true,
4189
+ mode: "date",
4190
+ precision: 3
4191
+ }),
4129
4192
  /** When the mirror last asked SES; drives the refresh sweep. */
4130
4193
  lastCheckedAt: timestamp("last_checked_at", {
4131
4194
  withTimezone: true,
@@ -4134,7 +4197,11 @@ const senderDomains = pgTable("sender_domains", {
4134
4197
  }),
4135
4198
  createdAt: createdAt(),
4136
4199
  updatedAt: updatedAt()
4137
- }, (table) => [uniqueIndex("sender_domains_domain_unique").on(table.domain), index("sender_domains_org_id_created_at_idx").on(table.orgId, table.createdAt)]);
4200
+ }, (table) => [
4201
+ uniqueIndex("sender_domains_org_id_domain_unique").on(table.orgId, table.domain),
4202
+ uniqueIndex("sender_domains_verified_domain_unique").on(table.domain).where(sql`${table.status} = 'success'`),
4203
+ index("sender_domains_org_id_created_at_idx").on(table.orgId, table.createdAt)
4204
+ ]);
4138
4205
  const selectSenderDomainSchema = createSelectSchema(senderDomains);
4139
4206
  const insertSenderDomainSchema = createInsertSchema(senderDomains);
4140
4207
 
@@ -4552,6 +4619,92 @@ const identifierDtoSchema = z.object({
4552
4619
  createdAt: z.iso.datetime()
4553
4620
  });
4554
4621
 
4622
+ //#endregion
4623
+ //#region ../../packages/shared/src/segment-definition.ts
4624
+ /**
4625
+ * A segment definition: a flat list of predicates over traits and event
4626
+ * history, combined with `all` or `any`. Deliberately flat — nested predicate groups are YAGNI for the
4627
+ * prototype, and a flat list keeps the pure evaluator a fold.
4628
+ *
4629
+ * `appId` scopes the EVENT side of a definition only. Traits are
4630
+ * per-profile and profiles merge across apps, so there is nothing
4631
+ * app-shaped to filter on the trait side.
4632
+ *
4633
+ * A definition and the membership it produces are both the
4634
+ * organization's.
4635
+ *
4636
+ * Apart from `./segments` because a journey's trigger carries a definition
4637
+ * and the trigger schema is bundled into the wasm guest, where an edge to
4638
+ * `@cowliss/db` (which `./segments` has, for the row schema) is a hard
4639
+ * bundler failure. Nothing here imports anything but zod.
4640
+ *
4641
+ * The object is strict: a definition holding the retired `sourceId` key
4642
+ * (which meant the app) fails loudly instead of parsing as an unfiltered
4643
+ * definition that evaluates over every app. There is deliberately no pipe
4644
+ * filter here — filtering by source is a later feature, and accepting one
4645
+ * now would make a stale `sourceId` parse as a filter on a pipe that does
4646
+ * not exist and silently match nothing.
4647
+ */
4648
+ const SEGMENT_TRAIT_OPS = [
4649
+ "eq",
4650
+ "neq",
4651
+ "gt",
4652
+ "gte",
4653
+ "lt",
4654
+ "lte",
4655
+ "exists",
4656
+ "notExists",
4657
+ "contains"
4658
+ ];
4659
+ /** Operators that read no comparison value: presence of the key is the test. */
4660
+ const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
4661
+ const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
4662
+ const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
4663
+ kind: z.literal("trait"),
4664
+ name: predicateNameSchema,
4665
+ op: z.enum(SEGMENT_TRAIT_OPS),
4666
+ /**
4667
+ * Compared against the stored trait, which is `unknown` because
4668
+ * identify accepts arbitrary JSON. The evaluator coerces both sides
4669
+ * before comparing, so authors here (CLI, MCP, dashboard) get the
4670
+ * form-field-friendly reading rather than strict JSON equality:
4671
+ * numeric-looking strings are compared as numbers for eq/neq and for
4672
+ * ordering ("150" matches 150, and orders like it), and "true"/"false"
4673
+ * are compared as booleans for eq/neq, trimmed and case-insensitively
4674
+ * (" TRUE " reads as true). Coercion needs both sides to agree on a
4675
+ * type: "0" never equals false. The numeric net is as wide as
4676
+ * `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
4677
+ * matters most for opaque ids: "007" is authored as the number 7.
4678
+ *
4679
+ * contains reads three ways. Against an array trait it is membership
4680
+ * under that same equality, so "true" matches `[true]` and "1" matches
4681
+ * `[1, 2]`. Against a string trait it is a plain substring search with
4682
+ * no coercion, since substrings only mean something between strings.
4683
+ * Against anything else it never matches. Ordering never coerces
4684
+ * booleans.
4685
+ */
4686
+ value: z.unknown().optional()
4687
+ }), z.object({
4688
+ kind: z.literal("event"),
4689
+ name: predicateNameSchema,
4690
+ op: z.enum(["performed", "notPerformed"]),
4691
+ /** How many matching events the predicate counts as "performed". */
4692
+ atLeast: z.number().int().min(1).default(1),
4693
+ /** Rolling window, relative to evaluation time; absent means all history. */
4694
+ withinDays: z.number().int().min(1).optional()
4695
+ })]).refine((predicate) => predicate.kind !== "trait" || VALUELESS_TRAIT_OPS.includes(predicate.op) || predicate.value !== void 0, { message: "value is required unless op is exists or notExists" });
4696
+ const appIdSchema = z.string().trim().min(1);
4697
+ const segmentDefinitionSchema = z.strictObject({
4698
+ match: z.enum(["all", "any"]).default("all"),
4699
+ /**
4700
+ * Optional app filter over the event side; null/absent spans apps.
4701
+ * A list matches any of the named apps, which is how one definition
4702
+ * names the development and the production id of the same app.
4703
+ */
4704
+ appId: z.union([appIdSchema, z.array(appIdSchema).min(1)]).nullish(),
4705
+ predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
4706
+ });
4707
+
4555
4708
  //#endregion
4556
4709
  //#region ../../packages/shared/src/journeys-v2/manifest.ts
4557
4710
  /**
@@ -4561,6 +4714,34 @@ const identifierDtoSchema = z.object({
4561
4714
  * changed, and stores that entry on the version it creates.
4562
4715
  */
4563
4716
  /**
4717
+ * A duration as journey code writes it: an ms-style string ("2d") or
4718
+ * milliseconds. It lives here rather than with the guest protocol because a
4719
+ * manifest carries one too (a journey's enrollment cooldown) and `./guest`
4720
+ * already imports this module, so the other direction would be a cycle.
4721
+ */
4722
+ const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
4723
+ const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
4724
+ const DURATION_UNIT_MS = {
4725
+ ms: 1,
4726
+ s: 1e3,
4727
+ m: 6e4,
4728
+ h: 36e5,
4729
+ d: 864e5,
4730
+ w: 6048e5
4731
+ };
4732
+ /**
4733
+ * A duration in milliseconds. The simulator's virtual clock and the runner's
4734
+ * timers both need it, and neither may pull in Temporal's `msToNumber` (one
4735
+ * runs in the CLI, the other inside workflow code).
4736
+ */
4737
+ function parseDuration(duration) {
4738
+ if (typeof duration === "number") return duration;
4739
+ const match = DURATION_PATTERN.exec(duration.trim());
4740
+ if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
4741
+ const unit = DURATION_UNIT_MS[match[2]];
4742
+ return Math.round(Number(match[1]) * unit);
4743
+ }
4744
+ /**
4564
4745
  * A journey or template key: the file basename under `journeys/` or
4565
4746
  * `emails/`, kebab-case and unique across the project. Becomes part of the
4566
4747
  * Temporal workflow id and travels in the journey chain, so it stays short.
@@ -4581,8 +4762,8 @@ const journeyKeySchema = z.string().max(64).regex(JOURNEY_KEY_PATTERN, "key must
4581
4762
  */
4582
4763
  const tagsSchema = z.array(z.string().trim().min(1).max(50, "a tag must be at most 50 characters")).max(20, "a journey or template takes at most 20 tags").default([]).transform((tags) => [...new Set(tags)]);
4583
4764
  /**
4584
- * A consent purpose key: camelCase, matching the fixed `emailMarketing` and
4585
- * `dataProcessing`. Purposes are keys in the `consent` map a customer reads
4765
+ * A consent purpose key: camelCase, matching the reserved `marketing` and
4766
+ * `transactional`. Purposes are keys in the `consent` map a customer reads
4586
4767
  * on their own profile, which is why they are not the kebab-case of a
4587
4768
  * journey key.
4588
4769
  */
@@ -4597,22 +4778,22 @@ const CONSENT_PURPOSE_KEY_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
4597
4778
  const consentPurposeKeySchema = z.string().max(50, "a purpose key must be at most 50 characters").regex(CONSENT_PURPOSE_KEY_PATTERN, "a consent purpose key must be camelCase (a letter first, then letters and digits)");
4598
4779
  /**
4599
4780
  * One purpose a project declares in `cow.json` (spec: Decisions). A declared
4600
- * purpose is marketing-class and sits under the `emailMarketing` umbrella,
4601
- * so `denied` is the only default it may carry: the purpose is absent on
4602
- * every profile that already exists, and a granted default would answer for
4603
- * all of them at once.
4781
+ * purpose is marketing-class and sits under the `marketing` umbrella, so
4782
+ * `denied` is the only default it may carry: the purpose is absent on every
4783
+ * profile that already exists, and a granted default would answer for all of
4784
+ * them at once.
4604
4785
  *
4605
4786
  * The field stays required rather than disappearing, so every `cow.json` and
4606
- * every stored manifest written before this still parses, and the column
4607
- * behind it still holds `granted` for the seeded `dataProcessing` row: this
4608
- * is a refusal at declaration time, not a narrower storage shape.
4787
+ * every stored manifest written before this still parses: this is a refusal
4788
+ * at declaration time, not a narrower storage shape.
4609
4789
  *
4610
- * The two fixed purposes cannot be declared. They are seeded for every org
4611
- * and owned by no project, so a project redeclaring one would be renaming
4612
- * the master switch every other project's journeys hang off.
4790
+ * Neither reserved purpose can be declared: `marketing` is the master switch
4791
+ * every other project's journeys hang off, and `transactional` is not a
4792
+ * consent purpose at all, so declaring it would promise a switch that no
4793
+ * send ever reads.
4613
4794
  */
4614
4795
  const declaredPurposeSchema = z.strictObject({
4615
- key: consentPurposeKeySchema.refine((key) => !CONSENT_PURPOSES.includes(key), `${CONSENT_PURPOSES.join(" and ")} are fixed purposes and cannot be declared`),
4796
+ key: consentPurposeKeySchema.refine((key) => !RESERVED_PURPOSES.includes(key), `${RESERVED_PURPOSES.join(" and ")} are reserved purposes and cannot be declared`),
4616
4797
  /** What the dashboard and the account modal render beside the switch. */
4617
4798
  label: z.string().trim().min(1).max(50, "a purpose label must be at most 50 characters"),
4618
4799
  /** What the purpose means for a profile whose map does not answer it. */
@@ -4634,18 +4815,26 @@ const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 2
4634
4815
  const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
4635
4816
  /**
4636
4817
  * What starts a journey: an event (optionally narrowed to an app id or a
4637
- * list of them) or a segment entry. The registry DTO in `../journeys`
4638
- * reuses it.
4639
- *
4640
- * Both members are strict, so a journey holding the retired `source` key
4641
- * fails to compile instead of silently triggering on every app.
4642
- * There is deliberately no pipe filter: a trigger narrows by the app the
4643
- * write is attributed to, the same token a segment definition names.
4818
+ * list of them), or entry into the segment the trigger itself describes.
4819
+ * The registry DTO in `../journeys` reuses it.
4820
+ *
4821
+ * A segment trigger carries the predicate list, not a name: the push
4822
+ * materializes one segment row per journey that inlines a definition, owned
4823
+ * by the journey and named after its key, so the segment exists because the
4824
+ * journey exists and there is no order to get wrong (ADR 0016). The
4825
+ * definition is data — validated here, carried on the version, never
4826
+ * compiled and never executed.
4827
+ *
4828
+ * Both members are strict, so a journey holding the retired `source` key, or
4829
+ * the retired `{ segment: "name" }` form, fails to build instead of silently
4830
+ * triggering on every app or on nothing. There is deliberately no pipe
4831
+ * filter: a trigger narrows by the app the write is attributed to, the same
4832
+ * token a segment definition names.
4644
4833
  */
4645
4834
  const triggerSchema = z.union([z.strictObject({
4646
4835
  event: patternSchema,
4647
4836
  appId: patternSchema.optional()
4648
- }), z.strictObject({ segment: z.string().min(1) })]);
4837
+ }), z.strictObject({ segment: segmentDefinitionSchema })]);
4649
4838
  /**
4650
4839
  * The address half of a from-header: a local part, an `@`, and a dotted
4651
4840
  * domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
@@ -4748,10 +4937,35 @@ const manifestJourneySchema = z.object({
4748
4937
  */
4749
4938
  purpose: consentPurposeKeySchema,
4750
4939
  /**
4940
+ * How often one recipient may enter. Enrollment derives from the purpose
4941
+ * (ADR 0015), so the only thing an author writes is how long after a
4942
+ * completed run the journey re-opens: absent means once, ever. Refused on
4943
+ * a transactional journey, which enrolls on every trigger — see
4944
+ * `manifestSchema` below, where the cross-field check lives (a refinement
4945
+ * on this object would break the `.pick()` the guest SDK and the guest
4946
+ * protocol both take of it).
4947
+ */
4948
+ enrollment: z.strictObject({ cooldown: durationSchema.refine((value) => {
4949
+ try {
4950
+ parseDuration(value);
4951
+ return true;
4952
+ } catch {
4953
+ return false;
4954
+ }
4955
+ }, "a cooldown must be milliseconds or an ms-style string like \"7d\"") }).optional(),
4956
+ /**
4957
+ * The author's own sentence about what this journey does, shown wherever
4958
+ * the journey is read. Capped like a segment's description; absent when
4959
+ * the author wrote none.
4960
+ */
4961
+ description: z.string().trim().max(500, `description must be at most ${500} characters`).optional(),
4962
+ /**
4751
4963
  * The address every `send.email` in this journey goes out as, unless the
4752
- * call names its own. Optional everywhere: an omitted one is the
4753
- * organization's shared fallback address, which is what makes day-one
4754
- * sending zero-config (ADR 0014).
4964
+ * call names its own. Required of the author (`defineJourney` types it so,
4965
+ * and `cow build` refuses a journey without it), and still optional here:
4966
+ * releases pushed before ADR 0014 was amended carry none, and this schema
4967
+ * parses stored manifests as well as new ones. A push with no `from` is
4968
+ * refused by the domain gate, which says what to do about it.
4755
4969
  */
4756
4970
  from: fromSchema.optional(),
4757
4971
  spine: z.array(spineEntrySchema),
@@ -4764,6 +4978,13 @@ const manifestTemplateSchema = z.object({
4764
4978
  sendClass: z.enum(SEND_CLASSES),
4765
4979
  /** True asks the host to mint a signed `verifyUrl` prop at send time. */
4766
4980
  verifyLink: z.boolean(),
4981
+ /**
4982
+ * True asks the host to mint a signed `unsubscribeUrl` prop at send time,
4983
+ * for the author's own footer link. Default false: a marketing send whose
4984
+ * HTML carries no unsubscribe link at all still gets one, appended as a
4985
+ * platform footer, so the link is never the author's to forget.
4986
+ */
4987
+ unsubscribeLink: z.boolean().default(false),
4767
4988
  /** JSON Schema of the template's `props`, converted by `cow build`. */
4768
4989
  propsSchema: z.record(z.string(), z.unknown()),
4769
4990
  bundle: digestSchema
@@ -4814,6 +5035,15 @@ const manifestSchema = z.object({
4814
5035
  uniqueKeys(manifest.journeys, ctx, "journeys");
4815
5036
  uniqueKeys(manifest.templates, ctx, "templates");
4816
5037
  uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
5038
+ for (const [index, journey] of manifest.journeys.entries()) if (journey.enrollment && journey.purpose === "transactional") ctx.addIssue({
5039
+ code: "custom",
5040
+ message: `journey "${journey.key}": a transactional journey enrolls on every trigger, so it takes no enrollment cooldown`,
5041
+ path: [
5042
+ "journeys",
5043
+ index,
5044
+ "enrollment"
5045
+ ]
5046
+ });
4817
5047
  });
4818
5048
  /**
4819
5049
  * One entry of a stored manifest: what a version is a snapshot of. Journeys
@@ -4877,7 +5107,7 @@ const traitBagSchema = z.custom((value) => value !== null && typeof value === "o
4877
5107
  * `identify` carries it too: the grant has to have a route in through
4878
5108
  * ingestion, or the only thing anyone can express is the revocation.
4879
5109
  */
4880
- const consentPatchSchema = z.partialRecord(consentPurposeKeySchema.meta({ examples: [EMAIL_MARKETING] }), z.boolean()).refine((data) => Object.keys(data).length > 0, { message: "at least one consent purpose is required" });
5110
+ const consentPatchSchema = z.partialRecord(consentPurposeKeySchema.meta({ examples: [MARKETING] }), z.boolean()).refine((data) => Object.keys(data).length > 0, { message: "at least one consent purpose is required" });
4881
5111
  /**
4882
5112
  * The identify payload fields, shared between the single-call body schema
4883
5113
  * and the batch item schema (which drops `sourceId`: a batch names one
@@ -5523,6 +5753,7 @@ const dnsRecordSchema = z.object({
5523
5753
  /** One sending domain on the wire: dates become ISO 8601 strings. */
5524
5754
  const senderDomainSchema = selectSenderDomainSchema.extend({
5525
5755
  dnsRecords: z.array(dnsRecordSchema),
5756
+ challengeVerifiedAt: z.iso.datetime().nullable(),
5526
5757
  lastCheckedAt: z.iso.datetime().nullable(),
5527
5758
  createdAt: z.iso.datetime(),
5528
5759
  updatedAt: z.iso.datetime()
@@ -5645,29 +5876,6 @@ function patternPlaceholder(pattern) {
5645
5876
  * a guest returns with these schemas before anything acts on it; the guest
5646
5877
  * SDK and the Node simulator produce and consume the same shapes.
5647
5878
  */
5648
- /** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
5649
- const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
5650
- const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
5651
- const DURATION_UNIT_MS = {
5652
- ms: 1,
5653
- s: 1e3,
5654
- m: 6e4,
5655
- h: 36e5,
5656
- d: 864e5,
5657
- w: 6048e5
5658
- };
5659
- /**
5660
- * A duration in milliseconds. The simulator's virtual clock and the runner's
5661
- * timers both need it, and neither may pull in Temporal's `msToNumber` (one
5662
- * runs in the CLI, the other inside workflow code).
5663
- */
5664
- function parseDuration(duration) {
5665
- if (typeof duration === "number") return duration;
5666
- const match = DURATION_PATTERN.exec(duration.trim());
5667
- if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
5668
- const unit = DURATION_UNIT_MS[match[2]];
5669
- return Math.round(Number(match[1]) * unit);
5670
- }
5671
5879
  /** The event a journey runs for, or waits on: name, properties, and when. */
5672
5880
  const guestEventSchema = z.object({
5673
5881
  name: z.string().min(1),
@@ -5707,8 +5915,9 @@ const commandSchema = z.discriminatedUnion("name", [
5707
5915
  /**
5708
5916
  * The address this mail leaves as. The guest SDK fills in the
5709
5917
  * journey's own whenever the call does not name one, so the host has
5710
- * one resolution path and never reads the manifest to find it;
5711
- * absent on both is the organization's shared fallback address.
5918
+ * one resolution path and never reads the manifest to find it. A
5919
+ * journey must name one, so absent on both is a release pushed before
5920
+ * that was true, and the `domain` gate refuses it.
5712
5921
  */
5713
5922
  from: fromSchema.optional(),
5714
5923
  /** Where a reply to this one mail goes, instead of the from-address. */
@@ -5852,11 +6061,14 @@ const manifestInputSchema = z.object({ kind: z.literal("manifest") });
5852
6061
  const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema.pick({
5853
6062
  trigger: true,
5854
6063
  purpose: true,
6064
+ enrollment: true,
6065
+ description: true,
5855
6066
  from: true,
5856
6067
  tags: true
5857
6068
  }).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
5858
6069
  sendClass: true,
5859
6070
  verifyLink: true,
6071
+ unsubscribeLink: true,
5860
6072
  propsSchema: true,
5861
6073
  tags: true
5862
6074
  }).extend({ kind: z.literal("template") })]);
@@ -6086,7 +6298,7 @@ const listVersionsQuerySchema = paginationQuerySchema.extend({
6086
6298
  });
6087
6299
  /**
6088
6300
  * One key of a project as `cow status` and the MCP `status` tool report it:
6089
- * the code the server holds for it, whether it is on, and what is still
6301
+ * the code the server holds for it, what state it is in, and what is still
6090
6302
  * running. Journeys and templates share the shape, because a developer asks
6091
6303
  * the same question of both; the two fields only a journey has are null on a
6092
6304
  * template.
@@ -6100,8 +6312,12 @@ const projectStatusKeySchema = z.object({
6100
6312
  * server holds. Null only for a journey whose versions are all pruned.
6101
6313
  */
6102
6314
  latestVersion: versionSchema.nullable(),
6103
- /** The journey's one flag; null on a template, which has none. */
6104
- enabled: z.boolean().nullable(),
6315
+ /**
6316
+ * The journey's one state; null on a template, which has none. Built from
6317
+ * the column here rather than imported from `journeys.ts`, which reads
6318
+ * this module for its version summary.
6319
+ */
6320
+ status: z.enum(journeyStatusEnum.enumValues).nullable(),
6105
6321
  /** Executions still running or waiting. */
6106
6322
  liveExecutions: z.number().int()
6107
6323
  });
@@ -6122,12 +6338,62 @@ const projectStatusSchema = z.object({
6122
6338
  warnings: z.array(z.string())
6123
6339
  });
6124
6340
 
6341
+ //#endregion
6342
+ //#region ../../packages/shared/src/segments.ts
6343
+ const segmentSchema = selectSegmentSchema.extend({
6344
+ definition: segmentDefinitionSchema,
6345
+ createdAt: z.iso.datetime(),
6346
+ updatedAt: z.iso.datetime()
6347
+ });
6348
+ /** The detail view adds the materialized member count. */
6349
+ const segmentDetailSchema = segmentSchema.extend({ memberCount: z.number().int().min(0) });
6350
+ const segmentMemberSchema = z.object({
6351
+ segmentId: z.string(),
6352
+ orgId: z.string(),
6353
+ profileId: z.string(),
6354
+ enteredAt: z.iso.datetime()
6355
+ });
6356
+ /**
6357
+ * The builder's design-time sanity check: run one not-yet-persisted
6358
+ * definition over the org's existing profiles. Members is a small sample of
6359
+ * matching profile ids, not the full member list: there is no segment row
6360
+ * and no membership entry, so there is no segmentId or enteredAt to report.
6361
+ *
6362
+ * The scan is bounded, so `memberCount` is the exact org-wide count only
6363
+ * when `truncated` is false. When it is true the scan stopped at the cap,
6364
+ * and `memberCount` is the count over the first `scanned` profiles only.
6365
+ */
6366
+ const previewSegmentBodySchema = z.object({ data: segmentDefinitionSchema });
6367
+ const segmentPreviewSchema = z.object({
6368
+ memberCount: z.number().int().min(0),
6369
+ members: z.array(z.string()),
6370
+ /** Profiles the preview actually evaluated. */
6371
+ scanned: z.number().int().min(0),
6372
+ /** True when the scan hit its cap, so memberCount is a floor. */
6373
+ truncated: z.boolean()
6374
+ });
6375
+ const nameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
6376
+ const descriptionSchema = z.string().trim().max(500, `description must be at most ${500} characters`);
6377
+ const createSegmentBodySchema = z.object({ data: z.object({
6378
+ name: nameSchema,
6379
+ description: descriptionSchema.nullish(),
6380
+ definition: segmentDefinitionSchema
6381
+ }) });
6382
+ const updateSegmentBodySchema = z.object({ data: z.object({
6383
+ name: nameSchema.optional(),
6384
+ description: descriptionSchema.nullish(),
6385
+ definition: segmentDefinitionSchema.optional()
6386
+ }).refine((data) => data.name !== void 0 || data.description !== void 0 || data.definition !== void 0, { message: "at least one field (name, description or definition) is required" }) });
6387
+ /** `q` is a substring search over the segment's name. */
6388
+ const listSegmentsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
6389
+ const listSegmentMembersQuerySchema = paginationQuerySchema.extend({});
6390
+
6125
6391
  //#endregion
6126
6392
  //#region ../../packages/shared/src/journeys.ts
6127
6393
  /**
6128
6394
  * Journeys as the API reports them: the rows a push derives from its
6129
6395
  * manifest, one per (org, key). Nothing here is authored through the API, so
6130
- * the only write is the enable flag.
6396
+ * the only write is the status.
6131
6397
  *
6132
6398
  * Derived from the Drizzle table via drizzle-zod so the wire DTO and the row
6133
6399
  * share one source of truth; the jsonb columns get explicit wire schemas
@@ -6140,11 +6406,19 @@ const journeyTriggerSchema = triggerSchema;
6140
6406
  * control flow is whatever its code does at run time.
6141
6407
  */
6142
6408
  const journeySpineEntrySchema = spineEntrySchema;
6409
+ /**
6410
+ * The one operational state a manager sets (ADR 0018). `on` lets recipients
6411
+ * enroll and executions run; `off` enrolls nobody and lets what is in flight
6412
+ * finish; `paused` enrolls nobody and holds what is in flight before its next
6413
+ * step. Turning a paused journey on resumes it.
6414
+ */
6415
+ const JOURNEY_STATUSES = journeyStatusEnum.enumValues;
6416
+ const journeyStatusSchema = z.enum(JOURNEY_STATUSES);
6143
6417
  const journeySchema = selectJourneySchema.extend({
6144
6418
  trigger: journeyTriggerSchema,
6145
6419
  purpose: consentPurposeKeySchema,
6146
6420
  spine: z.array(journeySpineEntrySchema),
6147
- enabled: z.boolean(),
6421
+ status: journeyStatusSchema,
6148
6422
  /**
6149
6423
  * The newest version of this key, whatever it compiled to, so a list can
6150
6424
  * say "pushed 2 hours ago" and "compiling" without a second read. What the
@@ -6157,14 +6431,24 @@ const journeySchema = selectJourneySchema.extend({
6157
6431
  updatedAt: z.iso.datetime()
6158
6432
  });
6159
6433
  /**
6434
+ * One journey, with the segment it owns when its trigger inlines a
6435
+ * definition (ADR 0016): the same row and member count the segments API
6436
+ * reports, so the journey's page can show who is in its segment without a
6437
+ * second concept. Null for an event trigger.
6438
+ *
6439
+ * On the read of one journey only. A list would be one member count per row,
6440
+ * and a list already shows the trigger.
6441
+ */
6442
+ const journeyDetailSchema = journeySchema.extend({ segment: segmentDetailSchema.nullable() });
6443
+ /**
6160
6444
  * Journey list query. `q` is a substring search over the key and the tags,
6161
6445
  * the two things an author names a journey by; `tag` is "has this tag",
6162
6446
  * exact and case-sensitive, so a badge in the table is the way into it.
6163
6447
  * Both are server-side, like every other list filter.
6164
6448
  */
6165
6449
  const listJourneysQuerySchema = paginationQuerySchema.extend({
6166
- /** On or off; the journey's one flag. */
6167
- enabled: z.enum(["true", "false"]).optional(),
6450
+ /** On, off or paused; the journey's one state. */
6451
+ status: journeyStatusSchema.optional(),
6168
6452
  q: z.string().trim().max(200, "q must be at most 200 characters").optional().transform((value) => value ? value : void 0),
6169
6453
  tag: z.string().trim().max(50, "tag must be at most 50 characters").optional()
6170
6454
  });
@@ -6192,18 +6476,24 @@ const journeyStatsSchema = z.object({
6192
6476
  deliveries: z.object({ byStatus: z.record(deliveryStatusSchema, z.number().int()) })
6193
6477
  });
6194
6478
  /**
6195
- * Which journeys the flag acts on: the keys the caller named, or every
6196
- * journey of one project. Exactly one of the two, because a call carrying
6197
- * both would have to decide which it meant.
6479
+ * Which journeys the status acts on, and which state to put them in: the
6480
+ * keys the caller named, or every journey of one project. Exactly one of the
6481
+ * two selectors, because a call carrying both would have to decide which it
6482
+ * meant.
6198
6483
  *
6199
6484
  * A list rather than one key per call so every surface can do what `cow
6200
6485
  * enable` does: name several keys, or `--all` (which is `project`, resolved
6201
6486
  * server-side, so an agent turning a project on is one call too).
6487
+ *
6488
+ * One body for three states rather than a route per verb (ADR 0018): the
6489
+ * transitions differ in what they do to what is in flight, not in who may
6490
+ * ask or what they name.
6202
6491
  */
6203
- const setJourneysEnabledBodySchema = z.object({ data: z.object({
6492
+ const setJourneysStatusBodySchema = z.object({ data: z.object({
6204
6493
  keys: z.array(journeyKeySchema).min(1).max(PUSH_LIMITS.journeys).optional(),
6205
6494
  /** Every journey of this project, by the name `cow.json` carries. */
6206
- project: projectNameSchema.optional()
6495
+ project: projectNameSchema.optional(),
6496
+ status: journeyStatusSchema
6207
6497
  }).refine((data) => data.keys === void 0 !== (data.project === void 0), { message: "name keys or a project, not both" }) });
6208
6498
  /**
6209
6499
  * Dry run: one real profile, the journey's latest ready version, and every
@@ -6212,6 +6502,106 @@ const setJourneysEnabledBodySchema = z.object({ data: z.object({
6212
6502
  * now and a v1 instance was a Temporal read.
6213
6503
  */
6214
6504
  const dryRunJourneyBodySchema = z.object({ data: z.object({ profileId: z.string().min(1) }) });
6505
+ /**
6506
+ * Enrollment (ADR 0015): what turning a journey on would do, or is doing,
6507
+ * to the people already in its audience.
6508
+ *
6509
+ * The same three numbers answer both questions, so they are one schema: a
6510
+ * preview walks the whole segment counting, and the running job counts the
6511
+ * same way as it goes. `scanned` is the segment's current membership,
6512
+ * `enrolled` the ones that entered (or in a preview would), and the skips
6513
+ * say why the rest did not.
6514
+ */
6515
+ const enrollmentCountsSchema = z.object({
6516
+ scanned: z.number().int(),
6517
+ enrolled: z.number().int(),
6518
+ skipped: z.object({
6519
+ /** They have been through this journey, and it enrolls once. */
6520
+ completed: z.number().int(),
6521
+ /** They finished recently and the journey's cooldown has not elapsed. */
6522
+ cooldown: z.number().int(),
6523
+ /** They are in the journey right now. */
6524
+ running: z.number().int()
6525
+ })
6526
+ });
6527
+ /**
6528
+ * How each skip is said to a person, in the order the surfaces list them.
6529
+ * The confirm screen, the progress card and `cow enable` all report the same
6530
+ * three numbers, and the words for them live here so the three cannot drift:
6531
+ * `tile` names the count on its own, `reason` completes "N ...".
6532
+ */
6533
+ const ENROLLMENT_SKIP_WORDS = {
6534
+ completed: {
6535
+ tile: "Already been through",
6536
+ reason: "have already been through it"
6537
+ },
6538
+ cooldown: {
6539
+ tile: "Too recently",
6540
+ reason: "went through it too recently"
6541
+ },
6542
+ running: {
6543
+ tile: "In it already",
6544
+ reason: "are in it right now"
6545
+ }
6546
+ };
6547
+ const enrollmentStatusSchema = enrollmentCountsSchema.extend({
6548
+ /**
6549
+ * `running` while the job works, `done` when it finished the segment,
6550
+ * `cancelled` when someone stopped it, `stopped` when the journey itself
6551
+ * stopped being on under it, `failed` when it could not finish. On any of
6552
+ * the three that end early the counts are what it reached, and the people
6553
+ * it already enrolled stay in the journey.
6554
+ */
6555
+ state: z.enum([
6556
+ "running",
6557
+ "done",
6558
+ "cancelled",
6559
+ "stopped",
6560
+ "failed"
6561
+ ]) });
6562
+ /**
6563
+ * What a journey turned on says about the audience it now has waiting.
6564
+ *
6565
+ * It rides on the status response rather than being fetched separately so
6566
+ * that every surface that moves the control sees it: `cow enable` prints it,
6567
+ * and the MCP tool, which is planned from the contract and has no code of
6568
+ * its own, returns it. An agent that cannot start the enrollment can still
6569
+ * tell the person who can how many people it would reach and where to go.
6570
+ *
6571
+ * Null on a journey whose trigger is an event: there is no standing audience
6572
+ * to reach. `counts` is null when the numbers could not be worked out just
6573
+ * then; the journey is on either way, because a count nobody could read is
6574
+ * no reason to leave the switch off.
6575
+ */
6576
+ const enrollmentOfferSchema = z.object({
6577
+ counts: enrollmentCountsSchema.nullable(),
6578
+ /** The journey's page, where the offer can be accepted. */
6579
+ url: z.string()
6580
+ });
6581
+ /**
6582
+ * A journey as the status route answers, carrying that offer. Null on every
6583
+ * status but `on`: a journey turned off or held reaches nobody, so there is
6584
+ * no audience to offer.
6585
+ */
6586
+ const journeyWithOfferSchema = journeySchema.extend({ enrollment: enrollmentOfferSchema.nullable() });
6587
+ /**
6588
+ * What holding a journey until a given day would end (ADR 0018). A wait
6589
+ * that comes due while the journey is held ends that person's run, so
6590
+ * before putting one on hold it is worth knowing how many runs that is.
6591
+ *
6592
+ * The day is the caller's, not a fixed horizon: "until tomorrow" and "until
6593
+ * next month" are different decisions, and the numbers behind them are what
6594
+ * tell them apart.
6595
+ */
6596
+ const waitsDueQuerySchema = z.object({
6597
+ /** Count the waits that come due before this instant. */
6598
+ before: z.iso.datetime() });
6599
+ const waitsDueSchema = z.object({
6600
+ /** Executions in flight whose next step comes due before then. */
6601
+ due: z.number().int(),
6602
+ /** Executions in flight altogether, due or not. */
6603
+ inFlight: z.number().int()
6604
+ });
6215
6605
 
6216
6606
  //#endregion
6217
6607
  //#region ../../packages/shared/src/journeys-v2/sandbox.ts
@@ -6330,7 +6720,7 @@ const RESEND_MAX_TAGS = 50;
6330
6720
  const recipientSchema = z.string().trim().max(320).refine((value) => parseFromAddress(value) !== null, { message: "Every recipient must be an address (\"ada@acme.com\") or a name and address (\"Ada <ada@acme.com>\")." });
6331
6721
  const recipientArraySchema = z.array(recipientSchema).min(1).max(50);
6332
6722
  /** Resend takes one address or a list wherever it takes recipients. */
6333
- const recipientsSchema = z.union([recipientSchema, recipientArraySchema]);
6723
+ const recipientsSchema$1 = z.union([recipientSchema, recipientArraySchema]);
6334
6724
  /**
6335
6725
  * Resend's wording for an absent required field. Three fields can be
6336
6726
  * missing: `to`, `subject`, and a body. Which answer each one gets is read
@@ -6361,11 +6751,11 @@ const templateSlotSchema = z.strictObject({
6361
6751
  /** Send body: Resend's fields, minus the ones Cowliss does not serve. */
6362
6752
  const resendSendBodySchema = z.strictObject({
6363
6753
  /**
6364
- * Optional here where Resend requires it: an omitted `from` is the
6365
- * organization's shared fallback address, which is what makes a first
6366
- * send work with no configuration at all.
6754
+ * Required, exactly as Resend requires it. There is no address to fall
6755
+ * back to: a send leaves from a domain the organization verified, and
6756
+ * nothing else. Missing is its own issue for the same reason `to`'s is.
6367
6757
  */
6368
- from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }).optional(),
6758
+ from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }).optional().nonoptional({ error: MISSING("from") }),
6369
6759
  /**
6370
6760
  * Required, and the absence is its own issue rather than the union's:
6371
6761
  * `optional().nonoptional()` lets the value through to the union when
@@ -6382,9 +6772,9 @@ const resendSendBodySchema = z.strictObject({
6382
6772
  subject: z.string().trim().min(1).max(RESEND_MAX_SUBJECT).optional(),
6383
6773
  html: z.string().optional(),
6384
6774
  text: z.string().optional(),
6385
- cc: recipientsSchema.optional(),
6386
- bcc: recipientsSchema.optional(),
6387
- reply_to: recipientsSchema.optional(),
6775
+ cc: recipientsSchema$1.optional(),
6776
+ bcc: recipientsSchema$1.optional(),
6777
+ reply_to: recipientsSchema$1.optional(),
6388
6778
  headers: z.record(z.string(), z.string()).optional(),
6389
6779
  /** Accepted and dropped: Cowliss tags a delivery by its own fields. */
6390
6780
  tags: z.array(z.object({
@@ -6573,133 +6963,71 @@ const listReviewQuerySchema = paginationQuerySchema.extend({
6573
6963
  });
6574
6964
 
6575
6965
  //#endregion
6576
- //#region ../../packages/shared/src/segments.ts
6966
+ //#region ../../packages/shared/src/send-email.ts
6577
6967
  /**
6578
- * Segments API contracts. The definition is the interesting part: a flat
6579
- * list of predicates over traits and event history, combined with `all` or
6580
- * `any`. Deliberately flat — nested predicate groups are YAGNI for the
6581
- * prototype, and a flat list keeps the pure evaluator a fold.
6582
- *
6583
- * `appId` scopes the EVENT side of a definition only. Traits are
6584
- * per-profile and profiles merge across apps, so there is nothing
6585
- * app-shaped to filter on the trait side.
6586
- *
6587
- * A definition and the membership it produces are both the
6588
- * organization's.
6968
+ * `POST /v1/emails`: the first-party transactional send, the one a
6969
+ * `@cowliss/sdk` client calls. The Resend-compatible facade (ADR 0014) is
6970
+ * the same send in somebody else's wire format; this is ours, so it is
6971
+ * camelCase, enveloped, and answers with our error codes.
6589
6972
  *
6590
- * The object is strict: a definition holding the retired `sourceId` key
6591
- * (which meant the app) fails loudly instead of parsing as an unfiltered
6592
- * definition that evaluates over every app. There is deliberately no pipe
6593
- * filter here — filtering by source is a later feature, and accepting one
6594
- * now would make a stale `sourceId` parse as a filter on a pipe that does
6595
- * not exist and silently match nothing.
6973
+ * The limits are deliberately the same numbers as the facade's: two shapes
6974
+ * over one send path must not accept different messages.
6596
6975
  */
6597
- const SEGMENT_TRAIT_OPS = [
6598
- "eq",
6599
- "neq",
6600
- "gt",
6601
- "gte",
6602
- "lt",
6603
- "lte",
6604
- "exists",
6605
- "notExists",
6606
- "contains"
6607
- ];
6608
- /** Operators that read no comparison value: presence of the key is the test. */
6609
- const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
6610
- const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
6611
- const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
6612
- kind: z.literal("trait"),
6613
- name: predicateNameSchema,
6614
- op: z.enum(SEGMENT_TRAIT_OPS),
6615
- /**
6616
- * Compared against the stored trait, which is `unknown` because
6617
- * identify accepts arbitrary JSON. The evaluator coerces both sides
6618
- * before comparing, so authors here (CLI, MCP, dashboard) get the
6619
- * form-field-friendly reading rather than strict JSON equality:
6620
- * numeric-looking strings are compared as numbers for eq/neq and for
6621
- * ordering ("150" matches 150, and orders like it), and "true"/"false"
6622
- * are compared as booleans for eq/neq, trimmed and case-insensitively
6623
- * (" TRUE " reads as true). Coercion needs both sides to agree on a
6624
- * type: "0" never equals false. The numeric net is as wide as
6625
- * `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
6626
- * matters most for opaque ids: "007" is authored as the number 7.
6627
- *
6628
- * contains reads three ways. Against an array trait it is membership
6629
- * under that same equality, so "true" matches `[true]` and "1" matches
6630
- * `[1, 2]`. Against a string trait it is a plain substring search with
6631
- * no coercion, since substrings only mean something between strings.
6632
- * Against anything else it never matches. Ordering never coerces
6633
- * booleans.
6634
- */
6635
- value: z.unknown().optional()
6636
- }), z.object({
6637
- kind: z.literal("event"),
6638
- name: predicateNameSchema,
6639
- op: z.enum(["performed", "notPerformed"]),
6640
- /** How many matching events the predicate counts as "performed". */
6641
- atLeast: z.number().int().min(1).default(1),
6642
- /** Rolling window, relative to evaluation time; absent means all history. */
6643
- withinDays: z.number().int().min(1).optional()
6644
- })]).refine((predicate) => predicate.kind !== "trait" || VALUELESS_TRAIT_OPS.includes(predicate.op) || predicate.value !== void 0, { message: "value is required unless op is exists or notExists" });
6645
- const appIdSchema = z.string().trim().min(1);
6646
- const segmentDefinitionSchema = z.strictObject({
6647
- match: z.enum(["all", "any"]).default("all"),
6976
+ const addressSchema = z.string().trim().max(320).refine((value) => parseFromAddress(value) !== null, { message: "An address is \"ada@acme.com\" or a name and address, \"Ada <ada@acme.com>\"." });
6977
+ /** One address or a list, wherever a message names recipients. */
6978
+ const recipientsSchema = z.union([addressSchema, z.array(addressSchema).min(1).max(50)]);
6979
+ /** A template this organization pushed, plus the props it declares. */
6980
+ const templateSchema$1 = z.strictObject({
6981
+ key: journeyKeySchema,
6982
+ props: z.record(z.string(), z.unknown()).optional()
6983
+ });
6984
+ const sendEmailDataSchema = z.strictObject({
6985
+ sourceId: z.string(),
6986
+ to: recipientsSchema,
6987
+ cc: recipientsSchema.optional(),
6988
+ bcc: recipientsSchema.optional(),
6989
+ replyTo: recipientsSchema.optional(),
6648
6990
  /**
6649
- * Optional app filter over the event side; null/absent spans apps.
6650
- * A list matches any of the named apps, which is how one definition
6651
- * names the development and the production id of the same app.
6991
+ * An address on a domain this organization verified. There is no other.
6992
+ * Shape is checked here, the same as the Resend body checks it, so an
6993
+ * address that is not one is a 422 naming the field rather than a 403
6994
+ * telling the caller to verify a domain they never named.
6652
6995
  */
6653
- appId: z.union([appIdSchema, z.array(appIdSchema).min(1)]).nullish(),
6654
- predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
6655
- });
6656
- const segmentSchema = selectSegmentSchema.extend({
6657
- definition: segmentDefinitionSchema,
6658
- createdAt: z.iso.datetime(),
6659
- updatedAt: z.iso.datetime()
6660
- });
6661
- /** The detail view adds the materialized member count. */
6662
- const segmentDetailSchema = segmentSchema.extend({ memberCount: z.number().int().min(0) });
6663
- const segmentMemberSchema = z.object({
6664
- segmentId: z.string(),
6665
- orgId: z.string(),
6666
- profileId: z.string(),
6667
- enteredAt: z.iso.datetime()
6668
- });
6669
- /**
6670
- * The builder's design-time sanity check: run one not-yet-persisted
6671
- * definition over the org's existing profiles. Members is a small sample of
6672
- * matching profile ids, not the full member list: there is no segment row
6673
- * and no membership entry, so there is no segmentId or enteredAt to report.
6674
- *
6675
- * The scan is bounded, so `memberCount` is the exact org-wide count only
6676
- * when `truncated` is false. When it is true the scan stopped at the cap,
6677
- * and `memberCount` is the count over the first `scanned` profiles only.
6678
- */
6679
- const previewSegmentBodySchema = z.object({ data: segmentDefinitionSchema });
6680
- const segmentPreviewSchema = z.object({
6681
- memberCount: z.number().int().min(0),
6682
- members: z.array(z.string()),
6683
- /** Profiles the preview actually evaluated. */
6684
- scanned: z.number().int().min(0),
6685
- /** True when the scan hit its cap, so memberCount is a floor. */
6686
- truncated: z.boolean()
6996
+ from: z.string().trim().max(200).refine((value) => parseFromAddress(value) !== null, { message: "The from address must be an address (\"billing@acme.com\") or a name and address (\"Billing <billing@acme.com>\")." }),
6997
+ subject: z.string().max(RESEND_MAX_SUBJECT).optional(),
6998
+ html: z.string().optional(),
6999
+ text: z.string().optional(),
7000
+ headers: z.record(z.string(), z.string()).optional(),
7001
+ template: templateSchema$1.optional()
7002
+ }).superRefine((body, ctx) => {
7003
+ const hasBody = body.html !== void 0 || body.text !== void 0;
7004
+ if (body.template) {
7005
+ for (const field of [
7006
+ "subject",
7007
+ "html",
7008
+ "text"
7009
+ ]) if (body[field] !== void 0) ctx.addIssue({
7010
+ code: "custom",
7011
+ path: [field],
7012
+ message: `A message names a template or writes its own body, not both. Drop \`${field}\`.`
7013
+ });
7014
+ return;
7015
+ }
7016
+ if (!hasBody) ctx.addIssue({
7017
+ code: "custom",
7018
+ path: ["html"],
7019
+ message: "A message needs a template, or an html or text body."
7020
+ });
7021
+ if (body.subject === void 0) ctx.addIssue({
7022
+ code: "custom",
7023
+ path: ["subject"],
7024
+ message: "A message that writes its own body needs a subject."
7025
+ });
6687
7026
  });
6688
- const nameSchema = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
6689
- const descriptionSchema = z.string().trim().max(500, "description must be at most 500 characters");
6690
- const createSegmentBodySchema = z.object({ data: z.object({
6691
- name: nameSchema,
6692
- description: descriptionSchema.nullish(),
6693
- definition: segmentDefinitionSchema
6694
- }) });
6695
- const updateSegmentBodySchema = z.object({ data: z.object({
6696
- name: nameSchema.optional(),
6697
- description: descriptionSchema.nullish(),
6698
- definition: segmentDefinitionSchema.optional()
6699
- }).refine((data) => data.name !== void 0 || data.description !== void 0 || data.definition !== void 0, { message: "at least one field (name, description or definition) is required" }) });
6700
- /** `q` is a substring search over the segment's name. */
6701
- const listSegmentsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
6702
- const listSegmentMembersQuerySchema = paginationQuerySchema.extend({});
7027
+ const sendEmailBodySchema = z.object({ data: sendEmailDataSchema });
7028
+ const sendEmailResultSchema = z.object({
7029
+ /** The delivery this send left behind: the row in Deliveries. */
7030
+ deliveryId: z.string() });
6703
7031
 
6704
7032
  //#endregion
6705
7033
  //#region ../../packages/shared/src/settings.ts
@@ -6722,9 +7050,9 @@ const orgSettingsDtoSchema = z.object({
6722
7050
  /**
6723
7051
  * Clerk's slug and display name for the org, cached by the API and
6724
7052
  * READ-ONLY here: Clerk owns them, and the org profile is edited in
6725
- * Clerk's own UI. Exposed because they are the shared fallback sender the
6726
- * domains page shows (`<slug>@<shared domain>` as `<name>`). Null until a
6727
- * member has hit the dashboard API once.
7053
+ * Clerk's own UI. Exposed because a template test-send composes its
7054
+ * from-address out of them (`orgFromAddress`). Null until a member has hit
7055
+ * the dashboard API once.
6728
7056
  */
6729
7057
  slug: z.string().nullable(),
6730
7058
  name: z.string().nullable()
@@ -6863,6 +7191,12 @@ const templateSchema = z.object({
6863
7191
  sendClass: z.enum(SEND_CLASSES),
6864
7192
  /** True when the template asks for a signed `verifyUrl` prop at send time. */
6865
7193
  verifyLink: z.boolean(),
7194
+ /**
7195
+ * True when the template asks for a signed `unsubscribeUrl` prop at send
7196
+ * time. A marketing template without one (or without rendering the link)
7197
+ * gets a platform footer with the link appended at send time instead.
7198
+ */
7199
+ unsubscribeLink: z.boolean().default(false),
6866
7200
  /** JSON Schema of the template's props, as `cow build` converted them. */
6867
7201
  propsSchema: z.record(z.string(), z.unknown()),
6868
7202
  /** The newest version of this key, whatever it compiled to. */
@@ -6912,6 +7246,20 @@ const userSegmentSchema = z.object({
6912
7246
  });
6913
7247
  const listUserSegmentsQuerySchema = paginationQuerySchema;
6914
7248
  /**
7249
+ * One journey the profile ran to completion
7250
+ * (GET /v1/users/:profileId/runs): which journey it was, when it finished,
7251
+ * and the execution it finished, which is null once that execution is no
7252
+ * longer kept. Paged on (completedAt, id) newest first, so the cursor's
7253
+ * sortAt slot carries the completion time.
7254
+ */
7255
+ const userRunSchema = z.object({
7256
+ id: z.string(),
7257
+ journeyKey: z.string(),
7258
+ completedAt: z.iso.datetime(),
7259
+ executionId: z.string().nullable()
7260
+ });
7261
+ const listUserRunsQuerySchema = paginationQuerySchema;
7262
+ /**
6915
7263
  * Profile list query. `q` is a free-text substring search over the
6916
7264
  * profile's identifier values and the identity traits in
6917
7265
  * PROFILE_SEARCH_TRAITS; absent means no filter. An empty or
@@ -7992,8 +8340,13 @@ async function buildProject(projectDir) {
7992
8340
  const manifestJourneys = [];
7993
8341
  const manifestTemplates = [];
7994
8342
  for (const built of bundles) {
7995
- const { module, runGuest } = await loadNodeBundle(projectDir, built.source.key, built.source.kind);
7996
- const report = await runGuest(module, { kind: "manifest" });
8343
+ let report;
8344
+ try {
8345
+ const { module, runGuest } = await loadNodeBundle(projectDir, built.source.key, built.source.kind);
8346
+ report = await runGuest(module, { kind: "manifest" });
8347
+ } catch (error) {
8348
+ throw new Error(`${built.source.relPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
8349
+ }
7997
8350
  const isJourney = built.source.kind === "journeys";
7998
8351
  if (report?.kind !== (isJourney ? "journey" : "template")) throw new Error(`${built.source.relPath} is not a ${isJourney ? "journey" : "template"}: a journey default-exports defineJourney({ ... }), a template exports a component and a zod \`props\` schema.`);
7999
8352
  if (report.kind === "journey") manifestJourneys.push({
@@ -8001,6 +8354,8 @@ async function buildProject(projectDir) {
8001
8354
  tags: report.tags,
8002
8355
  trigger: report.trigger,
8003
8356
  purpose: report.purpose,
8357
+ enrollment: report.enrollment,
8358
+ description: report.description,
8004
8359
  from: report.from,
8005
8360
  spine: readSpine(await readFile(built.source.file, "utf8")),
8006
8361
  bundle: built.digest
@@ -8010,6 +8365,7 @@ async function buildProject(projectDir) {
8010
8365
  tags: report.tags,
8011
8366
  sendClass: report.sendClass,
8012
8367
  verifyLink: report.verifyLink,
8368
+ unsubscribeLink: report.unsubscribeLink,
8013
8369
  propsSchema: report.propsSchema,
8014
8370
  bundle: built.digest
8015
8371
  });
@@ -8332,7 +8688,7 @@ function openBrowser(url) {
8332
8688
  //#region src/commands/auth.ts
8333
8689
  /** login/logout/whoami: session-token management, no contract route. */
8334
8690
  function registerAuth(program, env, io) {
8335
- program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a Clerk session token instead of the browser flow").option("--web <url>", `dashboard URL; overrides COW_WEB_URL and webUrl in the project config (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
8691
+ program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a session token instead of the browser flow").option("--web <url>", `dashboard URL; overrides COW_WEB_URL and webUrl in the project config (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
8336
8692
  const outcome = await login(env, {
8337
8693
  token: opts.token,
8338
8694
  webUrl: opts.web,
@@ -9300,11 +9656,11 @@ const domains = defineModule(defineRoute({
9300
9656
  path: "/v1/domains",
9301
9657
  operationId: "domains.create",
9302
9658
  tags: ["domains"],
9303
- summary: "Claim a sending domain",
9659
+ summary: "Add a sending domain",
9304
9660
  security: SESSION_AUTH,
9305
9661
  request: { body: jsonBody(createSenderDomainBodySchema) },
9306
9662
  responses: {
9307
- 201: envelope(senderDomainSchema, "The claimed domain with the DNS records to publish"),
9663
+ 201: envelope(senderDomainSchema, "The domain with the DNS records to publish, which is what claims it"),
9308
9664
  ...sessionErrors,
9309
9665
  ...errors("conflict", "validation_failed", "malformed_request", "dependency_unavailable")
9310
9666
  }
@@ -9357,7 +9713,7 @@ const domains = defineModule(defineRoute({
9357
9713
  path: "/v1/domains/{id}",
9358
9714
  operationId: "domains.delete",
9359
9715
  tags: ["domains"],
9360
- summary: "Give up a domain claim",
9716
+ summary: "Give up a sending domain",
9361
9717
  security: SESSION_AUTH,
9362
9718
  request: { params: params$9 },
9363
9719
  responses: {
@@ -9369,8 +9725,31 @@ const domains = defineModule(defineRoute({
9369
9725
 
9370
9726
  //#endregion
9371
9727
  //#region ../../packages/shared/src/contract/emails.ts
9372
- /** The public email-verification pages: the signed token is the authorization. */
9728
+ /**
9729
+ * The transactional send a developer's own code makes, plus the public
9730
+ * email-verification pages.
9731
+ *
9732
+ * `emails.send` is the first-party shape of the same send the Resend facade
9733
+ * serves (ADR 0014): one service, two wire formats, and this one answers in
9734
+ * the envelope with our error codes. It authenticates with an org API key
9735
+ * like the rest of the write path, which is why it is hidden from the CLI
9736
+ * and MCP surfaces: those hold a session.
9737
+ */
9373
9738
  const emails = defineModule(defineRoute({
9739
+ method: "post",
9740
+ path: "/v1/emails",
9741
+ operationId: "emails.send",
9742
+ tags: ["emails"],
9743
+ summary: "Send one transactional email",
9744
+ security: API_KEY_AUTH,
9745
+ surfaces: HIDDEN_FROM_TOOLS,
9746
+ request: { body: jsonBody(sendEmailBodySchema) },
9747
+ responses: {
9748
+ 201: envelope(sendEmailResultSchema, "Handed over; the delivery's id"),
9749
+ ...ingestionErrors,
9750
+ ...errors("forbidden", "not_found", "conflict")
9751
+ }
9752
+ }), defineRoute({
9374
9753
  method: "get",
9375
9754
  path: "/v1/public/verify",
9376
9755
  operationId: "emails.verifyPage",
@@ -9644,7 +10023,7 @@ const journeys = defineModule(defineRoute({
9644
10023
  security: SESSION_AUTH,
9645
10024
  request: { params: params$7 },
9646
10025
  responses: {
9647
- 200: envelope(journeySchema),
10026
+ 200: envelope(journeyDetailSchema),
9648
10027
  ...sessionErrors,
9649
10028
  ...errors("not_found")
9650
10029
  }
@@ -9663,31 +10042,33 @@ const journeys = defineModule(defineRoute({
9663
10042
  }
9664
10043
  }), defineRoute({
9665
10044
  method: "post",
9666
- path: "/v1/journeys/enable",
9667
- operationId: "journeys.enable",
10045
+ path: "/v1/journeys/status",
10046
+ operationId: "journeys.setStatus",
9668
10047
  tags: ["journeys"],
9669
- summary: "Turn journeys on",
10048
+ summary: "Put journeys on, off or on hold",
9670
10049
  security: PIPELINE_AUTH,
9671
10050
  surfaces: { cli: false },
9672
- request: { body: jsonBody(setJourneysEnabledBodySchema) },
10051
+ request: { body: jsonBody(setJourneysStatusBodySchema) },
9673
10052
  responses: {
9674
- 200: envelope(z.array(journeySchema), "The journeys as they now stand"),
10053
+ 200: envelope(z.array(journeyWithOfferSchema), "The journeys as they now stand, each with the members turning it on has waiting"),
9675
10054
  ...sessionErrors,
9676
10055
  ...errors("not_found", "project_missing", "validation_failed", "malformed_request")
9677
10056
  }
9678
10057
  }), defineRoute({
9679
- method: "post",
9680
- path: "/v1/journeys/disable",
9681
- operationId: "journeys.disable",
10058
+ method: "get",
10059
+ path: "/v1/journeys/{key}/waits",
10060
+ operationId: "journeys.waitsDue",
9682
10061
  tags: ["journeys"],
9683
- summary: "Turn journeys off",
9684
- security: PIPELINE_AUTH,
9685
- surfaces: { cli: false },
9686
- request: { body: jsonBody(setJourneysEnabledBodySchema) },
10062
+ summary: "Count the executions a hold until a given day would end",
10063
+ security: SESSION_AUTH,
10064
+ request: {
10065
+ params: params$7,
10066
+ query: waitsDueQuerySchema
10067
+ },
9687
10068
  responses: {
9688
- 200: envelope(z.array(journeySchema), "The journeys as they now stand"),
10069
+ 200: envelope(waitsDueSchema, "Executions in flight, and how many of them come due before that instant"),
9689
10070
  ...sessionErrors,
9690
- ...errors("not_found", "project_missing", "validation_failed", "malformed_request")
10071
+ ...errors("not_found", "validation_failed")
9691
10072
  }
9692
10073
  }), defineRoute({
9693
10074
  method: "delete",
@@ -9702,6 +10083,59 @@ const journeys = defineModule(defineRoute({
9702
10083
  ...sessionErrors,
9703
10084
  ...errors("not_found")
9704
10085
  }
10086
+ }), defineRoute({
10087
+ method: "get",
10088
+ path: "/v1/journeys/{key}/enrollment/preview",
10089
+ operationId: "journeys.enrollmentPreview",
10090
+ tags: ["journeys"],
10091
+ summary: "Count who turning this journey on would reach",
10092
+ security: SESSION_AUTH,
10093
+ request: { params: params$7 },
10094
+ responses: {
10095
+ 200: envelope(enrollmentCountsSchema, "What enrolling the journey's current members would do"),
10096
+ ...sessionErrors,
10097
+ ...errors("not_found", "dependency_unavailable")
10098
+ }
10099
+ }), defineRoute({
10100
+ method: "post",
10101
+ path: "/v1/journeys/{key}/enrollment",
10102
+ operationId: "journeys.startEnrollment",
10103
+ tags: ["journeys"],
10104
+ summary: "Enroll the journey's current members",
10105
+ security: SESSION_AUTH,
10106
+ surfaces: HIDDEN_FROM_TOOLS,
10107
+ request: { params: params$7 },
10108
+ responses: {
10109
+ 202: envelope(enrollmentStatusSchema, "The job, as it starts"),
10110
+ ...sessionErrors,
10111
+ ...errors("not_found")
10112
+ }
10113
+ }), defineRoute({
10114
+ method: "get",
10115
+ path: "/v1/journeys/{key}/enrollment",
10116
+ operationId: "journeys.enrollmentStatus",
10117
+ tags: ["journeys"],
10118
+ summary: "How far the journey's enrollment has got",
10119
+ security: SESSION_AUTH,
10120
+ request: { params: params$7 },
10121
+ responses: {
10122
+ 200: envelope(enrollmentStatusSchema),
10123
+ ...sessionErrors,
10124
+ ...errors("not_found")
10125
+ }
10126
+ }), defineRoute({
10127
+ method: "post",
10128
+ path: "/v1/journeys/{key}/enrollment/cancel",
10129
+ operationId: "journeys.cancelEnrollment",
10130
+ tags: ["journeys"],
10131
+ summary: "Stop a running enrollment",
10132
+ security: SESSION_AUTH,
10133
+ request: { params: params$7 },
10134
+ responses: {
10135
+ 200: envelope(enrollmentStatusSchema, "The job as it stood when the stop was asked for"),
10136
+ ...sessionErrors,
10137
+ ...errors("not_found")
10138
+ }
9705
10139
  }), defineRoute({
9706
10140
  method: "post",
9707
10141
  path: "/v1/journeys/{key}/dry-run",
@@ -10080,7 +10514,7 @@ const segments = defineModule(defineRoute({
10080
10514
  responses: {
10081
10515
  200: envelope(segmentDetailSchema),
10082
10516
  ...sessionErrors,
10083
- ...errors("not_found"),
10517
+ ...errors("not_found", "conflict"),
10084
10518
  ...writeErrors
10085
10519
  }
10086
10520
  }), defineRoute({
@@ -10094,7 +10528,7 @@ const segments = defineModule(defineRoute({
10094
10528
  responses: {
10095
10529
  200: envelope(deletedSchema),
10096
10530
  ...sessionErrors,
10097
- ...errors("not_found")
10531
+ ...errors("not_found", "conflict")
10098
10532
  }
10099
10533
  }), defineRoute({
10100
10534
  method: "get",
@@ -10567,6 +11001,23 @@ const users = defineModule(defineRoute({
10567
11001
  ...sessionErrors,
10568
11002
  ...errors("not_found", "validation_failed")
10569
11003
  }
11004
+ }), defineRoute({
11005
+ method: "get",
11006
+ path: "/v1/users/{profileId}/runs",
11007
+ operationId: "users.listRuns",
11008
+ tags: ["users"],
11009
+ summary: "The journeys a profile has completed",
11010
+ security: SESSION_AUTH,
11011
+ request: {
11012
+ params: params$2,
11013
+ query: listUserRunsQuerySchema
11014
+ },
11015
+ responses: {
11016
+ 200: list(userRunSchema),
11017
+ ...mergedRedirect,
11018
+ ...sessionErrors,
11019
+ ...errors("not_found", "validation_failed")
11020
+ }
10570
11021
  }));
10571
11022
 
10572
11023
  //#endregion
@@ -11120,29 +11571,65 @@ function registerContractCommands(program, run) {
11120
11571
  //#endregion
11121
11572
  //#region src/commands/enable.ts
11122
11573
  /**
11123
- * `cow enable` and `cow disable`: the one gate on a journey (ADR 0011, ADR
11124
- * 0013). Hand-written rather than derived from `journeys.enable`, because
11125
- * the commands are top-level names a developer types; the route itself
11126
- * takes the same set of keys they do.
11574
+ * `cow enable`, `cow disable` and `cow pause`: the one gate on a journey
11575
+ * (ADR 0011, ADR 0013, ADR 0018). Hand-written rather than derived from
11576
+ * `journeys.setStatus`, because the commands are top-level names a developer
11577
+ * types; the route itself takes the same set of keys they do.
11578
+ *
11579
+ * `cow enable` on a journey on hold is how it resumes: the server has one
11580
+ * transition to `on` and no second word for it.
11127
11581
  */
11128
11582
  /**
11129
11583
  * One call, whichever way the keys were named: the route takes the set, and
11130
11584
  * `--all` is the project name, resolved on the server against the rows it
11131
11585
  * owns (so it reaches a key whose file the tree lost). A key that is not
11132
- * there refuses the whole call, and nothing flips.
11133
- */
11134
- async function setEnabled(client, selector, enabled) {
11135
- const route = enabled ? contract.journeys["journeys.enable"] : contract.journeys["journeys.disable"];
11136
- return (await client.request(route, { body: selector })).data;
11586
+ * there refuses the whole call, and nothing changes.
11587
+ */
11588
+ async function setStatus(client, selector, status) {
11589
+ return (await client.request(contract.journeys["journeys.setStatus"], { body: {
11590
+ ...selector,
11591
+ status
11592
+ } })).data;
11593
+ }
11594
+ /**
11595
+ * Who is already standing in a journey that was just turned on, and where
11596
+ * to let them in (ADR 0015). Printed, never acted on: enrolling a cohort
11597
+ * spends real money and reaches real people, so it stays a person's click
11598
+ * in the dashboard and no command here starts one.
11599
+ */
11600
+ function segmentLines(journey) {
11601
+ const offer = journey.enrollment;
11602
+ if (!offer) return [];
11603
+ if (!offer.counts) return [` Who is in its segment could not be counted just now: ${offer.url}`];
11604
+ const { scanned, enrolled, skipped } = offer.counts;
11605
+ if (scanned === 0) return [" Nobody is in its segment yet."];
11606
+ return [
11607
+ ` ${scanned} in its segment now, ${enrolled} of them can enter it now.`,
11608
+ ...Object.entries(ENROLLMENT_SKIP_WORDS).map(([key, words]) => ({
11609
+ value: skipped[key],
11610
+ words
11611
+ })).filter(({ value }) => value > 0).map(({ value, words }) => ` ${value} ${words.reason}.`),
11612
+ ...enrolled > 0 ? [` To let those ${enrolled} in: ${offer.url}`] : []
11613
+ ];
11137
11614
  }
11615
+ /** How the terminal names each state. */
11616
+ const STATE_WORD = {
11617
+ on: "on",
11618
+ off: "off",
11619
+ paused: "on hold"
11620
+ };
11138
11621
  /** What the terminal says: one line per key, naming its new state. */
11139
- function flipSummary(journeys, enabled) {
11140
- if (journeys.length === 0) return `This project has no journeys to turn ${enabled ? "on" : "off"}.`;
11141
- return journeys.map((journey) => `${journey.key} is ${enabled ? "on" : "off"}.`).join("\n");
11622
+ function flipSummary(journeys, status) {
11623
+ if (journeys.length === 0) return `This project has no journeys to put ${STATE_WORD[status]}.`;
11624
+ return journeys.map((journey) => [`${journey.key} is ${STATE_WORD[status]}.`, ...segmentLines(journey)].join("\n")).join("\n");
11142
11625
  }
11143
- function register(program, clientFor, io, enabled) {
11144
- const verb = enabled ? "enable" : "disable";
11145
- program.command(verb).description(`turn journeys ${enabled ? "on" : "off"} (many keys, or --all for this project's)`).argument("[keys...]", "journey keys").option("--all", "every journey of this project").action(async (keys, opts) => {
11626
+ const DESCRIPTIONS = {
11627
+ on: "turn journeys on",
11628
+ off: "turn journeys off",
11629
+ paused: "hold journeys: nobody enters, and everyone part-way through waits"
11630
+ };
11631
+ function register(program, clientFor, io, verb, status) {
11632
+ program.command(verb).description(`${DESCRIPTIONS[status]} (many keys, or --all for this project's)`).argument("[keys...]", "journey keys").option("--all", "every journey of this project").action(async (keys, opts) => {
11146
11633
  const merged = {
11147
11634
  ...program.opts(),
11148
11635
  ...opts
@@ -11150,17 +11637,18 @@ function register(program, clientFor, io, enabled) {
11150
11637
  if (merged.all === true && keys.length > 0) throw new Error(`Name the journeys or pass --all, not both: cow ${verb} welcome.`);
11151
11638
  if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome.`);
11152
11639
  const selector = merged.all === true ? { project: (await assertCowConfig(process.cwd())).project } : { keys };
11153
- const updated = await setEnabled(await clientFor(merged), selector, enabled);
11640
+ const updated = await setStatus(await clientFor(merged), selector, status);
11154
11641
  if (merged.json === true) {
11155
11642
  emit({ data: updated }, io, true);
11156
11643
  return;
11157
11644
  }
11158
- io.stdout(`${flipSummary(updated, enabled)}\n`);
11645
+ io.stdout(`${flipSummary(updated, status)}\n`);
11159
11646
  });
11160
11647
  }
11161
11648
  function registerEnable(program, clientFor, io) {
11162
- register(program, clientFor, io, true);
11163
- register(program, clientFor, io, false);
11649
+ register(program, clientFor, io, "enable", "on");
11650
+ register(program, clientFor, io, "disable", "off");
11651
+ register(program, clientFor, io, "pause", "paused");
11164
11652
  }
11165
11653
 
11166
11654
  //#endregion
@@ -11699,8 +12187,7 @@ function versionPhrase(entry, now) {
11699
12187
  return `${bundle ? `version ${shortDigest(bundle)}` : "version"}, pushed ${ago(version.pushedAt, now)}${state}`;
11700
12188
  }
11701
12189
  function flagsPhrase(entry) {
11702
- if (entry.enabled === null) return "";
11703
- return entry.enabled ? "on" : "off";
12190
+ return entry.status === null ? "" : entry.status === "paused" ? "on hold" : entry.status;
11704
12191
  }
11705
12192
  function livePhrase(entry) {
11706
12193
  return entry.liveExecutions === 0 ? "" : `${entry.liveExecutions} running`;