--purposes --secret env:NAME|file:PATH [--org ] [--postal ] [--webhook-secret env:NAME]',
+ summary: 'Register a provider account; purposes are what your contract with the provider allows.',
+ flags: { provider: 'string', 'external-id': 'string', 'sender-name': 'string', 'sender-email': 'string', purposes: 'string', secret: 'string', org: 'string', postal: 'string', 'webhook-secret': 'string' },
+ async run({ flags, out, runtime }) {
+ const rt = await runtime();
+ const id = await registerProviderAccount(rt.engine, rt.ctx(), {
+ provider: required(flags, 'provider'),
+ externalAccountId: required(flags, 'external-id'),
+ sender: {
+ name: required(flags, 'sender-name'),
+ address: required(flags, 'sender-email'),
+ ...(flag(flags, 'org') ? { organization: flag(flags, 'org') as string } : {}),
+ ...(flag(flags, 'postal') ? { postalAddress: flag(flags, 'postal') as string } : {}),
+ },
+ purposes: parsePurposes(required(flags, 'purposes')),
+ secretRef: required(flags, 'secret'),
+ ...(flag(flags, 'webhook-secret') ? { webhookSecretRef: flag(flags, 'webhook-secret') as string } : {}),
+ });
+ out.result({ providerAccountId: id });
+ },
+ },
+ {
+ name: 'account check',
+ usage: 'outreach account check ',
+ summary: 'Ask the provider for the account health now (a revoked grant shows reauth_required).',
+ async run({ args, out, runtime }) {
+ const rt = await runtime();
+ rt.ctx();
+ out.result(await checkAccountHealth(rt.engine, arg(args, 0, 'provider-account-id')));
+ },
+ },
+ {
+ name: 'gate show',
+ usage: 'outreach gate show',
+ summary: 'Show the live-send gate for this workspace.',
+ async run({ out, runtime, options }) {
+ const rt = await runtime();
+ rt.ctx();
+ out.result(readSendGate(rt.db, options.workspace));
+ },
+ },
+ {
+ name: 'gate allowlist',
+ usage: 'outreach gate allowlist ... --reason ',
+ summary: 'Allow live email only to these recipients (admin).',
+ flags: { reason: 'string' },
+ async run({ args, flags, out, runtime }) {
+ const rt = await runtime();
+ setSendGate(rt.engine, rt.ctx(), { mode: 'allowlist', allow: [...args] }, required(flags, 'reason'));
+ out.result({ mode: 'allowlist', allow: args });
+ },
+ },
+ {
+ name: 'gate open',
+ usage: 'outreach gate open --reason ',
+ summary: 'Allow live email to any eligible recipient (admin, audited).',
+ flags: { reason: 'string' },
+ async run({ flags, out, runtime }) {
+ const rt = await runtime();
+ setSendGate(rt.engine, rt.ctx(), { mode: 'open' }, required(flags, 'reason'));
+ out.result({ mode: 'open' });
+ },
+ },
+ {
+ name: 'jurisdiction show',
+ usage: 'outreach jurisdiction show',
+ summary: 'Show where email may go and who signed that off (decision D5).',
+ async run({ out, runtime, options }) {
+ const rt = await runtime();
+ rt.ctx();
+ out.result(readJurisdictionPolicy(rt.db, options.workspace) ?? { policy: null, note: 'no policy recorded; the live-send gate cannot open' });
+ },
+ },
+ {
+ name: 'jurisdiction set',
+ usage:
+ 'outreach jurisdiction set [--allow US,GB] [--consent DE,CA] [--block FR] --default ' +
+ '--unknown --signed-off-by --reference ',
+ summary: 'Record per-country sending rules and their legal sign-off (admin, audited). Replaces the whole policy.',
+ flags: { allow: 'string', consent: 'string', block: 'string', default: 'string', unknown: 'string', 'signed-off-by': 'string', reference: 'string' },
+ async run({ flags, out, runtime }) {
+ const rt = await runtime();
+ const saved = setJurisdictionPolicy(
+ rt.engine,
+ rt.ctx(),
+ { rules: parseRules(flags), default: parseRule(required(flags, 'default'), 'default'), unknown: parseRule(required(flags, 'unknown'), 'unknown') },
+ { by: required(flags, 'signed-off-by'), reference: required(flags, 'reference') },
+ );
+ out.result(saved);
+ },
+ },
+ {
+ name: 'notify set',
+ usage: 'outreach notify set | outreach notify set --off',
+ summary: 'Route reply/opt-out/bounce/complaint notices to a notification account.',
+ flags: { off: 'boolean' },
+ async run({ args, flags, out, runtime }) {
+ const rt = await runtime();
+ const target = flags.off ? null : arg(args, 0, 'provider-account-id');
+ configureNotifications(rt.engine, rt.ctx(), target);
+ out.result({ notifyAccountId: target });
+ },
+ },
+ {
+ name: 'audit verify',
+ usage: 'outreach audit verify',
+ summary: 'Recompute the audit hash chain; non-zero exit if it was tampered with.',
+ async run({ out, runtime }) {
+ const rt = await runtime();
+ const result = verifyAuditChain(rt.db);
+ out.result(result);
+ return result.ok ? 0 : 3;
+ },
+ },
+];
diff --git a/outreach-engine/packages/outreach-cli/src/commands/types.ts b/outreach-engine/packages/outreach-cli/src/commands/types.ts
new file mode 100644
index 0000000..07629a8
--- /dev/null
+++ b/outreach-engine/packages/outreach-cli/src/commands/types.ts
@@ -0,0 +1,46 @@
+import type { Output } from '../output';
+import type { GlobalOptions, Runtime } from '../runtime';
+
+export type Flags = Readonly>;
+
+export interface CommandInput {
+ readonly args: readonly string[];
+ readonly flags: Flags;
+ readonly out: Output;
+ readonly options: GlobalOptions;
+ /** The environment of this invocation (never read process.env directly). */
+ readonly env: NodeJS.ProcessEnv;
+ /** Human-facing progress on stderr, shown even with --json (e.g. a sign-in URL). */
+ notice(text: string): void;
+ readonly openUrl?: (url: string) => void;
+ /** Opens the database and engine lazily (some commands, like `init`, prepare it themselves). */
+ runtime(): Promise;
+}
+
+export interface Command {
+ /** e.g. "campaign create". */
+ readonly name: string;
+ readonly usage: string;
+ readonly summary: string;
+ /** Flag name -> type. Unknown flags are usage errors. */
+ readonly flags?: Readonly>;
+ /** Returns a non-zero exit code for a completed-but-failed check (e.g. a broken audit chain). */
+ run(input: CommandInput): Promise;
+}
+
+export function flag(flags: Flags, name: string): string | undefined {
+ const value = flags[name];
+ return typeof value === 'string' ? value : undefined;
+}
+
+export function required(flags: Flags, name: string): string {
+ const value = flag(flags, name);
+ if (!value) throw new Error(`--${name} is required`);
+ return value;
+}
+
+export function arg(args: readonly string[], index: number, name: string): string {
+ const value = args[index];
+ if (!value) throw new Error(`<${name}> is required`);
+ return value;
+}
diff --git a/outreach-engine/packages/outreach-cli/src/connect.test.ts b/outreach-engine/packages/outreach-cli/src/connect.test.ts
new file mode 100644
index 0000000..75ad74d
--- /dev/null
+++ b/outreach-engine/packages/outreach-cli/src/connect.test.ts
@@ -0,0 +1,77 @@
+import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { FakeGmailServer, FakeGraphServer } from '@splitin/outreach-fakes';
+import { main } from './main';
+import { envSecrets } from './runtime';
+
+const cleanup: (() => Promise | void)[] = [];
+afterEach(async () => {
+ for (const step of cleanup.splice(0)) await step();
+});
+
+describe('outreach account connect gmail', () => {
+ it('signs in over loopback OAuth and writes the grant to an owner-only file usable as file: secret', async () => {
+ const gmail = await new FakeGmailServer({ mailbox: 'sam@example.com' }).start();
+ const dir = mkdtempSync(join(tmpdir(), 'outreach-connect-'));
+ cleanup.push(() => gmail.stop(), () => rmSync(dir, { recursive: true, force: true }));
+ const out = join(dir, 'nested', 'gmail.json');
+ const env = {
+ HOME: dir,
+ GOOGLE_CLIENT_SECRET: 'client-secret-value',
+ OUTREACH_GOOGLE_AUTH_URL: `${gmail.url}/authorize`,
+ OUTREACH_GOOGLE_TOKEN_URL: `${gmail.url}/token`,
+ OUTREACH_GMAIL_API: gmail.url,
+ };
+ let stdout = '';
+ let stderr = '';
+ const code = await main(['account', 'connect', 'gmail', '--client-id', 'client-id', '--client-secret-env', 'GOOGLE_CLIENT_SECRET', '--out', out, '--json'], {
+ env,
+ write: (text) => (stdout += text),
+ writeError: (text) => (stderr += text),
+ openUrl: (url) => void fetch(url), // The "browser": follows the consent redirect to the loopback callback.
+ });
+ expect(code).toBe(0);
+ expect(stderr).toMatch(/Open this URL/);
+ expect(JSON.parse(stdout)).toMatchObject({ connected: 'sam@example.com', secretRef: `file:${out}` });
+ expect(stdout).not.toContain('refresh-token-value');
+ expect(statSync(out).mode & 0o777).toBe(0o600);
+ expect(JSON.parse(readFileSync(out, 'utf8'))).toMatchObject({ clientId: 'client-id', refreshToken: 'refresh-token-value' });
+
+ expect(await envSecrets(env).get(`file:${out}`)).toContain('refresh-token-value');
+ chmodSync(out, 0o644);
+ await expect(envSecrets(env).get(`file:${out}`)).rejects.toThrow(/readable by other users/);
+ });
+
+ it('connects Outlook (public client, localhost redirect) and file: secrets accept rotated tokens atomically', async () => {
+ const graph = await new FakeGraphServer().start();
+ const dir = mkdtempSync(join(tmpdir(), 'outreach-connect-'));
+ cleanup.push(() => graph.stop(), () => rmSync(dir, { recursive: true, force: true }));
+ const out = join(dir, 'outlook.json');
+ const env = { HOME: dir, OUTREACH_MICROSOFT_AUTHORITY: graph.url, OUTREACH_GRAPH_API: graph.url };
+ let stdout = '';
+ const code = await main(['account', 'connect', 'outlook', '--tenant', graph.tenant, '--client-id', 'ms-client', '--out', out, '--json'], {
+ env,
+ write: (text) => (stdout += text),
+ writeError: () => {},
+ openUrl: (url) => void fetch(url),
+ });
+ expect(code).toBe(0);
+ expect(JSON.parse(stdout)).toMatchObject({ connected: 'sam@contoso.example', secretRef: `file:${out}` });
+ expect(statSync(out).mode & 0o777).toBe(0o600);
+
+ const secrets = envSecrets(env);
+ await secrets.put?.(`file:${out}`, JSON.stringify({ tenant: graph.tenant, clientId: 'ms-client', refreshToken: 'ms-refresh-2' }));
+ expect(statSync(out).mode & 0o777).toBe(0o600);
+ expect(JSON.parse(await secrets.get(`file:${out}`))).toMatchObject({ refreshToken: 'ms-refresh-2' });
+ await expect(secrets.put?.('env:NOPE', 'x')).rejects.toThrow(/cannot be updated/);
+ });
+
+ it('refuses to run without the client secret in the environment', async () => {
+ let stderr = '';
+ const code = await main(['account', 'connect', 'gmail', '--client-id', 'x', '--client-secret-env', 'MISSING'], { env: {}, writeError: (text) => (stderr += text) });
+ expect(code).toBe(2);
+ expect(stderr).toMatch(/MISSING is empty/);
+ });
+});
diff --git a/outreach-engine/packages/outreach-cli/src/index.ts b/outreach-engine/packages/outreach-cli/src/index.ts
new file mode 100644
index 0000000..248302c
--- /dev/null
+++ b/outreach-engine/packages/outreach-cli/src/index.ts
@@ -0,0 +1,3 @@
+export { main, COMMANDS, type MainIo } from './main';
+export { envSecrets, openRuntime, principalRef, UsageError, type GlobalOptions, type OutreachConfig, type Runtime } from './runtime';
+export type { Command, CommandInput } from './commands/types';
diff --git a/outreach-engine/packages/outreach-cli/src/main.ts b/outreach-engine/packages/outreach-cli/src/main.ts
new file mode 100644
index 0000000..fc19c5d
--- /dev/null
+++ b/outreach-engine/packages/outreach-cli/src/main.ts
@@ -0,0 +1,111 @@
+import { maintenanceCommands } from './commands/maintenance';
+import { parseArgs } from 'node:util';
+import { campaignCommands } from './commands/campaigns';
+import { connectCommands } from './commands/connect';
+import { contentCommands } from './commands/content';
+import { runtimeCommands } from './commands/serve';
+import { setupCommands } from './commands/setup';
+import type { Command, Flags } from './commands/types';
+import { createOutput } from './output';
+import { UsageError, openRuntime, type GlobalOptions, type Runtime } from './runtime';
+
+export const COMMANDS: readonly Command[] = [...setupCommands, ...connectCommands, ...contentCommands, ...campaignCommands, ...runtimeCommands, ...maintenanceCommands];
+
+const GLOBAL_FLAGS = {
+ db: { type: 'string' },
+ workspace: { type: 'string' },
+ config: { type: 'string' },
+ fake: { type: 'boolean' },
+ json: { type: 'boolean' },
+ help: { type: 'boolean', short: 'h' },
+} as const;
+
+function help(): string {
+ const width = Math.max(...COMMANDS.map((c) => c.name.length));
+ return [
+ 'outreach: provider-neutral outreach engine CLI',
+ '',
+ 'Global options: --db (OUTREACH_DB, default ./outreach.db) --workspace (OUTREACH_WORKSPACE, default "default")',
+ ' --config (default ./outreach.config.mjs) --fake (fake providers, sends nothing) --json',
+ '',
+ ...COMMANDS.map((c) => ` ${c.name.padEnd(width)} ${c.summary}`),
+ '',
+ 'Run `outreach --help` for its usage.',
+ ].join('\n');
+}
+
+function findCommand(positionals: readonly string[]): { command: Command; args: string[] } | null {
+ for (const words of [3, 2, 1]) {
+ const name = positionals.slice(0, words).join(' ');
+ const command = COMMANDS.find((c) => c.name === name);
+ if (command) return { command, args: positionals.slice(words) };
+ }
+ return null;
+}
+
+export interface MainIo {
+ readonly env?: NodeJS.ProcessEnv;
+ readonly write?: (text: string) => void;
+ readonly writeError?: (text: string) => void;
+ /** Stands in for the user's browser in tests of sign-in flows. By default URLs are only printed. */
+ readonly openUrl?: (url: string) => void;
+}
+
+/** Runs one CLI invocation. Returns the exit code: 0 ok, 1 error, 2 usage. */
+export async function main(argv: readonly string[], io: MainIo = {}): Promise {
+ const env = io.env ?? process.env;
+ const writeError = io.writeError ?? ((text: string) => process.stderr.write(text));
+ const allFlags: Record = { ...GLOBAL_FLAGS };
+ for (const command of COMMANDS) for (const [name, type] of Object.entries(command.flags ?? {})) allFlags[name] = { type };
+ let parsed: { values: Record; positionals: string[] };
+ try {
+ parsed = parseArgs({ args: [...argv], options: allFlags, allowPositionals: true, strict: true }) as typeof parsed;
+ } catch (error) {
+ writeError(`${(error as Error).message}\n\n${help()}\n`);
+ return 2;
+ }
+ const found = findCommand(parsed.positionals);
+ if (!found) {
+ (parsed.values.help || parsed.positionals.length === 0 ? (io.write ?? ((t: string) => process.stdout.write(t))) : writeError)(`${help()}\n`);
+ return parsed.values.help || parsed.positionals.length === 0 ? 0 : 2;
+ }
+ const { command, args } = found;
+ if (parsed.values.help) {
+ (io.write ?? ((t: string) => process.stdout.write(t)))(`${command.usage}\n\n${command.summary}\n`);
+ return 0;
+ }
+ const unknown = Object.keys(parsed.values).filter((name) => parsed.values[name] !== undefined && !(name in GLOBAL_FLAGS) && !(name in (command.flags ?? {})));
+ if (unknown.length) {
+ writeError(`unknown option${unknown.length > 1 ? 's' : ''} for "${command.name}": ${unknown.map((n) => `--${n}`).join(', ')}\n\nusage: ${command.usage}\n`);
+ return 2;
+ }
+ const options: GlobalOptions = {
+ db: (parsed.values.db as string | undefined) ?? env.OUTREACH_DB ?? 'outreach.db',
+ workspace: (parsed.values.workspace as string | undefined) ?? env.OUTREACH_WORKSPACE ?? 'default',
+ ...((parsed.values.config as string | undefined) ? { config: parsed.values.config as string } : {}),
+ fake: parsed.values.fake === true,
+ json: parsed.values.json === true,
+ };
+ const out = createOutput(options.json, io.write);
+ let runtime: Runtime | undefined;
+ try {
+ const code = await command.run({
+ args,
+ flags: parsed.values as Flags,
+ out,
+ options,
+ env,
+ notice: (text) => writeError(`${text}\n`),
+ ...(io.openUrl ? { openUrl: io.openUrl } : {}),
+ runtime: async () => (runtime ??= await openRuntime(options, env)),
+ });
+ return code ?? 0;
+ } catch (error) {
+ const err = error as Error & { issues?: readonly string[] };
+ writeError(`error: ${err.message}${err.issues ? `\n- ${err.issues.join('\n- ')}` : ''}\n`);
+ if (error instanceof UsageError) writeError(`usage: ${command.usage}\n`);
+ return error instanceof UsageError ? 2 : 1;
+ } finally {
+ runtime?.close();
+ }
+}
diff --git a/outreach-engine/packages/outreach-cli/src/maintenance.test.ts b/outreach-engine/packages/outreach-cli/src/maintenance.test.ts
new file mode 100644
index 0000000..e5832c5
--- /dev/null
+++ b/outreach-engine/packages/outreach-cli/src/maintenance.test.ts
@@ -0,0 +1,42 @@
+import { existsSync, mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, expect, it } from 'vitest';
+import { main } from './main';
+const dirs: string[] = [];
+afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); });
+async function fixture() {
+ const dir = mkdtempSync(join(tmpdir(), 'outreach-maintenance-')); dirs.push(dir);
+ const env = { OUTREACH_PRINCIPAL: 'cli:tester', OUTREACH_DB: join(dir, 'outreach.db'), OUTREACH_WORKSPACE: 'demo' };
+ const run = async (...args: string[]) => {
+ let stdout = ''; let stderr = '';
+ const code = await main(args, { env, write: (s) => { stdout += s; }, writeError: (s) => { stderr += s; } });
+ return { code, stdout, stderr, json: () => JSON.parse(stdout) as Record };
+ };
+ expect((await run('init', '--fake', '--json')).code).toBe(0);
+ return { dir, run };
+}
+it('requires explicit backup configuration, dry runs without destination writes, and respects cadence', async () => {
+ const { dir, run } = await fixture(); const destination = join(dir, 'backups');
+ expect((await run('backup', '--json')).code).not.toBe(0);
+ expect((await run('backup', '--backup-dir', destination, '--json')).code).not.toBe(0);
+ const args = ['backup', '--backup-dir', destination, '--backup-every-hours', '24', '--json'];
+ expect((await run(...args, '--dry-run')).json()).toMatchObject({ dryRun: true });
+ expect(existsSync(destination)).toBe(false);
+ expect((await run(...args)).json()).toMatchObject({ status: 'created' });
+ expect((await run(...args)).json()).toMatchObject({ status: 'not_due' });
+});
+it('worker backups are opt-in and retention requires exact reviewed hash and exclusions acknowledgement', async () => {
+ const { dir, run } = await fixture(); const destination = join(dir, 'backups');
+ expect((await run('worker', '--once', '--fake', '--json')).code).toBe(0);
+ expect(existsSync(destination)).toBe(false);
+ expect((await run('worker', '--once', '--fake', '--backup-dir', destination, '--backup-every-hours', '24', '--json')).code).toBe(0);
+ expect(existsSync(destination)).toBe(true);
+ expect((await run('retention', 'preview', '--json')).code).not.toBe(0);
+ const p = (await run('retention', 'preview', '--days', '30', '--json')).json();
+ expect(p.counts).toEqual({ contacts: 0, points: 0, actions: 0, attempts: 0, messages: 0, events: 0, tasks: 0, imports: 0 });
+ const args = ['retention', 'commit', String(p.id), '--confirm-hash', String(p.operationHash), '--json'];
+ expect((await run(...args)).code).not.toBe(0);
+ expect((await run(...args, '--acknowledge-exclusions')).json()).toEqual({ contacts: 0 });
+ expect((await run(...args, '--acknowledge-exclusions')).code).not.toBe(0);
+});
diff --git a/outreach-engine/packages/outreach-cli/src/output.ts b/outreach-engine/packages/outreach-cli/src/output.ts
new file mode 100644
index 0000000..193bef9
--- /dev/null
+++ b/outreach-engine/packages/outreach-cli/src/output.ts
@@ -0,0 +1,28 @@
+export interface Output {
+ /** Structured result: JSON with --json, otherwise a readable rendering. */
+ result(value: unknown): void;
+ line(text: string): void;
+}
+
+function render(value: unknown, indent = ''): string {
+ if (value === null || value === undefined) return `${indent}-`;
+ if (Array.isArray(value)) {
+ if (!value.length) return `${indent}(none)`;
+ return value.map((item) => (typeof item === 'object' && item !== null ? `${render(item, `${indent} `).replace(/^\s+/, `${indent}- `)}` : `${indent}- ${String(item)}`)).join('\n');
+ }
+ if (typeof value === 'object') {
+ return Object.entries(value as Record)
+ .map(([key, inner]) => (inner !== null && typeof inner === 'object' ? `${indent}${key}:\n${render(inner, `${indent} `)}` : `${indent}${key}: ${String(inner)}`))
+ .join('\n');
+ }
+ return `${indent}${String(value)}`;
+}
+
+export function createOutput(json: boolean, write: (text: string) => void = (text) => process.stdout.write(text)): Output {
+ return {
+ result: (value) => write(json ? `${JSON.stringify(value, null, 2)}\n` : `${render(value)}\n`),
+ line: (text) => {
+ if (!json) write(`${text}\n`);
+ },
+ };
+}
diff --git a/outreach-engine/packages/outreach-cli/src/runtime.ts b/outreach-engine/packages/outreach-cli/src/runtime.ts
new file mode 100644
index 0000000..2de47a7
--- /dev/null
+++ b/outreach-engine/packages/outreach-cli/src/runtime.ts
@@ -0,0 +1,125 @@
+import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
+import { homedir, userInfo } from 'node:os';
+import { resolve } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { ulid, type ManualTaskProvider, type ProviderAdapter, type SecretResolver, type SqlDatabase } from '@splitin/outreach-contracts';
+import { authenticate, createEngine, type AuthContext, type Engine } from '@splitin/outreach-core';
+import { FAKE_EMAIL_SECRET, FakeEmailProvider, FakeNotifier } from '@splitin/outreach-fakes';
+import { openSqliteDatabase } from '@splitin/outreach-store-sqlite';
+
+/** What an `outreach.config.mjs` default-exports: the host's adapters and optional manual-task hook. */
+export interface OutreachConfig {
+ readonly adapters: readonly ProviderAdapter[] | (() => readonly ProviderAdapter[] | Promise);
+ readonly manual?: ManualTaskProvider;
+}
+
+export interface GlobalOptions {
+ readonly db: string;
+ readonly workspace: string;
+ readonly config?: string;
+ /** Use in-memory fake providers (dry runs, demos, the soak test). Never sends anything. */
+ readonly fake: boolean;
+ readonly json: boolean;
+}
+
+export class UsageError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'UsageError';
+ }
+}
+
+export function expandHome(path: string, env: NodeJS.ProcessEnv = process.env): string {
+ return path === '~' || path.startsWith('~/') ? `${env.HOME ?? homedir()}${path.slice(1)}` : path;
+}
+
+/**
+ * Secrets are read at call time from the environment ("env:NAME") or from a file only its owner can read
+ * ("file:PATH", e.g. an OAuth grant written by `outreach account connect gmail`). The database stores the
+ * reference, never the value.
+ */
+export function envSecrets(env: NodeJS.ProcessEnv = process.env): SecretResolver {
+ return {
+ async get(ref) {
+ if (ref.startsWith('env:')) {
+ const value = env[ref.slice(4)];
+ if (value === undefined || value === '') throw new Error(`secret ${ref} is not set in the environment`);
+ return value;
+ }
+ if (ref.startsWith('file:')) {
+ const path = resolve(expandHome(ref.slice(5), env));
+ if (!existsSync(path)) throw new Error(`secret file ${path} does not exist`);
+ if (process.platform !== 'win32' && (statSync(path).mode & 0o077) !== 0) {
+ throw new Error(`secret file ${path} is readable by other users; run chmod 600 on it`);
+ }
+ return readFileSync(path, 'utf8').trim();
+ }
+ throw new Error(`secret reference ${ref} is not supported by the CLI; use env:NAME or file:PATH`);
+ },
+ // Only file: secrets can be rewritten (rotated OAuth refresh tokens); the write is atomic and owner-only.
+ async put(ref, value) {
+ if (!ref.startsWith('file:')) throw new Error(`secret ${ref} cannot be updated; store it as file:PATH to keep rotated tokens`);
+ const path = resolve(expandHome(ref.slice(5), env));
+ const temp = `${path}.${process.pid}.tmp`;
+ writeFileSync(temp, `${value}\n`, { mode: 0o600 });
+ renameSync(temp, path);
+ },
+ };
+}
+
+function fakeSecrets(): SecretResolver {
+ return { get: async (ref) => (ref.startsWith('env:') || ref.startsWith('keychain:') ? FAKE_EMAIL_SECRET : Promise.reject(new Error(`bad ref ${ref}`))) };
+}
+
+export function principalRef(env: NodeJS.ProcessEnv = process.env): string {
+ return env.OUTREACH_PRINCIPAL ?? `cli:${userInfo().username}`;
+}
+
+async function loadConfig(options: GlobalOptions): Promise<{ adapters: readonly ProviderAdapter[]; manual?: ManualTaskProvider }> {
+ if (options.fake) return { adapters: [new FakeEmailProvider().adapter(), new FakeNotifier().adapter()] };
+ const path = resolve(options.config ?? 'outreach.config.mjs');
+ if (!existsSync(path)) {
+ if (options.config) throw new UsageError(`config file ${path} not found`);
+ return { adapters: [] };
+ }
+ const module = (await import(pathToFileURL(path).href)) as { default?: OutreachConfig };
+ if (!module.default?.adapters) throw new UsageError(`${path} must default-export { adapters }`);
+ const adapters = typeof module.default.adapters === 'function' ? await module.default.adapters() : module.default.adapters;
+ return { adapters, ...(module.default.manual ? { manual: module.default.manual } : {}) };
+}
+
+export interface Runtime {
+ readonly options: GlobalOptions;
+ readonly db: SqlDatabase;
+ readonly engine: Engine;
+ /** The caller as a principal; throws if `outreach init` has not registered them. */
+ ctx(): AuthContext;
+ close(): void;
+}
+
+export async function openRuntime(options: GlobalOptions, env: NodeJS.ProcessEnv = process.env): Promise {
+ const db = openSqliteDatabase(resolve(options.db));
+ try {
+ const { adapters, manual } = await loadConfig(options);
+ const baseUrl = env.OUTREACH_PUBLIC_URL;
+ const secret = env.OUTREACH_UNSUBSCRIBE_SECRET;
+ const engine = createEngine({
+ db,
+ adapters,
+ secrets: options.fake ? fakeSecrets() : envSecrets(env),
+ workerId: `cli-${process.pid}-${ulid().slice(-6)}`,
+ ...(baseUrl && secret ? { unsubscribe: { baseUrl: `${baseUrl.replace(/\/+$/, '')}/u/`, secret } } : {}),
+ ...(manual ? { manual } : {}),
+ });
+ return {
+ options,
+ db,
+ engine,
+ ctx: () => authenticate(db, options.workspace, principalRef(env), 'cli', ulid()),
+ close: () => db.close(),
+ };
+ } catch (error) {
+ db.close();
+ throw error;
+ }
+}
diff --git a/outreach-engine/packages/outreach-cli/tsconfig.json b/outreach-engine/packages/outreach-cli/tsconfig.json
new file mode 100644
index 0000000..585a92d
--- /dev/null
+++ b/outreach-engine/packages/outreach-cli/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "outDir": "dist" },
+ "include": ["src"]
+}
diff --git a/outreach-engine/packages/outreach-cli/tsup.config.ts b/outreach-engine/packages/outreach-cli/tsup.config.ts
new file mode 100644
index 0000000..656673a
--- /dev/null
+++ b/outreach-engine/packages/outreach-cli/tsup.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig([
+ {
+ entry: ['src/index.ts'],
+ format: ['esm', 'cjs'],
+ dts: true,
+ sourcemap: true,
+ clean: true,
+ target: 'node22',
+ // node:sqlite exists only with the protocol prefix.
+ removeNodeProtocol: false,
+ },
+ {
+ entry: { bin: 'src/bin.ts' },
+ format: ['esm'],
+ sourcemap: true,
+ target: 'node22',
+ removeNodeProtocol: false,
+ banner: { js: '#!/usr/bin/env node' },
+ },
+]);
diff --git a/outreach-engine/packages/outreach-contracts/LICENSE b/outreach-engine/packages/outreach-contracts/LICENSE
new file mode 100644
index 0000000..62050eb
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2026 SplitInTech
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/outreach-engine/packages/outreach-contracts/README.md b/outreach-engine/packages/outreach-contracts/README.md
new file mode 100644
index 0000000..7810194
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/README.md
@@ -0,0 +1,13 @@
+# @splitin/outreach-contracts
+
+Provider-neutral outreach contracts: types, schemas, action state table, error taxonomy.
+
+Development version 0.0.0; not yet a published release. Requires Node 22.13+.
+See the [engine README](../../README.md), [build plan](../../BUILD_PLAN.md),
+[runbook](../../docs/runbook.md) and [release gates](../../docs/release.md).
+
+Build from the workspace root with `npm run build`; verify with `npm run check`.
+The engine keeps human approval, suppression and live-send gates in the core.
+Do not call provider send methods outside the durable execution pipeline.
+
+MIT license.
diff --git a/outreach-engine/packages/outreach-contracts/package.json b/outreach-engine/packages/outreach-contracts/package.json
new file mode 100644
index 0000000..45511ac
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@splitin/outreach-contracts",
+ "version": "0.0.0",
+ "description": "Provider-neutral outreach contracts: types, schemas, action state table, error taxonomy.",
+ "license": "MIT",
+ "author": "SplitInTech",
+ "homepage": "https://github.com/splitintech/open-internal-tools/tree/main/outreach-engine#readme",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/splitintech/open-internal-tools.git",
+ "directory": "outreach-engine/packages/outreach-contracts"
+ },
+ "type": "module",
+ "sideEffects": false,
+ "engines": { "node": ">=22.13" },
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ }
+ },
+ "files": ["dist", "README.md", "package.json"],
+ "publishConfig": { "access": "public" },
+ "scripts": {
+ "build": "tsup",
+ "typecheck": "tsc --noEmit -p tsconfig.json"
+ }
+}
diff --git a/outreach-engine/packages/outreach-contracts/src/actions.test.ts b/outreach-engine/packages/outreach-contracts/src/actions.test.ts
new file mode 100644
index 0000000..5f7b674
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/actions.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+import {
+ ACTION_STATES,
+ ACTION_TRANSITIONS,
+ CANCELLABLE_ACTION_STATES,
+ InvalidTransitionError,
+ TERMINAL_ACTION_STATES,
+ assertTransition,
+ canTransition,
+} from './actions';
+
+describe('action transition table', () => {
+ it('is closed: every target is a known state and every state has an entry', () => {
+ expect(Object.keys(ACTION_TRANSITIONS).sort()).toEqual([...ACTION_STATES].sort());
+ for (const targets of Object.values(ACTION_TRANSITIONS)) {
+ for (const target of targets) expect(ACTION_STATES).toContain(target);
+ }
+ });
+
+ it('has no exits from terminal states', () => {
+ for (const state of TERMINAL_ACTION_STATES) expect(ACTION_TRANSITIONS[state]).toEqual([]);
+ });
+
+ it('lets every cancellable state be cancelled', () => {
+ for (const state of CANCELLABLE_ACTION_STATES) expect(canTransition(state, 'cancelled')).toBe(true);
+ });
+
+ it('never lets executing or uncertain actions be cancelled or rescheduled directly', () => {
+ for (const state of ['executing', 'uncertain'] as const) {
+ expect(canTransition(state, 'cancelled')).toBe(false);
+ expect(canTransition(state, 'scheduled')).toBe(false);
+ expect(canTransition(state, 'claimed')).toBe(false);
+ }
+ });
+
+ it('only reaches executing from claimed', () => {
+ const sources = ACTION_STATES.filter((state) => canTransition(state, 'executing'));
+ expect(sources).toEqual(['claimed']);
+ });
+
+ it('only reaches scheduled from uncertain via reconciliation or review', () => {
+ const intoScheduled = ACTION_STATES.filter((state) => canTransition(state, 'scheduled'));
+ expect(intoScheduled).not.toContain('uncertain');
+ expect(intoScheduled).not.toContain('executing');
+ });
+
+ it('throws a typed error on invalid transitions', () => {
+ expect(() => assertTransition('succeeded', 'scheduled')).toThrow(InvalidTransitionError);
+ expect(() => assertTransition('scheduled', 'claimed')).not.toThrow();
+ });
+});
diff --git a/outreach-engine/packages/outreach-contracts/src/actions.ts b/outreach-engine/packages/outreach-contracts/src/actions.ts
new file mode 100644
index 0000000..c6a9101
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/actions.ts
@@ -0,0 +1,78 @@
+/**
+ * The action state machine (BUILD_PLAN.md §6.1, ADR 0002).
+ *
+ * `claimed` means a worker holds the lease but has not committed to acting: a crash is harmless.
+ * `executing` is committed together with a pending attempt BEFORE the provider call, so a crash
+ * after that point can only lead to `uncertain`, never to a blind resend.
+ */
+export const ACTION_STATES = [
+ 'planned',
+ 'awaiting_approval',
+ 'scheduled',
+ 'claimed',
+ 'executing',
+ 'succeeded',
+ 'retryable',
+ 'uncertain',
+ 'reconciling',
+ 'failed',
+ 'cancelled',
+ 'review',
+] as const;
+
+export type ActionState = (typeof ACTION_STATES)[number];
+
+export const ACTION_KINDS = ['email.send', 'email.reply', 'notify.publish', 'manual.task'] as const;
+
+export type ActionKind = (typeof ACTION_KINDS)[number];
+
+export const ACTION_TRANSITIONS: Readonly> = {
+ planned: ['awaiting_approval', 'scheduled', 'cancelled'],
+ awaiting_approval: ['scheduled', 'cancelled'],
+ scheduled: ['claimed', 'awaiting_approval', 'cancelled'],
+ // Preflight either proceeds, defers, blocks, or parks the action for a human.
+ claimed: ['executing', 'scheduled', 'awaiting_approval', 'cancelled', 'review'],
+ executing: ['succeeded', 'retryable', 'failed', 'uncertain', 'review'],
+ retryable: ['scheduled', 'failed', 'cancelled'],
+ // An uncertain action is never cancelled or retried directly: only reconciliation or a human decides.
+ uncertain: ['reconciling', 'review'],
+ reconciling: ['succeeded', 'scheduled', 'failed', 'uncertain', 'review'],
+ succeeded: [],
+ failed: [],
+ cancelled: [],
+ // A human resolves review items: sent, not sent (reschedule or drop), or failed.
+ review: ['succeeded', 'scheduled', 'failed', 'cancelled'],
+};
+
+export const TERMINAL_ACTION_STATES: readonly ActionState[] = ['succeeded', 'failed', 'cancelled'];
+
+/** States a stop (reply, opt-out, bounce, pause-to-stop) may cancel. `executing` and `uncertain` cannot be recalled. */
+export const CANCELLABLE_ACTION_STATES: readonly ActionState[] = [
+ 'planned',
+ 'awaiting_approval',
+ 'scheduled',
+ 'claimed',
+ 'retryable',
+];
+
+export class InvalidTransitionError extends Error {
+ constructor(
+ readonly from: ActionState,
+ readonly to: ActionState,
+ ) {
+ super(`Invalid action transition ${from} -> ${to}`);
+ this.name = 'InvalidTransitionError';
+ }
+}
+
+export function isActionState(value: unknown): value is ActionState {
+ return typeof value === 'string' && (ACTION_STATES as readonly string[]).includes(value);
+}
+
+export function canTransition(from: ActionState, to: ActionState): boolean {
+ return ACTION_TRANSITIONS[from].includes(to);
+}
+
+export function assertTransition(from: ActionState, to: ActionState): void {
+ if (!canTransition(from, to)) throw new InvalidTransitionError(from, to);
+}
diff --git a/outreach-engine/packages/outreach-contracts/src/audit.ts b/outreach-engine/packages/outreach-contracts/src/audit.ts
new file mode 100644
index 0000000..cabff68
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/audit.ts
@@ -0,0 +1,99 @@
+/**
+ * Append-only, hash-chained audit log (BUILD_PLAN.md §4.5). Every writer (core, importer, surfaces)
+ * appends through this function inside its own transaction, so `BEGIN IMMEDIATE` serializes the chain.
+ */
+import { canonicalize, sha256Hex } from './crypto';
+import type { SqlDatabase } from './sql';
+
+export type AuditActorKind = 'principal' | 'worker' | 'provider' | 'system';
+
+export interface AuditInput {
+ readonly workspaceId: string;
+ readonly at: number;
+ readonly actorKind: AuditActorKind;
+ readonly actorId: string;
+ readonly source: string;
+ readonly traceId: string;
+ readonly resourceKind: string;
+ readonly resourceId: string;
+ readonly action: string;
+ readonly detail?: unknown;
+}
+
+export const AUDIT_GENESIS_HASH = '0'.repeat(64);
+
+interface AuditRow {
+ seq: number;
+ workspace_id: string;
+ at: number;
+ actor_kind: string;
+ actor_id: string;
+ source: string;
+ trace_id: string;
+ resource_kind: string;
+ resource_id: string;
+ action: string;
+ detail: string;
+ prev_hash: string;
+ hash: string;
+}
+
+function rowBody(row: Omit): string {
+ return canonicalize(row);
+}
+
+function chainHash(prevHash: string, body: string): string {
+ return sha256Hex(`${prevHash}\n${body}`);
+}
+
+export function appendAudit(db: SqlDatabase, input: AuditInput): string {
+ const previous = db.prepare('SELECT hash FROM audit_events ORDER BY seq DESC LIMIT 1').get<{ hash: string }>();
+ const prevHash = previous?.hash ?? AUDIT_GENESIS_HASH;
+ const body = {
+ workspace_id: input.workspaceId,
+ at: input.at,
+ actor_kind: input.actorKind,
+ actor_id: input.actorId,
+ source: input.source,
+ trace_id: input.traceId,
+ resource_kind: input.resourceKind,
+ resource_id: input.resourceId,
+ action: input.action,
+ detail: canonicalize(input.detail ?? {}),
+ };
+ const hash = chainHash(prevHash, rowBody(body));
+ db.prepare(
+ `INSERT INTO audit_events (workspace_id, at, actor_kind, actor_id, source, trace_id, resource_kind,
+ resource_id, action, detail, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
+ ).run(
+ body.workspace_id,
+ body.at,
+ body.actor_kind,
+ body.actor_id,
+ body.source,
+ body.trace_id,
+ body.resource_kind,
+ body.resource_id,
+ body.action,
+ body.detail,
+ prevHash,
+ hash,
+ );
+ return hash;
+}
+
+export type AuditVerification = { ok: true; count: number } | { ok: false; count: number; brokenAtSeq: number };
+
+/** Recomputes the whole chain. Any edited, deleted or reordered row breaks it. */
+export function verifyAuditChain(db: SqlDatabase): AuditVerification {
+ const rows = db.prepare('SELECT * FROM audit_events ORDER BY seq ASC').all();
+ let prevHash = AUDIT_GENESIS_HASH;
+ for (const row of rows) {
+ const { seq, prev_hash, hash, ...rest } = row;
+ if (prev_hash !== prevHash || chainHash(prevHash, rowBody(rest)) !== hash) {
+ return { ok: false, count: rows.length, brokenAtSeq: seq };
+ }
+ prevHash = hash;
+ }
+ return { ok: true, count: rows.length };
+}
diff --git a/outreach-engine/packages/outreach-contracts/src/capabilities.test.ts b/outreach-engine/packages/outreach-contracts/src/capabilities.test.ts
new file mode 100644
index 0000000..56c928a
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/capabilities.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from 'vitest';
+import { purposePermitted } from './capabilities';
+
+describe('purposePermitted', () => {
+ it('requires both the adapter and the account to permit the purpose', () => {
+ expect(purposePermitted('automated_outreach', ['automated_outreach'], ['automated_outreach'])).toBe(true);
+ expect(purposePermitted('automated_outreach', ['manual_correspondence'], ['automated_outreach'])).toBe(false);
+ expect(purposePermitted('automated_outreach', ['automated_outreach'], ['manual_correspondence'])).toBe(false);
+ });
+});
diff --git a/outreach-engine/packages/outreach-contracts/src/capabilities.ts b/outreach-engine/packages/outreach-contracts/src/capabilities.ts
new file mode 100644
index 0000000..57af101
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/capabilities.ts
@@ -0,0 +1,41 @@
+/**
+ * Provider purposes and capabilities (BUILD_PLAN.md §5.1).
+ * A campaign may run only if its purpose is permitted by BOTH the adapter (the provider's terms)
+ * and the account (what the operator attests their contract allows).
+ */
+export const PROVIDER_PURPOSES = [
+ 'manual_correspondence',
+ 'transactional',
+ 'automated_outreach',
+ 'marketing',
+ 'bulk',
+] as const;
+
+export type ProviderPurpose = (typeof PROVIDER_PURPOSES)[number];
+
+export interface CapabilitySnapshot {
+ readonly provider: string;
+ readonly send: boolean;
+ readonly replyInThread: boolean;
+ /** Whether we can set Message-ID and List-Unsubscribe headers. */
+ readonly customHeaders: boolean;
+ /** Whether the provider deduplicates on our idempotency key. */
+ readonly externalIdempotency: boolean;
+ readonly inboundWebhook: boolean;
+ readonly mailboxPolling: boolean;
+ readonly reconcileBySentSearch: boolean;
+ readonly maxRecipientsPerMessage: number;
+ readonly discoveredAt: number;
+}
+
+export function isProviderPurpose(value: unknown): value is ProviderPurpose {
+ return typeof value === 'string' && (PROVIDER_PURPOSES as readonly string[]).includes(value);
+}
+
+export function purposePermitted(
+ purpose: ProviderPurpose,
+ adapterPurposes: readonly ProviderPurpose[],
+ accountPurposes: readonly ProviderPurpose[],
+): boolean {
+ return adapterPurposes.includes(purpose) && accountPurposes.includes(purpose);
+}
diff --git a/outreach-engine/packages/outreach-contracts/src/crypto.test.ts b/outreach-engine/packages/outreach-contracts/src/crypto.test.ts
new file mode 100644
index 0000000..ec0f5f2
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/crypto.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+import { canonicalize, digestCanonical, hmacSha256Hex, safeEqualHex, sha256Hex, ulid } from './crypto';
+
+describe('canonicalize', () => {
+ it('is independent of key order and drops undefined', () => {
+ expect(canonicalize({ b: 1, a: { d: [2, { z: 1, y: 2 }], c: undefined } })).toBe(
+ canonicalize({ a: { d: [2, { y: 2, z: 1 }] }, b: 1 }),
+ );
+ expect(digestCanonical({ a: 1, b: 2 })).toBe(digestCanonical({ b: 2, a: 1 }));
+ });
+});
+
+describe('hashing', () => {
+ it('produces known sha256 and hmac values', () => {
+ expect(sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
+ expect(hmacSha256Hex('key', 'The quick brown fox jumps over the lazy dog')).toBe(
+ 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8',
+ );
+ });
+
+ it('compares hex digests safely', () => {
+ expect(safeEqualHex('abcd', 'abcd')).toBe(true);
+ expect(safeEqualHex('abcd', 'abce')).toBe(false);
+ expect(safeEqualHex('abcd', 'abc')).toBe(false);
+ expect(safeEqualHex('zz', 'zz')).toBe(false);
+ });
+});
+
+describe('ulid', () => {
+ it('is 26 Crockford base32 characters and sorts by creation order', () => {
+ const ids = Array.from({ length: 500 }, (_, i) => ulid(1_700_000_000_000 + Math.floor(i / 50)));
+ for (const id of ids) expect(id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
+ expect([...ids].sort()).toEqual(ids);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+});
diff --git a/outreach-engine/packages/outreach-contracts/src/crypto.ts b/outreach-engine/packages/outreach-contracts/src/crypto.ts
new file mode 100644
index 0000000..026c1b2
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/crypto.ts
@@ -0,0 +1,71 @@
+import { createHash, createHmac, getRandomValues, timingSafeEqual } from 'node:crypto';
+
+/** Canonical JSON: recursively sorted keys, `undefined` dropped, no insignificant whitespace. */
+export function canonicalize(value: unknown): string {
+ return JSON.stringify(sortValue(value));
+}
+
+function sortValue(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(sortValue);
+ if (value && typeof value === 'object' && !(value instanceof Uint8Array)) {
+ const record = value as Record;
+ const out: Record = {};
+ for (const key of Object.keys(record).sort()) {
+ if (record[key] !== undefined) out[key] = sortValue(record[key]);
+ }
+ return out;
+ }
+ return value;
+}
+
+export function sha256Hex(payload: string | Uint8Array): string {
+ return createHash('sha256').update(payload).digest('hex');
+}
+
+export function digestCanonical(value: unknown): string {
+ return sha256Hex(canonicalize(value));
+}
+
+export function hmacSha256Hex(secret: string, payload: string | Uint8Array): string {
+ return createHmac('sha256', secret).update(payload).digest('hex');
+}
+
+/** Constant-time comparison of two hex digests. */
+export function safeEqualHex(a: string, b: string): boolean {
+ if (a.length !== b.length || !/^[0-9a-f]*$/i.test(a) || !/^[0-9a-f]*$/i.test(b)) return false;
+ return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
+}
+
+const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+let lastTime = -1;
+let lastRandom: number[] = [];
+
+/** Monotonic ULID: sortable by creation time, unique within a process even in the same millisecond. */
+export function ulid(now: number = Date.now()): string {
+ if (now === lastTime) {
+ lastRandom = incrementBase32(lastRandom);
+ } else {
+ lastTime = now;
+ lastRandom = Array.from(getRandomValues(new Uint8Array(16)), (byte) => byte % 32);
+ }
+ let time = '';
+ let remaining = now;
+ for (let i = 0; i < 10; i += 1) {
+ time = ULID_ALPHABET[remaining % 32] + time;
+ remaining = Math.floor(remaining / 32);
+ }
+ return time + lastRandom.map((digit) => ULID_ALPHABET[digit]).join('');
+}
+
+function incrementBase32(digits: number[]): number[] {
+ const next = [...digits];
+ for (let i = next.length - 1; i >= 0; i -= 1) {
+ const digit = next[i] ?? 0;
+ if (digit < 31) {
+ next[i] = digit + 1;
+ return next;
+ }
+ next[i] = 0;
+ }
+ throw new Error('ULID random component overflowed within one millisecond');
+}
diff --git a/outreach-engine/packages/outreach-contracts/src/errors.test.ts b/outreach-engine/packages/outreach-contracts/src/errors.test.ts
new file mode 100644
index 0000000..dda7708
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/errors.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+import { ERROR_CLASSES, ERROR_DISPOSITIONS, isErrorClass } from './errors';
+
+describe('error taxonomy', () => {
+ it('has a disposition for every class', () => {
+ expect(Object.keys(ERROR_DISPOSITIONS).sort()).toEqual([...ERROR_CLASSES].sort());
+ });
+
+ it('never retries classes that mean the recipient or account is bad', () => {
+ for (const cls of ['invalid_recipient', 'hard_bounce', 'auth_revoked', 'forbidden', 'complaint', 'unsupported'] as const) {
+ expect(ERROR_DISPOSITIONS[cls].retry).toBe('never');
+ }
+ });
+
+ it('gives non-retried classes a terminal state and retried classes none', () => {
+ for (const cls of ERROR_CLASSES) {
+ const disposition = ERROR_DISPOSITIONS[cls];
+ expect(disposition.retry === 'never').toBe(disposition.terminal !== null);
+ }
+ });
+
+ it('recognises classes', () => {
+ expect(isErrorClass('rate_limited')).toBe(true);
+ expect(isErrorClass('HTTP 500')).toBe(false);
+ });
+});
diff --git a/outreach-engine/packages/outreach-contracts/src/errors.ts b/outreach-engine/packages/outreach-contracts/src/errors.ts
new file mode 100644
index 0000000..bcb5e29
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/errors.ts
@@ -0,0 +1,64 @@
+/**
+ * Provider error taxonomy (BUILD_PLAN.md §5.3). Adapters map every failure into one class;
+ * the engine decides behaviour from the class alone, so no provider logic lives in the core.
+ */
+export const ERROR_CLASSES = [
+ 'auth_expired',
+ 'auth_revoked',
+ 'forbidden',
+ 'rate_limited',
+ 'invalid_recipient',
+ 'hard_bounce',
+ 'content_rejected',
+ 'policy_blocked',
+ 'complaint',
+ 'transient',
+ 'unsupported',
+] as const;
+
+export type ErrorClass = (typeof ERROR_CLASSES)[number];
+
+export type ErrorEffect =
+ | 'suppress_recipient'
+ | 'bounce_enrollment'
+ | 'account_unhealthy'
+ | 'account_reauth'
+ | 'engage_account_kill'
+ | 'pause_campaign';
+
+export interface ErrorDisposition {
+ /** `backoff`: retry after jittered delay; `after_reauth`: retry once the account is healthy again. */
+ readonly retry: 'never' | 'backoff' | 'after_reauth';
+ /** Final action state when not retried. */
+ readonly terminal: 'failed' | 'review' | null;
+ readonly effects: readonly ErrorEffect[];
+}
+
+const permanentAccount: ErrorDisposition = {
+ retry: 'never',
+ terminal: 'failed',
+ effects: ['account_unhealthy', 'pause_campaign'],
+};
+const badRecipient: ErrorDisposition = {
+ retry: 'never',
+ terminal: 'failed',
+ effects: ['suppress_recipient', 'bounce_enrollment'],
+};
+
+export const ERROR_DISPOSITIONS: Readonly> = {
+ auth_expired: { retry: 'after_reauth', terminal: null, effects: ['account_reauth'] },
+ auth_revoked: permanentAccount,
+ forbidden: permanentAccount,
+ rate_limited: { retry: 'backoff', terminal: null, effects: [] },
+ invalid_recipient: badRecipient,
+ hard_bounce: badRecipient,
+ content_rejected: { retry: 'never', terminal: 'review', effects: [] },
+ policy_blocked: { retry: 'never', terminal: 'failed', effects: ['engage_account_kill'] },
+ complaint: { retry: 'never', terminal: 'failed', effects: ['engage_account_kill', 'suppress_recipient'] },
+ transient: { retry: 'backoff', terminal: null, effects: [] },
+ unsupported: { retry: 'never', terminal: 'failed', effects: [] },
+};
+
+export function isErrorClass(value: unknown): value is ErrorClass {
+ return typeof value === 'string' && (ERROR_CLASSES as readonly string[]).includes(value);
+}
diff --git a/outreach-engine/packages/outreach-contracts/src/index.ts b/outreach-engine/packages/outreach-contracts/src/index.ts
new file mode 100644
index 0000000..a7d7455
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/index.ts
@@ -0,0 +1,9 @@
+export * from './version';
+export * from './actions';
+export * from './errors';
+export * from './capabilities';
+export * from './providers';
+export * from './crypto';
+export * from './sql';
+export * from './audit';
+export * from './mime';
diff --git a/outreach-engine/packages/outreach-contracts/src/mime.ts b/outreach-engine/packages/outreach-contracts/src/mime.ts
new file mode 100644
index 0000000..a12824c
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/mime.ts
@@ -0,0 +1,101 @@
+import { randomBytes } from 'node:crypto';
+import type { ApprovedEmail, EmailAddress } from './providers';
+
+/** The message cannot be expressed safely as MIME (header injection, reserved header, bad address). */
+export class MimeError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'MimeError';
+ }
+}
+
+/** Header the adapter adds to every message; reconciliation matches on it. */
+export const IDEMPOTENCY_HEADER = 'X-Outreach-Key';
+
+/** Headers the adapter owns. Callers pass anything else (List-Unsubscribe, ...) through `headers`. */
+const RESERVED = new Set([
+ 'from', 'to', 'cc', 'bcc', 'reply-to', 'subject', 'date', 'message-id', 'in-reply-to', 'references',
+ 'mime-version', 'content-type', 'content-transfer-encoding', IDEMPOTENCY_HEADER.toLowerCase(),
+]);
+const HEADER_NAME = /^[!-9;-~]+$/;
+const ADDRESS = /^[^\s<>()[\]\\,;:"@]+@[^\s<>()[\]\\,;:"@]+$/;
+const ASCII_PRINTABLE = /^[\x20-\x7e]*$/;
+
+function assertNoBreaks(value: string, what: string): void {
+ if (/[\r\n\0]/.test(value)) throw new MimeError(`${what} contains a line break`);
+}
+
+/** RFC 2047 encoded words of at most 45 UTF-8 bytes each (so every word stays under 75 characters). */
+function encodeWords(value: string): string {
+ if (ASCII_PRINTABLE.test(value)) return value;
+ const words: string[] = [];
+ let chunk = '';
+ for (const char of value) {
+ if (Buffer.byteLength(chunk + char, 'utf8') > 45) {
+ words.push(chunk);
+ chunk = '';
+ }
+ chunk += char;
+ }
+ if (chunk) words.push(chunk);
+ return words.map((word) => `=?UTF-8?B?${Buffer.from(word, 'utf8').toString('base64')}?=`).join('\r\n ');
+}
+
+function formatAddress(address: EmailAddress, what: string): string {
+ assertNoBreaks(address.address, what);
+ if (!ADDRESS.test(address.address)) throw new MimeError(`${what} "${address.address.slice(0, 80)}" is not a plain address`);
+ if (!address.name) return address.address;
+ assertNoBreaks(address.name, `${what} name`);
+ const name = ASCII_PRINTABLE.test(address.name) ? `"${address.name.replace(/["\\]/g, '\\$&')}"` : encodeWords(address.name);
+ return `${name} <${address.address}>`;
+}
+
+function base64Lines(text: string): string {
+ return (Buffer.from(text, 'utf8').toString('base64').match(/.{1,76}/g) ?? []).join('\r\n');
+}
+
+function messageIdToken(value: string, what: string): string {
+ assertNoBreaks(value, what);
+ if (!/^<[^<>\s]+>$/.test(value)) throw new MimeError(`${what} must look like `);
+ return value;
+}
+
+/**
+ * Builds an RFC 5322 message with CRLF line endings. Bodies are base64 (UTF-8); with HTML the message is
+ * multipart/alternative with the text part first.
+ */
+export function buildMime(email: ApprovedEmail, date: Date, boundary = `=_outreach_${randomBytes(12).toString('hex')}`): string {
+ if (email.to.length === 0) throw new MimeError('a message needs at least one recipient');
+ const lines: string[] = [
+ `From: ${formatAddress(email.from, 'From')}`,
+ `To: ${email.to.map((to) => formatAddress(to, 'To')).join(', ')}`,
+ ];
+ if (email.replyTo) lines.push(`Reply-To: ${formatAddress(email.replyTo, 'Reply-To')}`);
+ assertNoBreaks(email.subject, 'Subject');
+ lines.push(`Subject: ${encodeWords(email.subject)}`, `Date: ${date.toUTCString()}`, `Message-ID: ${messageIdToken(email.rfcMessageId, 'Message-ID')}`);
+ if (email.inReplyTo) lines.push(`In-Reply-To: ${messageIdToken(email.inReplyTo, 'In-Reply-To')}`);
+ if (email.references?.length) lines.push(`References: ${email.references.map((id) => messageIdToken(id, 'References')).join(' ')}`);
+ for (const [name, value] of Object.entries(email.headers)) {
+ if (!HEADER_NAME.test(name)) throw new MimeError(`invalid header name "${name.slice(0, 40)}"`);
+ if (RESERVED.has(name.toLowerCase())) throw new MimeError(`header ${name} is set by the adapter`);
+ assertNoBreaks(value, `header ${name}`);
+ lines.push(`${name}: ${ASCII_PRINTABLE.test(value) ? value : encodeWords(value)}`);
+ }
+ assertNoBreaks(email.idempotencyKey, IDEMPOTENCY_HEADER);
+ lines.push(`${IDEMPOTENCY_HEADER}: ${email.idempotencyKey}`, 'MIME-Version: 1.0');
+
+ const textPart = ['Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: base64', '', base64Lines(email.text)];
+ if (!email.html) return [...lines, ...textPart, ''].join('\r\n');
+ const htmlPart = ['Content-Type: text/html; charset=UTF-8', 'Content-Transfer-Encoding: base64', '', base64Lines(email.html)];
+ return [
+ ...lines,
+ `Content-Type: multipart/alternative; boundary="${boundary}"`,
+ '',
+ `--${boundary}`,
+ ...textPart,
+ `--${boundary}`,
+ ...htmlPart,
+ `--${boundary}--`,
+ '',
+ ].join('\r\n');
+}
diff --git a/outreach-engine/packages/outreach-contracts/src/providers.ts b/outreach-engine/packages/outreach-contracts/src/providers.ts
new file mode 100644
index 0000000..7b7f293
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/providers.ts
@@ -0,0 +1,212 @@
+/**
+ * Provider ports (BUILD_PLAN.md §5.2). Small capabilities instead of one all-purpose connector.
+ * Adapters implement only what their provider supports; the engine never falls back to another
+ * adapter or channel when a capability is missing.
+ */
+import type { CapabilitySnapshot, ProviderPurpose } from './capabilities';
+import type { ErrorClass } from './errors';
+
+export interface SenderIdentity {
+ readonly name: string;
+ readonly address: string;
+ readonly organization?: string;
+ readonly postalAddress?: string;
+ readonly replyTo?: string;
+}
+
+export interface ProviderAccountRef {
+ readonly id: string;
+ readonly provider: string;
+ readonly externalAccountId: string;
+ readonly sender: SenderIdentity;
+}
+
+/** Resolves a secret reference ("env:NAME", "keychain:NAME") at call time. Values are never stored. */
+export interface SecretResolver {
+ get(ref: string): Promise;
+ /**
+ * Replaces a secret's value, for credentials the provider rotates (Microsoft refresh tokens). Optional:
+ * resolvers that cannot write (environment variables) omit it, and callers must cope.
+ */
+ put?(ref: string, value: string): Promise;
+}
+
+export interface ProviderContext {
+ readonly workspaceId: string;
+ readonly account: ProviderAccountRef;
+ readonly secretRef: string;
+ readonly secrets: SecretResolver;
+ readonly traceId: string;
+ readonly signal: AbortSignal;
+ readonly now: () => number;
+}
+
+/**
+ * Supplies OAuth access tokens to adapters that act as a mailbox user. The grant may be held by the engine
+ * (a refresh token behind `secretRef`) or by a host, such as Papr's planned server-side connectors
+ * (BUILD_PLAN.md §19 D1). Adapters never see how the token was obtained.
+ */
+export interface AccessTokenSource {
+ get(ctx: ProviderContext): Promise;
+ /** Called after the provider rejects a token (HTTP 401), so the next `get` fetches a fresh one. */
+ invalidate(ctx: ProviderContext): void;
+}
+
+/** A token could not be obtained; `errorClass` says whether reconnecting the account is needed. */
+export class TokenError extends Error {
+ constructor(
+ readonly errorClass: ErrorClass,
+ message: string,
+ ) {
+ super(message);
+ this.name = 'TokenError';
+ }
+}
+
+export type AccountHealth = 'ok' | 'degraded' | 'unhealthy' | 'reauth_required';
+
+export interface AccountPort {
+ discover(ctx: ProviderContext): Promise;
+ health(ctx: ProviderContext): Promise<{ status: AccountHealth; detail?: string }>;
+}
+
+export interface EmailAddress {
+ readonly address: string;
+ readonly name?: string;
+}
+
+export interface EmailContent {
+ readonly from: EmailAddress;
+ readonly to: readonly EmailAddress[];
+ readonly replyTo?: EmailAddress;
+ readonly subject: string;
+ readonly text: string;
+ readonly html?: string;
+ readonly headers: Readonly>;
+ readonly inReplyTo?: string;
+ readonly references?: readonly string[];
+ readonly providerThreadId?: string;
+}
+
+export interface ApprovedEmail extends EmailContent {
+ readonly actionId: string;
+ readonly idempotencyKey: string;
+ /** Generated by the engine; used for reply correlation and reconciliation. */
+ readonly rfcMessageId: string;
+ readonly contentHash: string;
+}
+
+export interface UncertainEmail {
+ readonly actionId: string;
+ readonly idempotencyKey: string;
+ readonly rfcMessageId: string;
+ readonly to: readonly string[];
+ readonly attemptedAt: number;
+}
+
+export interface ProviderReceipt {
+ readonly providerMessageId: string;
+ readonly providerThreadId?: string;
+ readonly rfcMessageId?: string;
+ readonly acceptedAt: number;
+ /** Identifiers only. Never bodies, tokens or credentials. */
+ readonly raw?: Readonly>;
+}
+
+export type SendResult =
+ | { readonly kind: 'accepted'; readonly receipt: ProviderReceipt }
+ | {
+ readonly kind: 'rejected';
+ readonly errorClass: ErrorClass;
+ readonly retryAfterMs?: number;
+ readonly detail: string;
+ }
+ /** The request may have reached the provider (timeout, reset after send). Never retried blindly. */
+ | { readonly kind: 'unknown'; readonly detail: string };
+
+export type ReconcileResult =
+ | { readonly kind: 'found'; readonly receipt: ProviderReceipt }
+ /** Only when the provider can affirm the message was not sent. */
+ | { readonly kind: 'absent' }
+ | { readonly kind: 'still_unknown'; readonly detail: string };
+
+export interface EmailSender {
+ send(ctx: ProviderContext, email: ApprovedEmail): Promise;
+ reconcile(ctx: ProviderContext, email: UncertainEmail): Promise;
+}
+
+export type InboundKind = 'message' | 'bounce' | 'complaint' | 'delivery';
+
+export interface InboundMailEvent {
+ /** Stable provider event id, used for deduplication across webhook retries and poll overlap. */
+ readonly eventId: string;
+ readonly kind: InboundKind;
+ readonly providerMessageId: string;
+ readonly providerThreadId?: string;
+ readonly rfcMessageId?: string;
+ readonly inReplyTo?: string;
+ readonly references: readonly string[];
+ readonly from: string;
+ readonly to: readonly string[];
+ readonly subject?: string;
+ readonly receivedAt: number;
+ readonly headers: Readonly>;
+ readonly contentType?: string;
+ /** Delivery status notification details for bounces. */
+ readonly dsn?: { readonly status: string; readonly recipient?: string; readonly originalMessageId?: string };
+ /** At most 500 characters, used only for deterministic classification. */
+ readonly snippet?: string;
+}
+
+export interface MailboxReader {
+ readChanges(
+ ctx: ProviderContext,
+ cursor: string | null,
+ ): Promise<{ events: readonly InboundMailEvent[]; nextCursor: string }>;
+}
+
+export interface WebhookVerifier {
+ /** Verify over the raw body BEFORE parsing. Returns 'reject' on bad signature, stale timestamp or oversize body. */
+ verify(
+ rawBody: Uint8Array,
+ headers: Readonly>,
+ secret: string,
+ now: number,
+ ): Promise;
+}
+
+export interface Notification {
+ readonly title: string;
+ readonly lines: readonly string[];
+ readonly severity: 'info' | 'warning' | 'error';
+ readonly link?: string;
+}
+
+export interface NotificationPublisher {
+ publish(ctx: ProviderContext, notification: Notification & { idempotencyKey: string }): Promise;
+}
+
+export interface ManualTaskInput {
+ readonly taskId: string;
+ readonly channel: string;
+ readonly targetUrl?: string;
+ readonly draft: string;
+}
+
+/** Hosts may announce manual tasks (e.g. open a URL for the human). Completing a task is always a human action. */
+export interface ManualTaskProvider {
+ prepare(ctx: ProviderContext, input: ManualTaskInput): Promise;
+}
+
+/** Everything one provider integration offers. Missing ports mean "unsupported", never "fall back". */
+export interface ProviderAdapter {
+ readonly name: string;
+ /** Purposes the provider's terms permit. */
+ readonly purposes: readonly ProviderPurpose[];
+ readonly account: AccountPort;
+ readonly email?: EmailSender;
+ readonly mailbox?: MailboxReader;
+ readonly webhook?: WebhookVerifier;
+ readonly notify?: NotificationPublisher;
+ readonly manual?: ManualTaskProvider;
+}
diff --git a/outreach-engine/packages/outreach-contracts/src/sql.ts b/outreach-engine/packages/outreach-contracts/src/sql.ts
new file mode 100644
index 0000000..fcc178e
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/sql.ts
@@ -0,0 +1,26 @@
+/**
+ * Synchronous SQLite-dialect database port. `@splitin/outreach-store-sqlite` provides drivers and
+ * migrations; the core and importer write their queries against this port.
+ *
+ * Transactions are synchronous on purpose: a transaction can never span an `await`, so provider
+ * calls always happen outside them (BUILD_PLAN.md §6.6).
+ */
+export type SqlValue = string | number | bigint | null | Uint8Array;
+
+export interface SqlRunResult {
+ readonly changes: number;
+}
+
+export interface SqlStatement {
+ run(...params: SqlValue[]): SqlRunResult;
+ get>(...params: SqlValue[]): T | undefined;
+ all>(...params: SqlValue[]): T[];
+}
+
+export interface SqlDatabase {
+ exec(sql: string): void;
+ prepare(sql: string): SqlStatement;
+ /** Outermost call runs `BEGIN IMMEDIATE`; nested calls use savepoints. `fn` must be synchronous. */
+ transaction(fn: () => T): T;
+ close(): void;
+}
diff --git a/outreach-engine/packages/outreach-contracts/src/version.test.ts b/outreach-engine/packages/outreach-contracts/src/version.test.ts
new file mode 100644
index 0000000..a6121f9
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/version.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest';
+import { OUTREACH_API_VERSION, isSupportedApiVersion } from './version';
+
+describe('isSupportedApiVersion', () => {
+ it('accepts the current API version', () => {
+ expect(isSupportedApiVersion(OUTREACH_API_VERSION)).toBe(true);
+ });
+
+ it('rejects other versions and non-strings', () => {
+ expect(isSupportedApiVersion('outreach.splitin.net/v1')).toBe(false);
+ expect(isSupportedApiVersion(undefined)).toBe(false);
+ expect(isSupportedApiVersion(1)).toBe(false);
+ });
+});
diff --git a/outreach-engine/packages/outreach-contracts/src/version.ts b/outreach-engine/packages/outreach-contracts/src/version.ts
new file mode 100644
index 0000000..2a1f8ed
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/src/version.ts
@@ -0,0 +1,11 @@
+/**
+ * Playbook and API version this contracts package describes.
+ * Every playbook and API payload carries this value in `apiVersion`.
+ */
+export const OUTREACH_API_VERSION = 'outreach.splitin.net/v1alpha1' as const;
+
+export type OutreachApiVersion = typeof OUTREACH_API_VERSION;
+
+export function isSupportedApiVersion(value: unknown): value is OutreachApiVersion {
+ return value === OUTREACH_API_VERSION;
+}
diff --git a/outreach-engine/packages/outreach-contracts/tsconfig.json b/outreach-engine/packages/outreach-contracts/tsconfig.json
new file mode 100644
index 0000000..585a92d
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "outDir": "dist" },
+ "include": ["src"]
+}
diff --git a/outreach-engine/packages/outreach-contracts/tsup.config.ts b/outreach-engine/packages/outreach-contracts/tsup.config.ts
new file mode 100644
index 0000000..458d1fd
--- /dev/null
+++ b/outreach-engine/packages/outreach-contracts/tsup.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig({
+ entry: ['src/index.ts'],
+ format: ['esm', 'cjs'],
+ dts: true,
+ sourcemap: true,
+ clean: true,
+ target: 'node22',
+ // node:sqlite exists only with the protocol prefix.
+ removeNodeProtocol: false,
+});
diff --git a/outreach-engine/packages/outreach-core/LICENSE b/outreach-engine/packages/outreach-core/LICENSE
new file mode 100644
index 0000000..62050eb
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2026 SplitInTech
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/outreach-engine/packages/outreach-core/README.md b/outreach-engine/packages/outreach-core/README.md
new file mode 100644
index 0000000..4a08a1a
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/README.md
@@ -0,0 +1,13 @@
+# @splitin/outreach-core
+
+Outreach engine core: durable action execution, campaign domain, policy and inbound processing.
+
+Development version 0.0.0; not yet a published release. Requires Node 22.13+.
+See the [engine README](../../README.md), [build plan](../../BUILD_PLAN.md),
+[runbook](../../docs/runbook.md) and [release gates](../../docs/release.md).
+
+Build from the workspace root with `npm run build`; verify with `npm run check`.
+The engine keeps human approval, suppression and live-send gates in the core.
+Do not call provider send methods outside the durable execution pipeline.
+
+MIT license.
diff --git a/outreach-engine/packages/outreach-core/package.json b/outreach-engine/packages/outreach-core/package.json
new file mode 100644
index 0000000..0f6cf83
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@splitin/outreach-core",
+ "version": "0.0.0",
+ "description": "Outreach engine core: durable action execution, campaign domain, policy and inbound processing.",
+ "license": "MIT",
+ "author": "SplitInTech",
+ "homepage": "https://github.com/splitintech/open-internal-tools/tree/main/outreach-engine#readme",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/splitintech/open-internal-tools.git",
+ "directory": "outreach-engine/packages/outreach-core"
+ },
+ "type": "module",
+ "sideEffects": false,
+ "engines": {
+ "node": ">=22.13"
+ },
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ }
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "package.json"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "tsup",
+ "typecheck": "tsc --noEmit -p tsconfig.json"
+ },
+ "dependencies": {
+ "@splitin/outreach-contracts": "0.0.0",
+ "luxon": "^3.7.2",
+ "yaml": "^2.9.1",
+ "zod": "^4.6.5"
+ },
+ "devDependencies": {
+ "@splitin/outreach-fakes": "0.0.0",
+ "@splitin/outreach-store-sqlite": "0.0.0",
+ "@types/luxon": "^3.7.5"
+ }
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/access.ts b/outreach-engine/packages/outreach-core/src/domain/access.ts
new file mode 100644
index 0000000..926dac6
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/access.ts
@@ -0,0 +1,108 @@
+import { randomBytes } from 'node:crypto';
+import { safeEqualHex, sha256Hex, ulid, type SqlDatabase } from '@splitin/outreach-contracts';
+import type { SendGate } from '../execution/types';
+import { ForbiddenError, NotFoundError, ROLES, audit, requireRole, type AuthContext, type Role, type Surface } from './auth';
+import type { DomainEnv } from './env';
+import { readJurisdictionPolicy } from './jurisdictions';
+
+const TOKEN_RE = /^oet_([0-9A-HJKMNP-TV-Z]{26})\.([A-Za-z0-9_-]{43})$/;
+
+export interface CreatedToken {
+ readonly id: string;
+ /** Shown once. Only a SHA-256 of the secret part is stored. */
+ readonly token: string;
+ readonly expiresAt: number;
+}
+
+/**
+ * Issues a bearer token for a principal (BUILD_PLAN.md §11.2). The token can never carry more than the
+ * principal's own roles, and `roleCeiling` narrows it further (e.g. a read-only dashboard token).
+ */
+export function createApiToken(
+ env: DomainEnv,
+ ctx: AuthContext,
+ input: { principalId: string; name: string; roleCeiling: Role; ttlDays: number },
+): CreatedToken {
+ requireRole(ctx, 'admin');
+ if (input.ttlDays < 1 || input.ttlDays > 366) throw new Error('ttlDays must be between 1 and 366');
+ return env.db.transaction(() => {
+ const now = env.now();
+ const principal = env.db.prepare('SELECT id FROM principals WHERE workspace_id = ? AND id = ?').get(ctx.workspaceId, input.principalId);
+ if (!principal) throw new NotFoundError(`principal ${input.principalId}`);
+ const id = ulid(now);
+ const secret = randomBytes(32).toString('base64url');
+ const expiresAt = now + input.ttlDays * 86_400_000;
+ env.db.prepare(
+ `INSERT INTO api_tokens (id, workspace_id, principal_id, name, secret_sha256, role_ceiling, created_at, expires_at)
+ VALUES (?,?,?,?,?,?,?,?)`,
+ ).run(id, ctx.workspaceId, input.principalId, input.name, sha256Hex(secret), input.roleCeiling, now, expiresAt);
+ audit(env.db, ctx, now, 'api_token', id, 'created', { principalId: input.principalId, name: input.name, roleCeiling: input.roleCeiling, expiresAt });
+ return { id, token: `oet_${id}.${secret}`, expiresAt };
+ });
+}
+
+export function revokeApiToken(env: DomainEnv, ctx: AuthContext, tokenId: string): void {
+ requireRole(ctx, 'admin');
+ env.db.transaction(() => {
+ const now = env.now();
+ const changed = env.db.prepare('UPDATE api_tokens SET revoked_at = ? WHERE workspace_id = ? AND id = ? AND revoked_at IS NULL').run(now, ctx.workspaceId, tokenId);
+ if (changed.changes !== 1) throw new NotFoundError(`active token ${tokenId}`);
+ audit(env.db, ctx, now, 'api_token', tokenId, 'revoked');
+ });
+}
+
+interface TokenRow {
+ workspace_id: string;
+ principal_id: string;
+ secret_sha256: string;
+ role_ceiling: Role;
+ expires_at: number;
+ revoked_at: number | null;
+ roles: string;
+}
+
+/** Resolves a bearer token to an AuthContext. Every failure is the same ForbiddenError (no oracle). */
+export function authenticateToken(db: SqlDatabase, token: string, source: Surface, traceId: string, now: number): AuthContext {
+ const match = TOKEN_RE.exec(token.trim());
+ const denied = new ForbiddenError('invalid or expired token');
+ if (!match) throw denied;
+ const [, id, secret] = match;
+ const row = db
+ .prepare(
+ `SELECT t.workspace_id, t.principal_id, t.secret_sha256, t.role_ceiling, t.expires_at, t.revoked_at, p.roles
+ FROM api_tokens t JOIN principals p ON p.id = t.principal_id WHERE t.id = ?`,
+ )
+ .get(id ?? '');
+ if (!row || !safeEqualHex(sha256Hex(secret ?? ''), row.secret_sha256) || row.revoked_at !== null || now > row.expires_at) throw denied;
+ // Roles are cumulative, so the effective role is the lower of the principal's highest role and the ceiling.
+ const highest = Math.max(-1, ...(JSON.parse(row.roles) as string[]).map((role) => ROLES.indexOf(role as Role)));
+ const effective = Math.min(highest, ROLES.indexOf(row.role_ceiling));
+ db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(now, id ?? '');
+ const role = ROLES[effective];
+ return { workspaceId: row.workspace_id, principalId: row.principal_id, roles: role ? [role] : [], source, traceId };
+}
+
+/** The live-send gate is workspace state, changed only by an admin through an audited action (plan §12). */
+export function readSendGate(db: SqlDatabase, workspaceId: string): SendGate {
+ const row = db.prepare('SELECT settings FROM workspaces WHERE id = ?').get<{ settings: string }>(workspaceId);
+ const gate = (JSON.parse(row?.settings ?? '{}') as { sendGate?: SendGate }).sendGate;
+ if (gate?.mode === 'open') return { mode: 'open' };
+ if (gate?.mode === 'allowlist' && Array.isArray(gate.allow)) return { mode: 'allowlist', allow: gate.allow.map(String) };
+ return { mode: 'allowlist', allow: [] };
+}
+
+export function setSendGate(env: DomainEnv, ctx: AuthContext, gate: SendGate, reason: string): void {
+ requireRole(ctx, 'admin');
+ if (gate.mode === 'open' && reason.trim().length < 10) throw new ForbiddenError('opening the live-send gate needs a written reason (at least 10 characters)');
+ // Decision D5: nothing goes to arbitrary recipients until someone has signed off where it may go.
+ if (gate.mode === 'open' && !readJurisdictionPolicy(env.db, ctx.workspaceId)) {
+ throw new ForbiddenError('opening the live-send gate needs a signed-off jurisdiction policy first (outreach jurisdiction set)');
+ }
+ env.db.transaction(() => {
+ const now = env.now();
+ const row = env.db.prepare('SELECT settings FROM workspaces WHERE id = ?').get<{ settings: string }>(ctx.workspaceId);
+ const settings = { ...(JSON.parse(row?.settings ?? '{}') as Record), sendGate: gate };
+ env.db.prepare('UPDATE workspaces SET settings = ? WHERE id = ?').run(JSON.stringify(settings), ctx.workspaceId);
+ audit(env.db, ctx, now, 'workspace', ctx.workspaceId, 'send_gate_changed', { gate, reason });
+ });
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/approvals.ts b/outreach-engine/packages/outreach-core/src/domain/approvals.ts
new file mode 100644
index 0000000..b450c8d
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/approvals.ts
@@ -0,0 +1,188 @@
+import { digestCanonical, ulid, type SqlDatabase } from '@splitin/outreach-contracts';
+import { transitionAction } from '../execution/actions-repo';
+import type { ActionRow, PreflightVerdict } from '../execution/types';
+import { ConflictError, ForbiddenError, NotFoundError, actorOf, audit, requireRole, type AuthContext } from './auth';
+import type { DomainEnv } from './env';
+import { stopEnrollment } from './suppressions';
+
+export type ApprovalScope = 'action' | 'batch' | 'campaign_version';
+export type ApprovalDecision = 'pending' | 'approved' | 'rejected' | 'revoked' | 'expired';
+
+export interface ApprovalRow {
+ id: string;
+ workspace_id: string;
+ scope: ApprovalScope;
+ subject_id: string;
+ operation_hash: string;
+ preview: string;
+ requested_by: string;
+ decided_by: string | null;
+ decision: ApprovalDecision;
+ reason: string | null;
+ created_at: number;
+ expires_at: number;
+ decided_at: number | null;
+ consumed_count: number;
+}
+
+export const APPROVAL_TTL_MS = 72 * 3_600_000;
+
+export interface BatchPreviewItem {
+ readonly id: string;
+ readonly contentHash: string;
+ readonly recipient: string | null;
+}
+
+export function batchHash(items: readonly BatchPreviewItem[]): string {
+ return digestCanonical([...items].map((item) => `${item.id}:${item.contentHash}`).sort());
+}
+
+export function createApproval(
+ db: SqlDatabase,
+ input: { workspaceId: string; scope: ApprovalScope; subjectId: string; operationHash: string; preview: unknown; requestedBy: string },
+ now: number,
+): string {
+ const id = ulid(now);
+ db.prepare(
+ `INSERT INTO approvals (id, workspace_id, scope, subject_id, operation_hash, preview, requested_by, decision, created_at, expires_at)
+ VALUES (?,?,?,?,?,?,?,'pending',?,?)`,
+ ).run(id, input.workspaceId, input.scope, input.subjectId, input.operationHash, JSON.stringify(input.preview), input.requestedBy, now, now + APPROVAL_TTL_MS);
+ return id;
+}
+
+export function loadApproval(db: SqlDatabase, workspaceId: string, id: string): ApprovalRow | undefined {
+ return db.prepare('SELECT * FROM approvals WHERE workspace_id = ? AND id = ?').get(workspaceId, id);
+}
+
+/** Preflight view of an action's approval: only an unexpired approval of this exact content passes. */
+export function approvalVerdict(db: SqlDatabase, action: ActionRow, now: number): PreflightVerdict {
+ if (!action.approval_id) return { kind: 'pass' };
+ const approval = loadApproval(db, action.workspace_id, action.approval_id);
+ if (!approval) return { kind: 'await_approval', reason: 'approval_missing' };
+ if (approval.decision === 'rejected') return { kind: 'cancel', reason: 'approval_rejected' };
+ if (approval.decision !== 'approved') return { kind: 'await_approval', reason: `approval_${approval.decision}` };
+ if (approval.scope === 'campaign_version') {
+ const version = db.prepare('SELECT version_hash FROM campaign_versions WHERE id = ?').get<{ version_hash: string | null }>(approval.subject_id);
+ return version?.version_hash === approval.operation_hash ? { kind: 'pass' } : { kind: 'await_approval', reason: 'approval_hash_mismatch' };
+ }
+ if (now > approval.expires_at) return { kind: 'await_approval', reason: 'approval_expired' };
+ if (approval.scope === 'action') {
+ return approval.operation_hash === action.content_hash ? { kind: 'pass' } : { kind: 'await_approval', reason: 'approval_hash_mismatch' };
+ }
+ const items = (JSON.parse(approval.preview) as { actions: BatchPreviewItem[] }).actions;
+ return items.some((item) => item.id === action.id && item.contentHash === action.content_hash)
+ ? { kind: 'pass' }
+ : { kind: 'await_approval', reason: 'approval_hash_mismatch' };
+}
+
+function workspaceSettings(db: SqlDatabase, workspaceId: string): { separationOfDuties?: boolean } {
+ const row = db.prepare('SELECT settings FROM workspaces WHERE id = ?').get<{ settings: string }>(workspaceId);
+ return row ? (JSON.parse(row.settings) as { separationOfDuties?: boolean }) : {};
+}
+
+export interface DecisionInput {
+ readonly approvalId: string;
+ readonly decision: 'approved' | 'rejected';
+ /** The hash the approver was shown; must still match (BUILD_PLAN.md §9.3). */
+ readonly operationHash: string;
+ readonly reason?: string;
+}
+
+export function decideApproval(env: DomainEnv, ctx: AuthContext, input: DecisionInput): ApprovalRow {
+ requireRole(ctx, 'approver');
+ const { db } = env;
+ return db.transaction(() => {
+ const now = env.now();
+ const approval = loadApproval(db, ctx.workspaceId, input.approvalId);
+ if (!approval) throw new NotFoundError(`approval ${input.approvalId}`);
+ if (approval.decision !== 'pending') throw new ConflictError(`approval is ${approval.decision}`);
+ if (now > approval.expires_at) {
+ db.prepare(`UPDATE approvals SET decision = 'expired', decided_at = ? WHERE id = ?`).run(now, approval.id);
+ throw new ConflictError('approval expired; request a new one');
+ }
+ if (approval.operation_hash !== input.operationHash) throw new ConflictError('operation hash does not match what is pending');
+ if (workspaceSettings(db, ctx.workspaceId).separationOfDuties && approval.requested_by === ctx.principalId) {
+ throw new ForbiddenError('separation of duties: the requester cannot approve');
+ }
+ db.prepare('UPDATE approvals SET decision = ?, decided_by = ?, decided_at = ?, reason = ? WHERE id = ?')
+ .run(input.decision, ctx.principalId, now, input.reason ?? null, approval.id);
+ const actor = actorOf(ctx);
+ const waiting = db
+ .prepare(`SELECT * FROM scheduled_actions WHERE approval_id = ? AND state = 'awaiting_approval'`)
+ .all(approval.id);
+ for (const action of waiting) {
+ if (input.decision === 'approved') {
+ transitionAction(db, action, 'scheduled', now, actor, { state_reason: null });
+ } else {
+ transitionAction(db, action, 'cancelled', now, actor, { state_reason: 'approval_rejected' });
+ if (action.enrollment_id) stopEnrollment(db, action.enrollment_id, 'stopped', 'approval_rejected', actor, now);
+ }
+ }
+ audit(db, ctx, now, 'approval', approval.id, input.decision, { scope: approval.scope, actions: waiting.length, reason: input.reason ?? null });
+ return { ...approval, decision: input.decision, decided_by: ctx.principalId, decided_at: now };
+ });
+}
+
+export function revokeApproval(env: DomainEnv, ctx: AuthContext, approvalId: string, reason: string): void {
+ requireRole(ctx, 'approver');
+ env.db.transaction(() => {
+ const now = env.now();
+ const approval = loadApproval(env.db, ctx.workspaceId, approvalId);
+ if (!approval) throw new NotFoundError(`approval ${approvalId}`);
+ if (approval.decision !== 'approved' && approval.decision !== 'pending') throw new ConflictError(`approval is ${approval.decision}`);
+ env.db.prepare(`UPDATE approvals SET decision = 'revoked', reason = ?, decided_at = ? WHERE id = ?`).run(reason, now, approvalId);
+ audit(env.db, ctx, now, 'approval', approvalId, 'revoked', { reason });
+ });
+}
+
+/** Gathers a campaign's actions that wait without a live approval into one new batch approval. */
+export function requestBatchApproval(env: DomainEnv, ctx: AuthContext, campaignId: string): { approvalId: string; operationHash: string; count: number } | null {
+ requireRole(ctx, 'operator');
+ return env.db.transaction(() => {
+ const now = env.now();
+ const actions = env.db
+ .prepare(
+ `SELECT a.* FROM scheduled_actions a LEFT JOIN approvals p ON p.id = a.approval_id
+ WHERE a.workspace_id = ? AND a.campaign_id = ? AND a.state = 'awaiting_approval'
+ AND (p.id IS NULL OR p.decision <> 'pending' OR p.expires_at < ?)
+ ORDER BY a.due_at LIMIT 500`,
+ )
+ .all(ctx.workspaceId, campaignId, now);
+ if (!actions.length) return null;
+ const approvalId = createBatchApproval(env.db, ctx.workspaceId, campaignId, actions, ctx.principalId, now);
+ audit(env.db, ctx, now, 'approval', approvalId, 'requested', { scope: 'batch', count: actions.length });
+ const row = loadApproval(env.db, ctx.workspaceId, approvalId);
+ return { approvalId, operationHash: row?.operation_hash ?? '', count: actions.length };
+ });
+}
+
+export function createBatchApproval(
+ db: SqlDatabase,
+ workspaceId: string,
+ campaignId: string,
+ actions: readonly Pick[],
+ requestedBy: string,
+ now: number,
+): string {
+ const items: (BatchPreviewItem & { subject?: string })[] = actions.map((action) => ({
+ id: action.id,
+ contentHash: action.content_hash,
+ recipient: action.recipient_norm,
+ subject: (JSON.parse(action.payload) as { subject?: string }).subject,
+ }));
+ const approvalId = createApproval(
+ db,
+ { workspaceId, scope: 'batch', subjectId: campaignId, operationHash: batchHash(items), preview: { actions: items }, requestedBy },
+ now,
+ );
+ const update = db.prepare('UPDATE scheduled_actions SET approval_id = ? WHERE id = ?');
+ for (const item of items) update.run(approvalId, item.id);
+ return approvalId;
+}
+
+export function listApprovals(db: SqlDatabase, ctx: AuthContext, decision: ApprovalDecision = 'pending'): ApprovalRow[] {
+ requireRole(ctx, 'viewer');
+ return db
+ .prepare('SELECT * FROM approvals WHERE workspace_id = ? AND decision = ? ORDER BY created_at')
+ .all(ctx.workspaceId, decision);
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/auth.ts b/outreach-engine/packages/outreach-core/src/domain/auth.ts
new file mode 100644
index 0000000..4d051d3
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/auth.ts
@@ -0,0 +1,106 @@
+import { appendAudit, type SqlDatabase } from '@splitin/outreach-contracts';
+import type { Actor } from '../execution/actions-repo';
+
+/** Roles are cumulative: each includes everything below it (BUILD_PLAN.md §9.1). */
+export const ROLES = ['viewer', 'operator', 'approver', 'admin'] as const;
+export type Role = (typeof ROLES)[number];
+
+export type Surface = 'cli' | 'http' | 'mcp' | 'slack' | 'papr' | 'system' | 'test';
+
+/** Always derived from authenticated context by a surface, never from tool arguments or payloads. */
+export interface AuthContext {
+ readonly workspaceId: string;
+ readonly principalId: string;
+ readonly roles: readonly Role[];
+ readonly source: Surface;
+ readonly traceId: string;
+}
+
+export class ForbiddenError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'ForbiddenError';
+ }
+}
+
+export class NotFoundError extends Error {
+ constructor(what: string) {
+ super(`${what} not found`);
+ this.name = 'NotFoundError';
+ }
+}
+
+export class ConflictError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'ConflictError';
+ }
+}
+
+function rank(role: Role): number {
+ return ROLES.indexOf(role);
+}
+
+export function hasRole(ctx: AuthContext, required: Role): boolean {
+ return ctx.roles.some((role) => rank(role) >= rank(required));
+}
+
+export function requireRole(ctx: AuthContext, required: Role): void {
+ if (!hasRole(ctx, required)) throw new ForbiddenError(`${required} role required`);
+}
+
+export function actorOf(ctx: AuthContext): Actor {
+ return { kind: ctx.source === 'system' ? 'system' : 'principal', id: ctx.principalId, source: ctx.source, traceId: ctx.traceId };
+}
+
+export function audit(
+ db: SqlDatabase,
+ ctx: AuthContext,
+ now: number,
+ resourceKind: string,
+ resourceId: string,
+ action: string,
+ detail: Record = {},
+): void {
+ const actor = actorOf(ctx);
+ appendAudit(db, {
+ workspaceId: ctx.workspaceId,
+ at: now,
+ actorKind: actor.kind,
+ actorId: actor.id,
+ source: actor.source,
+ traceId: actor.traceId,
+ resourceKind,
+ resourceId,
+ action,
+ detail,
+ });
+}
+
+interface PrincipalRow {
+ id: string;
+ roles: string;
+}
+
+/**
+ * Resolves an authenticated external identity (e.g. "slack:T1:U2", "cli:alice") to a principal.
+ * Unknown identities get no roles and therefore can do nothing.
+ */
+export function authenticate(
+ db: SqlDatabase,
+ workspaceId: string,
+ externalRef: string,
+ source: Surface,
+ traceId: string,
+): AuthContext {
+ const row = db
+ .prepare('SELECT id, roles FROM principals WHERE workspace_id = ? AND external_ref = ?')
+ .get(workspaceId, externalRef);
+ if (!row) throw new ForbiddenError(`unknown principal ${externalRef}`);
+ const roles = (JSON.parse(row.roles) as string[]).filter((role): role is Role => (ROLES as readonly string[]).includes(role));
+ return { workspaceId, principalId: row.id, roles, source, traceId };
+}
+
+export function systemContext(workspaceId: string, traceId: string): AuthContext {
+ return { workspaceId, principalId: 'system', roles: ['admin'], source: 'system', traceId };
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/calendar.test.ts b/outreach-engine/packages/outreach-core/src/domain/calendar.test.ts
new file mode 100644
index 0000000..99211c9
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/calendar.test.ts
@@ -0,0 +1,74 @@
+import fc from 'fast-check';
+import { DateTime } from 'luxon';
+import { describe, expect, it } from 'vitest';
+import { addDuration, nextSlot, resolveZone, type SendWindow } from './calendar';
+
+const WINDOW: SendWindow = { timezone: 'recipient', fallback: 'America/New_York', days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], start: '09:30', end: '16:30' };
+const at = (iso: string, zone: string) => DateTime.fromISO(iso, { zone }).toMillis();
+const show = (ms: number, zone: string) => DateTime.fromMillis(ms, { zone }).toFormat("ccc yyyy-MM-dd'T'HH:mm");
+
+describe('nextSlot', () => {
+ it('keeps an instant already inside the window', () => {
+ const t = at('2025-03-04T10:00', 'America/New_York');
+ expect(nextSlot(t, WINDOW, 'America/New_York')).toBe(t);
+ });
+
+ it('moves early, late and weekend instants to the next opening', () => {
+ const zone = 'America/New_York';
+ expect(show(nextSlot(at('2025-03-04T07:00', zone), WINDOW, zone), zone)).toBe('Tue 2025-03-04T09:30');
+ expect(show(nextSlot(at('2025-03-04T17:00', zone), WINDOW, zone), zone)).toBe('Wed 2025-03-05T09:30');
+ expect(show(nextSlot(at('2025-03-07T16:45', zone), WINDOW, zone), zone)).toBe('Mon 2025-03-10T09:30');
+ });
+
+ it('skips holidays', () => {
+ const zone = 'Europe/Berlin';
+ const window = { ...WINDOW, holidays: ['2025-12-25', '2025-12-26'] };
+ expect(show(nextSlot(at('2025-12-24T18:00', zone), window, zone), zone)).toBe('Mon 2025-12-29T09:30');
+ });
+
+ it('handles the spring-forward gap and fall-back overlap', () => {
+ const zone = 'America/New_York';
+ const window: SendWindow = { ...WINDOW, days: ['Sun', 'Mon'], start: '02:30', end: '04:00' };
+ // 02:30 does not exist on 2025-03-09; luxon moves it forward to 03:30 local.
+ expect(show(nextSlot(at('2025-03-09T00:00', zone), window, zone), zone)).toBe('Sun 2025-03-09T03:30');
+ const fallBack = nextSlot(at('2025-11-02T00:00', zone), { ...window, start: '01:30', end: '03:00' }, zone);
+ expect(show(fallBack, zone)).toBe('Sun 2025-11-02T01:30');
+ });
+
+ it('resolves the recipient zone with a fallback', () => {
+ expect(resolveZone(WINDOW, 'Asia/Tokyo')).toBe('Asia/Tokyo');
+ expect(resolveZone(WINDOW, 'Mars/Olympus')).toBe('America/New_York');
+ expect(resolveZone({ ...WINDOW, timezone: 'Europe/London' }, 'Asia/Tokyo')).toBe('Europe/London');
+ });
+
+ it('always lands inside the window, never earlier, and is idempotent', () => {
+ const zones = ['America/New_York', 'Europe/Berlin', 'Asia/Kolkata', 'Australia/Lord_Howe', 'Pacific/Chatham', 'America/Sao_Paulo'];
+ fc.assert(
+ fc.property(fc.integer({ min: 1_700_000_000_000, max: 1_800_000_000_000 }), fc.constantFrom(...zones), (from, zone) => {
+ const slot = nextSlot(from, WINDOW, zone);
+ const local = DateTime.fromMillis(slot, { zone });
+ const minutes = local.hour * 60 + local.minute;
+ expect(slot).toBeGreaterThanOrEqual(from);
+ expect(local.weekday).toBeLessThanOrEqual(5);
+ expect(minutes).toBeGreaterThanOrEqual(9 * 60 + 30);
+ expect(minutes).toBeLessThan(16 * 60 + 30);
+ expect(nextSlot(slot, WINDOW, zone)).toBe(slot);
+ }),
+ { numRuns: 300 },
+ );
+ });
+});
+
+describe('addDuration', () => {
+ it('counts business days only on the business calendar', () => {
+ const zone = 'America/New_York';
+ const friday = at('2025-03-07T10:00', zone);
+ expect(show(addDuration(friday, 'P3D', 'business', WINDOW, zone), zone)).toBe('Wed 2025-03-12T10:00');
+ expect(show(addDuration(friday, 'P3D', 'calendar', WINDOW, zone), zone)).toBe('Mon 2025-03-10T10:00');
+ expect(show(addDuration(friday, 'PT4H', 'business', WINDOW, zone), zone)).toBe('Fri 2025-03-07T14:00');
+ });
+
+ it('rejects invalid durations', () => {
+ expect(() => addDuration(0, 'three days', 'business', WINDOW, 'UTC')).toThrow(/Invalid ISO duration/);
+ });
+});
diff --git a/outreach-engine/packages/outreach-core/src/domain/calendar.ts b/outreach-engine/packages/outreach-core/src/domain/calendar.ts
new file mode 100644
index 0000000..d498de2
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/calendar.ts
@@ -0,0 +1,84 @@
+import { DateTime, Duration, IANAZone } from 'luxon';
+
+export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const;
+export type Weekday = (typeof WEEKDAYS)[number];
+
+export interface SendWindow {
+ /** 'recipient' uses the contact's time zone, falling back to `fallback`. */
+ readonly timezone: string;
+ readonly fallback: string;
+ readonly days: readonly Weekday[];
+ /** "HH:mm", local to the resolved zone. start < end (windows do not cross midnight). */
+ readonly start: string;
+ readonly end: string;
+ /** ISO dates (yyyy-MM-dd) in the resolved zone on which nothing is sent. */
+ readonly holidays?: readonly string[];
+}
+
+export function isValidZone(zone: string | null | undefined): zone is string {
+ return !!zone && IANAZone.isValidZone(zone);
+}
+
+export function resolveZone(window: SendWindow, contactZone: string | null | undefined): string {
+ if (window.timezone === 'recipient') return isValidZone(contactZone) ? contactZone : window.fallback;
+ return window.timezone;
+}
+
+function minutesOf(hhmm: string): number {
+ const [h, m] = hhmm.split(':').map(Number);
+ return (h ?? 0) * 60 + (m ?? 0);
+}
+
+function allowedDay(dt: DateTime, window: SendWindow): boolean {
+ const day = WEEKDAYS[dt.weekday - 1];
+ return !!day && window.days.includes(day) && !(window.holidays ?? []).includes(dt.toISODate() ?? '');
+}
+
+function atMinutes(day: DateTime, minutes: number): DateTime {
+ // Luxon moves times that fall into a DST gap forward, so this never lands on a nonexistent instant.
+ return day.startOf('day').plus({ minutes });
+}
+
+/** The earliest instant >= `from` inside the window, in the resolved zone. */
+export function nextSlot(from: number, window: SendWindow, zone: string): number {
+ const start = minutesOf(window.start);
+ const end = minutesOf(window.end);
+ let dt = DateTime.fromMillis(from, { zone });
+ for (let i = 0; i < 400; i += 1) {
+ if (allowedDay(dt, window)) {
+ const open = atMinutes(dt, start);
+ const close = atMinutes(dt, end);
+ if (dt < open) return open.toMillis();
+ if (dt < close) return dt.toMillis();
+ }
+ dt = dt.plus({ days: 1 }).startOf('day');
+ }
+ throw new Error('Send window never opens (no allowed days within 400 days)');
+}
+
+/**
+ * Adds an ISO-8601 duration. With the business calendar, whole days count only allowed window days
+ * (P3D = three sending days later, same local time); hours and minutes are added as elapsed time.
+ */
+export function addDuration(from: number, iso: string, calendar: 'business' | 'calendar', window: SendWindow, zone: string): number {
+ const duration = Duration.fromISO(iso);
+ if (!duration.isValid) throw new Error(`Invalid ISO duration ${iso}`);
+ const days = Math.floor(duration.as('days'));
+ const rest = duration.minus({ days }).as('milliseconds');
+ let dt = DateTime.fromMillis(from, { zone });
+ if (calendar === 'calendar') return dt.plus({ days }).toMillis() + rest;
+ let counted = 0;
+ while (counted < days) {
+ dt = dt.plus({ days: 1 });
+ if (allowedDay(dt, window)) counted += 1;
+ }
+ return dt.toMillis() + rest;
+}
+
+export function isValidDuration(iso: string): boolean {
+ return /^P/.test(iso) && Duration.fromISO(iso).isValid;
+}
+
+export function durationMs(iso: string): number {
+ return Duration.fromISO(iso).as('milliseconds');
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/campaigns.test.ts b/outreach-engine/packages/outreach-core/src/domain/campaigns.test.ts
new file mode 100644
index 0000000..6a582d8
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/campaigns.test.ts
@@ -0,0 +1,235 @@
+import { DateTime } from 'luxon';
+import { describe, expect, it } from 'vitest';
+import { verifyAuditChain } from '@splitin/outreach-contracts';
+import { campaignStatus, listReview } from '../engine';
+import { ConflictError, ForbiddenError } from './auth';
+import { decideApproval, listApprovals, requestBatchApproval, revokeApproval } from './approvals';
+import { commitActivation, createCampaign, prepareActivation, setCampaignStatus } from './campaigns';
+import { PLAYBOOK, makeDomainEnv, type DomainTestEnv } from './domain.test-util';
+import { listManualTasks, recordManualOutcome, setEnrollmentState, suppress } from './operations';
+
+const DAY = 86_400_000;
+
+async function activate(env: DomainTestEnv, playbook = PLAYBOOK) {
+ const { campaignId } = createCampaign(env.engine, env.operator, { name: 'Intro', playbook, providerAccountId: env.accountId });
+ const preview = prepareActivation(env.engine, env.operator, campaignId);
+ if (preview.approvalId) decideApproval(env.engine, env.approver, { approvalId: preview.approvalId, decision: 'approved', operationHash: preview.operationHash });
+ const result = commitActivation(env.engine, env.operator, { campaignId, operationHash: preview.operationHash });
+ return { campaignId, preview, ...result };
+}
+
+function approveBatch(env: DomainTestEnv, approvalId: string | null) {
+ if (!approvalId) throw new Error('expected a batch approval');
+ const approval = listApprovals(env.engine.db, env.approver).find((row) => row.id === approvalId);
+ if (!approval) throw new Error('batch approval not pending');
+ decideApproval(env.engine, env.approver, { approvalId, decision: 'approved', operationHash: approval.operation_hash });
+}
+
+function enrollmentStatuses(env: DomainTestEnv, campaignId: string) {
+ return campaignStatus(env.engine, env.viewer, campaignId).enrollments;
+}
+
+describe('campaign lifecycle', () => {
+ it('runs import-to-completion: approvals, window, threaded follow-up, manual task, completion', async () => {
+ const env = await makeDomainEnv();
+ env.contact('ada@example.org');
+ env.contact('grace@example.net', { fullName: 'Grace Hopper', firstName: 'Grace' });
+ env.contact('noconsent@example.org', { consentBasis: 'unknown' });
+ env.contact('blocked@example.org');
+ suppress(env.engine, env.operator, { scope: 'global', value: 'blocked@example.org', reason: 'do_not_contact' });
+
+ const { campaignId, preview, enrolled, batchApprovalId } = await activate(env);
+ expect(preview.audienceCount).toBe(2);
+ expect(preview.excluded).toMatchObject({ suppressed: 1, noConsent: 1 });
+ expect(enrolled).toBe(2);
+
+ // The first batch waits for its own approval.
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(0);
+ approveBatch(env, batchApprovalId);
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(2);
+ const intro = env.fake.deliveries.find((d) => d.to[0] === 'ada@example.org');
+ expect(intro?.subject).toBe('Quick question, Ada');
+ expect(intro?.headers['List-Unsubscribe']).toMatch(/^]+>, $/);
+ expect(intro?.headers['List-Unsubscribe-Post']).toBe('List-Unsubscribe=One-Click');
+
+ // Three business days later (Tue -> Fri), the threaded follow-up goes out.
+ env.advance(2 * DAY);
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(2);
+ env.advance(DAY);
+ await env.drain();
+ const followups = env.fake.deliveries.filter((d) => d.subject === 'Re: Quick question, Ada');
+ expect(followups).toHaveLength(1);
+ expect(followups[0]?.providerThreadId).toBe(intro?.providerThreadId);
+
+ // The social touch is a human task, then the closing email follows once it is done.
+ const tasks = listManualTasks(env.engine, env.operator);
+ expect(tasks).toHaveLength(2);
+ expect(tasks[0]?.draft_text).toMatch(/^Hi (Ada|Grace), sent you an email about Analytical Engines\.$/);
+ for (const task of tasks) recordManualOutcome(env.engine, env.operator, task.id, 'done', 'sent from my own account');
+ await env.drain();
+ expect(env.fake.deliveries.filter((d) => d.subject.startsWith('Re:'))).toHaveLength(4);
+ expect(enrollmentStatuses(env, campaignId)).toEqual({ completed: 2 });
+ expect(verifyAuditChain(env.engine.db).ok).toBe(true);
+ });
+
+ it('sends only inside the recipient window', async () => {
+ const saturday = DateTime.fromISO('2025-03-08T12:00', { zone: 'America/New_York' }).toMillis();
+ const env = await makeDomainEnv({ start: saturday });
+ env.contact('tokyo@example.org', { timezone: 'Asia/Tokyo' });
+ approveBatch(env, (await activate(env)).batchApprovalId);
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(0);
+ env.setNow(DateTime.fromISO('2025-03-10T09:05', { zone: 'Asia/Tokyo' }).toMillis());
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(1);
+ });
+
+ it('refuses stale previews and unapproved activation', async () => {
+ const env = await makeDomainEnv();
+ env.contact('ada@example.org');
+ const { campaignId } = createCampaign(env.engine, env.operator, { name: 'x', playbook: PLAYBOOK, providerAccountId: env.accountId });
+ const first = prepareActivation(env.engine, env.operator, campaignId);
+ expect(() => commitActivation(env.engine, env.operator, { campaignId, operationHash: first.operationHash })).toThrow(ForbiddenError);
+ env.contact('late@example.org');
+ const second = prepareActivation(env.engine, env.operator, campaignId);
+ expect(second.operationHash).not.toBe(first.operationHash);
+ expect(() => decideApproval(env.engine, env.approver, { approvalId: second.approvalId ?? '', decision: 'approved', operationHash: first.operationHash })).toThrow(ConflictError);
+ expect(() => decideApproval(env.engine, env.operator, { approvalId: second.approvalId ?? '', decision: 'approved', operationHash: second.operationHash })).toThrow(ForbiddenError);
+ });
+
+ it('enforces separation of duties when the workspace asks for it', async () => {
+ const env = await makeDomainEnv();
+ env.engine.db.prepare(`UPDATE workspaces SET settings = '{"separationOfDuties":true}'`).run();
+ env.contact('ada@example.org');
+ const { campaignId } = createCampaign(env.engine, env.admin, { name: 'x', playbook: PLAYBOOK, providerAccountId: env.accountId });
+ const preview = prepareActivation(env.engine, env.admin, campaignId);
+ expect(() => decideApproval(env.engine, env.admin, { approvalId: preview.approvalId ?? '', decision: 'approved', operationHash: preview.operationHash })).toThrow(/separation of duties/);
+ decideApproval(env.engine, env.approver, { approvalId: preview.approvalId ?? '', decision: 'approved', operationHash: preview.operationHash });
+ });
+
+ it('rejecting the first batch cancels it and stops those enrollments', async () => {
+ const env = await makeDomainEnv();
+ env.contact('ada@example.org');
+ const { campaignId, batchApprovalId } = await activate(env);
+ const approval = listApprovals(env.engine.db, env.approver).find((row) => row.id === batchApprovalId);
+ decideApproval(env.engine, env.approver, { approvalId: batchApprovalId ?? '', decision: 'rejected', operationHash: approval?.operation_hash ?? '', reason: 'copy too long' });
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(0);
+ expect(enrollmentStatuses(env, campaignId)).toEqual({ stopped: 1 });
+ });
+
+ it('requires per-action approval under every_action', async () => {
+ const env = await makeDomainEnv();
+ env.contact('ada@example.org');
+ await activate(env, PLAYBOOK.replace('approval: first_batch_then_campaign', 'approval: every_action'));
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(0);
+ const pending = listApprovals(env.engine.db, env.approver).filter((row) => row.scope === 'action');
+ expect(pending).toHaveLength(1);
+ decideApproval(env.engine, env.approver, { approvalId: pending[0]?.id ?? '', decision: 'approved', operationHash: pending[0]?.operation_hash ?? '' });
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(1);
+ });
+
+ it('revoking the campaign approval holds later steps until a new batch is approved', async () => {
+ const env = await makeDomainEnv();
+ env.contact('ada@example.org');
+ const { campaignId, preview, batchApprovalId } = await activate(env);
+ approveBatch(env, batchApprovalId);
+ await env.drain();
+ revokeApproval(env.engine, env.approver, preview.approvalId ?? '', 'legal review');
+ env.advance(3 * DAY);
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(1);
+ const request = requestBatchApproval(env.engine, env.operator, campaignId);
+ expect(request?.count).toBe(1);
+ decideApproval(env.engine, env.approver, { approvalId: request?.approvalId ?? '', decision: 'approved', operationHash: request?.operationHash ?? '' });
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(2);
+ });
+});
+
+describe('stops and holds', () => {
+ it('a suppression added mid-sequence cancels pending steps and stops the enrollment', async () => {
+ const env = await makeDomainEnv();
+ env.contact('ada@example.org');
+ const { campaignId, batchApprovalId } = await activate(env);
+ approveBatch(env, batchApprovalId);
+ await env.drain();
+ suppress(env.engine, env.operator, { scope: 'domain', value: 'example.org', reason: 'do_not_contact' });
+ env.advance(3 * DAY);
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(1);
+ expect(enrollmentStatuses(env, campaignId)).toEqual({ stopped: 1 });
+ });
+
+ it('pausing a campaign or enrollment defers sends until resumed', async () => {
+ const env = await makeDomainEnv();
+ const contactId = env.contact('ada@example.org');
+ const { campaignId, batchApprovalId } = await activate(env);
+ approveBatch(env, batchApprovalId);
+ setCampaignStatus(env.engine, env.operator, campaignId, 'paused', 'holiday');
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(0);
+ setCampaignStatus(env.engine, env.operator, campaignId, 'active', 'back');
+ env.advance(16 * 60_000);
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(1);
+ const enrollment = env.engine.db.prepare('SELECT id FROM enrollments WHERE contact_id = ?').get<{ id: string }>(contactId);
+ setEnrollmentState(env.engine, env.operator, enrollment?.id ?? '', 'pause', 'asked to wait');
+ env.advance(3 * DAY);
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(1);
+ });
+
+ it('a hard bounce suppresses the address and marks the enrollment bounced', async () => {
+ const env = await makeDomainEnv();
+ env.contact('gone@example.org');
+ env.fake.script({ kind: 'reject', errorClass: 'hard_bounce' });
+ const { campaignId, batchApprovalId } = await activate(env);
+ approveBatch(env, batchApprovalId);
+ await env.drain();
+ expect(enrollmentStatuses(env, campaignId)).toEqual({ bounced: 1 });
+ const suppression = env.engine.db.prepare('SELECT scope, reason FROM suppressions WHERE value_norm = ?').get('gone@example.org');
+ expect(suppression).toEqual({ scope: 'channel', reason: 'hard_bounce' });
+ });
+
+ it('a contact missing a required template value is stopped with a reason, never sent a broken email', async () => {
+ const env = await makeDomainEnv();
+ env.contact('nofirst@example.org', { firstName: '' });
+ const { campaignId } = await activate(env);
+ expect(enrollmentStatuses(env, campaignId)).toEqual({ error: 1 });
+ const reason = env.engine.db.prepare('SELECT stop_reason FROM enrollments').get<{ stop_reason: string }>();
+ expect(reason?.stop_reason).toBe('template_missing:first_name');
+ });
+
+ it('the default send gate parks real sends for review', async () => {
+ const env = await makeDomainEnv({ sendGate: { mode: 'allowlist', allow: [] } });
+ env.contact('ada@example.org');
+ approveBatch(env, (await activate(env)).batchApprovalId);
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(0);
+ expect(listReview(env.engine, env.viewer).map((row) => row.state_reason)).toEqual(['not_in_live_allowlist']);
+ });
+});
+
+
+it('activation confirmation exposes actual content and becomes stale when personalization changes', async () => {
+ const env = await makeDomainEnv();
+ const contactId = env.contact('ada@example.org');
+ const { campaignId } = createCampaign(env.engine, env.operator, { name: 'Preview', playbook: PLAYBOOK, providerAccountId: env.accountId });
+ const preview = prepareActivation(env.engine, env.operator, campaignId);
+ expect(preview.templates[0]?.body_text).toContain('{{first_name}}');
+ expect(preview.sender.address).toBe('sender@example.com');
+ expect(preview.policy.window.start).toBe('09:00');
+ expect(preview.steps.some((step) => step.type === 'wait')).toBe(true);
+ if (preview.approvalId) decideApproval(env.engine, env.approver, { approvalId: preview.approvalId, decision: 'approved', operationHash: preview.operationHash });
+ env.engine.db.prepare('UPDATE contacts SET first_name = ? WHERE id = ?').run('Changed', contactId);
+ expect(() => commitActivation(env.engine, env.operator, { campaignId, operationHash: preview.operationHash })).toThrow('content changed');
+ expect(env.fake.deliveries).toHaveLength(0);
+ expect(campaignStatus(env.engine, env.viewer, campaignId).status).toBe('draft');
+ env.engine.db.close();
+});
diff --git a/outreach-engine/packages/outreach-core/src/domain/campaigns.ts b/outreach-engine/packages/outreach-core/src/domain/campaigns.ts
new file mode 100644
index 0000000..8a89eb1
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/campaigns.ts
@@ -0,0 +1,250 @@
+import { digestCanonical, ulid, type SenderIdentity } from '@splitin/outreach-contracts';
+import { ConflictError, ForbiddenError, NotFoundError, actorOf, audit, requireRole, type AuthContext } from './auth';
+import { createApproval, createBatchApproval, loadApproval } from './approvals';
+import { compileIssues } from './compile';
+import { loadCampaign, type CampaignVersionRow, type DomainEnv } from './env';
+import { assertMaterializationRuntime, loadStepContext, materializeNext } from './materialize';
+import { PlaybookError, parsePlaybook, type Audience, type Policy, type Step } from './playbook';
+import { jurisdictionVerdict, readJurisdictionPolicy } from './jurisdictions';
+import { isSuppressed } from './suppressions';
+import { findTemplate, type TemplateRow } from './templates';
+
+export interface CreateCampaignInput {
+ readonly name: string;
+ readonly playbook: string | unknown;
+ readonly providerAccountId: string;
+}
+
+export function createCampaign(env: DomainEnv, ctx: AuthContext, input: CreateCampaignInput): { campaignId: string; versionId: string } {
+ requireRole(ctx, 'operator');
+ const playbook = parsePlaybook(input.playbook);
+ const issues = compileIssues(env, ctx.workspaceId, playbook, input.providerAccountId);
+ if (issues.length) throw new PlaybookError(issues);
+ const { db } = env;
+ return db.transaction(() => {
+ const now = env.now();
+ const spec = { steps: playbook.spec.steps };
+ const specHash = digestCanonical(spec);
+ let sequence = db.prepare('SELECT id FROM sequence_versions WHERE workspace_id = ? AND spec_hash = ?').get<{ id: string }>(ctx.workspaceId, specHash);
+ if (!sequence) {
+ const latest = db.prepare('SELECT MAX(version) AS v FROM sequence_versions WHERE workspace_id = ? AND name = ?')
+ .get<{ v: number | null }>(ctx.workspaceId, playbook.metadata.name);
+ sequence = { id: ulid(now) };
+ db.prepare('INSERT INTO sequence_versions (id, workspace_id, name, version, spec, spec_hash, created_at) VALUES (?,?,?,?,?,?,?)')
+ .run(sequence.id, ctx.workspaceId, playbook.metadata.name, (latest?.v ?? 0) + 1, JSON.stringify(spec), specHash, now);
+ }
+ const campaignId = ulid(now);
+ const versionId = ulid(now);
+ db.prepare(`INSERT INTO campaigns (id, workspace_id, name, purpose, status, created_at, updated_at) VALUES (?,?,?,?,'draft',?,?)`)
+ .run(campaignId, ctx.workspaceId, input.name, playbook.spec.purpose, now, now);
+ db.prepare(
+ `INSERT INTO campaign_versions (id, campaign_id, version, sequence_version_id, provider_account_id, policy, policy_hash, audience)
+ VALUES (?,?,1,?,?,?,?,?)`,
+ ).run(versionId, campaignId, sequence.id, input.providerAccountId, JSON.stringify(playbook.spec.policy), digestCanonical(playbook.spec.policy), JSON.stringify(playbook.spec.audience));
+ audit(db, ctx, now, 'campaign', campaignId, 'created', { name: input.name, playbook: playbook.metadata.name, specHash });
+ return { campaignId, versionId };
+ });
+}
+
+interface MemberCandidate {
+ contact_id: string;
+ contact_point_id: string;
+ email: string;
+ consent_basis: string | null;
+ jurisdiction: string | null;
+}
+
+function draftVersion(env: DomainEnv, campaignId: string): CampaignVersionRow {
+ const version = env.db
+ .prepare('SELECT * FROM campaign_versions WHERE campaign_id = ? AND activated_at IS NULL ORDER BY version DESC LIMIT 1')
+ .get(campaignId);
+ if (!version) throw new ConflictError('campaign has no draft version to activate');
+ return version;
+}
+
+function candidates(env: DomainEnv, workspaceId: string, audience: Audience): MemberCandidate[] {
+ const base = `SELECT c.id AS contact_id, cp.id AS contact_point_id, cp.value_norm AS email, cp.consent_basis, cp.jurisdiction
+ FROM contacts c JOIN contact_points cp ON cp.contact_id = c.id AND cp.kind = 'email'
+ WHERE c.workspace_id = ? AND c.merged_into_id IS NULL`;
+ if (audience.source === 'all') return env.db.prepare(`${base} ORDER BY c.id`).all(workspaceId);
+ if ('importBatch' in audience.source) {
+ return env.db
+ .prepare(`${base} AND c.id IN (SELECT contact_id FROM import_rows WHERE batch_id = ? AND contact_id IS NOT NULL) ORDER BY c.id`)
+ .all(workspaceId, audience.source.importBatch);
+ }
+ const wanted = new Set(audience.source.contactIds);
+ return env.db.prepare(`${base} ORDER BY c.id`).all(workspaceId).filter((row) => wanted.has(row.contact_id));
+}
+
+export interface ActivationPreview {
+ readonly operationId: string;
+ readonly operationHash: string;
+ readonly requiresApproval: boolean;
+ readonly approvalId: string | null;
+ readonly audienceCount: number;
+ readonly excluded: { suppressed: number; noConsent: number; jurisdiction: number; alreadyEnrolled: number; duplicateContact: number };
+ readonly sampleRecipients: readonly string[];
+ readonly steps: readonly Step[];
+ readonly templates: readonly TemplateRow[];
+ readonly sender: SenderIdentity;
+ readonly policy: Policy;
+ readonly audienceHash: string;
+}
+
+
+function activationContent(env: DomainEnv, workspaceId: string, version: CampaignVersionRow, steps: readonly Step[]) {
+ const templates = [...new Set(steps.flatMap((step) => 'template' in step ? [step.template] : []))]
+ .map((ref) => {
+ const template = findTemplate(env.db, workspaceId, ref);
+ if (!template) throw new ConflictError('activation template no longer exists');
+ return template;
+ });
+ const account = env.db.prepare('SELECT sender_identity FROM provider_accounts WHERE workspace_id = ? AND id = ?')
+ .get<{ sender_identity: string }>(workspaceId, version.provider_account_id);
+ if (!account) throw new NotFoundError('provider account');
+ const sender = JSON.parse(account.sender_identity) as SenderIdentity;
+ const contacts = env.db.prepare(`SELECT c.*, cp.value_norm, cp.consent_basis, cp.jurisdiction
+ FROM audience_members a JOIN contacts c ON c.id = a.contact_id JOIN contact_points cp ON cp.id = a.contact_point_id
+ WHERE a.campaign_version_id = ? AND c.workspace_id = ? ORDER BY a.contact_point_id`)
+ .all(version.id, workspaceId);
+ return { templates, sender, contacts };
+}
+function activationHash(version: CampaignVersionRow, steps: readonly Step[], content: ReturnType, audienceHash: string) {
+ return digestCanonical({ steps, policy: version.policy, audienceHash, account: version.provider_account_id, ...content });
+}
+
+/** Snapshots the audience, computes the exact version hash, and requests approval if the policy needs it. */
+export function prepareActivation(env: DomainEnv, ctx: AuthContext, campaignId: string): ActivationPreview {
+ requireRole(ctx, 'operator');
+ const { db } = env;
+ return db.transaction(() => {
+ const now = env.now();
+ const campaign = loadCampaign(db, ctx.workspaceId, campaignId);
+ if (!campaign) throw new NotFoundError(`campaign ${campaignId}`);
+ if (campaign.status !== 'draft') throw new ConflictError(`campaign is ${campaign.status}`);
+ const version = draftVersion(env, campaignId);
+ const audience = JSON.parse(version.audience) as Audience;
+ const excluded = { suppressed: 0, noConsent: 0, jurisdiction: 0, alreadyEnrolled: 0, duplicateContact: 0 };
+ const lawPolicy = readJurisdictionPolicy(db, ctx.workspaceId);
+ const members: MemberCandidate[] = [];
+ const seenContacts = new Set();
+ for (const row of candidates(env, ctx.workspaceId, audience)) {
+ if (seenContacts.has(row.contact_id)) {
+ excluded.duplicateContact += 1;
+ continue;
+ }
+ if (isSuppressed(db, ctx.workspaceId, row.email, version.provider_account_id)) excluded.suppressed += 1;
+ else if (audience.eligibility.includes('consent_or_legitimate_interest') && !['consent', 'legitimate_interest', 'existing_relationship'].includes(row.consent_basis ?? '')) excluded.noConsent += 1;
+ else if (!jurisdictionVerdict(lawPolicy, row.jurisdiction, row.consent_basis).ok) excluded.jurisdiction += 1;
+ else if (db.prepare(`SELECT 1 FROM enrollments WHERE workspace_id = ? AND contact_id = ? AND status IN ('active','paused') LIMIT 1`).get(ctx.workspaceId, row.contact_id)) excluded.alreadyEnrolled += 1;
+ else {
+ members.push(row);
+ seenContacts.add(row.contact_id);
+ }
+ }
+ db.prepare('DELETE FROM audience_members WHERE campaign_version_id = ?').run(version.id);
+ const insert = db.prepare('INSERT INTO audience_members (id, campaign_version_id, contact_id, contact_point_id, eligibility) VALUES (?,?,?,?,?)');
+ for (const member of members) insert.run(ulid(now), version.id, member.contact_id, member.contact_point_id, JSON.stringify({ consent: member.consent_basis, jurisdiction: member.jurisdiction }));
+ const audienceHash = digestCanonical(members.map((member) => member.contact_point_id).sort());
+ const sequence = db.prepare('SELECT spec, spec_hash FROM sequence_versions WHERE id = ?').get<{ spec: string; spec_hash: string }>(version.sequence_version_id);
+ const steps = (JSON.parse(sequence?.spec ?? '{"steps":[]}') as { steps: Step[] }).steps;
+ const content = activationContent(env, ctx.workspaceId, version, steps);
+ const { templates, sender } = content;
+ const versionHash = activationHash(version, steps, content, audienceHash);
+ const policy = JSON.parse(version.policy) as Policy;
+ let approvalId: string | null = null;
+ if (policy.approval !== 'none') {
+ db.prepare(`UPDATE approvals SET decision = 'expired', decided_at = ? WHERE scope = 'campaign_version' AND subject_id = ? AND decision = 'pending'`).run(now, version.id);
+ approvalId = createApproval(db, {
+ workspaceId: ctx.workspaceId,
+ scope: 'campaign_version',
+ subjectId: version.id,
+ operationHash: versionHash,
+ preview: { campaign: campaign.name, audienceCount: members.length, excluded, steps: steps.map((s) => ({ id: s.id, type: s.type })), templates },
+ requestedBy: ctx.principalId,
+ }, now);
+ }
+ db.prepare('UPDATE campaign_versions SET audience_hash = ?, version_hash = ?, approval_id = ? WHERE id = ?').run(audienceHash, versionHash, approvalId, version.id);
+ audit(db, ctx, now, 'campaign', campaignId, 'activation_prepared', { versionHash, audience: members.length, excluded });
+ return {
+ operationId: version.id,
+ operationHash: versionHash,
+ requiresApproval: approvalId !== null,
+ approvalId,
+ audienceCount: members.length,
+ excluded,
+ sampleRecipients: members.slice(0, 5).map((member) => member.email),
+ steps, templates, sender, policy, audienceHash,
+ };
+ });
+}
+
+/** Activates exactly what was previewed (and approved): enrolls the snapshot and materializes first steps. */
+export function commitActivation(env: DomainEnv, ctx: AuthContext, input: { campaignId: string; operationHash: string }): { enrolled: number; batchApprovalId: string | null } {
+ requireRole(ctx, 'operator');
+ const { db } = env;
+ return db.transaction(() => {
+ const now = env.now();
+ const campaign = loadCampaign(db, ctx.workspaceId, input.campaignId);
+ if (!campaign) throw new NotFoundError(`campaign ${input.campaignId}`);
+ if (campaign.status !== 'draft') throw new ConflictError(`campaign is ${campaign.status}`);
+ const version = draftVersion(env, input.campaignId);
+ if (!version.version_hash || version.version_hash !== input.operationHash) throw new ConflictError('preview is stale; prepare the activation again');
+ const sequence = db.prepare('SELECT spec FROM sequence_versions WHERE id = ?').get<{ spec: string }>(version.sequence_version_id);
+ const steps = (JSON.parse(sequence?.spec ?? '{"steps":[]}') as { steps: Step[] }).steps;
+ const content = activationContent(env, ctx.workspaceId, version, steps);
+ assertMaterializationRuntime(env, ctx.workspaceId, JSON.parse(version.policy) as Policy, steps);
+ if (activationHash(version, steps, content, version.audience_hash ?? '') !== version.version_hash) throw new ConflictError('activation content changed; prepare again');
+ const policy = JSON.parse(version.policy) as Policy;
+ if (policy.approval !== 'none') {
+ const approval = version.approval_id ? loadApproval(db, ctx.workspaceId, version.approval_id) : undefined;
+ if (approval?.decision !== 'approved' || approval.operation_hash !== version.version_hash) throw new ForbiddenError('activation requires an approved campaign-version approval');
+ }
+ db.prepare(`UPDATE campaigns SET status = 'active', active_version_id = ?, updated_at = ? WHERE id = ?`).run(version.id, now, campaign.id);
+ db.prepare('UPDATE campaign_versions SET activated_at = ?, activated_by = ? WHERE id = ?').run(now, ctx.principalId, version.id);
+ const actor = actorOf(ctx);
+ const batch = policy.approval === 'first_batch_then_campaign' ? { ids: [] as string[], size: policy.firstBatchSize } : undefined;
+ const members = db.prepare('SELECT contact_id, contact_point_id FROM audience_members WHERE campaign_version_id = ? ORDER BY id')
+ .all<{ contact_id: string; contact_point_id: string }>(version.id);
+ let enrolled = 0;
+ for (const member of members) {
+ const enrollmentId = ulid(now);
+ const inserted = db.prepare(
+ `INSERT INTO enrollments (id, workspace_id, campaign_id, campaign_version_id, contact_id, contact_point_id, status, enrolled_at, updated_at)
+ SELECT ?,?,?,?,?,?,'active',?,? WHERE NOT EXISTS (SELECT 1 FROM enrollments WHERE workspace_id = ? AND contact_id = ? AND status IN ('active','paused'))`,
+ ).run(enrollmentId, ctx.workspaceId, campaign.id, version.id, member.contact_id, member.contact_point_id, now, now, ctx.workspaceId, member.contact_id);
+ if (inserted.changes !== 1) continue;
+ enrolled += 1;
+ const sc = loadStepContext(db, enrollmentId);
+ if (sc) materializeNext(env, sc, -1, now, actor, { ...(batch ? { batch } : {}), requestActionApproval: actionApprover(env, ctx.workspaceId, ctx.principalId) });
+ }
+ let batchApprovalId: string | null = null;
+ if (batch && batch.ids.length) {
+ const placeholders = batch.ids.map(() => '?').join(',');
+ const actions = db.prepare(`SELECT id, content_hash, recipient_norm, payload FROM scheduled_actions WHERE id IN (${placeholders})`)
+ .all<{ id: string; content_hash: string; recipient_norm: string | null; payload: string }>(...batch.ids);
+ batchApprovalId = createBatchApproval(db, ctx.workspaceId, campaign.id, actions, ctx.principalId, now);
+ }
+ audit(db, ctx, now, 'campaign', campaign.id, 'activated', { versionId: version.id, enrolled, batchApprovalId });
+ return { enrolled, batchApprovalId };
+ });
+}
+
+export function actionApprover(env: DomainEnv, workspaceId: string, requestedBy: string): (actionId: string, contentHash: string) => string {
+ return (actionId, contentHash) =>
+ createApproval(env.db, { workspaceId, scope: 'action', subjectId: actionId, operationHash: contentHash, preview: { actionId }, requestedBy }, env.now());
+}
+
+export function setCampaignStatus(env: DomainEnv, ctx: AuthContext, campaignId: string, status: 'paused' | 'active' | 'completed', reason: string): void {
+ requireRole(ctx, 'operator');
+ env.db.transaction(() => {
+ const now = env.now();
+ const campaign = loadCampaign(env.db, ctx.workspaceId, campaignId);
+ if (!campaign) throw new NotFoundError(`campaign ${campaignId}`);
+ const allowed = status === 'paused' ? ['active'] : status === 'active' ? ['paused'] : ['active', 'paused'];
+ if (!allowed.includes(campaign.status)) throw new ConflictError(`cannot move campaign from ${campaign.status} to ${status}`);
+ env.db.prepare('UPDATE campaigns SET status = ?, paused_reason = ?, updated_at = ? WHERE id = ?').run(status, status === 'paused' ? reason : null, now, campaignId);
+ audit(env.db, ctx, now, 'campaign', campaignId, status, { reason });
+ });
+}
+
diff --git a/outreach-engine/packages/outreach-core/src/domain/compile.ts b/outreach-engine/packages/outreach-core/src/domain/compile.ts
new file mode 100644
index 0000000..de015ff
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/compile.ts
@@ -0,0 +1,59 @@
+import { purposePermitted, type ProviderPurpose, type SenderIdentity } from '@splitin/outreach-contracts';
+import type { DomainEnv } from './env';
+import type { Playbook } from './playbook';
+import { findTemplate } from './templates';
+
+/**
+ * Semantic checks that need the database and adapters (BUILD_PLAN.md §7.2). Structural checks already
+ * ran in parsePlaybook. Returns human-readable issues; an empty list means the playbook can run.
+ */
+export function compileIssues(env: DomainEnv, workspaceId: string, playbook: Playbook, providerAccountId: string): string[] {
+ const issues: string[] = [];
+ const { steps, policy, purpose } = playbook.spec;
+
+ const seen = new Set();
+ let sentBefore = false;
+ steps.forEach((step, index) => {
+ const at = `steps[${index}] (${step.id})`;
+ if (seen.has(step.id)) issues.push(`${at}: duplicate step id`);
+ seen.add(step.id);
+ if (step.type === 'email.reply' && !sentBefore) issues.push(`${at}: email.reply needs an earlier email.send to reply to`);
+ if (step.type === 'email.send') sentBefore = true;
+ if (step.type === 'wait') {
+ if (index === steps.length - 1) issues.push(`${at}: a trailing wait has no effect`);
+ return;
+ }
+ const template = findTemplate(env.db, workspaceId, step.template);
+ if (!template) {
+ issues.push(`${at}: template ${step.template} does not exist`);
+ return;
+ }
+ const expected = step.type === 'manual.task' ? [step.channel, 'manual'] : ['email'];
+ if (!expected.includes(template.channel)) issues.push(`${at}: template ${step.template} is for channel ${template.channel}`);
+ if (step.type === 'email.send' && !template.subject) issues.push(`${at}: template ${step.template} has no subject (only reply templates may omit it)`);
+ const tokens = JSON.parse(template.required_tokens) as string[];
+ if (tokens.includes('unsubscribe_url') && !env.unsubscribe) issues.push(`${at}: uses {{unsubscribe_url}} but no unsubscribe URL is configured`);
+ });
+ if (!steps.some((step) => step.type !== 'wait')) issues.push('steps: no actionable step');
+
+ const account = env.db
+ .prepare('SELECT provider, purposes, sender_identity FROM provider_accounts WHERE workspace_id = ? AND id = ?')
+ .get<{ provider: string; purposes: string; sender_identity: string }>(workspaceId, providerAccountId);
+ const sendsEmail = steps.some((step) => step.type === 'email.send' || step.type === 'email.reply');
+ if (!account) {
+ issues.push(`provider account ${providerAccountId} does not exist in this workspace`);
+ return issues;
+ }
+ if (sendsEmail) {
+ const adapter = env.adapters.get(account.provider);
+ if (!adapter) issues.push(`no adapter registered for provider ${account.provider}`);
+ else if (!adapter.email) issues.push(`provider ${account.provider} cannot send email`);
+ else if (!purposePermitted(purpose, adapter.purposes, JSON.parse(account.purposes) as ProviderPurpose[])) {
+ issues.push(`purpose ${purpose} is not permitted by both provider ${account.provider} and the account`);
+ }
+ const sender = JSON.parse(account.sender_identity) as SenderIdentity;
+ if (policy.requirePostalAddress && !sender.postalAddress) issues.push('policy.requirePostalAddress: the sending account has no postal address');
+ if (policy.unsubscribe === 'link' && !env.unsubscribe) issues.push('policy.unsubscribe is "link" but no unsubscribe URL is configured (use "reply" or configure one)');
+ }
+ return issues;
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/domain.test-util.ts b/outreach-engine/packages/outreach-core/src/domain/domain.test-util.ts
new file mode 100644
index 0000000..d9412b3
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/domain.test-util.ts
@@ -0,0 +1,123 @@
+import { FAKE_EMAIL_SECRET, FakeEmailProvider, FakeNotifier, staticSecrets } from '@splitin/outreach-fakes';
+import { openSqliteDatabase } from '@splitin/outreach-store-sqlite';
+import { DateTime } from 'luxon';
+import { bootstrapWorkspace, createEngine, type Engine } from '../engine';
+import type { SendGate } from '../execution/types';
+import { authenticate, type AuthContext } from './auth';
+import type { UnsubscribeConfig } from './env';
+import { addContact, addPrincipal, registerProviderAccount } from './operations';
+import { createTemplate } from './templates';
+
+/** Tuesday 2025-03-04 14:00 UTC = 09:00 in New York: inside a 09:00-17:00 window. */
+export const TUESDAY_9AM_NY = DateTime.fromISO('2025-03-04T09:00:00', { zone: 'America/New_York' }).toMillis();
+
+export interface DomainTestEnv {
+ readonly engine: Engine;
+ readonly fake: FakeEmailProvider;
+ readonly notifier: FakeNotifier;
+ readonly admin: AuthContext;
+ readonly operator: AuthContext;
+ readonly approver: AuthContext;
+ readonly viewer: AuthContext;
+ readonly accountId: string;
+ now(): number;
+ advance(ms: number): void;
+ setNow(ms: number): void;
+ /** Runs worker passes until nothing changes (bounded). */
+ drain(passes?: number): Promise;
+ contact(email: string, extra?: Partial[2]>): string;
+}
+
+export const PLAYBOOK = `
+apiVersion: outreach.splitin.net/v1alpha1
+kind: Playbook
+metadata: { name: b2b-intro }
+spec:
+ purpose: automated_outreach
+ policy:
+ approval: first_batch_then_campaign
+ firstBatchSize: 2
+ unsubscribe: link
+ window: { timezone: recipient, fallback: America/New_York, days: [Mon, Tue, Wed, Thu, Fri], start: "09:00", end: "17:00" }
+ limits: { accountPerDay: 50, domainPerDay: 50, recipientMinGap: P1D }
+ steps:
+ - { id: intro, type: email.send, template: intro@1 }
+ - { id: wait1, type: wait, duration: P3D }
+ - { id: followup, type: email.reply, template: followup@1, when: no_reply }
+ - { id: social, type: manual.task, channel: linkedin, template: social@1, when: no_reply }
+ - { id: close, type: email.reply, template: close@1, when: no_reply }
+`;
+
+export async function makeDomainEnv(options: { sendGate?: SendGate; start?: number; unsubscribe?: UnsubscribeConfig } = {}): Promise {
+ const db = openSqliteDatabase(':memory:');
+ const fake = new FakeEmailProvider();
+ const notifier = new FakeNotifier();
+ let now = options.start ?? TUESDAY_9AM_NY;
+ const engine = createEngine({
+ db,
+ adapters: [fake.adapter(), notifier.adapter()],
+ secrets: staticSecrets({ 'env:FAKE_EMAIL': FAKE_EMAIL_SECRET, 'env:FAKE_WEBHOOK': 'fake-webhook-secret-value' }),
+ workerId: 'worker-1',
+ sendGate: options.sendGate ?? { mode: 'open' },
+ unsubscribe: options.unsubscribe ?? { baseUrl: 'https://outreach.example.com/u/', secret: 'unsubscribe-test-secret-fixture' },
+ now: () => now,
+ execution: { reconcileDelayMs: 0 },
+ pollIntervalMs: 0,
+ random: () => 0.5,
+ });
+ bootstrapWorkspace(db, { workspaceId: 'ws', name: 'Test', adminRef: 'test:admin', adminName: 'Admin' }, now);
+ const admin = authenticate(db, 'ws', 'test:admin', 'test', 't-admin');
+ addPrincipal(engine, admin, { externalRef: 'test:operator', displayName: 'Operator', roles: ['operator'] });
+ addPrincipal(engine, admin, { externalRef: 'test:approver', displayName: 'Approver', roles: ['approver'] });
+ addPrincipal(engine, admin, { externalRef: 'test:viewer', displayName: 'Viewer', roles: ['viewer'] });
+ const operator = authenticate(db, 'ws', 'test:operator', 'test', 't-op');
+ const approver = authenticate(db, 'ws', 'test:approver', 'test', 't-ap');
+ const viewer = authenticate(db, 'ws', 'test:viewer', 'test', 't-view');
+ const accountId = await registerProviderAccount(engine, admin, {
+ provider: 'fake-email',
+ externalAccountId: 'sender@example.com',
+ sender: { name: 'Sam Sender', address: 'sender@example.com', organization: 'Example Co', postalAddress: '1 Example Street, Springfield' },
+ purposes: ['automated_outreach'],
+ secretRef: 'env:FAKE_EMAIL',
+ webhookSecretRef: 'env:FAKE_WEBHOOK',
+ });
+ createTemplate(db, operator, { name: 'intro', channel: 'email', subject: 'Quick question, {{first_name}}', text: 'Hi {{first_name}}, I saw {{org_name}} is growing. Worth a chat?\n{{sender_name}}' }, now);
+ createTemplate(db, operator, { name: 'followup', channel: 'email', text: 'Following up on my note, {{first_name}}.' }, now);
+ createTemplate(db, operator, { name: 'social', channel: 'linkedin', text: 'Hi {{first_name}}, sent you an email about {{org_name}}.' }, now);
+ createTemplate(db, operator, { name: 'close', channel: 'email', text: 'Last note from me, {{first_name}}.' }, now);
+
+ const env: DomainTestEnv = {
+ engine,
+ fake,
+ notifier,
+ admin,
+ operator,
+ approver,
+ viewer,
+ accountId,
+ now: () => now,
+ advance: (ms) => {
+ now += ms;
+ },
+ setNow: (ms) => {
+ now = ms;
+ },
+ drain: async (passes = 5) => {
+ for (let i = 0; i < passes; i += 1) {
+ const report = await engine.runOnce();
+ if (report.execute.claimed === 0 && report.reconcile.found + report.reconcile.absent === 0 && report.inbound.processed === 0) return;
+ }
+ },
+ contact: (email, extra = {}) =>
+ addContact(engine, operator, {
+ fullName: 'Ada Lovelace',
+ firstName: 'Ada',
+ email,
+ timezone: 'America/New_York',
+ organization: { name: 'Analytical Engines', domain: email.split('@')[1] ?? 'example.org' },
+ consentBasis: 'legitimate_interest',
+ ...extra,
+ }),
+ };
+ return env;
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/env.ts b/outreach-engine/packages/outreach-core/src/domain/env.ts
new file mode 100644
index 0000000..40495be
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/env.ts
@@ -0,0 +1,74 @@
+import type { ProviderAdapter, SqlDatabase } from '@splitin/outreach-contracts';
+import type { ExecutionDeps } from '../execution/types';
+
+export interface UnsubscribeConfig {
+ /** Public base URL, e.g. https://outreach.example.com/u/ (decision D3). */
+ readonly baseUrl: string;
+ /** HMAC secret for tokens; resolved once at startup, never stored in the database. */
+ readonly secret: string;
+ /**
+ * Whether `baseUrl` is this engine's own POST /u/:token endpoint, so mail may advertise RFC 8058 one-click
+ * (List-Unsubscribe-Post). Set false when the link opens a page instead (e.g. a hosted confirmation page):
+ * a mailbox provider's one-click POST to a page would silently do nothing. Default true.
+ */
+ readonly oneClick?: boolean;
+}
+
+export interface DomainEnv {
+ readonly db: SqlDatabase;
+ readonly now: () => number;
+ readonly adapters: ReadonlyMap;
+ readonly unsubscribe?: UnsubscribeConfig;
+ /** Execution wiring, used by services that trigger worker-level operations (review resolution). */
+ readonly exec: ExecutionDeps;
+}
+
+export interface CampaignVersionRow {
+ id: string;
+ campaign_id: string;
+ version: number;
+ sequence_version_id: string;
+ provider_account_id: string;
+ policy: string;
+ policy_hash: string;
+ audience: string;
+ audience_hash: string | null;
+ version_hash: string | null;
+ approval_id: string | null;
+ activated_at: number | null;
+}
+
+export interface CampaignRow {
+ id: string;
+ workspace_id: string;
+ name: string;
+ purpose: string;
+ status: 'draft' | 'active' | 'paused' | 'completed' | 'archived';
+ active_version_id: string | null;
+ paused_reason: string | null;
+}
+
+export interface EnrollmentRow {
+ id: string;
+ workspace_id: string;
+ campaign_id: string;
+ campaign_version_id: string;
+ contact_id: string;
+ contact_point_id: string;
+ status: 'active' | 'paused' | 'replied' | 'opted_out' | 'bounced' | 'completed' | 'stopped' | 'error';
+ stop_reason: string | null;
+ current_step_id: string | null;
+ row_version: number;
+}
+
+export function loadCampaign(db: SqlDatabase, workspaceId: string, id: string): CampaignRow | undefined {
+ return db.prepare('SELECT * FROM campaigns WHERE workspace_id = ? AND id = ?').get(workspaceId, id);
+}
+
+export function loadVersion(db: SqlDatabase, id: string): CampaignVersionRow | undefined {
+ return db.prepare('SELECT * FROM campaign_versions WHERE id = ?').get(id);
+}
+
+export function loadEnrollment(db: SqlDatabase, id: string): EnrollmentRow | undefined {
+ return db.prepare('SELECT * FROM enrollments WHERE id = ?').get(id);
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/health.test.ts b/outreach-engine/packages/outreach-core/src/domain/health.test.ts
new file mode 100644
index 0000000..5453ba4
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/health.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from 'vitest';
+import { decideApproval, listApprovals } from './approvals';
+import { commitActivation, createCampaign, prepareActivation } from './campaigns';
+import { makeDomainEnv, PLAYBOOK, type DomainTestEnv } from './domain.test-util';
+import { checkAccountHealth, nextHealth, recordAccountHealth } from './health';
+import { configureNotifications, registerProviderAccount } from './operations';
+
+const MINUTE = 60_000;
+
+async function activate(env: DomainTestEnv) {
+ const { campaignId } = createCampaign(env.engine, env.operator, { name: 'Intro', playbook: PLAYBOOK, providerAccountId: env.accountId });
+ const preview = prepareActivation(env.engine, env.operator, campaignId);
+ if (preview.approvalId) decideApproval(env.engine, env.approver, { approvalId: preview.approvalId, decision: 'approved', operationHash: preview.operationHash });
+ commitActivation(env.engine, env.operator, { campaignId, operationHash: preview.operationHash });
+ for (const approval of listApprovals(env.engine.db, env.approver)) {
+ decideApproval(env.engine, env.approver, { approvalId: approval.id, decision: 'approved', operationHash: approval.operation_hash });
+ }
+}
+
+const health = (env: DomainTestEnv) =>
+ env.engine.db.prepare('SELECT health, health_detail FROM provider_accounts WHERE id = ?').get<{ health: string; health_detail: string | null }>(env.accountId);
+
+describe('nextHealth', () => {
+ it.each([
+ ['ok', { status: 'reauth_required', affirmative: true }, 'reauth_required'],
+ ['ok', { status: 'degraded', affirmative: false }, 'degraded'],
+ ['reauth_required', { status: 'ok', affirmative: true }, 'ok'],
+ ['reauth_required', { status: 'degraded', affirmative: true }, 'reauth_required'],
+ ['unhealthy', { status: 'degraded', affirmative: false }, 'unhealthy'],
+ ['unhealthy', { status: 'reauth_required', affirmative: true }, 'reauth_required'],
+ ['degraded', { status: 'ok', affirmative: true }, 'ok'],
+ ] as const)('%s + %o -> %s', (current, observed, expected) => {
+ expect(nextHealth(current, observed)).toBe(expected);
+ });
+});
+
+describe('account health checker', () => {
+ it('lets an account broken by a send recover only when the provider affirms ok, then sends resume', async () => {
+ const env = await makeDomainEnv();
+ env.contact('ada@example.org');
+ await activate(env);
+ env.fake.script({ kind: 'reject', errorClass: 'auth_expired' });
+ await env.drain();
+ expect(health(env)?.health).toBe('reauth_required');
+ expect(env.fake.deliveries).toHaveLength(0);
+
+ // A check that cannot reach a verdict must not re-enable sending.
+ recordAccountHealth(env.engine.db, env.accountId, { status: 'degraded', affirmative: false, detail: 'timeout' }, env.now());
+ expect(health(env)?.health).toBe('reauth_required');
+
+ // The operator reconnects; the next check sees ok and the held send goes out.
+ await checkAccountHealth(env.engine, env.accountId);
+ expect(health(env)?.health).toBe('ok');
+ env.advance(10 * MINUTE);
+ await env.drain();
+ expect(env.fake.deliveries).toHaveLength(1);
+ const changes = env.engine.db.prepare(`SELECT detail FROM audit_events WHERE action = 'health_changed'`).all<{ detail: string }>();
+ expect(changes.map((row) => JSON.parse(row.detail) as { to: string }).map((d) => d.to)).toEqual(['ok']);
+ });
+
+ it('runs on its interval inside the worker pass and notifies on a bad transition through another account', async () => {
+ const env = await makeDomainEnv();
+ const notifyId = await registerProviderAccount(env.engine, env.admin, { provider: 'fake-notify', externalAccountId: '#gtm', sender: { name: 'Ops', address: 'ops@example.com' }, purposes: ['transactional'], secretRef: 'env:FAKE_EMAIL' });
+ configureNotifications(env.engine, env.admin, notifyId);
+ env.fake.healthStatus = 'reauth_required';
+
+ expect((await env.engine.runOnce()).health.checked).toBe(0); // Checked at registration; not due yet.
+ env.advance(16 * MINUTE);
+ const report = await env.engine.runOnce();
+ expect(report.health).toEqual({ checked: 2, changed: 1 });
+ expect(health(env)?.health).toBe('reauth_required');
+ await env.drain();
+ expect(env.notifier.published.map((n) => n.title)).toEqual(['Reconnect sending account sender@example.com']);
+ });
+
+ it('checks at registration and accepts file: secret references', async () => {
+ const env = await makeDomainEnv();
+ env.fake.healthStatus = 'unhealthy';
+ const id = await registerProviderAccount(env.engine, env.admin, {
+ provider: 'fake-email',
+ externalAccountId: 'second@example.com',
+ sender: { name: 'Second', address: 'second@example.com' },
+ purposes: ['automated_outreach'],
+ secretRef: 'file:~/.config/outreach/gmail-second@example.com.json',
+ });
+ const row = env.engine.db.prepare('SELECT health, secret_ref FROM provider_accounts WHERE id = ?').get<{ health: string; secret_ref: string }>(id);
+ expect(row).toEqual({ health: 'unhealthy', secret_ref: 'file:~/.config/outreach/gmail-second@example.com.json' });
+ await expect(registerProviderAccount(env.engine, env.admin, { provider: 'fake-email', externalAccountId: 'x', sender: { name: 'X', address: 'x@example.com' }, purposes: [], secretRef: 'plain-secret' })).rejects.toThrow(/secretRef must look like/);
+ });
+});
diff --git a/outreach-engine/packages/outreach-core/src/domain/health.ts b/outreach-engine/packages/outreach-core/src/domain/health.ts
new file mode 100644
index 0000000..eff7db9
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/health.ts
@@ -0,0 +1,119 @@
+import { appendAudit, ulid, type AccountHealth, type SqlDatabase } from '@splitin/outreach-contracts';
+import { loadAccount } from '../execution/actions-repo';
+import { enqueueAction } from '../execution/enqueue';
+import { providerContext } from '../execution/invoke';
+import type { AccountRow } from '../execution/types';
+import { NotFoundError } from './auth';
+import type { DomainEnv } from './env';
+
+const CHECK_TIMEOUT_MS = 30_000;
+const BAD: ReadonlySet = new Set(['unhealthy', 'reauth_required']);
+
+export interface HealthObservation {
+ readonly status: AccountHealth;
+ readonly detail?: string;
+ /** False when the check could not reach a verdict (it threw or timed out). */
+ readonly affirmative: boolean;
+}
+
+export interface HealthResult {
+ readonly accountId: string;
+ readonly status: AccountHealth;
+ readonly detail: string | null;
+ readonly changed: boolean;
+}
+
+/**
+ * Which state to store. An account marked unhealthy or reauth_required (by a check or by a failed send)
+ * recovers only on an affirmative `ok`: a timeout or a `degraded` answer must not re-enable sending on a
+ * revoked account.
+ */
+export function nextHealth(current: AccountHealth, observed: HealthObservation): AccountHealth {
+ if (!BAD.has(current)) return observed.affirmative ? observed.status : 'degraded';
+ if (!observed.affirmative) return current;
+ return observed.status === 'ok' || BAD.has(observed.status) ? observed.status : current;
+}
+
+function notifyChange(db: SqlDatabase, account: AccountRow, from: AccountHealth, to: AccountHealth, detail: string | null, now: number): void {
+ const settings = JSON.parse(db.prepare('SELECT settings FROM workspaces WHERE id = ?').get<{ settings: string }>(account.workspace_id)?.settings ?? '{}') as { notifyAccountId?: string };
+ // Never route a notice about an account through that same account.
+ if (!settings.notifyAccountId || settings.notifyAccountId === account.id) return;
+ const recovered = to === 'ok' && BAD.has(from);
+ if (!BAD.has(to) && !recovered) return;
+ const sender = (JSON.parse(account.sender_identity) as { address?: string }).address ?? account.external_account_id;
+ enqueueAction(db, {
+ workspaceId: account.workspace_id,
+ kind: 'notify.publish',
+ providerAccountId: settings.notifyAccountId,
+ idempotencyKey: `health:${account.id}:${to}:${now}`,
+ dueAt: now,
+ payload: {
+ title: recovered ? `Sending account ${sender} is healthy again` : to === 'reauth_required' ? `Reconnect sending account ${sender}` : `Sending account ${sender} is unhealthy`,
+ lines: [`Provider: ${account.provider}`, `Status: ${from} -> ${to}`, ...(detail ? [`Detail: ${detail.slice(0, 200)}`] : [])],
+ severity: recovered ? 'info' : 'error',
+ },
+ }, { kind: 'system', id: 'health-checker', source: 'worker', traceId: ulid(now) }, now);
+}
+
+/** Stores an observation in one transaction; audits and notifies only when the stored state changes. */
+export function recordAccountHealth(db: SqlDatabase, accountId: string, observed: HealthObservation, now: number): HealthResult {
+ return db.transaction(() => {
+ const account = loadAccount(db, accountId);
+ if (!account) throw new NotFoundError(`provider account ${accountId}`);
+ const status = nextHealth(account.health, observed);
+ const detail = observed.detail?.slice(0, 500) ?? null;
+ db.prepare('UPDATE provider_accounts SET health = ?, health_detail = ?, health_checked_at = ? WHERE id = ?').run(status, detail, now, accountId);
+ const changed = status !== account.health;
+ if (changed) {
+ appendAudit(db, {
+ workspaceId: account.workspace_id,
+ at: now,
+ actorKind: 'system',
+ actorId: 'health-checker',
+ source: 'worker',
+ traceId: ulid(now),
+ resourceKind: 'provider_account',
+ resourceId: accountId,
+ action: 'health_changed',
+ detail: { from: account.health, to: status, detail },
+ });
+ notifyChange(db, account, account.health, status, detail, now);
+ }
+ return { accountId, status, detail, changed };
+ });
+}
+
+/** Asks the adapter for the account's health (BUILD_PLAN.md §6.5) and records the answer. */
+export async function checkAccountHealth(env: DomainEnv, accountId: string): Promise {
+ const account = loadAccount(env.db, accountId);
+ if (!account) throw new NotFoundError(`provider account ${accountId}`);
+ const adapter = env.adapters.get(account.provider);
+ if (!adapter) throw new NotFoundError(`adapter ${account.provider} (not registered in this process)`);
+ let observed: HealthObservation;
+ try {
+ const answer = await adapter.account.health(providerContext(env.exec, account, ulid(), AbortSignal.timeout(CHECK_TIMEOUT_MS)));
+ observed = { status: answer.status, affirmative: true, ...(answer.detail ? { detail: answer.detail } : {}) };
+ } catch (error) {
+ observed = { status: 'degraded', affirmative: false, detail: `health check failed: ${(error as Error).message}` };
+ }
+ return recordAccountHealth(env.db, accountId, observed, env.now());
+}
+
+/**
+ * Checks every account last checked more than `intervalMs` ago whose adapter this process has. Accounts
+ * whose adapter is missing are left alone: preflight already holds their sends for review.
+ */
+export async function checkDueAccountHealth(env: DomainEnv, intervalMs: number): Promise<{ checked: number; changed: number }> {
+ const due = env.db
+ .prepare('SELECT id, provider FROM provider_accounts WHERE health_checked_at IS NULL OR health_checked_at <= ? ORDER BY health_checked_at')
+ .all<{ id: string; provider: string }>(env.now() - intervalMs);
+ let checked = 0;
+ let changed = 0;
+ for (const account of due) {
+ if (!env.adapters.has(account.provider)) continue;
+ const result = await checkAccountHealth(env, account.id);
+ checked += 1;
+ if (result.changed) changed += 1;
+ }
+ return { checked, changed };
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/index.ts b/outreach-engine/packages/outreach-core/src/domain/index.ts
new file mode 100644
index 0000000..1c81d6e
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/index.ts
@@ -0,0 +1,19 @@
+export * from './auth';
+export * from './calendar';
+export * from './playbook';
+export * from './templates';
+export * from './env';
+export * from './suppressions';
+export * from './approvals';
+export * from './compile';
+export * from './campaigns';
+export * from './materialize';
+export * from './wiring';
+export * from './operations';
+export * from './access';
+export * from './jurisdictions';
+export * from './health';
+
+export * from './prepared';
+
+export { prepareRetention, commitRetention, RETENTION_LIMITS, type RetentionInput, type RetentionPreview } from './retention';
diff --git a/outreach-engine/packages/outreach-core/src/domain/jurisdictions.test.ts b/outreach-engine/packages/outreach-core/src/domain/jurisdictions.test.ts
new file mode 100644
index 0000000..ffcb241
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/jurisdictions.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest';
+import { ZodError } from 'zod';
+import { verifyAuditChain } from '@splitin/outreach-contracts';
+import { campaignStatus } from '../engine';
+import { readSendGate, setSendGate } from './access';
+import { ForbiddenError } from './auth';
+import { decideApproval, listApprovals } from './approvals';
+import { commitActivation, createCampaign, prepareActivation } from './campaigns';
+import { makeDomainEnv, PLAYBOOK, type DomainTestEnv } from './domain.test-util';
+import { jurisdictionVerdict, readJurisdictionPolicy, setJurisdictionPolicy, type JurisdictionPolicy, type JurisdictionPolicyInput } from './jurisdictions';
+
+const STRICT: JurisdictionPolicyInput = { rules: { US: 'allow', GB: 'allow', DE: 'consent_required', CA: 'consent_required' }, default: 'block', unknown: 'allow' };
+const SIGNOFF = { by: 'Counsel', reference: 'LEGAL-12 outreach memo' };
+
+function policy(input: JurisdictionPolicyInput): JurisdictionPolicy {
+ return { ...input, signoff: { ...SIGNOFF, at: 0, recordedBy: 'p' } };
+}
+
+function prepare(env: DomainTestEnv) {
+ const { campaignId } = createCampaign(env.engine, env.operator, { name: 'Intro', playbook: PLAYBOOK, providerAccountId: env.accountId });
+ return { campaignId, preview: prepareActivation(env.engine, env.operator, campaignId) };
+}
+
+describe('jurisdictionVerdict', () => {
+ const strict = policy(STRICT);
+ it.each([
+ ['US', 'legitimate_interest', true],
+ ['us', 'unknown', true],
+ ['DE', 'legitimate_interest', false],
+ ['DE', 'consent', true],
+ ['CA', 'existing_relationship', true],
+ ['FR', 'consent', false],
+ [null, 'legitimate_interest', true],
+ ['unknown', 'legitimate_interest', true],
+ ] as const)('%s with %s -> %s', (country, basis, ok) => {
+ expect(jurisdictionVerdict(strict, country, basis).ok).toBe(ok);
+ });
+
+ it('names the rule that refused', () => {
+ expect(jurisdictionVerdict(strict, 'FR', 'consent')).toEqual({ ok: false, reason: 'jurisdiction_blocked:FR' });
+ expect(jurisdictionVerdict(strict, 'DE', null)).toEqual({ ok: false, reason: 'jurisdiction_needs_consent:DE' });
+ expect(jurisdictionVerdict(policy({ ...STRICT, unknown: 'block' }), null, 'consent')).toEqual({ ok: false, reason: 'jurisdiction_blocked:unknown' });
+ });
+
+ it('permits everything while no policy is recorded (the live-send gate is the limit then)', () => {
+ expect(jurisdictionVerdict(null, 'DE', null).ok).toBe(true);
+ });
+});
+
+describe('jurisdiction policy', () => {
+ it('is admin-only, needs a sign-off, validates country codes and is audited', async () => {
+ const env = await makeDomainEnv();
+ expect(() => setJurisdictionPolicy(env.engine, env.operator, STRICT, SIGNOFF)).toThrow(ForbiddenError);
+ expect(() => setJurisdictionPolicy(env.engine, env.admin, STRICT, { by: 'x', reference: '' })).toThrow(/sign-off/);
+ expect(() => setJurisdictionPolicy(env.engine, env.admin, { ...STRICT, rules: { Germany: 'block' } }, SIGNOFF)).toThrow(ZodError);
+ expect(readJurisdictionPolicy(env.engine.db, 'ws')).toBeNull();
+
+ const saved = setJurisdictionPolicy(env.engine, env.admin, STRICT, SIGNOFF);
+ expect(readJurisdictionPolicy(env.engine.db, 'ws')).toEqual(saved);
+ expect(saved.signoff).toMatchObject({ by: 'Counsel', reference: 'LEGAL-12 outreach memo', recordedBy: env.admin.principalId });
+ const audited = env.engine.db.prepare(`SELECT detail FROM audit_events WHERE action = 'jurisdiction_policy_changed'`).all<{ detail: string }>();
+ expect(audited).toHaveLength(1);
+ expect(verifyAuditChain(env.engine.db).ok).toBe(true);
+ });
+
+ it('must exist before the live-send gate opens; allowlist testing does not need it', async () => {
+ const env = await makeDomainEnv();
+ setSendGate(env.engine, env.admin, { mode: 'allowlist', allow: ['@example.org'] }, 'pilot with our own addresses');
+ expect(() => setSendGate(env.engine, env.admin, { mode: 'open' }, 'D1 decided, going live')).toThrow(/jurisdiction policy/);
+ setJurisdictionPolicy(env.engine, env.admin, STRICT, SIGNOFF);
+ setSendGate(env.engine, env.admin, { mode: 'open' }, 'D1 decided, going live');
+ expect(readSendGate(env.engine.db, 'ws')).toEqual({ mode: 'open' });
+ });
+
+ it('rejects an invalid jurisdiction on a new contact instead of storing it as unknown', async () => {
+ const env = await makeDomainEnv();
+ expect(() => env.contact('hans@example.de', { jurisdiction: 'Germany' })).toThrow(ZodError);
+ env.contact('hans@example.de', { jurisdiction: 'de' });
+ const row = env.engine.db.prepare(`SELECT jurisdiction FROM contact_points WHERE value_norm = 'hans@example.de'`).get<{ jurisdiction: string }>();
+ expect(row?.jurisdiction).toBe('DE');
+ });
+});
+
+describe('enforcement', () => {
+ it('excludes recipients the policy does not permit when the audience is snapshotted', async () => {
+ const env = await makeDomainEnv();
+ setJurisdictionPolicy(env.engine, env.admin, STRICT, SIGNOFF);
+ env.contact('ada@example.com', { jurisdiction: 'US' });
+ env.contact('hans@example.de', { jurisdiction: 'DE' });
+ env.contact('greta@example.de', { jurisdiction: 'DE', consentBasis: 'consent' });
+ env.contact('marie@example.fr', { jurisdiction: 'FR' });
+ env.contact('someone@example.org');
+ const { preview } = prepare(env);
+ expect(preview.audienceCount).toBe(3);
+ expect(preview.excluded).toMatchObject({ jurisdiction: 2, noConsent: 0 });
+ });
+
+ it('re-checks at send time: a policy tightened after activation cancels the send and stops the enrollment', async () => {
+ const env = await makeDomainEnv();
+ env.contact('ada@example.com', { jurisdiction: 'US' });
+ env.contact('hans@example.de', { jurisdiction: 'DE' });
+ const { campaignId, preview } = prepare(env);
+ expect(preview.audienceCount).toBe(2);
+ if (preview.approvalId) decideApproval(env.engine, env.approver, { approvalId: preview.approvalId, decision: 'approved', operationHash: preview.operationHash });
+ const { batchApprovalId } = commitActivation(env.engine, env.operator, { campaignId, operationHash: preview.operationHash });
+
+ setJurisdictionPolicy(env.engine, env.admin, STRICT, SIGNOFF);
+ const batch = listApprovals(env.engine.db, env.approver).find((row) => row.id === batchApprovalId);
+ if (!batch) throw new Error('expected a pending batch approval');
+ decideApproval(env.engine, env.approver, { approvalId: batch.id, decision: 'approved', operationHash: batch.operation_hash });
+ await env.drain();
+
+ expect(env.fake.deliveries.map((d) => d.to[0])).toEqual(['ada@example.com']);
+ expect(campaignStatus(env.engine, env.viewer, campaignId).enrollments).toEqual({ active: 1, stopped: 1 });
+ const cancelled = env.engine.db
+ .prepare(`SELECT state, state_reason FROM scheduled_actions WHERE recipient_norm = 'hans@example.de'`)
+ .get<{ state: string; state_reason: string }>();
+ expect(cancelled).toEqual({ state: 'cancelled', state_reason: 'jurisdiction_needs_consent:DE' });
+ });
+});
diff --git a/outreach-engine/packages/outreach-core/src/domain/jurisdictions.ts b/outreach-engine/packages/outreach-core/src/domain/jurisdictions.ts
new file mode 100644
index 0000000..61db31c
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/jurisdictions.ts
@@ -0,0 +1,101 @@
+import { z } from 'zod';
+import type { SqlDatabase } from '@splitin/outreach-contracts';
+import { ForbiddenError, audit, requireRole, type AuthContext } from './auth';
+import type { DomainEnv } from './env';
+
+/**
+ * Where a recipient is decides which law applies (BUILD_PLAN.md decision D5). The workspace records, per
+ * ISO 3166 country, what a message needs before it may go out; the record carries who signed it off.
+ *
+ * - `allow`: no consent requirement beyond the playbook's own eligibility rules (e.g. US CAN-SPAM).
+ * - `consent_required`: only contacts with recorded consent or an existing relationship (e.g. DE, CA).
+ * - `block`: never send.
+ */
+export const JURISDICTION_RULES = ['allow', 'consent_required', 'block'] as const;
+export type JurisdictionRule = (typeof JURISDICTION_RULES)[number];
+
+const Rule = z.enum(JURISDICTION_RULES);
+export const JurisdictionCode = z.string().regex(/^[A-Z]{2}$/, 'country codes are ISO 3166 alpha-2, upper case');
+
+export const JurisdictionPolicyInput = z.strictObject({
+ rules: z.record(JurisdictionCode, Rule),
+ /** Countries not listed in `rules`. */
+ default: Rule,
+ /** Contacts whose country was never recorded. */
+ unknown: Rule,
+});
+export type JurisdictionPolicyInput = z.infer;
+
+export interface JurisdictionSignoff {
+ /** The person accountable for the legal basis, e.g. counsel or the founder. */
+ readonly by: string;
+ /** Where the decision is recorded: a memo, ticket or document link. */
+ readonly reference: string;
+}
+
+export interface JurisdictionPolicy extends JurisdictionPolicyInput {
+ readonly signoff: JurisdictionSignoff & { readonly at: number; readonly recordedBy: string };
+}
+
+export type JurisdictionVerdict = { readonly ok: true } | { readonly ok: false; readonly reason: string };
+
+/** Consent bases that satisfy `consent_required`. Legitimate interest does not. */
+const CONSENTED = new Set(['consent', 'existing_relationship']);
+
+function settingsOf(db: SqlDatabase, workspaceId: string): Record {
+ const row = db.prepare('SELECT settings FROM workspaces WHERE id = ?').get<{ settings: string }>(workspaceId);
+ return JSON.parse(row?.settings ?? '{}') as Record;
+}
+
+/** The signed-off policy, or null when none has been recorded (sending is then limited by the live-send gate). */
+export function readJurisdictionPolicy(db: SqlDatabase, workspaceId: string): JurisdictionPolicy | null {
+ const stored = settingsOf(db, workspaceId).jurisdictions as JurisdictionPolicy | undefined;
+ return stored && JurisdictionPolicyInput.safeParse({ rules: stored.rules, default: stored.default, unknown: stored.unknown }).success ? stored : null;
+}
+
+/** Records the policy and its sign-off in one audited change. Admin only. */
+export function setJurisdictionPolicy(env: DomainEnv, ctx: AuthContext, input: JurisdictionPolicyInput, signoff: JurisdictionSignoff): JurisdictionPolicy {
+ requireRole(ctx, 'admin');
+ const parsed = JurisdictionPolicyInput.parse(input);
+ if (signoff.by.trim().length < 2 || signoff.reference.trim().length < 5) {
+ throw new ForbiddenError('a jurisdiction policy needs a sign-off: who approved it (--signed-off-by) and where it is recorded (--reference)');
+ }
+ return env.db.transaction(() => {
+ const now = env.now();
+ const policy: JurisdictionPolicy = {
+ ...parsed,
+ signoff: { by: signoff.by.trim(), reference: signoff.reference.trim(), at: now, recordedBy: ctx.principalId },
+ };
+ const settings = { ...settingsOf(env.db, ctx.workspaceId), jurisdictions: policy };
+ env.db.prepare('UPDATE workspaces SET settings = ? WHERE id = ?').run(JSON.stringify(settings), ctx.workspaceId);
+ audit(env.db, ctx, now, 'workspace', ctx.workspaceId, 'jurisdiction_policy_changed', { policy });
+ return policy;
+ });
+}
+
+/** Normalizes a stored jurisdiction to an ISO code, or null when unknown. */
+export function normalizeJurisdiction(value: string | null | undefined): string | null {
+ const code = value?.trim().toUpperCase();
+ return code && /^[A-Z]{2}$/.test(code) ? code : null;
+}
+
+/** Whether a message to a contact point in `jurisdiction` with `consentBasis` is permitted. No policy: permitted. */
+export function jurisdictionVerdict(policy: JurisdictionPolicy | null, jurisdiction: string | null, consentBasis: string | null): JurisdictionVerdict {
+ if (!policy) return { ok: true };
+ const code = normalizeJurisdiction(jurisdiction);
+ const rule = code ? (policy.rules[code] ?? policy.default) : policy.unknown;
+ const label = code ?? 'unknown';
+ if (rule === 'allow') return { ok: true };
+ if (rule === 'block') return { ok: false, reason: `jurisdiction_blocked:${label}` };
+ return CONSENTED.has(consentBasis ?? '') ? { ok: true } : { ok: false, reason: `jurisdiction_needs_consent:${label}` };
+}
+
+/** The verdict for the contact point an action is addressed to. */
+export function contactPointVerdict(db: SqlDatabase, workspaceId: string, contactPointId: string | null): JurisdictionVerdict {
+ const policy = readJurisdictionPolicy(db, workspaceId);
+ if (!policy) return { ok: true };
+ const row = contactPointId
+ ? db.prepare('SELECT jurisdiction, consent_basis FROM contact_points WHERE id = ?').get<{ jurisdiction: string | null; consent_basis: string | null }>(contactPointId)
+ : undefined;
+ return jurisdictionVerdict(policy, row?.jurisdiction ?? null, row?.consent_basis ?? null);
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/materialize.ts b/outreach-engine/packages/outreach-core/src/domain/materialize.ts
new file mode 100644
index 0000000..0b35305
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/materialize.ts
@@ -0,0 +1,245 @@
+import type { CapabilitySnapshot, SenderIdentity, SqlDatabase } from '@splitin/outreach-contracts';
+import { enqueueAction } from '../execution/enqueue';
+import type { Actor } from '../execution/actions-repo';
+import type { EmailPayload, ManualPayload } from '../execution/invoke';
+import { addDuration, nextSlot, resolveZone, durationMs } from './calendar';
+import { loadCampaign, loadEnrollment, loadVersion, type CampaignRow, type CampaignVersionRow, type DomainEnv, type EnrollmentRow } from './env';
+import type { Policy, Step } from './playbook';
+import { createUnsubscribeToken, stopEnrollment } from './suppressions';
+import { ForbiddenError } from './auth';
+import { TemplateRenderError, escapeHtml, findTemplate, render } from './templates';
+
+/** Control surfaces must not silently change compliance content while freezing actions. */
+export function assertMaterializationRuntime(env: DomainEnv, workspaceId: string, policy: Policy, steps: readonly Step[]): void {
+ if (env.unsubscribe) return;
+ const needsLink = policy.unsubscribe === 'link' && steps.some((step) => step.type === 'email.send' || step.type === 'email.reply');
+ const usesToken = steps.some((step) => 'template' in step && findTemplate(env.db, workspaceId, step.template)?.required_tokens.includes('unsubscribe_url'));
+ if (needsLink || usesToken) throw new ForbiddenError('configured unsubscribe runtime is required; use the configured CLI or API to commit');
+}
+
+export interface StepContext {
+ readonly enrollment: EnrollmentRow;
+ readonly campaign: CampaignRow;
+ readonly version: CampaignVersionRow;
+ readonly steps: readonly Step[];
+ readonly policy: Policy;
+}
+
+interface ContactView {
+ full_name: string;
+ first_name: string | null;
+ title: string | null;
+ timezone: string | null;
+ attributes: string;
+ org_name: string | null;
+ org_domain: string | null;
+ email: string;
+ profile_url: string | null;
+}
+
+export function loadStepContext(db: SqlDatabase, enrollmentId: string): StepContext | null {
+ const enrollment = loadEnrollment(db, enrollmentId);
+ if (!enrollment) return null;
+ const campaign = loadCampaign(db, enrollment.workspace_id, enrollment.campaign_id);
+ const version = loadVersion(db, enrollment.campaign_version_id);
+ if (!campaign || !version) return null;
+ const sequence = db.prepare('SELECT spec FROM sequence_versions WHERE id = ?').get<{ spec: string }>(version.sequence_version_id);
+ if (!sequence) return null;
+ const spec = JSON.parse(sequence.spec) as { steps: Step[] };
+ return { enrollment, campaign, version, steps: spec.steps, policy: JSON.parse(version.policy) as Policy };
+}
+
+function loadContact(db: SqlDatabase, enrollment: EnrollmentRow): ContactView | undefined {
+ return db
+ .prepare(
+ `SELECT c.full_name, c.first_name, c.title, c.timezone, c.attributes, o.name AS org_name, o.domain_norm AS org_domain,
+ cp.value_norm AS email,
+ (SELECT value_norm FROM contact_points s WHERE s.contact_id = c.id AND s.kind = 'social_profile' LIMIT 1) AS profile_url
+ FROM contacts c JOIN contact_points cp ON cp.id = ? LEFT JOIN organizations o ON o.id = c.organization_id
+ WHERE c.id = ?`,
+ )
+ .get(enrollment.contact_point_id, enrollment.contact_id);
+}
+
+function senderOf(db: SqlDatabase, accountId: string): { sender: SenderIdentity; caps: Partial } {
+ const row = db.prepare('SELECT sender_identity, capabilities FROM provider_accounts WHERE id = ?').get<{ sender_identity: string; capabilities: string }>(accountId);
+ if (!row) throw new Error(`provider account ${accountId} not found`);
+ return { sender: JSON.parse(row.sender_identity) as SenderIdentity, caps: JSON.parse(row.capabilities) as Partial };
+}
+
+function tokenValues(env: DomainEnv, sc: StepContext, contact: ContactView, sender: SenderIdentity): Record {
+ const attributes = JSON.parse(contact.attributes) as Record;
+ const values: Record = {
+ first_name: contact.first_name ?? undefined,
+ full_name: contact.full_name,
+ title: contact.title ?? undefined,
+ org_name: contact.org_name ?? undefined,
+ org_domain: contact.org_domain ?? undefined,
+ sender_name: sender.name,
+ sender_email: sender.address,
+ sender_org: sender.organization ?? undefined,
+ sender_address: sender.postalAddress ?? undefined,
+ };
+ for (const [key, value] of Object.entries(attributes)) {
+ if (typeof value === 'string' || typeof value === 'number') values[`attr.${key.toLowerCase()}`] = String(value);
+ }
+ if (env.unsubscribe) {
+ values.unsubscribe_url = `${env.unsubscribe.baseUrl}${createUnsubscribeToken(env.unsubscribe.secret, {
+ w: sc.enrollment.workspace_id,
+ cp: sc.enrollment.contact_point_id,
+ c: sc.campaign.id,
+ })}`;
+ }
+ return values;
+}
+
+function footer(sc: StepContext, sender: SenderIdentity, unsubscribeUrl: string | undefined): string[] {
+ const lines: string[] = [];
+ if (sc.policy.requirePostalAddress) lines.push(`${sender.organization ?? sender.name}, ${sender.postalAddress ?? ''}`.trim());
+ if (sc.policy.unsubscribe === 'link' && unsubscribeUrl) lines.push(`Unsubscribe: ${unsubscribeUrl}`);
+ else lines.push('Reply "unsubscribe" and we will not contact you again.');
+ return lines;
+}
+
+function buildEmail(env: DomainEnv, sc: StepContext, step: Extract, contact: ContactView): EmailPayload {
+ const { db } = env;
+ const template = findTemplate(db, sc.campaign.workspace_id, step.template);
+ if (!template) throw new TemplateRenderError([`template:${step.template}`]);
+ const { sender, caps } = senderOf(db, sc.version.provider_account_id);
+ const values = tokenValues(env, sc, contact, sender);
+ const unsubscribeUrl = sc.policy.unsubscribe === 'link' ? values.unsubscribe_url : undefined;
+ const lines = footer(sc, sender, unsubscribeUrl);
+ const text = `${render(template.body_text, values, 'text')}\n\n--\n${lines.join('\n')}`;
+ const html = template.body_html
+ ? `${render(template.body_html, values, 'html')}
${lines.map(escapeHtml).join('
')}
`
+ : undefined;
+ const headers: Record = {};
+ if (caps.customHeaders) {
+ // The mailto target is the sending mailbox, which the engine polls: a mailbox provider's "Unsubscribe"
+ // button then arrives as an opt-out even with no public URL (BUILD_PLAN.md §19 D3).
+ const targets = [...(unsubscribeUrl ? [unsubscribeUrl] : []), `mailto:${sender.address}?subject=unsubscribe`];
+ headers['List-Unsubscribe'] = targets.map((target) => `<${target}>`).join(', ');
+ if (unsubscribeUrl && env.unsubscribe?.oneClick !== false) headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click';
+ }
+ let subject = template.subject ? render(template.subject, values, 'text') : '';
+ const base = {
+ from: { address: sender.address, name: sender.name },
+ to: [{ address: contact.email, name: contact.full_name }],
+ ...(sender.replyTo ? { replyTo: { address: sender.replyTo } } : {}),
+ text,
+ ...(html ? { html } : {}),
+ headers,
+ };
+ if (step.type === 'email.send') return { ...base, subject };
+ const previous = db
+ .prepare(`SELECT * FROM messages WHERE enrollment_id = ? AND direction = 'outbound' ORDER BY at DESC LIMIT 1`)
+ .get<{ rfc_message_id: string | null; references_ids: string; provider_thread_id: string | null; subject: string | null }>(sc.enrollment.id);
+ if (!previous?.rfc_message_id) throw new TemplateRenderError(['previous_message']);
+ if (!subject) subject = /^re:/i.test(previous.subject ?? '') ? (previous.subject ?? '') : `Re: ${previous.subject ?? ''}`;
+ const references = [...(JSON.parse(previous.references_ids) as string[]), previous.rfc_message_id];
+ return {
+ ...base,
+ subject,
+ inReplyTo: previous.rfc_message_id,
+ references,
+ ...(previous.provider_thread_id ? { providerThreadId: previous.provider_thread_id } : {}),
+ };
+}
+
+function renderManual(env: DomainEnv, sc: StepContext, ref: string, contact: ContactView): string {
+ const template = findTemplate(env.db, sc.campaign.workspace_id, ref);
+ if (!template) throw new TemplateRenderError([`template:${ref}`]);
+ return render(template.body_text, tokenValues(env, sc, contact, senderOf(env.db, sc.version.provider_account_id).sender), 'text');
+}
+
+export interface MaterializeOptions {
+ /** During activation: collect the first N email actions for one batch approval. */
+ readonly batch?: { ids: string[]; size: number };
+ /** Creates an action-scope approval for an action (every_action policy, or step approval: always). */
+ readonly requestActionApproval: (actionId: string, contentHash: string) => string;
+}
+
+export type MaterializeResult = 'created' | 'completed' | 'error' | 'not_live';
+
+/** Creates the next actionable step after `afterIndex`, honouring waits and the send window. */
+export function materializeNext(
+ env: DomainEnv,
+ sc: StepContext,
+ afterIndex: number,
+ from: number,
+ actor: Actor,
+ options: MaterializeOptions,
+): MaterializeResult {
+ const { db } = env;
+ const now = env.now();
+ if (sc.enrollment.status !== 'active') return 'not_live';
+ const contact = loadContact(db, sc.enrollment);
+ if (!contact) return 'error';
+ const window = sc.policy.window;
+ const zone = resolveZone(window, contact.timezone);
+ let due = from;
+ for (let index = afterIndex + 1; index < sc.steps.length; index += 1) {
+ const step = sc.steps[index];
+ if (!step) break;
+ if (step.type === 'wait') {
+ due = addDuration(due, step.duration, step.calendar, window, zone);
+ continue;
+ }
+ const dueAt = nextSlot(Math.max(due, now), window, zone);
+ try {
+ const payload: EmailPayload | ManualPayload =
+ step.type === 'manual.task'
+ ? {
+ channel: step.channel,
+ draft: renderManual(env, sc, step.template, contact),
+ ...(contact.profile_url ? { targetUrl: contact.profile_url } : {}),
+ }
+ : buildEmail(env, sc, step, contact);
+ const isEmail = step.type !== 'manual.task';
+ const collectForBatch = isEmail && !!options.batch && options.batch.ids.length < options.batch.size;
+ const perAction = isEmail && (sc.policy.approval === 'every_action' || step.approval === 'always') && !collectForBatch;
+ const { action } = enqueueAction(
+ db,
+ {
+ workspaceId: sc.enrollment.workspace_id,
+ kind: step.type,
+ payload,
+ idempotencyKey: `seq:${sc.enrollment.id}:${step.id}`,
+ dueAt,
+ notAfter: dueAt + durationMs(sc.policy.expireAfter),
+ providerAccountId: isEmail ? sc.version.provider_account_id : null,
+ purpose: sc.campaign.purpose,
+ recipient: isEmail ? contact.email : null,
+ enrollmentId: sc.enrollment.id,
+ campaignId: sc.campaign.id,
+ stepId: step.id,
+ contactPointId: sc.enrollment.contact_point_id,
+ senderDomain: senderOf(db, sc.version.provider_account_id).sender.address.split('@')[1] ?? 'outreach.invalid',
+ awaitingApproval: collectForBatch || perAction,
+ approvalId: isEmail && !collectForBatch && !perAction && sc.policy.approval !== 'none' ? sc.version.approval_id : null,
+ },
+ actor,
+ now,
+ );
+ if (collectForBatch) options.batch?.ids.push(action.id);
+ if (perAction) {
+ const approvalId = options.requestActionApproval(action.id, action.content_hash);
+ db.prepare('UPDATE scheduled_actions SET approval_id = ? WHERE id = ?').run(approvalId, action.id);
+ }
+ db.prepare('UPDATE enrollments SET current_step_id = ?, row_version = row_version + 1, updated_at = ? WHERE id = ?').run(step.id, now, sc.enrollment.id);
+ return 'created';
+ } catch (error) {
+ if (!(error instanceof TemplateRenderError)) throw error;
+ stopEnrollment(db, sc.enrollment.id, 'error', `template_missing:${error.missing.join(',')}`.slice(0, 200), actor, now);
+ return 'error';
+ }
+ }
+ db.prepare(`UPDATE enrollments SET status = 'completed', stop_reason = 'sequence_finished', row_version = row_version + 1, updated_at = ? WHERE id = ? AND status = 'active'`).run(now, sc.enrollment.id);
+ return 'completed';
+}
+
+export function stepIndex(sc: StepContext, stepId: string | null): number {
+ return sc.steps.findIndex((step) => step.id === stepId);
+}
+
+
diff --git a/outreach-engine/packages/outreach-core/src/domain/operations.ts b/outreach-engine/packages/outreach-core/src/domain/operations.ts
new file mode 100644
index 0000000..981ed84
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/operations.ts
@@ -0,0 +1,184 @@
+import { ulid, type ProviderPurpose, type SenderIdentity } from '@splitin/outreach-contracts';
+import { providerContext } from '../execution/invoke';
+import type { AccountRow } from '../execution/types';
+import { ConflictError, NotFoundError, actorOf, audit, requireRole, type AuthContext, type Role } from './auth';
+import type { DomainEnv, EnrollmentRow } from './env';
+import { assertMaterializationRuntime, loadStepContext, materializeNext, stepIndex } from './materialize';
+import { actionApprover } from './campaigns';
+import { checkAccountHealth } from './health';
+import { JurisdictionCode } from './jurisdictions';
+import { addSuppression, normalizeEmail, stopEnrollment, type SuppressionInput } from './suppressions';
+
+export function addPrincipal(env: DomainEnv, ctx: AuthContext, input: { externalRef: string; displayName: string; roles: Role[] }): string {
+ requireRole(ctx, 'admin');
+ return env.db.transaction(() => {
+ const now = env.now();
+ const id = ulid(now);
+ env.db.prepare('INSERT INTO principals (id, workspace_id, external_ref, display_name, roles) VALUES (?,?,?,?,?)')
+ .run(id, ctx.workspaceId, input.externalRef, input.displayName, JSON.stringify(input.roles));
+ audit(env.db, ctx, now, 'principal', id, 'created', { externalRef: input.externalRef, roles: input.roles });
+ return id;
+ });
+}
+
+export interface RegisterAccountInput {
+ readonly provider: string;
+ readonly externalAccountId: string;
+ readonly sender: SenderIdentity;
+ /** Purposes the operator attests their contract with the provider allows. */
+ readonly purposes: ProviderPurpose[];
+ readonly secretRef: string;
+ readonly webhookSecretRef?: string;
+}
+
+/** Registers a sending account and records the adapter's capability snapshot. Secrets are references only. */
+export async function registerProviderAccount(env: DomainEnv, ctx: AuthContext, input: RegisterAccountInput): Promise {
+ requireRole(ctx, 'admin');
+ if (!/^((env|keychain):[A-Za-z0-9_.-]+|file:[^\0\r\n]+)$/.test(input.secretRef)) {
+ throw new Error('secretRef must look like env:NAME, keychain:NAME or file:PATH');
+ }
+ const adapter = env.adapters.get(input.provider);
+ if (!adapter) throw new NotFoundError(`adapter ${input.provider}`);
+ const id = ulid(env.now());
+ const row: AccountRow = {
+ id,
+ workspace_id: ctx.workspaceId,
+ provider: input.provider,
+ external_account_id: input.externalAccountId,
+ sender_identity: JSON.stringify(input.sender),
+ purposes: JSON.stringify(input.purposes),
+ capabilities: '{}',
+ secret_ref: input.secretRef,
+ webhook_secret_ref: input.webhookSecretRef ?? null,
+ health: 'ok',
+ };
+ const capabilities = await adapter.account.discover(providerContext(env.exec, row, ctx.traceId, AbortSignal.timeout(30_000)));
+ env.db.transaction(() => {
+ const now = env.now();
+ env.db.prepare(
+ `INSERT INTO provider_accounts (id, workspace_id, provider, external_account_id, sender_identity, purposes, capabilities,
+ secret_ref, webhook_secret_ref, health, health_checked_at) VALUES (?,?,?,?,?,?,?,?,?, 'ok', ?)`,
+ ).run(id, ctx.workspaceId, input.provider, input.externalAccountId, row.sender_identity, row.purposes, JSON.stringify(capabilities), input.secretRef, row.webhook_secret_ref, now);
+ audit(env.db, ctx, now, 'provider_account', id, 'registered', { provider: input.provider, purposes: input.purposes });
+ });
+ // Surface a wrong mailbox or a broken grant now, not at the first send.
+ await checkAccountHealth(env, id);
+ return id;
+}
+
+export interface ContactInput {
+ readonly fullName: string;
+ readonly firstName?: string;
+ readonly title?: string;
+ readonly email: string;
+ readonly timezone?: string;
+ readonly organization?: { name: string; domain?: string };
+ readonly consentBasis: 'consent' | 'legitimate_interest' | 'existing_relationship' | 'unknown';
+ /** ISO 3166 alpha-2 country of the recipient, when known (decision D5). */
+ readonly jurisdiction?: string;
+ readonly attributes?: Record;
+}
+
+/** Adds a single contact by hand. Bulk import lives in @splitin/outreach-import. */
+export function addContact(env: DomainEnv, ctx: AuthContext, input: ContactInput): string {
+ requireRole(ctx, 'operator');
+ return env.db.transaction(() => {
+ const now = env.now();
+ const email = normalizeEmail(input.email);
+ if (env.db.prepare(`SELECT 1 FROM contact_points WHERE workspace_id = ? AND kind = 'email' AND value_norm = ?`).get(ctx.workspaceId, email)) {
+ throw new ConflictError(`contact with ${email} already exists`);
+ }
+ let organizationId: string | null = null;
+ if (input.organization) {
+ const domain = input.organization.domain?.toLowerCase() ?? null;
+ const existing = domain ? env.db.prepare('SELECT id FROM organizations WHERE workspace_id = ? AND domain_norm = ?').get<{ id: string }>(ctx.workspaceId, domain) : undefined;
+ organizationId = existing?.id ?? ulid(now);
+ if (!existing) env.db.prepare('INSERT INTO organizations (id, workspace_id, name, domain_norm) VALUES (?,?,?,?)').run(organizationId, ctx.workspaceId, input.organization.name, domain);
+ }
+ const contactId = ulid(now);
+ env.db.prepare(
+ `INSERT INTO contacts (id, workspace_id, organization_id, full_name, first_name, title, timezone, attributes, created_at, updated_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?)`,
+ ).run(contactId, ctx.workspaceId, organizationId, input.fullName, input.firstName ?? null, input.title ?? null, input.timezone ?? null, JSON.stringify(input.attributes ?? {}), now, now);
+ env.db.prepare(
+ `INSERT INTO contact_points (id, workspace_id, contact_id, kind, value_norm, value_raw, source, consent_basis, consent_at, jurisdiction, permitted_channels)
+ VALUES (?,?,?,'email',?,?,'manual',?,?,?,'["email"]')`,
+ ).run(ulid(now), ctx.workspaceId, contactId, email, input.email, input.consentBasis, now, input.jurisdiction === undefined ? 'unknown' : JurisdictionCode.parse(input.jurisdiction.trim().toUpperCase()));
+ audit(env.db, ctx, now, 'contact', contactId, 'created', { source: 'manual' });
+ return contactId;
+ });
+}
+
+export function suppress(env: DomainEnv, ctx: AuthContext, input: Omit): void {
+ requireRole(ctx, 'operator');
+ env.db.transaction(() => {
+ const now = env.now();
+ addSuppression(env.db, { ...input, workspaceId: ctx.workspaceId, source: `principal:${ctx.principalId}` }, now);
+ audit(env.db, ctx, now, 'suppression', normalizeEmail(input.value), 'added', { scope: input.scope, reason: input.reason });
+ });
+}
+
+export function setEnrollmentState(env: DomainEnv, ctx: AuthContext, enrollmentId: string, change: 'pause' | 'resume' | 'stop', reason: string): void {
+ requireRole(ctx, 'operator');
+ env.db.transaction(() => {
+ const now = env.now();
+ const row = env.db.prepare('SELECT * FROM enrollments WHERE workspace_id = ? AND id = ?').get(ctx.workspaceId, enrollmentId);
+ if (!row) throw new NotFoundError(`enrollment ${enrollmentId}`);
+ if (change === 'stop') {
+ if (!stopEnrollment(env.db, enrollmentId, 'stopped', reason, actorOf(ctx), now)) throw new ConflictError(`enrollment is ${row.status}`);
+ } else {
+ const [from, to] = change === 'pause' ? ['active', 'paused'] : ['paused', 'active'];
+ const result = env.db.prepare('UPDATE enrollments SET status = ?, row_version = row_version + 1, updated_at = ? WHERE id = ? AND status = ?').run(to ?? '', now, enrollmentId, from ?? '');
+ if (result.changes !== 1) throw new ConflictError(`enrollment is ${row.status}`);
+ }
+ audit(env.db, ctx, now, 'enrollment', enrollmentId, change, { reason });
+ });
+}
+
+/** Routes inbound notifications (replies, opt-outs, bounces, complaints) to a notification account, or turns them off. */
+export function configureNotifications(env: DomainEnv, ctx: AuthContext, notifyAccountId: string | null): void {
+ requireRole(ctx, 'admin');
+ env.db.transaction(() => {
+ const now = env.now();
+ const row = env.db.prepare('SELECT settings FROM workspaces WHERE id = ?').get<{ settings: string }>(ctx.workspaceId);
+ const settings = { ...(JSON.parse(row?.settings ?? '{}') as Record), notifyAccountId };
+ env.db.prepare('UPDATE workspaces SET settings = ? WHERE id = ?').run(JSON.stringify(settings), ctx.workspaceId);
+ audit(env.db, ctx, now, 'workspace', ctx.workspaceId, 'notifications_configured', { notifyAccountId });
+ });
+}
+
+export interface ManualTaskRow {
+ id: string;
+ action_id: string;
+ enrollment_id: string | null;
+ channel: string;
+ target_url: string | null;
+ draft_text: string;
+ status: 'open' | 'done' | 'skipped' | 'expired';
+}
+
+export function listManualTasks(env: DomainEnv, ctx: AuthContext, status: ManualTaskRow['status'] = 'open'): ManualTaskRow[] {
+ requireRole(ctx, 'viewer');
+ return env.db.prepare('SELECT * FROM manual_tasks WHERE workspace_id = ? AND status = ? ORDER BY created_at').all(ctx.workspaceId, status);
+}
+
+/** Only a human can complete a manual task (ADR 0003). Completion advances the enrollment. */
+export function recordManualOutcome(env: DomainEnv, ctx: AuthContext, taskId: string, outcome: 'done' | 'skipped', note?: string): void {
+ requireRole(ctx, 'operator');
+ env.db.transaction(() => {
+ const now = env.now();
+ const task = env.db.prepare('SELECT * FROM manual_tasks WHERE workspace_id = ? AND id = ?').get(ctx.workspaceId, taskId);
+ if (!task) throw new NotFoundError(`task ${taskId}`);
+ if (task.status !== 'open') throw new ConflictError(`task is ${task.status}`);
+ env.db.prepare('UPDATE manual_tasks SET status = ?, confirmed_by = ?, confirmed_at = ?, note = ? WHERE id = ?').run(outcome, ctx.principalId, now, note ?? null, taskId);
+ audit(env.db, ctx, now, 'manual_task', taskId, outcome, { note: note ?? null });
+ if (!task.enrollment_id) return;
+ const sc = loadStepContext(env.db, task.enrollment_id);
+ const step = env.db.prepare('SELECT step_id FROM scheduled_actions WHERE id = ?').get<{ step_id: string | null }>(task.action_id);
+ if (sc && step) {
+ assertMaterializationRuntime(env, ctx.workspaceId, sc.policy, sc.steps.slice(stepIndex(sc, step.step_id) + 1));
+ materializeNext(env, sc, stepIndex(sc, step.step_id), now, actorOf(ctx), { requestActionApproval: actionApprover(env, ctx.workspaceId, ctx.principalId) });
+ }
+ });
+}
+
diff --git a/outreach-engine/packages/outreach-core/src/domain/playbook.test.ts b/outreach-engine/packages/outreach-core/src/domain/playbook.test.ts
new file mode 100644
index 0000000..b6eee69
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/playbook.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest';
+import { ForbiddenError } from './auth';
+import { createCampaign } from './campaigns';
+import { compileIssues } from './compile';
+import { PLAYBOOK, makeDomainEnv } from './domain.test-util';
+import { PlaybookError, parsePlaybook } from './playbook';
+import { TemplateRenderError, createTemplate, render } from './templates';
+
+describe('parsePlaybook', () => {
+ it('parses YAML and fills policy defaults', () => {
+ const playbook = parsePlaybook(`
+apiVersion: outreach.splitin.net/v1alpha1
+kind: Playbook
+metadata: { name: minimal }
+spec:
+ purpose: automated_outreach
+ steps: [{ id: intro, type: email.send, template: intro@1 }]
+`);
+ expect(playbook.spec.policy).toMatchObject({ approval: 'first_batch_then_campaign', firstBatchSize: 20, unsubscribe: 'link', expireAfter: 'P14D' });
+ expect(playbook.spec.policy.window).toMatchObject({ timezone: 'recipient', start: '09:30', end: '16:30' });
+ expect(playbook.spec.audience).toEqual({ source: 'all', require: ['email'], eligibility: ['consent_or_legitimate_interest'] });
+ });
+
+ it('reports structural errors with paths', () => {
+ const bad = PLAYBOOK.replace('duration: P3D', 'duration: three days').replace('start: "09:00"', 'start: "9am"');
+ const error = (() => {
+ try {
+ parsePlaybook(bad);
+ } catch (e) {
+ return e as PlaybookError;
+ }
+ throw new Error('expected failure');
+ })();
+ expect(error).toBeInstanceOf(PlaybookError);
+ expect(error.issues.join('\n')).toMatch(/steps\.1\.duration/);
+ expect(error.issues.join('\n')).toMatch(/policy\.window\.start/);
+ });
+
+ it('rejects social steps that are not manual tasks and unknown fields', () => {
+ expect(() => parsePlaybook(PLAYBOOK.replace('type: manual.task, channel: linkedin', 'type: linkedin.connect, channel: linkedin'))).toThrow(PlaybookError);
+ expect(() => parsePlaybook(PLAYBOOK.replace('firstBatchSize: 2', 'firstBatchSize: 2\n stealth: true'))).toThrow(PlaybookError);
+ });
+});
+
+describe('compileIssues', () => {
+ it('passes the reference playbook', async () => {
+ const env = await makeDomainEnv();
+ expect(compileIssues(env.engine, 'ws', parsePlaybook(PLAYBOOK), env.accountId)).toEqual([]);
+ });
+
+ it('finds semantic problems', async () => {
+ const env = await makeDomainEnv();
+ const playbook = parsePlaybook(PLAYBOOK.replace('purpose: automated_outreach', 'purpose: marketing').replace('template: intro@1', 'template: missing@3'));
+ const replyFirst = parsePlaybook(PLAYBOOK.replace('type: email.send, template: intro@1', 'type: email.reply, template: intro@1'));
+ const issues = compileIssues(env.engine, 'ws', playbook, env.accountId).join('\n');
+ expect(issues).toMatch(/template missing@3 does not exist/);
+ expect(issues).toMatch(/purpose marketing is not permitted/);
+ expect(compileIssues(env.engine, 'ws', replyFirst, env.accountId).join('\n')).toMatch(/needs an earlier email.send/);
+ expect(compileIssues(env.engine, 'ws', parsePlaybook(PLAYBOOK), 'nope')).toEqual(['provider account nope does not exist in this workspace']);
+ });
+
+ it('refuses to create campaigns from invalid playbooks or without the operator role', async () => {
+ const env = await makeDomainEnv();
+ expect(() => createCampaign(env.engine, env.operator, { name: 'x', playbook: PLAYBOOK.replace('intro@1', 'intro@9'), providerAccountId: env.accountId })).toThrow(PlaybookError);
+ expect(() => createCampaign(env.engine, env.viewer, { name: 'x', playbook: PLAYBOOK, providerAccountId: env.accountId })).toThrow(ForbiddenError);
+ });
+});
+
+describe('templates', () => {
+ it('render fails closed and escapes HTML', () => {
+ expect(render('Hi {{ first_name }}', { first_name: 'Ada' }, 'html')).toBe('Hi <b>Ada</b>');
+ expect(render('Hi {{first_name}}', { first_name: 'Ada\r\nBcc: x@example.com' }, 'text')).toBe('Hi Ada Bcc: x@example.com');
+ expect(() => render('Hi {{first_name}} at {{org_name}}', { first_name: ' ' }, 'text')).toThrow(TemplateRenderError);
+ });
+
+ it('versions immutably and rejects unknown tokens', async () => {
+ const env = await makeDomainEnv();
+ const v2 = createTemplate(env.engine.db, env.operator, { name: 'intro', channel: 'email', subject: 'Hi', text: 'v2 {{attr.segment}}' }, env.now());
+ expect(v2.version).toBe(2);
+ expect(() => createTemplate(env.engine.db, env.operator, { name: 'bad', channel: 'email', subject: 'x', text: '{{password}}' }, env.now())).toThrow(/Unknown template tokens: password/);
+ });
+});
diff --git a/outreach-engine/packages/outreach-core/src/domain/playbook.ts b/outreach-engine/packages/outreach-core/src/domain/playbook.ts
new file mode 100644
index 0000000..39bbbc0
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/playbook.ts
@@ -0,0 +1,108 @@
+import { OUTREACH_API_VERSION, PROVIDER_PURPOSES } from '@splitin/outreach-contracts';
+import { parse as parseYaml } from 'yaml';
+import { z } from 'zod';
+import { WEEKDAYS, isValidDuration, isValidZone } from './calendar';
+
+const hhmm = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'expected HH:mm');
+const isoDuration = z.string().refine(isValidDuration, 'expected an ISO-8601 duration such as P3D or PT4H');
+const zone = z.string().refine(isValidZone, 'expected an IANA time zone');
+const templateRef = z.string().regex(/^[a-z0-9][a-z0-9_.-]*@\d+$/, 'expected name@version');
+const stepId = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/, 'expected a lowercase step id');
+const when = z.enum(['always', 'no_reply']).default('always');
+
+const emailStep = {
+ id: stepId,
+ template: templateRef,
+ approval: z.enum(['inherit', 'always']).default('inherit'),
+ when,
+};
+
+export const StepSchema = z.discriminatedUnion('type', [
+ z.object({ ...emailStep, type: z.literal('email.send') }).strict(),
+ z.object({ ...emailStep, type: z.literal('email.reply') }).strict(),
+ z.object({ id: stepId, type: z.literal('wait'), duration: isoDuration, calendar: z.enum(['business', 'calendar']).default('business') }).strict(),
+ z.object({ id: stepId, type: z.literal('manual.task'), channel: z.string().min(1).max(40), template: templateRef, when }).strict(),
+]);
+
+export const PolicySchema = z
+ .object({
+ approval: z.enum(['none', 'every_action', 'first_batch_then_campaign']).default('first_batch_then_campaign'),
+ firstBatchSize: z.number().int().min(1).max(500).default(20),
+ window: z
+ .object({
+ timezone: z.union([z.literal('recipient'), zone]).default('recipient'),
+ fallback: zone.default('America/New_York'),
+ days: z.array(z.enum(WEEKDAYS)).min(1).default(['Mon', 'Tue', 'Wed', 'Thu']),
+ start: hhmm.default('09:30'),
+ end: hhmm.default('16:30'),
+ holidays: z.array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)).default([]),
+ })
+ .strict()
+ .refine((w) => w.start < w.end, 'window start must be before end')
+ .default({ timezone: 'recipient', fallback: 'America/New_York', days: ['Mon', 'Tue', 'Wed', 'Thu'], start: '09:30', end: '16:30', holidays: [] }),
+ limits: z
+ .object({
+ accountPerDay: z.number().int().min(1).max(10_000).default(40),
+ domainPerDay: z.number().int().min(1).max(1_000).default(3),
+ campaignPerDay: z.number().int().min(1).max(10_000).optional(),
+ recipientMinGap: isoDuration.default('P3D'),
+ })
+ .strict()
+ .default({ accountPerDay: 40, domainPerDay: 3, recipientMinGap: 'P3D' }),
+ requirePostalAddress: z.boolean().default(true),
+ unsubscribe: z.enum(['link', 'reply']).default('link'),
+ expireAfter: isoDuration.default('P14D'),
+ })
+ .strict();
+
+export const AudienceSchema = z
+ .object({
+ source: z
+ .union([
+ z.literal('all'),
+ z.object({ importBatch: z.string().min(1) }).strict(),
+ z.object({ contactIds: z.array(z.string().min(1)).min(1).max(100_000) }).strict(),
+ ])
+ .default('all'),
+ require: z.array(z.literal('email')).default(['email']),
+ eligibility: z.array(z.enum(['consent_or_legitimate_interest', 'any'])).default(['consent_or_legitimate_interest']),
+ })
+ .strict();
+
+export const PlaybookSchema = z
+ .object({
+ apiVersion: z.literal(OUTREACH_API_VERSION),
+ kind: z.literal('Playbook'),
+ metadata: z.object({ name: z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/) }).strict(),
+ spec: z
+ .object({
+ purpose: z.enum(PROVIDER_PURPOSES),
+ audience: AudienceSchema.default({ source: 'all', require: ['email'], eligibility: ['consent_or_legitimate_interest'] }),
+ policy: PolicySchema.default(PolicySchema.parse({})),
+ steps: z.array(StepSchema).min(1).max(50),
+ })
+ .strict(),
+ })
+ .strict();
+
+export type Playbook = z.infer;
+export type Step = z.infer;
+export type Policy = z.infer;
+export type Audience = z.infer;
+
+export class PlaybookError extends Error {
+ constructor(readonly issues: readonly string[]) {
+ super(`Invalid playbook:\n- ${issues.join('\n- ')}`);
+ this.name = 'PlaybookError';
+ }
+}
+
+/** Parses YAML or an object into a validated playbook. Structural checks only; see compile for semantic ones. */
+export function parsePlaybook(input: string | unknown): Playbook {
+ const raw = typeof input === 'string' ? (parseYaml(input) as unknown) : input;
+ const result = PlaybookSchema.safeParse(raw);
+ if (!result.success) {
+ throw new PlaybookError(result.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`));
+ }
+ return result.data;
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/prepared.ts b/outreach-engine/packages/outreach-core/src/domain/prepared.ts
new file mode 100644
index 0000000..256d3f0
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/prepared.ts
@@ -0,0 +1,78 @@
+import { z } from 'zod';
+import { digestCanonical, ulid } from '@splitin/outreach-contracts';
+import { ConflictError, NotFoundError, audit, requireRole, type AuthContext } from './auth';
+import type { DomainEnv } from './env';
+
+export type PreparedKind = 'campaign.activate' | 'import.commit' | 'campaign.pause' | 'manual.done';
+export interface PreparedPayload {
+ readonly kind: PreparedKind;
+ readonly targetId: string;
+ readonly expectedHash?: string;
+ readonly reason?: string;
+ readonly outcome?: 'done' | 'skipped';
+ readonly preview?: unknown;
+}
+const PreparedPayloadSchema = z.object({
+ kind: z.enum(['campaign.activate', 'import.commit', 'campaign.pause', 'manual.done']),
+ targetId: z.string().min(1).max(200),
+ expectedHash: z.string().regex(/^[0-9a-f]{64}$/).optional(),
+ reason: z.string().max(500).optional(),
+ outcome: z.enum(['done', 'skipped']).optional(),
+ preview: z.unknown().optional(),
+}).strict().refine((payload) => !['campaign.activate', 'import.commit'].includes(payload.kind) || payload.expectedHash !== undefined, 'expected hash required')
+ .refine((payload) => payload.kind !== 'manual.done' || payload.outcome !== undefined, 'manual outcome required');
+interface PreparedRow {
+ id: string;
+ payload: string;
+ operation_hash: string;
+ expires_at: number;
+ committed_at: number | null;
+}
+export interface PreparedOperation {
+ readonly operationId: string;
+ readonly operationHash: string;
+ readonly expiresAt: number;
+}
+
+/** Durable, caller-bound envelope; the underlying domain operation still validates its own state. */
+export function prepareOperation(env: DomainEnv, ctx: AuthContext, payload: PreparedPayload): PreparedOperation {
+ requireRole(ctx, 'operator');
+ PreparedPayloadSchema.parse(payload);
+ return env.db.transaction(() => {
+ const now = env.now();
+ const operationId = ulid(now);
+ const expiresAt = now + 15 * 60_000;
+ const operationHash = digestCanonical({ operationId, workspaceId: ctx.workspaceId, principalId: ctx.principalId, payload, expiresAt });
+ env.db.prepare(`INSERT INTO prepared_operations (id, workspace_id, principal_id, payload, operation_hash, expires_at, created_at)
+ VALUES (?,?,?,?,?,?,?)`).run(operationId, ctx.workspaceId, ctx.principalId, JSON.stringify(payload), operationHash, expiresAt, now);
+ audit(env.db, ctx, now, 'prepared_operation', operationId, 'prepared', { kind: payload.kind, targetId: payload.targetId, operationHash });
+ return { operationId, operationHash, expiresAt };
+ });
+}
+
+/** Read the exact stored operation for a host confirmation; never accept a replacement preview from tool input. */
+export function inspectOperation(env: DomainEnv, ctx: AuthContext, input: { operationId: string; operationHash: string }): PreparedPayload {
+ requireRole(ctx, 'operator');
+ const row = env.db.prepare('SELECT * FROM prepared_operations WHERE id = ? AND workspace_id = ? AND principal_id = ?')
+ .get(input.operationId, ctx.workspaceId, ctx.principalId);
+ if (!row) throw new NotFoundError('prepared operation');
+ if (row.committed_at !== null) throw new ConflictError('operation already committed');
+ if (env.now() >= row.expires_at) throw new ConflictError('operation expired; prepare again');
+ if (input.operationHash !== row.operation_hash) throw new ConflictError('operation hash mismatch');
+ const payload = PreparedPayloadSchema.parse(JSON.parse(row.payload));
+ const digest = digestCanonical({ operationId: row.id, workspaceId: ctx.workspaceId, principalId: ctx.principalId, payload, expiresAt: row.expires_at });
+ if (digest !== row.operation_hash) throw new ConflictError('prepared operation integrity mismatch; prepare again');
+ return payload;
+}
+
+/** Hash possession is not human consent. A host must obtain consent before invoking this service. */
+export function commitOperation(env: DomainEnv, ctx: AuthContext, input: { operationId: string; operationHash: string }, apply: (payload: PreparedPayload) => T): T {
+ requireRole(ctx, 'operator');
+ return env.db.transaction(() => {
+ const payload = inspectOperation(env, ctx, input);
+ const result = apply(payload);
+ env.db.prepare('UPDATE prepared_operations SET committed_at = ? WHERE id = ?').run(env.now(), input.operationId);
+ audit(env.db, ctx, env.now(), 'prepared_operation', input.operationId, 'committed', { kind: payload.kind, targetId: payload.targetId });
+ return result;
+ });
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/retention-selection.ts b/outreach-engine/packages/outreach-core/src/domain/retention-selection.ts
new file mode 100644
index 0000000..7c8ebc9
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/retention-selection.ts
@@ -0,0 +1,121 @@
+import { sha256Hex, type SqlDatabase } from '@splitin/outreach-contracts';
+import { normalizeEmail } from './suppressions';
+
+export interface RetentionPolicy { readonly days: number; readonly cutoff: number; readonly limit: number; readonly afterId: string }
+type Row = Record;
+export interface RetentionContact {
+ readonly contact: Row;
+ readonly retained: { pruned_at: number } | null;
+ readonly points: Row[];
+ readonly suppressions: Row[];
+ readonly enrollments: Row[];
+ readonly actions: Row[];
+ readonly attempts: Row[];
+ readonly messages: Row[];
+ readonly events: Row[];
+ readonly tasks: Row[];
+ readonly imports: Row[];
+}
+const rows = (db: SqlDatabase, sql: string, ...args: string[]) => db.prepare(sql).all(...args);
+function byIds(db: SqlDatabase, table: string, column: string, ids: string[]): Row[] {
+ if (!ids.length) return [];
+ // Table and column are internal constants, never user input.
+ return rows(db, `SELECT * FROM ${table} WHERE ${column} IN (${ids.map(() => '?').join(',')}) ORDER BY id`, ...ids);
+}
+function historyRows(db: SqlDatabase, table: string, workspaceId: string): Row[] {
+ const result = rows(db, `SELECT * FROM ${table} WHERE workspace_id = ? ORDER BY id LIMIT 10001`, workspaceId);
+ if (result.length > 10000) throw new Error('Retention history scan exceeds 10000 rows per table; use reviewed offline tooling rather than an unbounded writer lock');
+ return result;
+}
+function addressIndex(items: Row[]): Map {
+ const index = new Map();
+ for (const row of items) {
+ if (typeof row.recipient_norm !== 'string') continue;
+ const address = normalizeEmail(row.recipient_norm);
+ const group = index.get(address) ?? []; group.push(row); index.set(address, group);
+ }
+ return index;
+}
+function byAddresses(index: Map, emails: Row[]): Row[] {
+ return emails.flatMap((p) => index.get(normalizeEmail(String(p.value_norm))) ?? []);
+}
+function relatedEvents(all: Row[], emails: Row[], messages: Row[], enrollments: Row[]): Row[] {
+ const addresses = new Set(emails.map((p) => normalizeEmail(String(p.value_norm))));
+ return all.filter((row) => {
+ if (enrollments.some((e) => e.id === row.enrollment_id)) return true;
+ const event = JSON.parse(String(row.payload)) as { from?: string; providerThreadId?: string; inReplyTo?: string; references?: string[]; dsn?: { recipient?: string; originalMessageId?: string } };
+ if ([event.from, event.dsn?.recipient].some((a) => typeof a === 'string' && addresses.has(normalizeEmail(a)))) return true;
+ const references = [event.inReplyTo, event.dsn?.originalMessageId, ...(Array.isArray(event.references) ? event.references : [])];
+ return messages.some((m) => (event.providerThreadId && m.provider_account_id === row.provider_account_id && m.provider_thread_id === event.providerThreadId)
+ || (m.rfc_message_id && references.includes(String(m.rfc_message_id))));
+ });
+}
+const ids = (items: Row[]) => items.map((r) => String(r.id));
+const recent = (items: Row[], field: string, cutoff: number) => items.some((r) => Number(r[field]) > cutoff);
+
+function hasNewPii(contact: Row, points: Row[], actions: Row[], attempts: Row[], messages: Row[], events: Row[], tasks: Row[], imports: Row[]): boolean {
+ return contact.full_name !== '[pruned]' || contact.first_name !== null || contact.attributes !== '{}'
+ || ['title', 'timezone', 'locale', 'organization_id'].some((key) => contact[key] !== null)
+ || points.some((p) => p.source !== 'retention-v1' || p.kind !== 'other' || p.value_norm !== `retained-contact-point:${p.id}`
+ || p.value_raw !== '' || p.permitted_channels !== '[]'
+ || ['consent_basis', 'consent_evidence', 'consent_at', 'jurisdiction'].some((key) => p[key] !== null))
+ || actions.some((a) => a.payload !== '{"retained":true}' || a.recipient_norm !== null || a.state_reason !== 'retention_pruned')
+ || attempts.some((a) => a.error_detail !== null || a.receipt !== null)
+ || messages.some((m) => m.from_addr !== '' || m.to_addrs !== '[]' || m.subject !== null || m.recipient_norm !== null)
+ || events.some((e) => e.payload !== '{"retained":true}' || e.detail !== null)
+ || tasks.some((t) => t.draft_text !== '' || t.target_url !== null || t.note !== null)
+ || imports.some((r) => r.raw !== '{}' || r.normalized !== null || r.errors !== '[]' || r.locator !== '[pruned]');
+}
+
+/** Conservative opt-out-only scope. Does not change data or select uncertain operational records. */
+export function selectRetention(db: SqlDatabase, workspaceId: string, policy: RetentionPolicy): { contacts: RetentionContact[]; scanned: number; nextAfterId: string | null } {
+ // An unresolved event can acquire a contact association later. Do not prune while any exist.
+ const allEvents = historyRows(db, 'provider_events', workspaceId);
+ if (allEvents.some((e) => !['processed', 'ignored'].includes(String(e.status)))) return { contacts: [], scanned: 0, nextAfterId: null };
+ const actionIndex = addressIndex(historyRows(db, 'scheduled_actions', workspaceId));
+ const messageIndex = addressIndex(historyRows(db, 'messages', workspaceId));
+ const candidates = db.prepare(`SELECT * FROM contacts c WHERE c.workspace_id = ? AND c.updated_at <= ?
+ AND c.merged_into_id IS NULL AND NOT EXISTS (SELECT 1 FROM contacts m WHERE m.merged_into_id = c.id)
+ AND c.id > ? ORDER BY c.id LIMIT 1000`).all(workspaceId, policy.cutoff, policy.afterId);
+ const selected: RetentionContact[] = [];
+ let scanned = 0; let nextAfterId: string | null = null;
+ for (const contact of candidates) {
+ if (selected.length >= policy.limit) break;
+ scanned += 1; nextAfterId = String(contact.id);
+ const points = rows(db, 'SELECT * FROM contact_points WHERE workspace_id = ? AND contact_id = ? ORDER BY id', workspaceId, String(contact.id));
+ const emails = points.filter((p) => p.kind === 'email');
+ const retained = db.prepare('SELECT pruned_at FROM retained_contacts WHERE workspace_id = ? AND contact_id = ?').get<{ pruned_at: number }>(workspaceId, String(contact.id));
+ if ((!emails.length && !retained) || (retained && retained.pruned_at > policy.cutoff)) continue;
+ const suppressions: Row[] = [];
+ for (const point of emails) {
+ const suppression = db.prepare(`SELECT * FROM suppressions WHERE workspace_id = ? AND scope = 'global'
+ AND channel = '*' AND value_norm = ? AND reason = 'opt_out' AND effective_at <= ?`).get(workspaceId, normalizeEmail(String(point.value_norm)), policy.cutoff);
+ const fingerprint = suppression ? undefined : db.prepare("SELECT * FROM suppression_fingerprints WHERE workspace_id = ? AND scheme = 'sha256-email-v1' AND digest = ? AND effective_at <= ?")
+ .get(workspaceId, sha256Hex(normalizeEmail(String(point.value_norm))), policy.cutoff);
+ if (suppression) suppressions.push(suppression);
+ else if (fingerprint) suppressions.push({ ...fingerprint, value_norm: normalizeEmail(String(point.value_norm)) });
+ }
+ if (suppressions.length !== emails.length) continue;
+ const enrollments = rows(db, 'SELECT * FROM enrollments WHERE workspace_id = ? AND contact_id = ? ORDER BY id', workspaceId, String(contact.id));
+ if (enrollments.some((e) => ['active', 'paused'].includes(String(e.status))) || recent(enrollments, 'updated_at', policy.cutoff)) continue;
+ const actions = [...new Map([...byIds(db, 'scheduled_actions', 'enrollment_id', ids(enrollments)),
+ ...byIds(db, 'scheduled_actions', 'contact_point_id', ids(points)), ...byAddresses(actionIndex, emails)].map((a) => [a.id, a])).values()].sort((a, b) => String(a.id).localeCompare(String(b.id)));
+ if (actions.some((a) => !['succeeded', 'failed', 'cancelled'].includes(String(a.state))) || recent(actions, 'updated_at', policy.cutoff)) continue;
+ const attempts = byIds(db, 'action_attempts', 'action_id', ids(actions));
+ if (attempts.some((a) => ['pending', 'uncertain'].includes(String(a.outcome))) || recent(attempts, 'finished_at', policy.cutoff)) continue;
+ const messages = [...new Map([...byIds(db, 'messages', 'enrollment_id', ids(enrollments)), ...byIds(db, 'messages', 'action_id', ids(actions)), ...byAddresses(messageIndex, emails)]
+ .map((m) => [m.id, m])).values()].sort((a, b) => String(a.id).localeCompare(String(b.id)));
+ const events = relatedEvents(allEvents, emails, messages, enrollments);
+ if (events.some((e) => !['processed', 'ignored'].includes(String(e.status))) || recent(events, 'received_at', policy.cutoff) || recent(events, 'processed_at', policy.cutoff) || recent(messages, 'at', policy.cutoff)) continue;
+ const tasks = byIds(db, 'manual_tasks', 'action_id', ids(actions));
+ if (tasks.some((t) => t.status === 'open') || recent(tasks, 'confirmed_at', policy.cutoff)) continue;
+ const imports = rows(db, `SELECT r.*, b.created_at AS batch_created_at, b.committed_at AS batch_committed_at FROM import_rows r JOIN import_batches b ON b.id = r.batch_id
+ WHERE b.workspace_id = ? AND r.contact_id = ? ORDER BY r.id`, workspaceId, String(contact.id));
+ if (recent(imports, 'batch_created_at', policy.cutoff) || recent(imports, 'batch_committed_at', policy.cutoff)) continue;
+ // Already-minimized identities can receive new linked mail later. Revisit only actual new PII,
+ // retaining the original tombstones without reconstructing the address from its fingerprint.
+ if (retained && !hasNewPii(contact, points, actions, attempts, messages, events, tasks, imports)) continue;
+ selected.push({ contact, retained: retained ?? null, points, suppressions, enrollments, actions, attempts, messages, events, tasks, imports });
+ }
+ return { contacts: selected, scanned, nextAfterId };
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/retention.test.ts b/outreach-engine/packages/outreach-core/src/domain/retention.test.ts
new file mode 100644
index 0000000..6f9e421
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/retention.test.ts
@@ -0,0 +1,196 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { digestCanonical, sha256Hex, ulid } from '@splitin/outreach-contracts';
+import { PLAYBOOK, makeDomainEnv, type DomainTestEnv } from './domain.test-util';
+import { prepareRetention, commitRetention } from './retention';
+import { addSuppression, createUnsubscribeToken, isSuppressed } from './suppressions';
+import { enqueueAction } from '../execution/enqueue';
+import { actorOf } from './auth';
+
+import { decideApproval, listApprovals } from './approvals';
+import { createCampaign, prepareActivation, commitActivation } from './campaigns';
+import { handleUnsubscribe } from '../inbound/unsubscribe';
+import type { FakeDelivery } from '@splitin/outreach-fakes';
+
+const open: DomainTestEnv[] = [];
+afterEach(() => { for (const env of open.splice(0)) env.engine.db.close(); });
+async function fixture() {
+ const env = await makeDomainEnv(); open.push(env);
+ const id = env.contact('person@example.org');
+ addSuppression(env.engine.db, { workspaceId: 'ws', scope: 'global', value: 'person@example.org', reason: 'opt_out', source: 'synthetic-test' }, env.now());
+ env.advance(40 * 86_400_000);
+ return { env, id };
+}
+function action(env: DomainTestEnv) {
+ return enqueueAction(env.engine.db, {
+ workspaceId: 'ws', kind: 'email.send', providerAccountId: env.accountId, purpose: 'automated_outreach',
+ recipient: 'person@example.org', idempotencyKey: `test:${env.now()}`, dueAt: env.now() - 1000,
+ payload: { from: { address: 'sender@example.com' }, to: [{ address: 'person@example.org' }], subject: 'Synthetic', text: 'test', headers: {} },
+ }, actorOf(env.admin), env.now()).action.id;
+}
+describe('explicit contact retention', () => {
+ it('previews without pruning; commits once and preserves normalized suppression after reimport', async () => {
+ const { env, id } = await fixture(); const db = env.engine.db;
+ const preview = prepareRetention(env.engine, env.admin, { days: 30 });
+ expect(preview.contactIds).toEqual([id]);
+ expect(db.prepare('SELECT full_name FROM contacts WHERE id = ?').get<{ full_name: string }>(id)?.full_name).toBe('Ada Lovelace');
+ expect(commitRetention(env.engine, env.admin, preview.id, preview.operationHash)).toEqual({ contacts: 1 });
+ expect(db.prepare('SELECT full_name FROM contacts WHERE id = ?').get<{ full_name: string }>(id)?.full_name).toBe('[pruned]');
+ expect(db.prepare('SELECT * FROM suppressions').all()).toHaveLength(0);
+ expect(db.prepare('SELECT scheme, digest FROM suppression_fingerprints').get()).toEqual({ scheme: 'sha256-email-v1', digest: sha256Hex('person@example.org') });
+ expect(isSuppressed(db, 'ws', ' mailto:PERSON@example.org ', null)).toBe('global:opt_out');
+ expect(isSuppressed(db, 'other', 'person@example.org', null)).toBeNull();
+ const reimported = env.contact('person@example.org'); expect(reimported).not.toBe(id);
+ const a = action(env); await env.engine.runOnce();
+ expect(db.prepare('SELECT state FROM scheduled_actions WHERE id = ?').get<{ state: string }>(a)?.state).toBe('cancelled');
+ expect(env.fake.deliveries).toHaveLength(0);
+ expect(() => commitRetention(env.engine, env.admin, preview.id, preview.operationHash)).toThrow(/consumed/);
+ });
+ it('requires explicit valid windows and current admin role, caller and workspace', async () => {
+ const { env } = await fixture();
+ expect(() => prepareRetention(env.engine, env.admin, { days: 0 })).toThrow(/days/);
+ expect(() => prepareRetention(env.engine, env.operator, { days: 30 })).toThrow(/admin/);
+ const p = prepareRetention(env.engine, env.admin, { days: 30 });
+ expect(() => commitRetention(env.engine, { ...env.admin, workspaceId: 'other' }, p.id, p.operationHash)).toThrow();
+ expect(() => commitRetention(env.engine, { ...env.admin, principalId: env.operator.principalId }, p.id, p.operationHash)).toThrow();
+ env.engine.db.prepare('UPDATE principals SET roles = ? WHERE id = ?').run('["viewer"]', env.admin.principalId);
+ expect(() => commitRetention(env.engine, env.admin, p.id, p.operationHash)).toThrow(/admin/);
+ });
+ it('rejects changed content, tampered policy, wrong hash and expired previews', async () => {
+ const { env, id } = await fixture();
+ const p = prepareRetention(env.engine, env.admin, { days: 30 });
+ expect(() => commitRetention(env.engine, env.admin, p.id, '0'.repeat(64))).toThrow(/changed/);
+ env.engine.db.prepare('UPDATE contacts SET first_name = ? WHERE id = ?').run('Changed', id);
+ expect(() => commitRetention(env.engine, env.admin, p.id, p.operationHash)).toThrow(/changed/);
+ const q = prepareRetention(env.engine, env.admin, { days: 30 });
+ env.engine.db.prepare('UPDATE retention_previews SET policy = ? WHERE id = ?').run(JSON.stringify({ ...q.policy, days: 1 }), q.id);
+ expect(() => commitRetention(env.engine, env.admin, q.id, q.operationHash)).toThrow(/changed/);
+ const r = prepareRetention(env.engine, env.admin, { days: 30 }); env.advance(16 * 60_000);
+ expect(() => commitRetention(env.engine, env.admin, r.id, r.operationHash)).toThrow(/expired/);
+ });
+ it('rejects newly scheduled work atomically and excludes uncertain or recent work', async () => {
+ const { env, id } = await fixture(); const p = prepareRetention(env.engine, env.admin, { days: 30 });
+ const a = action(env);
+ expect(prepareRetention(env.engine, env.admin, { days: 30 }).contactIds).toEqual([]);
+ expect(() => commitRetention(env.engine, env.admin, p.id, p.operationHash)).toThrow(/changed/);
+ expect(env.engine.db.prepare('SELECT full_name FROM contacts WHERE id = ?').get<{ full_name: string }>(id)?.full_name).not.toBe('[pruned]');
+ env.engine.db.prepare("UPDATE scheduled_actions SET state = 'uncertain', updated_at = ? WHERE id = ?").run(env.now() - 40 * 86_400_000, a);
+ expect(prepareRetention(env.engine, env.admin, { days: 30 }).contactIds).toEqual([]);
+ });
+ it('rolls back all redaction and retains the suppression if any step fails', async () => {
+ const { env, id } = await fixture(); const p = prepareRetention(env.engine, env.admin, { days: 30 });
+ env.engine.db.exec("CREATE TRIGGER fail_retention BEFORE INSERT ON retained_contacts BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END");
+ expect(() => commitRetention(env.engine, env.admin, p.id, p.operationHash)).toThrow(/synthetic failure/);
+ expect(env.engine.db.prepare('SELECT full_name FROM contacts WHERE id = ?').get<{ full_name: string }>(id)?.full_name).toBe('Ada Lovelace');
+ expect(isSuppressed(env.engine.db, 'ws', 'person@example.org', null)).toBe('global:opt_out');
+ expect(env.engine.db.prepare('SELECT committed_at FROM retention_previews WHERE id = ?').get<{ committed_at: null }>(p.id)?.committed_at).toBeNull();
+ });
+ it('keeps unexpired opt-outs, unsuppressed alternate addresses and legacy scopes out of pruning', async () => {
+ const { env } = await fixture();
+ expect(prepareRetention(env.engine, env.admin, { days: 60 }).contactIds).toEqual([]);
+ const other = env.contact('unsubscribed-later@example.org');
+ expect(prepareRetention(env.engine, env.admin, { days: 30 }).contactIds).not.toContain(other);
+ addSuppression(env.engine.db, { workspaceId: 'ws', scope: 'domain', value: 'blocked.example', reason: 'manual', source: 'test' }, env.now());
+ expect(isSuppressed(env.engine.db, 'ws', 'p@blocked.example', null)).toBe('domain:manual');
+ });
+ it('redacts old terminal payloads and supports later reimports using the typed fingerprint', async () => {
+ const { env, id } = await fixture(); const db = env.engine.db;
+ const a = action(env); await env.engine.runOnce(); env.advance(40 * 86_400_000);
+ const p = prepareRetention(env.engine, env.admin, { days: 30 });
+ expect(p.counts.actions).toBe(1);
+ commitRetention(env.engine, env.admin, p.id, p.operationHash);
+ expect(db.prepare('SELECT payload, recipient_norm FROM scheduled_actions WHERE id = ?').get(a))
+ .toEqual({ payload: '{"retained":true}', recipient_norm: null });
+ const reimported = env.contact('person@example.org'); expect(reimported).not.toBe(id);
+ env.advance(40 * 86_400_000);
+ const q = prepareRetention(env.engine, env.admin, { days: 30 });
+ expect(q.contactIds).toEqual([reimported]);
+ commitRetention(env.engine, env.admin, q.id, q.operationHash);
+ expect(db.prepare('SELECT * FROM suppression_fingerprints').all()).toHaveLength(1);
+ expect(isSuppressed(db, 'ws', 'PERSON@example.org', null)).toBe('global:opt_out');
+ });
+ it('bounds pages deterministically and never selects a contact already pruned', async () => {
+ const { env, id } = await fixture();
+ const other = env.contact('second@example.org');
+ addSuppression(env.engine.db, { workspaceId: 'ws', scope: 'global', value: 'second@example.org', reason: 'opt_out', source: 'test' }, env.now());
+ env.advance(40 * 86_400_000);
+ const p = prepareRetention(env.engine, env.admin, { days: 30, limit: 1 });
+ expect(p.contactIds).toHaveLength(1); expect(p.scanned).toBe(1);
+ const q = prepareRetention(env.engine, env.admin, { days: 30, limit: 1, afterId: p.nextAfterId! });
+ expect(new Set([...p.contactIds, ...q.contactIds])).toEqual(new Set([id, other]));
+ commitRetention(env.engine, env.admin, p.id, p.operationHash);
+ const fresh = prepareRetention(env.engine, env.admin, { days: 30 });
+ expect(fresh.contactIds).not.toContain(p.contactIds[0]);
+ });
+
+ it('excludes noncanonical direct recipients and all unresolved inbound correlations', async () => {
+ const { env } = await fixture(); const db = env.engine.db;
+ const a = action(env);
+ db.prepare("UPDATE scheduled_actions SET recipient_norm = ' mailto:PERSON@EXAMPLE.ORG ', state = 'uncertain' WHERE id = ?").run(a);
+ expect(prepareRetention(env.engine, env.admin, { days: 30 }).contactIds).toEqual([]);
+ db.prepare("UPDATE scheduled_actions SET state = 'cancelled', updated_at = ? WHERE id = ?").run(env.now() - 40 * 86_400_000, a);
+ expect(prepareRetention(env.engine, env.admin, { days: 30 }).contactIds).toHaveLength(1);
+ const event = { eventId: 'synthetic-bounce', kind: 'bounce', providerMessageId: 'synthetic-message', from: 'postmaster@example.net', to: ['sender@example.com'], references: [], receivedAt: env.now(), headers: {}, dsn: { status: '5.1.1', recipient: 'person@example.org' } };
+ db.prepare("INSERT INTO provider_events (id,workspace_id,provider_account_id,provider_event_id,kind,payload,payload_digest,status,received_at) VALUES (?,?,?,?,?,?,?,'pending',?)")
+ .run(ulid(env.now()), 'ws', env.accountId, event.eventId, 'bounce', JSON.stringify(event), digestCanonical(event), env.now());
+ expect(prepareRetention(env.engine, env.admin, { days: 30 }).contactIds).toEqual([]);
+ db.prepare("UPDATE provider_events SET status = 'processed', processed_at = ?").run(env.now());
+ expect(prepareRetention(env.engine, env.admin, { days: 30 }).contactIds).toEqual([]);
+ env.advance(40 * 86_400_000);
+ const p = prepareRetention(env.engine, env.admin, { days: 30 }); expect(p.counts.events).toBe(1);
+ commitRetention(env.engine, env.admin, p.id, p.operationHash);
+ expect(db.prepare('SELECT payload FROM provider_events').get()).toEqual({ payload: '{"retained":true}' });
+ });
+
+ it('fails closed on history above the bounded maintenance scan', async () => {
+ const { env } = await fixture(); const db = env.engine.db;
+ db.transaction(() => {
+ const insert = db.prepare("INSERT INTO provider_events (id,workspace_id,provider_account_id,provider_event_id,kind,payload,payload_digest,status,received_at) VALUES (?,?,?,?,?,'{}','synthetic','ignored',?)");
+ for (let i = 0; i <= 10000; i += 1) insert.run(`event-${i}`, 'ws', env.accountId, `provider-${i}`, 'message', env.now());
+ });
+ expect(() => prepareRetention(env.engine, env.admin, { days: 30 })).toThrow(/10000/);
+ expect(db.prepare('SELECT * FROM retention_previews').all()).toHaveLength(0);
+ });
+
+ it('revisits aged late replies linked to a pruned identity without losing suppression', async () => {
+ const env = await makeDomainEnv(); open.push(env); const db = env.engine.db;
+ const id = env.contact('person@example.org');
+ const { campaignId } = createCampaign(env.engine, env.operator, { name: 'Late reply', playbook: PLAYBOOK, providerAccountId: env.accountId });
+ const activation = prepareActivation(env.engine, env.operator, campaignId);
+ decideApproval(env.engine, env.approver, { approvalId: activation.approvalId!, decision: 'approved', operationHash: activation.operationHash });
+ const { batchApprovalId } = commitActivation(env.engine, env.operator, { campaignId, operationHash: activation.operationHash });
+ const batch = listApprovals(db, env.approver).find((r) => r.id === batchApprovalId)!;
+ decideApproval(env.engine, env.approver, { approvalId: batch.id, decision: 'approved', operationHash: batch.operation_hash });
+ await env.drain(); const first = env.fake.deliveries[0] as FakeDelivery;
+ const point = db.prepare('SELECT id FROM contact_points WHERE contact_id = ?').get<{ id: string }>(id)!;
+ handleUnsubscribe(env.engine, createUnsubscribeToken(env.engine.unsubscribe!.secret, { w: 'ws', cp: point.id, c: campaignId }));
+ env.advance(40 * 86_400_000);
+ const p = prepareRetention(env.engine, env.admin, { days: 30 }); expect(p.contactIds).toContain(id);
+ commitRetention(env.engine, env.admin, p.id, p.operationHash);
+ const fingerprints = db.prepare('SELECT * FROM suppression_fingerprints').all();
+ env.fake.reply(first, { at: env.now(), subject: 'Person private subject' }); await env.engine.runOnce();
+ expect(prepareRetention(env.engine, env.admin, { days: 30 }).contactIds).toEqual([]);
+ env.advance(40 * 86_400_000);
+ const q = prepareRetention(env.engine, env.admin, { days: 30 }); expect(q.contactIds).toEqual([id]);
+ db.prepare('UPDATE retained_contacts SET pruned_at = pruned_at - 1 WHERE contact_id = ?').run(id);
+ expect(() => commitRetention(env.engine, env.admin, q.id, q.operationHash)).toThrow(/changed/);
+ const fresh = prepareRetention(env.engine, env.admin, { days: 30 });
+ commitRetention(env.engine, env.admin, fresh.id, fresh.operationHash);
+ expect(db.prepare("SELECT from_addr, subject FROM messages WHERE direction = 'inbound'").get()).toEqual({ from_addr: '', subject: null });
+ expect(db.prepare('SELECT * FROM suppression_fingerprints').all()).toEqual(fingerprints);
+ expect(isSuppressed(db, 'ws', 'person@example.org', null)).toBe('global:opt_out');
+ env.advance(40 * 86_400_000);
+ expect(prepareRetention(env.engine, env.admin, { days: 30 }).contactIds).toEqual([]);
+ });
+
+ it('does not grandfather an unsuppressed email newly linked to an already pruned identity', async () => {
+ const { env, id } = await fixture(); const db = env.engine.db;
+ const p = prepareRetention(env.engine, env.admin, { days: 30 }); commitRetention(env.engine, env.admin, p.id, p.operationHash);
+ const other = env.contact('fresh@example.org');
+ db.prepare('UPDATE contact_points SET contact_id = ? WHERE contact_id = ?').run(id, other);
+ env.advance(40 * 86_400_000);
+ expect(prepareRetention(env.engine, env.admin, { days: 30 }).contactIds).not.toContain(id);
+ expect(isSuppressed(db, 'ws', 'person@example.org', null)).toBe('global:opt_out');
+ expect(isSuppressed(db, 'ws', 'fresh@example.org', null)).toBeNull();
+ });
+
+});
diff --git a/outreach-engine/packages/outreach-core/src/domain/retention.ts b/outreach-engine/packages/outreach-core/src/domain/retention.ts
new file mode 100644
index 0000000..7c9faa8
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/retention.ts
@@ -0,0 +1,86 @@
+import { digestCanonical, sha256Hex, ulid } from '@splitin/outreach-contracts';
+import { ConflictError, ForbiddenError, audit, requireRole, type AuthContext } from './auth';
+import type { DomainEnv } from './env';
+import { normalizeEmail } from './suppressions';
+import { selectRetention, type RetentionContact, type RetentionPolicy } from './retention-selection';
+
+export const RETENTION_LIMITS = { maxBatch: 100, maxScanned: 1000, previewTtlMs: 15 * 60_000 } as const;
+export interface RetentionInput { readonly days: number; readonly limit?: number; readonly afterId?: string }
+export interface RetentionPreview {
+ readonly id: string; readonly operationHash: string; readonly expiresAt: number;
+ readonly policy: RetentionPolicy; readonly contactIds: string[];
+ readonly counts: Record; readonly scanned: number; readonly nextAfterId: string | null; readonly exclusions: readonly string[];
+}
+const EXCLUSIONS = ['immutable audit history', 'shared approval/prepared previews and rate-limit/reservation metadata', 'unlinked or free-text copies and non-opt-out suppression records', 'provider mailboxes', 'external exports and previous backups'] as const;
+function currentAdmin(env: DomainEnv, ctx: AuthContext): void {
+ requireRole(ctx, 'admin');
+ const principal = env.db.prepare('SELECT roles FROM principals WHERE workspace_id = ? AND id = ?').get<{ roles: string }>(ctx.workspaceId, ctx.principalId);
+ if (!principal || !(JSON.parse(principal.roles) as string[]).includes('admin')) throw new ForbiddenError('current admin role required');
+}
+function counts(contacts: RetentionContact[]): Record {
+ const result: Record = { contacts: contacts.length };
+ for (const key of ['points', 'actions', 'attempts', 'messages', 'events', 'tasks', 'imports'] as const) result[key] = contacts.reduce((n, c) => n + c[key].length, 0);
+ return result;
+}
+function digest(ctx: AuthContext, id: string, policy: RetentionPolicy, expiresAt: number, selected: ReturnType): string {
+ return digestCanonical({ scheme: 'contact-retention-v1', workspaceId: ctx.workspaceId, principalId: ctx.principalId, id, policy, expiresAt, selected });
+}
+/** Preview writes only an opaque approval envelope and audit record; no contact data is pruned. */
+export function prepareRetention(env: DomainEnv, ctx: AuthContext, input: RetentionInput): RetentionPreview {
+ currentAdmin(env, ctx);
+ if (!Number.isSafeInteger(input.days) || input.days < 1 || input.days > 36500) throw new Error('Explicit retention days must be an integer from 1 to 36500');
+ const limit = input.limit ?? 100;
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error('Retention batch limit must be 1 to 100');
+ return env.db.transaction(() => {
+ currentAdmin(env, ctx);
+ const now = env.now(); const id = ulid(now); const expiresAt = now + RETENTION_LIMITS.previewTtlMs;
+ const policy = { days: input.days, cutoff: now - input.days * 86_400_000, limit, afterId: input.afterId ?? '' };
+ const selected = selectRetention(env.db, ctx.workspaceId, policy);
+ const operationHash = digest(ctx, id, policy, expiresAt, selected);
+ env.db.prepare('INSERT INTO retention_previews (id, workspace_id, principal_id, policy, operation_hash, created_at, expires_at) VALUES (?,?,?,?,?,?,?)')
+ .run(id, ctx.workspaceId, ctx.principalId, JSON.stringify(policy), operationHash, now, expiresAt);
+ audit(env.db, ctx, now, 'retention', id, 'previewed', { operationHash, policy, counts: counts(selected.contacts) });
+ return { id, operationHash, expiresAt, policy, contactIds: selected.contacts.map((s) => String(s.contact.id)), counts: counts(selected.contacts), scanned: selected.scanned, nextAfterId: selected.nextAfterId, exclusions: EXCLUSIONS };
+ });
+}
+function prune(env: DomainEnv, ctx: AuthContext, selected: RetentionContact[], now: number): void {
+ const db = env.db;
+ for (const item of selected) {
+ for (const suppression of item.suppressions) {
+ db.prepare(`INSERT INTO suppression_fingerprints (workspace_id, scheme, digest, reason, effective_at)
+ VALUES (?, 'sha256-email-v1', ?, 'opt_out', ?) ON CONFLICT DO NOTHING`)
+ .run(ctx.workspaceId, sha256Hex(normalizeEmail(String(suppression.value_norm))), Number(suppression.effective_at));
+ if (suppression.id) db.prepare('DELETE FROM suppressions WHERE id = ? AND workspace_id = ?').run(String(suppression.id), ctx.workspaceId);
+ }
+ db.prepare(`UPDATE contacts SET full_name = '[pruned]', first_name = NULL, title = NULL, timezone = NULL,
+ locale = NULL, organization_id = NULL, attributes = '{}', updated_at = ? WHERE id = ? AND workspace_id = ?`)
+ .run(now, String(item.contact.id), ctx.workspaceId);
+ // Keep FK identities, but no contact address is left in these operational records.
+ for (const point of item.points) db.prepare(`UPDATE contact_points SET kind = 'other', value_norm = ?, value_raw = '',
+ source = 'retention-v1', consent_basis = NULL, consent_evidence = NULL, consent_at = NULL, jurisdiction = NULL,
+ permitted_channels = '[]' WHERE id = ? AND workspace_id = ?`).run(`retained-contact-point:${point.id}`, String(point.id), ctx.workspaceId);
+ for (const row of item.imports) db.prepare(`UPDATE import_rows SET raw = '{}', normalized = NULL, errors = '[]', locator = '[pruned]' WHERE id = ?`).run(String(row.id));
+ for (const row of item.actions) db.prepare(`UPDATE scheduled_actions SET recipient_norm = NULL, payload = '{"retained":true}', state_reason = 'retention_pruned' WHERE id = ?`).run(String(row.id));
+ for (const row of item.attempts) db.prepare('UPDATE action_attempts SET error_detail = NULL, receipt = NULL WHERE id = ?').run(String(row.id));
+ for (const row of item.messages) db.prepare(`UPDATE messages SET from_addr = '', to_addrs = '[]', recipient_norm = NULL, subject = NULL WHERE id = ?`).run(String(row.id));
+ for (const row of item.events) db.prepare(`UPDATE provider_events SET payload = '{"retained":true}', detail = NULL WHERE id = ?`).run(String(row.id));
+ for (const row of item.tasks) db.prepare(`UPDATE manual_tasks SET target_url = NULL, draft_text = '', note = NULL WHERE id = ?`).run(String(row.id));
+ db.prepare('INSERT INTO retained_contacts (workspace_id, contact_id, pruned_at) VALUES (?,?,?) ON CONFLICT (workspace_id, contact_id) DO UPDATE SET pruned_at = excluded.pruned_at').run(ctx.workspaceId, String(item.contact.id), now);
+ }
+}
+/** Destructive: only call after a trusted operator explicitly confirms this exact preview hash. */
+export function commitRetention(env: DomainEnv, ctx: AuthContext, id: string, confirmedHash: string): { contacts: number } {
+ return env.db.transaction(() => {
+ currentAdmin(env, ctx);
+ const row = env.db.prepare('SELECT * FROM retention_previews WHERE id = ? AND workspace_id = ? AND principal_id = ?')
+ .get<{ policy: string; operation_hash: string; expires_at: number; committed_at: number | null }>(id, ctx.workspaceId, ctx.principalId);
+ if (!row || row.committed_at !== null || env.now() >= row.expires_at) throw new ConflictError('Retention preview missing, expired or already consumed');
+ const policy = JSON.parse(row.policy) as RetentionPolicy;
+ const selected = selectRetention(env.db, ctx.workspaceId, policy);
+ if (row.operation_hash !== confirmedHash || digest(ctx, id, policy, row.expires_at, selected) !== confirmedHash) throw new ConflictError('Retention preview changed; review a fresh preview');
+ prune(env, ctx, selected.contacts, env.now());
+ env.db.prepare('UPDATE retention_previews SET committed_at = ? WHERE id = ?').run(env.now(), id);
+ audit(env.db, ctx, env.now(), 'retention', id, 'committed', { operationHash: confirmedHash, counts: counts(selected.contacts), exclusions: EXCLUSIONS });
+ return { contacts: selected.contacts.length };
+ });
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/suppressions.ts b/outreach-engine/packages/outreach-core/src/domain/suppressions.ts
new file mode 100644
index 0000000..c9818d2
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/suppressions.ts
@@ -0,0 +1,113 @@
+import { hmacSha256Hex, safeEqualHex, sha256Hex, ulid, type SqlDatabase } from '@splitin/outreach-contracts';
+import { CANCELLABLE_ACTION_STATES } from '@splitin/outreach-contracts';
+import { transitionAction, type Actor } from '../execution/actions-repo';
+import type { ActionRow } from '../execution/types';
+import type { EnrollmentRow } from './env';
+
+export type SuppressionScope = 'global' | 'channel' | 'provider_account' | 'domain';
+export type SuppressionReason = 'opt_out' | 'hard_bounce' | 'complaint' | 'manual' | 'do_not_contact' | 'legal';
+
+export interface SuppressionInput {
+ readonly workspaceId: string;
+ readonly scope: SuppressionScope;
+ /** Channel name for 'channel' scope, provider account id for 'provider_account', '*' otherwise. */
+ readonly channel?: string;
+ readonly value: string;
+ readonly reason: SuppressionReason;
+ readonly source: string;
+}
+
+export function normalizeEmail(value: string): string {
+ return value.trim().replace(/^mailto:/i, '').toLowerCase();
+}
+
+/** Idempotent: suppressing an already-suppressed value keeps the earliest record. Must run in a transaction. */
+export function addSuppression(db: SqlDatabase, input: SuppressionInput, now: number): void {
+ db.prepare(
+ `INSERT INTO suppressions (id, workspace_id, scope, channel, value_norm, reason, source, effective_at)
+ VALUES (?,?,?,?,?,?,?,?) ON CONFLICT (workspace_id, scope, channel, value_norm) DO NOTHING`,
+ ).run(ulid(now), input.workspaceId, input.scope, input.channel ?? '*', normalizeEmail(input.value), input.reason, input.source, now);
+}
+
+/** Checks every scope that can cover an email recipient: global, email channel, sending account and domain. */
+export function isSuppressed(db: SqlDatabase, workspaceId: string, recipient: string, providerAccountId: string | null): string | null {
+ const value = normalizeEmail(recipient);
+ const domain = value.split('@')[1] ?? '';
+ const row = db
+ .prepare(
+ `SELECT scope, reason FROM suppressions WHERE workspace_id = ? AND (
+ (scope = 'global' AND value_norm = ?) OR
+ (scope = 'channel' AND channel IN ('email','*') AND value_norm = ?) OR
+ (scope = 'provider_account' AND channel = ? AND value_norm = ?) OR
+ (scope = 'domain' AND value_norm = ?)
+ ) LIMIT 1`,
+ )
+ .get<{ scope: string; reason: string }>(workspaceId, value, value, providerAccountId ?? '', value, domain);
+ if (row) return `${row.scope}:${row.reason}`;
+ const fingerprint = db.prepare("SELECT reason FROM suppression_fingerprints WHERE workspace_id = ? AND scheme = 'sha256-email-v1' AND digest = ?")
+ .get<{ reason: string }>(workspaceId, sha256Hex(value));
+ return fingerprint ? `global:${fingerprint.reason}` : null;
+}
+
+type StopStatus = 'replied' | 'opted_out' | 'bounced' | 'completed' | 'stopped' | 'error';
+
+/**
+ * Stops an enrollment atomically: status change plus cancellation of every pending action and open task.
+ * `executing` and `uncertain` actions cannot be recalled (ADR 0002). Returns false if it was not live.
+ */
+export function stopEnrollment(
+ db: SqlDatabase,
+ enrollmentId: string,
+ status: StopStatus,
+ reason: string,
+ actor: Actor,
+ now: number,
+): boolean {
+ const changed = db
+ .prepare(
+ `UPDATE enrollments SET status = ?, stop_reason = ?, row_version = row_version + 1, updated_at = ?
+ WHERE id = ? AND status IN ('active','paused')`,
+ )
+ .run(status, reason, now, enrollmentId);
+ if (changed.changes !== 1) return false;
+ const placeholders = CANCELLABLE_ACTION_STATES.map(() => '?').join(',');
+ const pending = db
+ .prepare(`SELECT * FROM scheduled_actions WHERE enrollment_id = ? AND state IN (${placeholders})`)
+ .all(enrollmentId, ...CANCELLABLE_ACTION_STATES);
+ for (const action of pending) {
+ transitionAction(db, action, 'cancelled', now, actor, { lease_owner: null, lease_expires_at: null, state_reason: `enrollment_${status}` });
+ }
+ db.prepare(`UPDATE manual_tasks SET status = 'expired', note = ? WHERE enrollment_id = ? AND status = 'open'`).run(`enrollment_${status}`, enrollmentId);
+ return true;
+}
+
+export function liveEnrollmentsForContact(db: SqlDatabase, workspaceId: string, contactId: string): EnrollmentRow[] {
+ return db
+ .prepare(`SELECT * FROM enrollments WHERE workspace_id = ? AND contact_id = ? AND status IN ('active','paused')`)
+ .all(workspaceId, contactId);
+}
+
+export interface UnsubscribeClaims {
+ readonly w: string;
+ readonly cp: string;
+ readonly c: string;
+}
+
+/** Self-verifying token: base64url(claims).hmac — no database lookup is needed to validate it. */
+export function createUnsubscribeToken(secret: string, claims: UnsubscribeClaims): string {
+ const body = Buffer.from(JSON.stringify(claims)).toString('base64url');
+ return `${body}.${hmacSha256Hex(secret, body)}`;
+}
+
+export function verifyUnsubscribeToken(secret: string, token: string): UnsubscribeClaims | null {
+ const [body, signature] = token.split('.');
+ if (!body || !signature || !safeEqualHex(hmacSha256Hex(secret, body), signature)) return null;
+ try {
+ const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Partial;
+ return typeof claims.w === 'string' && typeof claims.cp === 'string' && typeof claims.c === 'string'
+ ? { w: claims.w, cp: claims.cp, c: claims.c }
+ : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/templates.ts b/outreach-engine/packages/outreach-core/src/domain/templates.ts
new file mode 100644
index 0000000..ed06b8f
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/templates.ts
@@ -0,0 +1,118 @@
+import { ulid, type SqlDatabase } from '@splitin/outreach-contracts';
+import { audit, requireRole, type AuthContext } from './auth';
+
+/** Tokens a template may use. `attr.` reads contact attributes from the import. */
+export const KNOWN_TOKENS = [
+ 'first_name',
+ 'full_name',
+ 'title',
+ 'org_name',
+ 'org_domain',
+ 'sender_name',
+ 'sender_email',
+ 'sender_org',
+ 'sender_address',
+ 'unsubscribe_url',
+] as const;
+
+const TOKEN_RE = /\{\{\s*([a-z_]+(?:\.[a-z0-9_]+)?)\s*\}\}/gi;
+
+export interface TemplateRow {
+ id: string;
+ workspace_id: string;
+ name: string;
+ version: number;
+ channel: string;
+ subject: string | null;
+ body_text: string;
+ body_html: string | null;
+ required_tokens: string;
+}
+
+export function extractTokens(...sources: (string | null | undefined)[]): string[] {
+ const tokens = new Set();
+ for (const source of sources) {
+ for (const match of (source ?? '').matchAll(TOKEN_RE)) tokens.add((match[1] ?? '').toLowerCase());
+ }
+ return [...tokens].sort();
+}
+
+export function unknownTokens(tokens: readonly string[]): string[] {
+ return tokens.filter((token) => !(KNOWN_TOKENS as readonly string[]).includes(token) && !/^attr\.[a-z0-9_]+$/.test(token));
+}
+
+export class TemplateRenderError extends Error {
+ constructor(readonly missing: readonly string[]) {
+ super(`Missing template values: ${missing.join(', ')}`);
+ this.name = 'TemplateRenderError';
+ }
+}
+
+function stripControl(value: string): string {
+ let out = '';
+ for (const char of value) {
+ const code = char.codePointAt(0) ?? 0;
+ out += code < 0x20 || code === 0x7f ? ' ' : char;
+ }
+ return out.replace(/ {2,}/g, ' ').trim();
+}
+
+export function escapeHtml(value: string): string {
+ return value.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
+}
+
+/** Renders `{{token}}` placeholders. Fails closed on any missing or empty value. Values are escaped in HTML. */
+export function render(source: string, values: Readonly>, mode: 'text' | 'html'): string {
+ const missing = new Set();
+ const out = source.replace(TOKEN_RE, (_, raw: string) => {
+ const value = values[raw.toLowerCase()];
+ if (value === undefined || value.trim() === '') {
+ missing.add(raw.toLowerCase());
+ return '';
+ }
+ // Contact data is untrusted: strip control characters and keep it on one line.
+ const clean = stripControl(value);
+ return mode === 'html' ? escapeHtml(clean) : clean;
+ });
+ if (missing.size) throw new TemplateRenderError([...missing].sort());
+ return out;
+}
+
+export interface TemplateInput {
+ readonly name: string;
+ readonly channel: string;
+ readonly subject?: string;
+ readonly text: string;
+ readonly html?: string;
+}
+
+/** Creates the next immutable version of a template. Existing versions are never edited. */
+export function createTemplate(db: SqlDatabase, ctx: AuthContext, input: TemplateInput, now: number): TemplateRow {
+ requireRole(ctx, 'operator');
+ if (!/^[a-z0-9][a-z0-9_.-]{0,63}$/.test(input.name)) throw new Error('template name must be lowercase [a-z0-9_.-]');
+ const tokens = extractTokens(input.subject, input.text, input.html);
+ const unknown = unknownTokens(tokens);
+ if (unknown.length) throw new Error(`Unknown template tokens: ${unknown.join(', ')}`);
+ return db.transaction(() => {
+ const latest = db
+ .prepare('SELECT MAX(version) AS v FROM templates WHERE workspace_id = ? AND name = ?')
+ .get<{ v: number | null }>(ctx.workspaceId, input.name);
+ const version = (latest?.v ?? 0) + 1;
+ const id = ulid(now);
+ db.prepare(
+ `INSERT INTO templates (id, workspace_id, name, version, channel, subject, body_text, body_html, required_tokens, created_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?)`,
+ ).run(id, ctx.workspaceId, input.name, version, input.channel, input.subject ?? null, input.text, input.html ?? null, JSON.stringify(tokens), now);
+ audit(db, ctx, now, 'template', id, 'created', { name: input.name, version });
+ const row = db.prepare('SELECT * FROM templates WHERE id = ?').get(id);
+ if (!row) throw new Error('template vanished');
+ return row;
+ });
+}
+
+export function findTemplate(db: SqlDatabase, workspaceId: string, ref: string): TemplateRow | undefined {
+ const [name, version] = ref.split('@');
+ return db
+ .prepare('SELECT * FROM templates WHERE workspace_id = ? AND name = ? AND version = ?')
+ .get(workspaceId, name ?? '', Number(version));
+}
diff --git a/outreach-engine/packages/outreach-core/src/domain/unsubscribe-headers.test.ts b/outreach-engine/packages/outreach-core/src/domain/unsubscribe-headers.test.ts
new file mode 100644
index 0000000..91ba6c2
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/unsubscribe-headers.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import { commitActivation, createCampaign, prepareActivation } from './campaigns';
+import { decideApproval, listApprovals } from './approvals';
+import { makeDomainEnv, PLAYBOOK, type DomainTestEnv } from './domain.test-util';
+
+async function firstEmail(env: DomainTestEnv, playbook: string) {
+ env.contact('ada@example.org');
+ const { campaignId } = createCampaign(env.engine, env.operator, { name: 'Intro', playbook, providerAccountId: env.accountId });
+ const preview = prepareActivation(env.engine, env.operator, campaignId);
+ if (preview.approvalId) decideApproval(env.engine, env.approver, { approvalId: preview.approvalId, decision: 'approved', operationHash: preview.operationHash });
+ commitActivation(env.engine, env.operator, { campaignId, operationHash: preview.operationHash });
+ for (const approval of listApprovals(env.engine.db, env.approver)) {
+ decideApproval(env.engine, env.approver, { approvalId: approval.id, decision: 'approved', operationHash: approval.operation_hash });
+ }
+ await env.drain();
+ const email = env.fake.deliveries[0];
+ if (!email) throw new Error('nothing was sent');
+ return email;
+}
+
+describe('List-Unsubscribe', () => {
+ it('reply policy: a mailto to the sending mailbox, and no one-click claim', async () => {
+ const env = await makeDomainEnv();
+ const email = await firstEmail(env, PLAYBOOK.replace('unsubscribe: link', 'unsubscribe: reply'));
+ expect(email.headers['List-Unsubscribe']).toBe('');
+ expect(email.headers['List-Unsubscribe-Post']).toBeUndefined();
+ });
+
+ it('a link that opens a page (oneClick: false) keeps the mailto fallback but never advertises one-click', async () => {
+ const env = await makeDomainEnv({ unsubscribe: { baseUrl: 'https://apps.example.com/unsub/?u=', secret: 'unsubscribe-test-secret-fixture', oneClick: false } });
+ const email = await firstEmail(env, PLAYBOOK);
+ expect(email.headers['List-Unsubscribe']).toMatch(/^]+>, $/);
+ expect(email.headers['List-Unsubscribe-Post']).toBeUndefined();
+ });
+});
diff --git a/outreach-engine/packages/outreach-core/src/domain/wiring.ts b/outreach-engine/packages/outreach-core/src/domain/wiring.ts
new file mode 100644
index 0000000..a1cfdc7
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/domain/wiring.ts
@@ -0,0 +1,126 @@
+import type { ErrorEffect, SqlDatabase } from '@splitin/outreach-contracts';
+import type { Actor } from '../execution/actions-repo';
+import type { ActionEffects, ActionRow, PreflightCheck, RatePolicy, RateLimit } from '../execution/types';
+import { approvalVerdict } from './approvals';
+import { actionApprover } from './campaigns';
+import { durationMs, nextSlot, resolveZone } from './calendar';
+import { loadEnrollment, loadVersion, type DomainEnv } from './env';
+import { loadStepContext, materializeNext, stepIndex } from './materialize';
+import type { Policy } from './playbook';
+import { contactPointVerdict } from './jurisdictions';
+import { addSuppression, isSuppressed, stopEnrollment } from './suppressions';
+
+const PAUSED_DEFER_MS = 15 * 60_000;
+const DAY_MS = 86_400_000;
+
+function engineActor(action: ActionRow): Actor {
+ return { kind: 'worker', id: 'engine', source: 'worker', traceId: action.id };
+}
+
+function policyOf(db: SqlDatabase, action: ActionRow): Policy | null {
+ if (!action.enrollment_id) return null;
+ const enrollment = loadEnrollment(db, action.enrollment_id);
+ const version = enrollment ? loadVersion(db, enrollment.campaign_version_id) : undefined;
+ return version ? (JSON.parse(version.policy) as Policy) : null;
+}
+
+/** Domain checks, run inside preflight after the kill-switch and expiry checks (BUILD_PLAN.md §6.4). */
+export function domainChecks(): PreflightCheck[] {
+ const enrollmentLive: PreflightCheck = ({ db, action, now }) => {
+ if (!action.enrollment_id) return { kind: 'pass' };
+ const enrollment = loadEnrollment(db, action.enrollment_id);
+ if (!enrollment) return { kind: 'cancel', reason: 'enrollment_missing' };
+ if (enrollment.status === 'paused') return { kind: 'defer', until: now + PAUSED_DEFER_MS, reason: 'enrollment_paused' };
+ return enrollment.status === 'active' ? { kind: 'pass' } : { kind: 'cancel', reason: `enrollment_${enrollment.status}` };
+ };
+ const campaignLive: PreflightCheck = ({ db, action, now }) => {
+ if (!action.campaign_id) return { kind: 'pass' };
+ const row = db.prepare('SELECT status FROM campaigns WHERE id = ?').get<{ status: string }>(action.campaign_id);
+ if (row?.status === 'paused') return { kind: 'defer', until: now + PAUSED_DEFER_MS, reason: 'campaign_paused' };
+ return row?.status === 'active' ? { kind: 'pass' } : { kind: 'cancel', reason: `campaign_${row?.status ?? 'missing'}` };
+ };
+ const notSuppressed: PreflightCheck = ({ db, action }) => {
+ if (!action.recipient_norm || action.kind === 'notify.publish') return { kind: 'pass' };
+ const hit = isSuppressed(db, action.workspace_id, action.recipient_norm, action.provider_account_id);
+ return hit ? { kind: 'cancel', reason: `suppressed:${hit}` } : { kind: 'pass' };
+ };
+ // Re-checked at send time: the policy may have changed since the audience was snapshotted.
+ const lawfulBasis: PreflightCheck = ({ db, action }) => {
+ if (action.kind !== 'email.send' && action.kind !== 'email.reply') return { kind: 'pass' };
+ const verdict = contactPointVerdict(db, action.workspace_id, action.contact_point_id);
+ return verdict.ok ? { kind: 'pass' } : { kind: 'cancel', reason: verdict.reason };
+ };
+ const approved: PreflightCheck = ({ db, action, now }) => approvalVerdict(db, action, now);
+ const inWindow: PreflightCheck = ({ db, action, now }) => {
+ if (action.kind !== 'email.send' && action.kind !== 'email.reply') return { kind: 'pass' };
+ const policy = policyOf(db, action);
+ if (!policy || !action.enrollment_id) return { kind: 'pass' };
+ const contact = db
+ .prepare('SELECT c.timezone FROM enrollments e JOIN contacts c ON c.id = e.contact_id WHERE e.id = ?')
+ .get<{ timezone: string | null }>(action.enrollment_id);
+ const slot = nextSlot(now, policy.window, resolveZone(policy.window, contact?.timezone));
+ return slot > now ? { kind: 'defer', until: slot, reason: 'outside_send_window' } : { kind: 'pass' };
+ };
+ return [enrollmentLive, campaignLive, notSuppressed, lawfulBasis, approved, inWindow];
+}
+
+/** Per-campaign budgets from the playbook policy; notifications and manual tasks are not budgeted. */
+export function domainRatePolicy(db: SqlDatabase, action: ActionRow): RatePolicy {
+ const policy = policyOf(db, action);
+ if (!policy || (action.kind !== 'email.send' && action.kind !== 'email.reply')) return { limits: [] };
+ const limits: RateLimit[] = [
+ { scopeKey: `account:${action.provider_account_id}:day`, windowMs: DAY_MS, limit: policy.limits.accountPerDay },
+ ];
+ const domain = action.recipient_norm?.split('@')[1];
+ if (domain) limits.push({ scopeKey: `domain:${domain}:day`, windowMs: DAY_MS, limit: policy.limits.domainPerDay });
+ if (policy.limits.campaignPerDay && action.campaign_id) {
+ limits.push({ scopeKey: `campaign:${action.campaign_id}:day`, windowMs: DAY_MS, limit: policy.limits.campaignPerDay });
+ }
+ // The gap applies from the second touch on: a reply step follows its own send by design.
+ const gap = action.kind === 'email.send' ? durationMs(policy.limits.recipientMinGap) : 0;
+ return { limits, recipientMinGapMs: gap };
+}
+
+/** Enrollment progression and stop rules, run inside the transaction that records each outcome. */
+export function domainEffects(env: DomainEnv): ActionEffects {
+ return {
+ onSucceeded(db, action, _receipt, now) {
+ if (action.approval_id) db.prepare('UPDATE approvals SET consumed_count = consumed_count + 1 WHERE id = ?').run(action.approval_id);
+ // A manual task advances only when a human records its outcome.
+ if (!action.enrollment_id || action.kind === 'manual.task') return;
+ const sc = loadStepContext(db, action.enrollment_id);
+ if (!sc) return;
+ materializeNext(env, sc, stepIndex(sc, action.step_id), now, engineActor(action), {
+ requestActionApproval: actionApprover(env, action.workspace_id, 'engine'),
+ });
+ },
+ onFailed(db, action, errorClass, now) {
+ if (!action.enrollment_id) return;
+ stopEnrollment(db, action.enrollment_id, 'error', `action_failed:${errorClass ?? action.state_reason ?? 'unknown'}`, engineActor(action), now);
+ },
+ onCancelled(db, action, reason, now) {
+ if (!action.enrollment_id) return;
+ if (reason.startsWith('suppressed') || reason.startsWith('jurisdiction_')) stopEnrollment(db, action.enrollment_id, 'stopped', reason, engineActor(action), now);
+ else if (reason === 'expired') stopEnrollment(db, action.enrollment_id, 'stopped', 'step_expired', engineActor(action), now);
+ },
+ onErrorEffects(db, action, effects: readonly ErrorEffect[], now) {
+ if (effects.includes('suppress_recipient') && action.recipient_norm) {
+ addSuppression(db, {
+ workspaceId: action.workspace_id,
+ scope: 'channel',
+ channel: 'email',
+ value: action.recipient_norm,
+ reason: action.last_error_class === 'complaint' ? 'complaint' : 'hard_bounce',
+ source: `action:${action.id}`,
+ }, now);
+ }
+ if (effects.includes('bounce_enrollment') && action.enrollment_id) {
+ stopEnrollment(db, action.enrollment_id, 'bounced', action.last_error_class ?? 'bounce', engineActor(action), now);
+ }
+ if (effects.includes('pause_campaign') && action.campaign_id) {
+ db.prepare(`UPDATE campaigns SET status = 'paused', paused_reason = ?, updated_at = ? WHERE id = ? AND status = 'active'`)
+ .run(`provider:${action.last_error_class ?? 'error'}`, now, action.campaign_id);
+ }
+ },
+ };
+}
diff --git a/outreach-engine/packages/outreach-core/src/engine.ts b/outreach-engine/packages/outreach-core/src/engine.ts
new file mode 100644
index 0000000..64ee373
--- /dev/null
+++ b/outreach-engine/packages/outreach-core/src/engine.ts
@@ -0,0 +1,205 @@
+import { ulid, type ManualTaskProvider, type ProviderAdapter, type SecretResolver, type SqlDatabase } from '@splitin/outreach-contracts';
+import { runExecutionPass, type ExecutionPassReport } from './execution/executor';
+import { setKillSwitch, type KillSwitchScope } from './execution/kill-switches';
+import { resolveReviewAction, type ReviewResolution } from './execution/review';
+import type { ActionRow, CrashHooks, ExecutionConfig, ExecutionDeps, SendGate } from './execution/types';
+import { readSendGate } from './domain/access';
+import { NotFoundError, actorOf, audit, requireRole, type AuthContext, type Role } from './domain/auth';
+import type { DomainEnv, UnsubscribeConfig } from './domain/env';
+import { domainChecks, domainEffects, domainRatePolicy } from './domain/wiring';
+import { checkDueAccountHealth } from './domain/health';
+import { pollDueMailboxes } from './inbound/ingest';
+import { processInboundEvents, type ProcessReport } from './inbound/process';
+
+export interface EngineConfig {
+ readonly db: SqlDatabase;
+ readonly adapters: readonly ProviderAdapter[];
+ readonly secrets: SecretResolver;
+ readonly workerId: string;
+ /**
+ * Defaults to the workspace's audited setting (see setSendGate), which itself defaults to an empty
+ * allowlist: nothing is emailed until an admin opens the gate.
+ */
+ readonly sendGate?: SendGate;
+ readonly unsubscribe?: UnsubscribeConfig;
+ readonly now?: () => number;
+ readonly execution?: Partial;
+ readonly manual?: ManualTaskProvider;
+ readonly hooks?: CrashHooks;
+ readonly random?: () => number;
+ /** How often each mailbox is polled as a webhook fallback (default 5 minutes). */
+ readonly pollIntervalMs?: number;
+ /** How often each account's health is re-checked with its provider (default 15 minutes, §6.5). */
+ readonly healthIntervalMs?: number;
+}
+
+export interface WorkerPassReport extends ExecutionPassReport {
+ readonly health: { readonly checked: number; readonly changed: number };
+ readonly polled: number;
+ readonly inbound: ProcessReport;
+}
+
+export interface Engine extends DomainEnv {
+ readonly exec: ExecutionDeps;
+ /**
+ * One worker pass: re-check due account health, poll due mailboxes, apply inbound events (so replies stop sequences BEFORE the next
+ * send), then sweep leases, reconcile and execute due actions. Idempotent and safe to run concurrently.
+ */
+ runOnce(): Promise;
+}
+
+export function createEngine(config: EngineConfig): Engine {
+ const now = config.now ?? Date.now;
+ const adapters = new Map(config.adapters.map((adapter) => [adapter.name, adapter]));
+ const holder: { env?: DomainEnv } = {};
+ const exec: ExecutionDeps = {
+ db: config.db,
+ adapters,
+ secrets: config.secrets,
+ now,
+ workerId: config.workerId,
+ sendGate: config.sendGate ?? readSendGate,
+ checks: domainChecks(),
+ ratePolicy: domainRatePolicy,
+ // Effects need the domain env, which needs exec: resolve lazily.
+ effects: {
+ onSucceeded: (...args) => holder.env && domainEffects(holder.env).onSucceeded?.(...args),
+ onFailed: (...args) => holder.env && domainEffects(holder.env).onFailed?.(...args),
+ onCancelled: (...args) => holder.env && domainEffects(holder.env).onCancelled?.(...args),
+ onErrorEffects: (...args) => holder.env && domainEffects(holder.env).onErrorEffects?.(...args),
+ },
+ ...(config.execution ? { config: config.execution } : {}),
+ ...(config.manual ? { manual: config.manual } : {}),
+ ...(config.hooks ? { hooks: config.hooks } : {}),
+ ...(config.random ? { random: config.random } : {}),
+ };
+ const env: DomainEnv = { db: config.db, now, adapters, exec, ...(config.unsubscribe ? { unsubscribe: config.unsubscribe } : {}) };
+ holder.env = env;
+ const pollIntervalMs = config.pollIntervalMs ?? 5 * 60_000;
+ const healthIntervalMs = config.healthIntervalMs ?? 15 * 60_000;
+ return {
+ ...env,
+ exec,
+ runOnce: async () => {
+ // Health first: an account that recovered (or broke) since the last pass is treated accordingly now.
+ const health = await checkDueAccountHealth(env, healthIntervalMs);
+ const polled = await pollDueMailboxes(env, pollIntervalMs);
+ const inbound = processInboundEvents(env);
+ return { health, polled, inbound, ...(await runExecutionPass(exec)) };
+ },
+ };
+}
+
+/** Creates a workspace and its first admin. The only operation that needs no existing principal. */
+export function bootstrapWorkspace(db: SqlDatabase, input: { workspaceId: string; name: string; adminRef: string; adminName: string }, now = Date.now()): string {
+ return db.transaction(() => {
+ db.prepare('INSERT INTO workspaces (id, name, created_at) VALUES (?,?,?)').run(input.workspaceId, input.name, now);
+ const principalId = ulid(now);
+ const roles: Role[] = ['admin'];
+ db.prepare('INSERT INTO principals (id, workspace_id, external_ref, display_name, roles) VALUES (?,?,?,?,?)')
+ .run(principalId, input.workspaceId, input.adminRef, input.adminName, JSON.stringify(roles));
+ audit(db, { workspaceId: input.workspaceId, principalId: 'system', roles: ['admin'], source: 'system', traceId: 'bootstrap' }, now, 'workspace', input.workspaceId, 'bootstrapped', { adminRef: input.adminRef });
+ return principalId;
+ });
+}
+
+export function setKillSwitchAs(env: DomainEnv, ctx: AuthContext, input: { scope: KillSwitchScope; targetId?: string; engaged: boolean; reason: string }): void {
+ // Anyone who can operate may stop sending; only approvers may resume (BUILD_PLAN.md §9.4).
+ requireRole(ctx, input.engaged ? 'operator' : 'approver');
+ if (input.scope === 'global') requireRole(ctx, 'admin');
+ env.db.transaction(() =>
+ setKillSwitch(env.db, { workspaceId: ctx.workspaceId, scope: input.scope, targetId: input.targetId ?? '*', engaged: input.engaged, reason: input.reason }, actorOf(ctx), env.now()),
+ );
+}
+
+export function listReview(env: DomainEnv, ctx: AuthContext): ActionRow[] {
+ requireRole(ctx, 'viewer');
+ return env.db.prepare(`SELECT * FROM scheduled_actions WHERE workspace_id = ? AND state = 'review' ORDER BY updated_at`).all