|
| 1 | +/** |
| 2 | + * CRM autonomy guardrails. |
| 3 | + * |
| 4 | + * The CRM runs without human approval. These are the hard limits it may never |
| 5 | + * cross, plus the deterministic checks every self-written copy variant has to |
| 6 | + * pass before it can go live. Anything that fails is quarantined and logged |
| 7 | + * instead of being sent. |
| 8 | + */ |
| 9 | + |
| 10 | +export const GUARDRAILS = { |
| 11 | + /** Cadence limits — the learning loop can never widen these. */ |
| 12 | + maxEmailsPer7Days: 2, |
| 13 | + minHoursBetweenEmails: 48, |
| 14 | + /** Copy shape limits. */ |
| 15 | + maxSubjectChars: 70, |
| 16 | + maxHeadingChars: 60, |
| 17 | + maxIntroChars: 200, |
| 18 | + /** How much the loop may change on its own in a single run. */ |
| 19 | + maxAutoPausesPerRun: 3, |
| 20 | + maxAutoActivationsPerRun: 2, |
| 21 | + /** A trigger always keeps at least one and at most this many live variants. */ |
| 22 | + minActiveVariantsPerTrigger: 1, |
| 23 | + maxActiveVariantsPerTrigger: 4, |
| 24 | + /** No self-written variant goes live for a trigger with too little evidence. */ |
| 25 | + minSentBeforeAutoActivation: 20, |
| 26 | +} as const; |
| 27 | + |
| 28 | +/** Plain-language list of what the CRM may and may not do on its own. */ |
| 29 | +export const AUTONOMY_RULES: Array<{ allowed: boolean; rule: string }> = [ |
| 30 | + { allowed: true, rule: "Pick the copy variant and the send hour with the best measured outcome." }, |
| 31 | + { allowed: true, rule: "Pause a variant once it is statistically behind the leader." }, |
| 32 | + { allowed: true, rule: "Write a replacement variant and publish it when it passes every copy check." }, |
| 33 | + { allowed: true, rule: "Rank triggers by measured business value and back off for unengaged customers." }, |
| 34 | + { allowed: false, rule: "Send more than 2 emails per customer per 7 days, or closer than 48 hours apart." }, |
| 35 | + { allowed: false, rule: "Email a suppressed, unsubscribed or bounced address." }, |
| 36 | + { allowed: false, rule: "Publish copy with invented metrics, guarantees, urgency pressure or discounts." }, |
| 37 | + { allowed: false, rule: "Publish copy in a language other than English, or with emojis." }, |
| 38 | + { allowed: false, rule: "Leave a trigger without a working variant, or run more than 4 live variants." }, |
| 39 | + { allowed: false, rule: "Change cadence caps, suppression rules or the unsubscribe footer." }, |
| 40 | +]; |
| 41 | + |
| 42 | +/** Claims and pressure tactics the CRM is not allowed to make on its own. */ |
| 43 | +const BANNED_PATTERNS: Array<{ re: RegExp; reason: string }> = [ |
| 44 | + { re: /\b(guarantee[ds]?|guaranteed results|risk[- ]free)\b/i, reason: "promises a guaranteed result" }, |
| 45 | + { re: /\b(\d{2,3}\s?%\s?(more|less|faster|cheaper|increase|boost))/i, reason: "invents a performance metric" }, |
| 46 | + { re: /\b(\d+x)\s+(faster|better|more|cheaper)\b/i, reason: "invents a multiplier claim" }, |
| 47 | + { re: /\b(act now|last chance|final warning|hurry|expires? (today|tonight)|only \d+ (spots|hours) left)\b/i, reason: "uses urgency pressure" }, |
| 48 | + { re: /\b(free money|no strings|cash back|\d+\s?% off|discount code|coupon)\b/i, reason: "offers pricing terms the CRM cannot authorise" }, |
| 49 | + { re: /\b(refund|chargeback|invoice|credit card|password|api key|token)\b/i, reason: "touches billing or credential topics" }, |
| 50 | + { re: /\b(you must|you have to|failure to (act|respond)|legal action)\b/i, reason: "uses coercive language" }, |
| 51 | + { re: /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u, reason: "contains emojis" }, |
| 52 | + { re: /\b(garantia|clique aqui|voc[êe]|gr[áa]tis|obrigado|aqui est[áa])\b/i, reason: "is not written in English" }, |
| 53 | + { re: /\{\{|\}\}|\[insert|TODO|lorem ipsum/i, reason: "contains unfilled placeholders" }, |
| 54 | +]; |
| 55 | + |
| 56 | +export type CopyCandidate = { |
| 57 | + label: string; |
| 58 | + subject: string; |
| 59 | + heading: string; |
| 60 | + intro: string; |
| 61 | +}; |
| 62 | + |
| 63 | +export type GuardrailVerdict = { ok: boolean; violations: string[] }; |
| 64 | + |
| 65 | +/** Deterministic gate for self-written copy. No model in the loop. */ |
| 66 | +export function checkCopy(candidate: CopyCandidate): GuardrailVerdict { |
| 67 | + const violations: string[] = []; |
| 68 | + const { label, subject, heading, intro } = candidate; |
| 69 | + |
| 70 | + if (!label.trim() || !subject.trim() || !heading.trim() || !intro.trim()) |
| 71 | + violations.push("is missing a label, subject, heading or intro"); |
| 72 | + if (subject.length > GUARDRAILS.maxSubjectChars) |
| 73 | + violations.push(`subject is longer than ${GUARDRAILS.maxSubjectChars} characters`); |
| 74 | + if (heading.length > GUARDRAILS.maxHeadingChars) |
| 75 | + violations.push(`heading is longer than ${GUARDRAILS.maxHeadingChars} characters`); |
| 76 | + if (intro.length > GUARDRAILS.maxIntroChars) |
| 77 | + violations.push(`intro is longer than ${GUARDRAILS.maxIntroChars} characters`); |
| 78 | + if (subject === subject.toUpperCase() && subject.replace(/[^A-Z]/g, "").length > 6) |
| 79 | + violations.push("subject shouts in all caps"); |
| 80 | + if ((subject.match(/!/g) ?? []).length > 0) violations.push("subject uses exclamation marks"); |
| 81 | + |
| 82 | + const blob = `${label}\n${subject}\n${heading}\n${intro}`; |
| 83 | + for (const { re, reason } of BANNED_PATTERNS) if (re.test(blob)) violations.push(reason); |
| 84 | + |
| 85 | + // Non-ASCII beyond normal punctuation is a strong signal of another language. |
| 86 | + if (/[À-ÿ]/.test(blob)) violations.push("contains non-English characters"); |
| 87 | + |
| 88 | + return { ok: violations.length === 0, violations }; |
| 89 | +} |
| 90 | + |
| 91 | +/** Can this trigger accept one more live variant right now? */ |
| 92 | +export function canActivate(activeCount: number, leaderSent: number): GuardrailVerdict { |
| 93 | + const violations: string[] = []; |
| 94 | + if (activeCount >= GUARDRAILS.maxActiveVariantsPerTrigger) |
| 95 | + violations.push(`trigger already runs ${GUARDRAILS.maxActiveVariantsPerTrigger} live variants`); |
| 96 | + if (leaderSent < GUARDRAILS.minSentBeforeAutoActivation) |
| 97 | + violations.push( |
| 98 | + `only ${leaderSent} sends measured, ${GUARDRAILS.minSentBeforeAutoActivation} required before publishing new copy`, |
| 99 | + ); |
| 100 | + return { ok: violations.length === 0, violations }; |
| 101 | +} |
| 102 | + |
| 103 | +/** Can this variant be paused without leaving the trigger empty or over-churning? */ |
| 104 | +export function canPause(activeCount: number, pausesThisRun: number): GuardrailVerdict { |
| 105 | + const violations: string[] = []; |
| 106 | + if (activeCount - 1 < GUARDRAILS.minActiveVariantsPerTrigger) |
| 107 | + violations.push("pausing it would leave the trigger without a working variant"); |
| 108 | + if (pausesThisRun >= GUARDRAILS.maxAutoPausesPerRun) |
| 109 | + violations.push(`already paused ${GUARDRAILS.maxAutoPausesPerRun} variants in this run`); |
| 110 | + return { ok: violations.length === 0, violations }; |
| 111 | +} |
0 commit comments