All files / lib/rrm step-schema.ts

100% Statements 147/147
100% Branches 128/128
100% Functions 13/13
100% Lines 130/130

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697                                                    1x                 1x                   1x                         1x               1x       1x                                                                                 1x                                       1x           87x       263x             202x                             63x   5x   4x   4x   50x                                 46x   46x 1x               45x 1x                       44x 44x   44x 69x 69x 69x 39x     69x 3x 3x     66x 66x 2x 2x         64x 1x 1x   63x   63x 349x 349x   1x     63x 63x 2x       63x 14x 1x     63x 63x 59x         54x   5x   4x   1x     63x 63x 25x 1x     63x 1x   63x 1x         63x 3x   63x       3x       63x 6x   2x       57x 1x       63x 8x 7x   1x             63x 6x         3x   3x         63x 18x 1x   17x 17x 20x 1x 19x 1x   18x     17x         63x 20x 1x   19x 19x 32x   1x         19x           63x   34x 34x 34x 34x 34x 34x 34x 34x 34x 34x     69x                                                                                                                       9x 9x 2x   7x 7x 2x   5x               1x                           28x       11x                               6x 36x                                             5x   11x 12x   5x                           1x                   1x                                                                                                                                                                                                                                                                                                
import { RRM_TASK_TYPES } from "../../db/schema/enums";
// DAY_MS is imported rather than redefined — one source for the constant.
import { DAY_MS } from "./time";
 
/**
 * The sequence step program — the JSON stored in `rrm_sequences.steps`.
 *
 * This file is not a description of the send program; it *is* the send program.
 * Sequences are data (P1-3.1: editable without a deploy), which means a typo in
 * an admin textarea is a production incident. Everything here is therefore
 * validated strictly and up front, at write time, rather than being discovered
 * by the scheduler at 10:00 IST with 150 prospects in the tranche.
 */
 
// ─────────────────────────────────────────────────────────────────────────────
// Vocabularies
// ─────────────────────────────────────────────────────────────────────────────
 
/**
 * How a step reaches the prospect.
 *
 * `freeform_only` is not a cheaper `freeform_or_template` — it is a promise
 * that the step will be *dropped* when the 24h window is closed. The gateway
 * returns `window_closed` and nothing is sent. Choose it only when sending
 * nothing is genuinely better than sending a template.
 */
export const STEP_PATHS = [
	"template",
	"freeform_or_template",
	"freeform_only",
	"task",
] as const;
export type StepPath = (typeof STEP_PATHS)[number];
 
/** Branch triggers (§7.4). All three are prospect actions, never timers. */
export const STEP_TRIGGERS = [
	"on_reply",
	"on_visit_no_submit",
	"on_submit",
] as const;
export type StepTrigger = (typeof STEP_TRIGGERS)[number];
 
/**
 * What `delayMs` counts from. See `computeDueAt` for why this exists.
 */
export const STEP_ANCHORS = [
	"run_started",
	"reply",
	"visit",
	"submit",
	"link_sent",
] as const;
export type StepAnchor = (typeof STEP_ANCHORS)[number];
 
/** Anchors that are events rather than the run itself. */
export type EventAnchor = Exclude<StepAnchor, "run_started">;
 
/** Events that cancel a still-pending step. Opt-out cancels everything (§9.2). */
export const STEP_CANCEL_REASONS = ["replied", "submitted", "dnc"] as const;
export type StepCancelReason = (typeof STEP_CANCEL_REASONS)[number];
 
/**
 * `prospect` = send in `prospects.locale`.
 * `switch_te` = if `locale_source='default'` we have never heard this person
 * speak, so try Telugu once with the switch acknowledgement prefix.
 */
export const STEP_LOCALE_POLICIES = ["prospect", "switch_te"] as const;
export type StepLocalePolicy = (typeof STEP_LOCALE_POLICIES)[number];
 
/** Preconditions re-evaluated by the scheduler at fire time, not at schedule time. */
export const STEP_REQUIRES_KEYS = ["noReply", "noVisit", "linkSent"] as const;
export type StepRequiresKey = (typeof STEP_REQUIRES_KEYS)[number];
export type StepRequires = Partial<Record<StepRequiresKey, boolean>>;
 
export type RrmTaskType = (typeof RRM_TASK_TYPES)[number];
 
/**
 * A parsed step. `delayMs` and `anchor` are non-optional here even though both
 * may be omitted in the stored JSON: normalising them during parse is what
 * stops every downstream caller from re-deciding the default (and disagreeing).
 */
