@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/README.md CHANGED
@@ -44,7 +44,7 @@ import { defineJourney } from "@cowliss/cli/journeys";
44
44
 
45
45
  export default defineJourney({
46
46
  trigger: { event: "checkout_started" },
47
- purpose: "emailMarketing",
47
+ purpose: "marketing",
48
48
  run: async (event, api) => {
49
49
  const purchased = await api.waitForEvent("purchase_completed", {
50
50
  timeout: "24h",
@@ -1,4 +1,4 @@
1
- import { a as manifestOutputSchema, i as journeyStepOutputSchema, o as templateRenderOutputSchema, r as guestInputSchema, t as CapabilityError } from "./journeys-BrcKEXz0.js";
1
+ import { a as manifestOutputSchema, i as journeyStepOutputSchema, o as templateRenderOutputSchema, r as guestInputSchema, t as CapabilityError } from "./journeys-Dpsp225V.js";
2
2
  import { z } from "zod";
3
3
  import { createElement } from "react";
4
4
  import { renderToStaticMarkup } from "react-dom/server.browser";
@@ -5573,6 +5573,8 @@ function readManifest(module) {
5573
5573
  kind: "journey",
5574
5574
  trigger: journey.trigger,
5575
5575
  purpose: journey.purpose,
5576
+ enrollment: journey.enrollment,
5577
+ description: journey.description,
5576
5578
  from: journey.from,
5577
5579
  tags: journey.tags
5578
5580
  });
@@ -5583,6 +5585,7 @@ function readManifest(module) {
5583
5585
  tags: template.tags,
5584
5586
  sendClass: template.sendClass ?? "marketing",
5585
5587
  verifyLink: template.verifyLink ?? false,
5588
+ unsubscribeLink: template.unsubscribeLink ?? false,
5586
5589
  propsSchema: z.toJSONSchema(template.props, { target: "draft-2020-12" })
5587
5590
  });
5588
5591
  }
@@ -1,4 +1,4 @@
1
- import { a as ManifestOutput, i as JourneyStepOutput, o as TemplateRenderOutput } from "./index-CXcCEcAg.js";
1
+ import { a as JourneyStepOutput, o as ManifestOutput, s as TemplateRenderOutput } from "./index-3PMqJKmc.js";
2
2
  //#region src/guest/driver.d.ts
3
3
  /**
4
4
  * The in-process driver: JSON in, JSON out (spec: Guest protocol). One
@@ -1,3 +1,3 @@
1
- import { t as runGuest } from "./driver-DBt5l1Z5.js";
1
+ import { t as runGuest } from "./driver-C1bsCjZT.js";
2
2
 
3
3
  export { runGuest };
@@ -36,6 +36,12 @@ type Template<Schema extends z.ZodType = z.ZodType> = {
36
36
  tags?: string[];
37
37
  /** True asks the host to mint a signed `verifyUrl` prop at send time. */
38
38
  verifyLink?: boolean;
39
+ /**
40
+ * True asks the host to mint a signed `unsubscribeUrl` prop at send time,
41
+ * for the author's own footer link. Leave it off and a marketing template
42
+ * still gets a platform footer with the link appended at send time.
43
+ */
44
+ unsubscribeLink?: boolean;
39
45
  };
40
46
  //#endregion
41
47
  export { type SendClass, Subject, Template };
@@ -6,9 +6,6 @@ import { z } from "zod";
6
6
  * a guest returns with these schemas before anything acts on it; the guest
7
7
  * SDK and the Node simulator produce and consume the same shapes.
8
8
  */
9
- /** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
10
- declare const durationSchema: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
11
- type Duration = z.infer<typeof durationSchema>;
12
9
  /** The event a journey runs for, or waits on: name, properties, and when. */
13
10
  declare const guestEventSchema: z.ZodObject<{
14
11
  name: z.ZodString;
@@ -257,9 +254,44 @@ declare const manifestOutputSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
257
254
  event: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
258
255
  appId: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
259
256
  }, z.core.$strict>, z.ZodObject<{
260
- segment: z.ZodString;
257
+ segment: z.ZodObject<{
258
+ match: z.ZodDefault<z.ZodEnum<{
259
+ all: "all";
260
+ any: "any";
261
+ }>>;
262
+ appId: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>>;
263
+ predicates: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
264
+ kind: z.ZodLiteral<"trait">;
265
+ name: z.ZodString;
266
+ op: z.ZodEnum<{
267
+ contains: "contains";
268
+ eq: "eq";
269
+ exists: "exists";
270
+ gt: "gt";
271
+ gte: "gte";
272
+ lt: "lt";
273
+ lte: "lte";
274
+ neq: "neq";
275
+ notExists: "notExists";
276
+ }>;
277
+ value: z.ZodOptional<z.ZodUnknown>;
278
+ }, z.core.$strip>, z.ZodObject<{
279
+ kind: z.ZodLiteral<"event">;
280
+ name: z.ZodString;
281
+ op: z.ZodEnum<{
282
+ notPerformed: "notPerformed";
283
+ performed: "performed";
284
+ }>;
285
+ atLeast: z.ZodDefault<z.ZodNumber>;
286
+ withinDays: z.ZodOptional<z.ZodNumber>;
287
+ }, z.core.$strip>], "kind">>;
288
+ }, z.core.$strict>;
261
289
  }, z.core.$strict>]>;
262
290
  purpose: z.ZodString;
291
+ enrollment: z.ZodOptional<z.ZodObject<{
292
+ cooldown: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
293
+ }, z.core.$strict>>;
294
+ description: z.ZodOptional<z.ZodString>;
263
295
  from: z.ZodOptional<z.ZodString>;
264
296
  kind: z.ZodLiteral<"journey">;
265
297
  }, z.core.$strip>, z.ZodObject<{
@@ -269,28 +301,88 @@ declare const manifestOutputSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
269
301
  transactional: "transactional";
270
302
  }>;
271
303
  verifyLink: z.ZodBoolean;
304
+ unsubscribeLink: z.ZodDefault<z.ZodBoolean>;
272
305
  propsSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
273
306
  kind: z.ZodLiteral<"template">;
274
307
  }, z.core.$strip>], "kind">;
275
308
  type ManifestOutput = z.infer<typeof manifestOutputSchema>;
276
309
  //#endregion
277
310
  //#region ../../packages/shared/src/journeys-v2/manifest.d.ts
311
+ /**
312
+ * The pushed manifest (spec: Build; Push and compile): what `cow build`
313
+ * extracts from a project and `cow push` uploads with the bundles. The
314
+ * server validates it with these schemas, compiles every bundle whose key
315
+ * changed, and stores that entry on the version it creates.
316
+ */
317
+ /**
318
+ * A duration as journey code writes it: an ms-style string ("2d") or
319
+ * milliseconds. It lives here rather than with the guest protocol because a
320
+ * manifest carries one too (a journey's enrollment cooldown) and `./guest`
321
+ * already imports this module, so the other direction would be a cycle.
322
+ */
323
+ declare const durationSchema: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
324
+ type Duration = z.infer<typeof durationSchema>;
278
325
  /**
279
326
  * What starts a journey: an event (optionally narrowed to an app id or a
280
- * list of them) or a segment entry. The registry DTO in `../journeys`
281
- * reuses it.
327
+ * list of them), or entry into the segment the trigger itself describes.
328
+ * The registry DTO in `../journeys` reuses it.
329
+ *
330
+ * A segment trigger carries the predicate list, not a name: the push
331
+ * materializes one segment row per journey that inlines a definition, owned
332
+ * by the journey and named after its key, so the segment exists because the
333
+ * journey exists and there is no order to get wrong (ADR 0016). The
334
+ * definition is data — validated here, carried on the version, never
335
+ * compiled and never executed.
282
336
  *
283
- * Both members are strict, so a journey holding the retired `source` key
284
- * fails to compile instead of silently triggering on every app.
285
- * There is deliberately no pipe filter: a trigger narrows by the app the
286
- * write is attributed to, the same token a segment definition names.
337
+ * Both members are strict, so a journey holding the retired `source` key, or
338
+ * the retired `{ segment: "name" }` form, fails to build instead of silently
339
+ * triggering on every app or on nothing. There is deliberately no pipe
340
+ * filter: a trigger narrows by the app the write is attributed to, the same
341
+ * token a segment definition names.
287
342
  */