export type SequenceStep = {
	key: string;
	delayMs: number;
	trigger?: StepTrigger;
	anchor: StepAnchor;
	path: StepPath;
	template?: string;
	copyKey?: string;
	localePolicy?: StepLocalePolicy;
	variants?: string[];
	requires?: StepRequires;
	cancelOn?: StepCancelReason[];
	taskType?: RrmTaskType;
};
 
// ─────────────────────────────────────────────────────────────────────────────
// Parsing
// ─────────────────────────────────────────────────────────────────────────────
 
export type StepParseError = {
	/** Index in the submitted array. `-1` when the fault is the array itself. */
	index: number;
	key: string | null;
	message: string;
};
 
export type ParseStepsResult =
	| { ok: true; steps: SequenceStep[] }
	| { ok: false; errors: StepParseError[] };
 
const KNOWN_STEP_KEYS = new Set<string>([
	"key",
	"delayMs",
	"trigger",
	"anchor",
	"path",
	"template",
	"copyKey",
	"localePolicy",
	"variants",
	"requires",
	"cancelOn",
	"taskType",
]);
 
/**
 * Fields that existed in an earlier draft of the spec and are deliberately
 * gone. Named rejections, not a generic "unknown field", because the whole
 * point is that the next person to hand-edit a sequence learns *why* it went.
 */
const RETIRED_STEP_KEYS: Record<string, string> = {
	onComplete:
		"`onComplete` is retired: the scheduler's unreachable sweep (§7.2 step 5) is the single writer of the end-of-ladder outcome. Two writers raced.",
};
 
function isPlainObject(value: unknown): value is Record<string, unknown> {
	return typeof value === "object" && value !== null && !Array.isArray(value);
}
 
function isNonEmptyString(value: unknown): value is string {
	return typeof value === "string" && value.trim().length > 0;
}
 
function inVocabulary<T extends string>(
	value: unknown,
	vocabulary: readonly T[],
): value is T {
	return (
		typeof value === "string" &&
		(vocabulary as readonly string[]).includes(value)
	);
}
 
/**
 * A triggered step with no explicit anchor anchors on its own trigger.
 *
 * Without this, the spec's own N3 — `{ trigger: "on_visit_no_submit",
 * delayMs: 86400000 }` — would parse as "24 hours after the run started",
 * which for a prospect who visits on day 3 is a message that was already due
 * two days before the visit it is reacting to.
 */
function defaultAnchorFor(trigger: StepTrigger | undefined): StepAnchor {
	switch (trigger) {
		case "on_reply":
			return "reply";
		case "on_visit_no_submit":
			return "visit";
		case "on_submit":
			return "submit";
		default:
			return "run_started";
	}
}
 
/**
 * Validates a stored step program and returns typed steps, or every error at
 * once.
 *
 * Errors accumulate rather than short-circuiting: an operator pasting a fixed
 * ladder into the admin UI should see all of its problems in one round trip.
 *
 * Unknown fields are rejected. That is the deliberate, slightly aggressive
 * choice: `delyMs` silently defaulting to 0 turns a D+3 nudge into a second
 * message inside the first hour, and there is no way to notice that from the
 * outside until the frequency cap starts blocking real sends.
 */