288
343
  declare const triggerSchema: z.ZodUnion<readonly [z.ZodObject<{
289
344
  event: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
290
345
  appId: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
291
346
  }, z.core.$strict>, z.ZodObject<{
292
- segment: z.ZodString;
347
+ segment: z.ZodObject<{
348
+ match: z.ZodDefault<z.ZodEnum<{
349
+ all: "all";
350
+ any: "any";
351
+ }>>;
352
+ appId: z.ZodOptional<z.ZodNullable<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>>;
353
+ predicates: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
354
+ kind: z.ZodLiteral<"trait">;
355
+ name: z.ZodString;
356
+ op: z.ZodEnum<{
357
+ contains: "contains";
358
+ eq: "eq";
359
+ exists: "exists";
360
+ gt: "gt";
361
+ gte: "gte";
362
+ lt: "lt";
363
+ lte: "lte";
364
+ neq: "neq";
365
+ notExists: "notExists";
366
+ }>;
367
+ value: z.ZodOptional<z.ZodUnknown>;
368
+ }, z.core.$strip>, z.ZodObject<{
369
+ kind: z.ZodLiteral<"event">;
370
+ name: z.ZodString;
371
+ op: z.ZodEnum<{
372
+ notPerformed: "notPerformed";
373
+ performed: "performed";
374
+ }>;
375
+ atLeast: z.ZodDefault<z.ZodNumber>;
376
+ withinDays: z.ZodOptional<z.ZodNumber>;
377
+ }, z.core.$strip>], "kind">>;
378
+ }, z.core.$strict>;
293
379
  }, z.core.$strict>]>;
294
380
  type Trigger = z.infer<typeof triggerSchema>;
381
+ /**
382
+ * A trigger as an author writes it, before zod fills in a predicate's
383
+ * `match` and `atLeast` defaults. `defineJourney` takes this shape;
384
+ * everything downstream reads the parsed `Trigger`.
385
+ */
386
+ type TriggerInput = z.input<typeof triggerSchema>;
295
387
  //#endregion
296
- export { ManifestOutput as a, JourneyStepOutput as i, Duration as n, TemplateRenderOutput as o, GuestEvent as r, Trigger as t };
388
+ export { JourneyStepOutput as a, GuestEvent as i, Trigger as n, ManifestOutput as o, TriggerInput as r, TemplateRenderOutput as s, Duration as t };
@@ -9,16 +9,24 @@ const DOCS_URL = "https://docs.cowliss.com";
9
9
  */
10
10
  const CLERK_ORG_SUBJECT_PREFIX = "org_";
11
11
  /**
12
- * Fixed consent purposes for the prototype. Consent is a per-purpose map on
13
- * the profile, checked at send-step execution time.
14
- *
15
- * The two names describe what the recipient agreed to, not the pipe it
16
- * arrives on: "emails I did not ask for individually" and "my data leaving
17
- * for somewhere else". Naming them after the channel (`email`, `webhook`)
18
- * said nothing a recipient could consent to, and transactional mail already
19
- * bypasses the email purpose, so it was only ever marketing consent.
12
+ * The marketing purpose by name, since it is the one every gate, the
13
+ * unsubscribe route, and the developer's own toggle all reach for.
14
+ */
15
+ const MARKETING = "marketing";
16
+ /**
17
+ * A legal journey `purpose` that is not a consent purpose: the send gates
18
+ * pass it unconditionally, no profile's map stores it and no editor renders
19
+ * it. What protects the channel itself (suppression, the SES gates, the
20
+ * quota) still applies.
21
+ */
22
+ const TRANSACTIONAL = "transactional";
23
+ /**
24
+ * The purposes a project may not declare, which are the same two that sit
25
+ * outside the `marketing` umbrella: `marketing` is the umbrella itself and
26
+ * `transactional` is the case consent does not govern. Everything a project
27
+ * declares is a marketing-mail category and so sits under it.
20
28
  */