export function parseSteps(json: unknown): ParseStepsResult {
	const errors: StepParseError[] = [];
 
	if (!Array.isArray(json)) {
		return {
			ok: false,
			errors: [{ index: -1, key: null, message: "steps must be an array" }],
		};
	}
	// An active sequence with no steps enrols prospects into nothing and the
	// unreachable sweep then marks every one of them `unreachable` — a silent
	// way to burn a tranche. Refuse it here rather than explain it later.
	if (json.length === 0) {
		return {
			ok: false,
			errors: [
				{
					index: -1,
					key: null,
					message: "steps must contain at least one step",
				},
			],
		};
	}
 
	const steps: SequenceStep[] = [];
	const seenKeys = new Set<string>();
 
	for (let index = 0; index < json.length; index++) {
		const raw: unknown = json[index];
		const before = errors.length;
		const fail = (message: string, key: string | null = null) => {
			errors.push({ index, key, message });
		};
 
		if (!isPlainObject(raw)) {
			fail("step must be an object");
			continue;
		}
 
		const key = raw.key;
		if (!isNonEmptyString(key)) {
			fail("`key` must be a non-empty string");
			continue;
		}
		// The key is the identity used by `rrm_scheduled_steps.step_key` and by
		// the (run_id, step_key) unique index that makes enrolment idempotent.
		// Duplicates would collapse two steps into one scheduled row.
		if (seenKeys.has(key)) {
			fail(`duplicate step key \`${key}\``, key);
			continue;
		}
		seenKeys.add(key);
 
		for (const field of Object.keys(raw)) {
			const retired = RETIRED_STEP_KEYS[field];
			if (retired) fail(retired, key);
			else if (!KNOWN_STEP_KEYS.has(field))
				fail(`unknown field \`${field}\``, key);
		}
 
		const path = raw.path;
		if (!inVocabulary(path, STEP_PATHS)) {
			fail(`\`path\` must be one of ${STEP_PATHS.join(", ")}`, key);
		}
 
		let trigger: StepTrigger | undefined;
		if (raw.trigger !== undefined) {
			if (inVocabulary(raw.trigger, STEP_TRIGGERS)) trigger = raw.trigger;
			else fail(`\`trigger\` must be one of ${STEP_TRIGGERS.join(", ")}`, key);
		}
 
		let delayMs = 0;
		if (raw.delayMs !== undefined) {
			if (
				typeof raw.delayMs === "number" &&
				Number.isInteger(raw.delayMs) &&
				raw.delayMs >= 0
			) {
				delayMs = raw.delayMs;
			} else {
				fail("`delayMs` must be a non-negative integer", key);
			}
		} else if (trigger === undefined) {
			// Neither a clock nor an event: nothing would ever schedule this step.
			fail("step must declare `delayMs`, `trigger`, or both", key);
		}
 
		let anchor = defaultAnchorFor(trigger);
		if (raw.anchor !== undefined) {
			if (inVocabulary(raw.anchor, STEP_ANCHORS)) anchor = raw.anchor;
			else fail(`\`anchor\` must be one of ${STEP_ANCHORS.join(", ")}`, key);
		}
 
		if (raw.template !== undefined && !isNonEmptyString(raw.template)) {
			fail("`template` must be a non-empty string", key);
		}
		if (raw.copyKey !== undefined && !isNonEmptyString(raw.copyKey)) {
			fail("`copyKey` must be a non-empty string", key);
		}
 
		// Path obligations. A `template` step with no template name has nothing
		// to send; a free-form step with no copy key has nothing to write.
		if (path === "template" && !isNonEmptyString(raw.template)) {
			fail("`template` path requires a `template` name", key);
		}
		if (
			(path === "freeform_only" || path === "freeform_or_template") &&
			!isNonEmptyString(raw.copyKey)
		) {
			fail(`\`${path}\` path requires a \`copyKey\``, key);
		}
 
		let taskType: RrmTaskType | undefined;
		if (path === "task") {
			if (inVocabulary(raw.taskType, RRM_TASK_TYPES)) taskType = raw.taskType;
			else
				fail(
					`\`task\` path requires a \`taskType\` (${RRM_TASK_TYPES.join(", ")})`,
					key,
				);
		} else if (raw.taskType !== undefined) {
			fail("`taskType` is only valid on the `task` path", key);
		}
 
		let localePolicy: StepLocalePolicy | undefined;
		if (raw.localePolicy !== undefined) {
			if (inVocabulary(raw.localePolicy, STEP_LOCALE_POLICIES))
				localePolicy = raw.localePolicy;
			else
				fail(
					`\`localePolicy\` must be one of ${STEP_LOCALE_POLICIES.join(", ")}`,
					key,
				);
		}
 
		let variants: string[] | undefined;
		if (raw.variants !== undefined) {
			if (
				Array.isArray(raw.variants) &&
				raw.variants.length > 0 &&
				raw.variants.every(isNonEmptyString)
			) {
				variants = [...raw.variants];
			} else {
				fail("`variants` must be a non-empty array of non-empty strings", key);
			}
		}
 
		let requires: StepRequires | undefined;
		if (raw.requires !== undefined) {
			if (!isPlainObject(raw.requires)) {
				fail("`requires` must be an object", key);
			} else {
				const parsed: StepRequires = {};
				for (const [name, value] of Object.entries(raw.requires)) {
					if (!inVocabulary(name, STEP_REQUIRES_KEYS)) {
						fail(`unknown \`requires\` condition \`${name}\``, key);
					} else if (typeof value !== "boolean") {
						fail(`\`requires.${name}\` must be a boolean`, key);
					} else {
						parsed[name] = value;
					}
				}
				requires = parsed;
			}
		}
 
		let cancelOn: StepCancelReason[] | undefined;
		if (raw.cancelOn !== undefined) {
			if (!Array.isArray(raw.cancelOn)) {
				fail("`cancelOn` must be an array", key);
			} else {
				const parsed: StepCancelReason[] = [];
				for (const reason of raw.cancelOn) {
					if (inVocabulary(reason, STEP_CANCEL_REASONS)) parsed.push(reason);
					else
						fail(
							`\`cancelOn\` must contain only ${STEP_CANCEL_REASONS.join(", ")}`,
							key,
						);
				}
				cancelOn = parsed;
			}
		}
 
		// Only build the typed step if this one is clean — a half-valid step in
		// the output is worse than no output.
		if (errors.length !== before || !inVocabulary(path, STEP_PATHS)) continue;
 
		const step: SequenceStep = { key, delayMs, anchor, path };
		if (trigger !== undefined) step.trigger = trigger;
		if (isNonEmptyString(raw.template)) step.template = raw.template;
		if (isNonEmptyString(raw.copyKey)) step.copyKey = raw.copyKey;
		if (localePolicy !== undefined) step.localePolicy = localePolicy;
		if (variants !== undefined) step.variants = variants;
		if (requires !== undefined) step.requires = requires;
		if (cancelOn !== undefined) step.cancelOn = cancelOn;
		if (taskType !== undefined) step.taskType = taskType;
		steps.push(step);
	}
 
	return errors.length > 0 ? { ok: false, errors } : { ok: true, steps };
}
 