21
- const CONSENT_PURPOSES = ["emailMarketing", "dataProcessing"];
29
+ const RESERVED_PURPOSES = [MARKETING, TRANSACTIONAL];
22
30
  /**
23
31
  * The two classes of send, declared on the template rather than passed per
24
32
  * call so the class cannot drift between two sends of the same message.
@@ -56,6 +64,92 @@ const PUSH_LIMITS = {
56
64
  const COW_CONFIG_SCHEMA_PATH = "/schemas/cow.json";
57
65
  const COW_CONFIG_SCHEMA_URL = `${DOCS_URL}${COW_CONFIG_SCHEMA_PATH}`;
58
66
 
67
+ //#endregion
68
+ //#region ../../packages/shared/src/segment-definition.ts
69
+ /**
70
+ * A segment definition: a flat list of predicates over traits and event
71
+ * history, combined with `all` or `any`. Deliberately flat — nested predicate groups are YAGNI for the
72
+ * prototype, and a flat list keeps the pure evaluator a fold.
73
+ *
74
+ * `appId` scopes the EVENT side of a definition only. Traits are
75
+ * per-profile and profiles merge across apps, so there is nothing
76
+ * app-shaped to filter on the trait side.
77
+ *
78
+ * A definition and the membership it produces are both the
79
+ * organization's.
80
+ *
81
+ * Apart from `./segments` because a journey's trigger carries a definition
82
+ * and the trigger schema is bundled into the wasm guest, where an edge to
83
+ * `@cowliss/db` (which `./segments` has, for the row schema) is a hard
84
+ * bundler failure. Nothing here imports anything but zod.
85
+ *
86
+ * The object is strict: a definition holding the retired `sourceId` key
87
+ * (which meant the app) fails loudly instead of parsing as an unfiltered
88
+ * definition that evaluates over every app. There is deliberately no pipe
89
+ * filter here — filtering by source is a later feature, and accepting one
90
+ * now would make a stale `sourceId` parse as a filter on a pipe that does
91
+ * not exist and silently match nothing.
92
+ */
93
+ const SEGMENT_TRAIT_OPS = [
94
+ "eq",
95
+ "neq",
96
+ "gt",
97
+ "gte",
98
+ "lt",
99
+ "lte",
100
+ "exists",
101
+ "notExists",
102
+ "contains"
103
+ ];
104
+ /** Operators that read no comparison value: presence of the key is the test. */
105
+ const VALUELESS_TRAIT_OPS = ["exists", "notExists"];
106
+ const predicateNameSchema = z.string().trim().min(1, "predicate name is required").max(200);
107
+ const segmentPredicateSchema = z.discriminatedUnion("kind", [z.object({
108
+ kind: z.literal("trait"),
109
+ name: predicateNameSchema,
110
+ op: z.enum(SEGMENT_TRAIT_OPS),
111
+ /**
112
+ * Compared against the stored trait, which is `unknown` because
113
+ * identify accepts arbitrary JSON. The evaluator coerces both sides
114
+ * before comparing, so authors here (CLI, MCP, dashboard) get the
115
+ * form-field-friendly reading rather than strict JSON equality:
116
+ * numeric-looking strings are compared as numbers for eq/neq and for
117
+ * ordering ("150" matches 150, and orders like it), and "true"/"false"
118
+ * are compared as booleans for eq/neq, trimmed and case-insensitively
119
+ * (" TRUE " reads as true). Coercion needs both sides to agree on a
120
+ * type: "0" never equals false. The numeric net is as wide as
121
+ * `Number()`, so "0x64", "0b11" and "1e2" read as numbers too, which
122
+ * matters most for opaque ids: "007" is authored as the number 7.
123
+ *
124
+ * contains reads three ways. Against an array trait it is membership
125
+ * under that same equality, so "true" matches `[true]` and "1" matches
126
+ * `[1, 2]`. Against a string trait it is a plain substring search with
127
+ * no coercion, since substrings only mean something between strings.
128
+ * Against anything else it never matches. Ordering never coerces
129
+ * booleans.
130
+ */
131
+ value: z.unknown().optional()
132
+ }), z.object({
133
+ kind: z.literal("event"),
134
+ name: predicateNameSchema,
135
+ op: z.enum(["performed", "notPerformed"]),
136
+ /** How many matching events the predicate counts as "performed". */
137
+ atLeast: z.number().int().min(1).default(1),
138
+ /** Rolling window, relative to evaluation time; absent means all history. */
139
+ withinDays: z.number().int().min(1).optional()
140
+ })]).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" });
141
+ const appIdSchema = z.string().trim().min(1);
142
+ const segmentDefinitionSchema = z.strictObject({
143
+ match: z.enum(["all", "any"]).default("all"),
144
+ /**
145
+ * Optional app filter over the event side; null/absent spans apps.
146
+ * A list matches any of the named apps, which is how one definition
147
+ * names the development and the production id of the same app.
148
+ */
149
+ appId: z.union([appIdSchema, z.array(appIdSchema).min(1)]).nullish(),
150
+ predicates: z.array(segmentPredicateSchema).min(1, "at least one predicate is required")
151
+ });
152
+
59
153
  //#endregion
60
154
  //#region ../../packages/shared/src/journeys-v2/manifest.ts
61
155
  /**
@@ -65,6 +159,34 @@ const COW_CONFIG_SCHEMA_URL = `${DOCS_URL}${COW_CONFIG_SCHEMA_PATH}`;
65
159
  * changed, and stores that entry on the version it creates.
66
160
  */
67
161
  /**
162
+ * A duration as journey code writes it: an ms-style string ("2d") or
163
+ * milliseconds. It lives here rather than with the guest protocol because a
164
+ * manifest carries one too (a journey's enrollment cooldown) and `./guest`
165
+ * already imports this module, so the other direction would be a cycle.
166
+ */
167
+ const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
168
+ const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/;
169
+ const DURATION_UNIT_MS = {
170
+ ms: 1,
171
+ s: 1e3,
172
+ m: 6e4,
173
+ h: 36e5,
174
+ d: 864e5,
175
+ w: 6048e5
176
+ };
177
+ /**
178
+ * A duration in milliseconds. The simulator's virtual clock and the runner's
179
+ * timers both need it, and neither may pull in Temporal's `msToNumber` (one
180
+ * runs in the CLI, the other inside workflow code).
181
+ */
182
+ function parseDuration(duration) {
183
+ if (typeof duration === "number") return duration;
184
+ const match = DURATION_PATTERN.exec(duration.trim());
185
+ if (!match) throw new Error(`Duration ${JSON.stringify(duration)} is not a duration: use milliseconds or an ms-style string like "1h" or "2d".`);
186
+ const unit = DURATION_UNIT_MS[match[2]];
187
+ return Math.round(Number(match[1]) * unit);
188
+ }
189
+ /**
68
190
  * A journey or template key: the file basename under `journeys/` or
69
191
  * `emails/`, kebab-case and unique across the project. Becomes part of the
70
192
  * Temporal workflow id and travels in the journey chain, so it stays short.
@@ -85,8 +207,8 @@ const journeyKeySchema = z.string().max(64).regex(JOURNEY_KEY_PATTERN, "key must
85
207
  */