// ─────────────────────────────────────────────────────────────────────────────
// Due-at computation
// ─────────────────────────────────────────────────────────────────────────────
 
export type DueAtContext = {
	/** `rrm_sequence_runs.started_at`. */
	startedAt: Date;
	/**
	 * When each anchoring event happened. Absent until it does — a run that has
	 * had no reply has no `reply` anchor, and no step anchored on one is
	 * schedulable yet.
	 */
	anchors?: Partial<Record<EventAnchor, Date | null | undefined>>;
};
 
export type DueAtResult =
	| { ok: true; dueAt: Date }
	| {
			ok: false;
			reason: "missing_anchor" | "invalid_anchor_time";
			anchor: StepAnchor;
	  };
 
/**
 * Resolves when a step is due.
 *
 * **Resolution of a spec ambiguity.** §7.1 gives every step a `delayMs` and
 * never says what it counts from, yet the two readings differ by days. N3 is
 * the proof: `{ trigger:"on_visit_no_submit", delayMs:86400000 }` is described
 * in the requirements as "D+1 *after a visit*", but a naive scheduler reading
 * `delayMs` off the run would fire it 24h after enrolment — before the visit
 * that is supposed to cause it. Same for N4's "D+2 after link sent".
 *
 * The rule this file fixes, and the only one callers may assume:
 *
 *   `delayMs` is relative to `run.startedAt` UNLESS the step declares an
 *   `anchor`, in which case it is relative to the timestamp of that event.
 *
 * A triggered step defaults its anchor to its own trigger (see
 * `defaultAnchorFor`), so the spec's JSON means what its prose says without
 * being rewritten.
 *
 * Returns a result rather than throwing: this runs inside the 5-minute cron
 * loop over a batch of runs, and one malformed run must not abandon the other
 * 199. The reason is returned so the caller can record it — a step that
 * silently never schedules is indistinguishable from a step nobody wrote.
 *
 * Note on units: `delayMs` is milliseconds (the spec's JSON is), while
 * `due_at` persists as epoch seconds through Drizzle's `mode:"timestamp"`.
 * Sub-second truncation on write is irrelevant to a scheduler that ticks every
 * five minutes and then applies 0–90 minutes of jitter.
 */
export function computeDueAt(
	step: SequenceStep,
	ctx: DueAtContext,
): DueAtResult {
	const base =
		step.anchor === "run_started" ? ctx.startedAt : ctx.anchors?.[step.anchor];
	if (!base)
		return { ok: false, reason: "missing_anchor", anchor: step.anchor };
 
	const baseMs = base.getTime();
	if (!Number.isFinite(baseMs)) {
		return { ok: false, reason: "invalid_anchor_time", anchor: step.anchor };
	}
	return { ok: true, dueAt: new Date(baseMs + step.delayMs) };
}
 
// ─────────────────────────────────────────────────────────────────────────────
// Template budget
// ─────────────────────────────────────────────────────────────────────────────
 