86
208
  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)]);
87
209
  /**
88
- * A consent purpose key: camelCase, matching the fixed `emailMarketing` and
89
- * `dataProcessing`. Purposes are keys in the `consent` map a customer reads
210
+ * A consent purpose key: camelCase, matching the reserved `marketing` and
211
+ * `transactional`. Purposes are keys in the `consent` map a customer reads
90
212
  * on their own profile, which is why they are not the kebab-case of a
91
213
  * journey key.
92
214
  */
@@ -101,22 +223,22 @@ const CONSENT_PURPOSE_KEY_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
101
223
  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)");
102
224
  /**
103
225
  * One purpose a project declares in `cow.json` (spec: Decisions). A declared
104
- * purpose is marketing-class and sits under the `emailMarketing` umbrella,
105
- * so `denied` is the only default it may carry: the purpose is absent on
106
- * every profile that already exists, and a granted default would answer for
107
- * all of them at once.
226
+ * purpose is marketing-class and sits under the `marketing` umbrella, so
227
+ * `denied` is the only default it may carry: the purpose is absent on every
228
+ * profile that already exists, and a granted default would answer for all of
229
+ * them at once.
108
230
  *
109
231
  * The field stays required rather than disappearing, so every `cow.json` and
110
- * every stored manifest written before this still parses, and the column
111
- * behind it still holds `granted` for the seeded `dataProcessing` row: this
112
- * is a refusal at declaration time, not a narrower storage shape.
232
+ * every stored manifest written before this still parses: this is a refusal
233
+ * at declaration time, not a narrower storage shape.
113
234
  *
114
- * The two fixed purposes cannot be declared. They are seeded for every org
115
- * and owned by no project, so a project redeclaring one would be renaming
116
- * the master switch every other project's journeys hang off.
235
+ * Neither reserved purpose can be declared: `marketing` is the master switch
236
+ * every other project's journeys hang off, and `transactional` is not a
237
+ * consent purpose at all, so declaring it would promise a switch that no
238
+ * send ever reads.
117
239
  */
118
240
  const declaredPurposeSchema = z.strictObject({
119
- key: consentPurposeKeySchema.refine((key) => !CONSENT_PURPOSES.includes(key), `${CONSENT_PURPOSES.join(" and ")} are fixed purposes and cannot be declared`),
241
+ key: consentPurposeKeySchema.refine((key) => !RESERVED_PURPOSES.includes(key), `${RESERVED_PURPOSES.join(" and ")} are reserved purposes and cannot be declared`),
120
242
  /** What the dashboard and the account modal render beside the switch. */
121
243
  label: z.string().trim().min(1).max(50, "a purpose label must be at most 50 characters"),
122
244
  /** What the purpose means for a profile whose map does not answer it. */
@@ -138,18 +260,26 @@ const onePatternSchema = z.string().min(1).max(200, "a pattern must be at most 2
138
260
  const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1).max(20, "a matcher takes at most 20 patterns")]);
139
261
  /**
140
262
  * What starts a journey: an event (optionally narrowed to an app id or a
141
- * list of them) or a segment entry. The registry DTO in `../journeys`
142
- * reuses it.
263
+ * list of them), or entry into the segment the trigger itself describes.
264
+ * The registry DTO in `../journeys` reuses it.
265
+ *
266
+ * A segment trigger carries the predicate list, not a name: the push
267
+ * materializes one segment row per journey that inlines a definition, owned
268
+ * by the journey and named after its key, so the segment exists because the
269
+ * journey exists and there is no order to get wrong (ADR 0016). The
270
+ * definition is data — validated here, carried on the version, never
271
+ * compiled and never executed.
143
272
  *
144
- * Both members are strict, so a journey holding the retired `source` key
145
- * fails to compile instead of silently triggering on every app.
146
- * There is deliberately no pipe filter: a trigger narrows by the app the
147
- * write is attributed to, the same token a segment definition names.
273
+ * Both members are strict, so a journey holding the retired `source` key, or
274
+ * the retired `{ segment: "name" }` form, fails to build instead of silently
275
+ * triggering on every app or on nothing. There is deliberately no pipe
276
+ * filter: a trigger narrows by the app the write is attributed to, the same
277
+ * token a segment definition names.
148
278
  */
149
279
  const triggerSchema = z.union([z.strictObject({
150
280
  event: patternSchema,
151
281
  appId: patternSchema.optional()
152
- }), z.strictObject({ segment: z.string().min(1) })]);
282
+ }), z.strictObject({ segment: segmentDefinitionSchema })]);
153
283
  /**
154
284
  * The address half of a from-header: a local part, an `@`, and a dotted
155
285
  * domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
@@ -252,10 +382,35 @@ const manifestJourneySchema = z.object({
252
382
  */
253
383
  purpose: consentPurposeKeySchema,
254
384
  /**
385
+ * How often one recipient may enter. Enrollment derives from the purpose
386
+ * (ADR 0015), so the only thing an author writes is how long after a
387
+ * completed run the journey re-opens: absent means once, ever. Refused on
388
+ * a transactional journey, which enrolls on every trigger — see
389
+ * `manifestSchema` below, where the cross-field check lives (a refinement
390
+ * on this object would break the `.pick()` the guest SDK and the guest
391
+ * protocol both take of it).
392
+ */
393
+ enrollment: z.strictObject({ cooldown: durationSchema.refine((value) => {
394
+ try {
395
+ parseDuration(value);
396
+ return true;
397
+ } catch {
398
+ return false;
399
+ }
400
+ }, "a cooldown must be milliseconds or an ms-style string like \"7d\"") }).optional(),
401
+ /**
402
+ * The author's own sentence about what this journey does, shown wherever
403
+ * the journey is read. Capped like a segment's description; absent when
404
+ * the author wrote none.
405
+ */
406
+ description: z.string().trim().max(500, `description must be at most ${500} characters`).optional(),
407
+ /**
255
408
  * The address every `send.email` in this journey goes out as, unless the
256
- * call names its own. Optional everywhere: an omitted one is the
257
- * organization's shared fallback address, which is what makes day-one
258
- * sending zero-config (ADR 0014).
409
+ * call names its own. Required of the author (`defineJourney` types it so,
410
+ * and `cow build` refuses a journey without it), and still optional here:
411
+ * releases pushed before ADR 0014 was amended carry none, and this schema
412
+ * parses stored manifests as well as new ones. A push with no `from` is
413
+ * refused by the domain gate, which says what to do about it.
259
414
  */
260
415
  from: fromSchema.optional(),
261
416
  spine: z.array(spineEntrySchema),