/** §9.4 / P1-7.12: platform-initiated messages per prospect, ever. */
export const TEMPLATE_LIFETIME_CAP = 4;
 
export type TemplateBudgetResult = {
	ok: boolean;
	cap: number;
	count: number;
	/** Keys that spend the budget, in the order they come due. */
	counted: string[];
	/** The tail beyond the cap — the sends the gateway would refuse. */
	overflow: string[];
};
 
/** True when the step can put a template on the wire (`kind='template'`). */
export function isTemplateStep(step: SequenceStep): boolean {
	if (step.path === "template") return true;
	// `freeform_or_template` only costs a template when the window is closed,
	// but the budget must assume the expensive branch: it is the one that
	// happens to a prospect who is not currently talking to us.
	return step.path === "freeform_or_template" && step.template !== undefined;
}
 
/**
 * The steps a prospect who never replies can actually receive.
 *
 * Two exclusions, both load-bearing:
 *  - a `trigger` is a prospect action (reply / visit / submit), so a triggered
 *    step is never reached by silence;
 *  - `requires.linkSent` is only ever true after N1, and N1 is `on_reply`, so
 *    a link-gated step is a reply-path step even without a trigger of its own.
 *    This is what keeps N4's template fallback out of the non-replier's budget.
 */
export function nonReplyPathSteps(
	steps: readonly SequenceStep[],
): SequenceStep[] {
	return steps.filter(
		(step) => step.trigger === undefined && step.requires?.linkSent !== true,
	);
}
 
/**
 * Asserts the non-reply ladder fits inside the lifetime cap.
 *
 * The cap is enforced for real in the gateway by counting `rrm_messages`, and
 * that enforcement is a *refusal*: the fifth template returns
 * `{ok:false, decision:'frequency'}` and the prospect simply never gets the
 * message the sequence promised. Discovering that at send time means an
 * operator authored a ladder whose last rung was always dead. So the scheduler
 * rejects the sequence up front instead — §7.1 is explicit that adding a
 * template step requires removing one.
 *
 * Overflow is reported as the *latest* steps because those are the ones the
 * gateway would drop; ordering is by `delayMs`, which is exact for the
 * run-anchored steps that make up the non-reply path.
 */
export function validateTemplateBudget(
	steps: readonly SequenceStep[],
	lifetimeCap: number = TEMPLATE_LIFETIME_CAP,
): TemplateBudgetResult {
	const counted = nonReplyPathSteps(steps)
		.filter(isTemplateStep)
		.sort((a, b) => a.delayMs - b.delayMs)
		.map((step) => step.key);
 
	return {
		ok: counted.length <= lifetimeCap,
		cap: lifetimeCap,
		count: counted.length,
		counted,
		overflow: counted.slice(lifetimeCap),
	};
}
 
// ─────────────────────────────────────────────────────────────────────────────
// The default programme
// ─────────────────────────────────────────────────────────────────────────────
 
/** `rrm_sequences.key` for the programme below. */
export const CP_RECRUIT_V1_KEY = "cp_recruit_v1";
 
/**
 * The Hyderabad channel-partner recruitment ladder (§7.1), corrected.
 *
 * Three deliberate departures from the spec's JSON are marked ⚠ below. Every
 * other field is the spec's, unchanged — including the ones that look odd
 * (N7 keeps `copyKey` on a `template` path; the copy key names the operator
 * follow-up text, not the template body).
 */
export const CP_RECRUIT_V1_STEPS: readonly SequenceStep[] = [
	// D0 — opener. Question only, no link: the first message has to earn a
	// reply, and a link in a cold template is what gets a number blocked.
	{
		key: "N0",
		delayMs: 0,
		anchor: "run_started",
		path: "template",
		template: "rrm_partner_opener_v1",
		localePolicy: "prospect",
		variants: ["A", "B", "C"],
		cancelOn: ["replied", "dnc"],
	},
 
	// On reply — the join link, inside the window they just opened.
	{
		key: "N1",
		delayMs: 0,
		trigger: "on_reply",
		anchor: "reply",
		path: "freeform_or_template",
		template: "rrm_partner_joinlink",
		copyKey: "n1",
		cancelOn: ["dnc"],
	},
 
	// D+1 — the Telugu switch. One attempt, only for prospects whose locale is
	// still the default (we have never heard them speak).
	{
		key: "N2T",
		delayMs: DAY_MS,
		anchor: "run_started",
		path: "template",
		template: "rrm_partner_nudge_v1",
		localePolicy: "switch_te",
		requires: { noReply: true },
		cancelOn: ["replied", "dnc"],
	},
 
	// D+3 — the soft "either answer is fine" nudge.
	{
		key: "N2",
		delayMs: 3 * DAY_MS,
		anchor: "run_started",
		path: "template",
		template: "rrm_partner_nudge_v1",
		localePolicy: "prospect",
		requires: { noReply: true },
		cancelOn: ["replied", "dnc"],
	},
 
	// D+1 after a visit with no submit — the highest-intent moment in the
	// ladder. Anchored on the visit, which is the whole reason anchors exist.
	{
		key: "N3",
		delayMs: DAY_MS,
		trigger: "on_visit_no_submit",
		anchor: "visit",
		path: "freeform_or_template",
		copyKey: "n3",
		cancelOn: ["submitted", "dnc"],
	},
 
	// ⚠ DEPARTURE 2 — N4 was `freeform_only`, which made it unreachable.
	//
	// `requires.linkSent` is only true after N1, and N1 only runs on a reply —
	// so N4 always targets someone who replied at least once. But it fires 48h
	// after the link went out, and the window closes 24h after their *last*
	// inbound. Unless they messaged again in between, a `freeform_only` step
	// hits `window_closed` and sends nothing, every time.
	//
	// Scoping it to repliers therefore fixes nothing (it is already reply-only);
	// the blocker is the window, and only a template crosses a closed window.
	// So: template fallback, using `rrm_partner_joinlink` — a UTILITY
	// template whose two variables (name, link) are exactly N4's content
	// ("restate the value in one line, re-send link"), and which the marketing
	// halt does not stop. Budget-safe: `linkSent` keeps this off the non-reply
	// path, so the four-template ladder below is untouched.
	{
		key: "N4",
		delayMs: 2 * DAY_MS,
		anchor: "link_sent",
		path: "freeform_or_template",
		template: "rrm_partner_joinlink",
		copyKey: "n4",
		requires: { linkSent: true, noVisit: true },
		cancelOn: ["dnc"],
	},
 
	// On submit — welcome, and the one ask that produces the success metric.
	{
		key: "N5",
		delayMs: 0,
		trigger: "on_submit",
		anchor: "submit",
		path: "freeform_or_template",
		template: "rrm_partner_welcome",
		copyKey: "n5",
	},
 
	// D+4 — a call task, not a message. Costs no template budget.
	{
		key: "N6",
		delayMs: 4 * DAY_MS,
		anchor: "run_started",
		path: "task",
		taskType: "call",
		requires: { noReply: true },
	},
 
	// ⚠ DEPARTURE 1 — N7 moves from D+7 to D+8.
	//
	// The gateway blocks a template when ≥3 have gone out in the trailing 7
	// days. At D+7 the lookback opens exactly on N0's send, so whether N0 is
	// counted comes down to jitter: N0 delayed into the quiet-hours window and
	// N7 not delayed means {N0, N2T, N2} = 3 and the final message is silently
	// refused. At D+8 the lookback opens at D+1, so the worst case is
	// {N2T, N2} = 2. Maximum drift is a quiet-hours reschedule (~14.5h) plus
	// 90 minutes of jitter, comfortably inside the 24h of margin — so the
	// ordering holds rather than being a coin flip. Lifetime count before this
	// send is 3, so the 4-message cap still permits it: exactly at the cap.
	//
	// ⚠ DEPARTURE 3 — `onComplete: {stage:"not_now", snoozeDays:60}` removed.
	//
	// It raced the scheduler's unreachable sweep (§7.2 step 5), which fires on
	// the same run at the same tick and writes `unreachable` + exit_reason.
	// Two writers, one row, no ordering. The sweep wins and is the single
	// writer. This also resolves a contradiction the spec never noticed:
	// P1-7.12 says a never-replier ends `unreachable` and stops permanently,
	// while the ladder table says `not_now` + a 60-day snooze — which would
	// re-open them for contact. `unreachable` is the stricter of the two and
	// the one the requirement mandates. Do not re-add this field; `parseSteps`
	// rejects it by name.
	{
		key: "N7",
		delayMs: 8 * DAY_MS,
		anchor: "run_started",
		path: "template",
		template: "rrm_partner_nudge_v1",
		copyKey: "n7_final",
		requires: { noReply: true },
		cancelOn: ["replied", "dnc"],
	},
];