@@ -268,6 +423,13 @@ const manifestTemplateSchema = z.object({
268
423
  sendClass: z.enum(SEND_CLASSES),
269
424
  /** True asks the host to mint a signed `verifyUrl` prop at send time. */
270
425
  verifyLink: z.boolean(),
426
+ /**
427
+ * True asks the host to mint a signed `unsubscribeUrl` prop at send time,
428
+ * for the author's own footer link. Default false: a marketing send whose
429
+ * HTML carries no unsubscribe link at all still gets one, appended as a
430
+ * platform footer, so the link is never the author's to forget.
431
+ */
432
+ unsubscribeLink: z.boolean().default(false),
271
433
  /** JSON Schema of the template's `props`, converted by `cow build`. */
272
434
  propsSchema: z.record(z.string(), z.unknown()),
273
435
  bundle: digestSchema
@@ -318,6 +480,15 @@ const manifestSchema = z.object({
318
480
  uniqueKeys(manifest.journeys, ctx, "journeys");
319
481
  uniqueKeys(manifest.templates, ctx, "templates");
320
482
  uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
483
+ for (const [index, journey] of manifest.journeys.entries()) if (journey.enrollment && journey.purpose === "transactional") ctx.addIssue({
484
+ code: "custom",
485
+ message: `journey "${journey.key}": a transactional journey enrolls on every trigger, so it takes no enrollment cooldown`,
486
+ path: [
487
+ "journeys",
488
+ index,
489
+ "enrollment"
490
+ ]
491
+ });
321
492
  });
322
493
  /**
323
494
  * One entry of a stored manifest: what a version is a snapshot of. Journeys
@@ -377,8 +548,6 @@ const cowConfigSchema = z.strictObject({
377
548
  * a guest returns with these schemas before anything acts on it; the guest
378
549
  * SDK and the Node simulator produce and consume the same shapes.
379
550
  */
380
- /** A duration as journey code writes it: an ms-style string ("2d") or milliseconds. */
381
- const durationSchema = z.union([z.string().min(1), z.number().int().nonnegative()]);
382
551
  /** The event a journey runs for, or waits on: name, properties, and when. */
383
552
  const guestEventSchema = z.object({
384
553
  name: z.string().min(1),
@@ -418,8 +587,9 @@ const commandSchema = z.discriminatedUnion("name", [
418
587
  /**
419
588
  * The address this mail leaves as. The guest SDK fills in the
420
589
  * journey's own whenever the call does not name one, so the host has
421
- * one resolution path and never reads the manifest to find it;
422
- * absent on both is the organization's shared fallback address.
590
+ * one resolution path and never reads the manifest to find it. A
591
+ * journey must name one, so absent on both is a release pushed before
592
+ * that was true, and the `domain` gate refuses it.
423
593
  */
424
594
  from: fromSchema.optional(),
425
595
  /** Where a reply to this one mail goes, instead of the from-address. */
@@ -563,11 +733,14 @@ const manifestInputSchema = z.object({ kind: z.literal("manifest") });
563
733
  const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema.pick({
564
734
  trigger: true,
565
735
  purpose: true,
736
+ enrollment: true,
737
+ description: true,
566
738
  from: true,
567
739
  tags: true
568
740
  }).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
569
741
  sendClass: true,
570
742
  verifyLink: true,
743
+ unsubscribeLink: true,
571
744
  propsSchema: true,
572
745
  tags: true
573
746
  }).extend({ kind: z.literal("template") })]);
@@ -683,6 +856,8 @@ var CapabilityError = class extends Error {
683
856
  const journeyConfigSchema = manifestJourneySchema.pick({
684
857
  trigger: true,
685
858
  purpose: true,
859
+ enrollment: true,
860
+ description: true,
686
861
  from: true,
687
862
  tags: true
688
863
  });
@@ -690,16 +865,21 @@ const journeyConfigSchema = manifestJourneySchema.pick({
690
865
  * Author a journey. Throws at definition time on an invalid config.
691
866
  *
692
867
  * There is no environment gate here: an organization has one data space, and
693
- * a journey runs wherever it is enabled and nowhere else (ADR 0011, ADR 0013).
868
+ * a journey runs wherever it is on and nowhere else (ADR 0011, ADR 0013).
694
869
  */
695
870
  function defineJourney(input) {
871
+ const config = journeyConfigSchema.parse({
872
+ trigger: input.trigger,
873
+ purpose: input.purpose,
874
+ enrollment: input.enrollment,
875
+ description: input.description,
876
+ from: input.from,
877
+ tags: input.tags
878
+ });
879
+ if (config.from === void 0) throw new Error("defineJourney needs a \"from\": the address this journey sends as, on a domain your organization verified. Add and verify it on the Domains page (Settings, Domains). For example from: \"Billing <billing@your-domain.com>\".");
696
880
  return {
697
- ...journeyConfigSchema.parse({
698
- trigger: input.trigger,
699
- purpose: input.purpose,
700
- from: input.from,
701
- tags: input.tags
702
- }),
881
+ ...config,
882
+ from: config.from,
703
883
  run: input.run
704
884
  };
705
885
  }