@catalystiq/envoy-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2072 @@
1
+ import { Resend } from 'resend';
2
+ import Anthropic from '@anthropic-ai/sdk';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
5
+
6
+ /**
7
+ * Minimal structural shape of a `pg`-compatible query result. We only depend on `rows`
8
+ * (and intentionally NOT on `rowCount` — see invariant 1). `T` is the row shape.
9
+ */
10
+ interface SdkQueryResult<T = Record<string, unknown>> {
11
+ rows: T[];
12
+ }
13
+ /**
14
+ * The host-supplied pool. Structurally compatible with node-postgres' `Pool` and Neon's
15
+ * serverless `Pool` — both expose `query(text, params?) => Promise<{ rows }>`. We keep this
16
+ * deliberately narrow so the SDK takes no hard dependency on a specific `pg` package
17
+ * (the host owns the driver; the SDK ships no `pg` in its dependencies).
18
+ */
19
+ interface SdkPool {
20
+ query<T = Record<string, unknown>>(text: string, params?: ReadonlyArray<unknown>): Promise<SdkQueryResult<T>>;
21
+ }
22
+ /**
23
+ * Canonicalize an email to the single casing every key-bearing path agrees on (lowercase, trimmed).
24
+ *
25
+ * Email addresses are case-insensitive in practice, but the SDK keys `sdk_contacts.email`,
26
+ * `sdk_topic_consent.contact`, and `sdk_enrollments.contact` on the email verbatim — while the
27
+ * webhook resolves with `lower(email)`. A mixed-case enrollment (`Mixed.Case@x.com`) therefore
28
+ * never matched a lowercased webhook unsubscribe, and the gate read a different row than the one
29
+ * the host wrote. Normalizing at the single boundary (enroll, consent.set, gate, and the webhook
30
+ * resolve all call this) makes every path key on the same string, so suppression converges.
31
+ *
32
+ * A non-string / empty value is returned as the empty string; callers that require a non-empty
33
+ * email validate that separately.
34
+ */
35
+ declare function normalizeEmail(email: string): string;
36
+ /**
37
+ * A pool wrapper bound to one install namespace. All key-bearing writes/reads go through
38
+ * `namespaceKey` so rows are isolated per install. Construct one with `createDb`.
39
+ */
40
+ declare class NamespacedDb {
41
+ readonly namespace: string;
42
+ private readonly pool;
43
+ constructor(pool: SdkPool, namespace: string);
44
+ /**
45
+ * Prefix a bare logical key with this install's namespace. The same bare key under two
46
+ * different namespaces yields two distinct stored keys (KTD7). Callers store/read the
47
+ * RESULT of this, never the bare key.
48
+ */
49
+ namespaceKey(key: string): string;
50
+ /**
51
+ * Strip this install's namespace prefix off a stored key, returning the bare key. Throws if
52
+ * the stored key belongs to a different namespace — a cross-namespace read is a fail-loud
53
+ * condition (R38), not something to silently paper over.
54
+ */
55
+ stripNamespace(storedKey: string): string;
56
+ /**
57
+ * Raw query passthrough. Returns the full result so callers can inspect `rows`. Use this for
58
+ * SELECTs and for writes where you want the returned rows; prefer `execWrite` when you only
59
+ * need "did it affect a row".
60
+ */
61
+ query<T = Record<string, unknown>>(text: string, params?: ReadonlyArray<unknown>): Promise<SdkQueryResult<T>>;
62
+ /**
63
+ * Run a write and report success from `rows.length` (invariant 1). The SQL MUST use
64
+ * `RETURNING` so an effective write yields ≥1 row. Returns the affected count and rows.
65
+ *
66
+ * This is the canonical "did the write land" helper: a CAS gate / claim-on-conflict
67
+ * (`INSERT … ON CONFLICT DO NOTHING RETURNING …`) returns 0 rows when it lost the race,
68
+ * ≥1 when it won — derived from `rows.length`, never `rowCount`.
69
+ */
70
+ execWrite<T = Record<string, unknown>>(text: string, params?: ReadonlyArray<unknown>): Promise<{
71
+ count: number;
72
+ rows: T[];
73
+ }>;
74
+ }
75
+ /**
76
+ * Construct a namespaced DB wrapper around a host-supplied pool. This is the single entry
77
+ * point the rest of the SDK uses to reach Postgres.
78
+ */
79
+ declare function createDb(pool: SdkPool, namespace: string): NamespacedDb;
80
+
81
+ interface MigrateOptions {
82
+ /** Override the directory the `.sql` files are read from (tests). Defaults to the shipped dir. */
83
+ migrationsDir?: string;
84
+ /** Sink for progress logging. Defaults to a no-op (secrets/PII never flow here, but stay quiet). */
85
+ log?: (message: string) => void;
86
+ }
87
+ interface MigrateResult {
88
+ /** Number of migration files applied on this run (0 when already up to date). */
89
+ applied: number;
90
+ /** Versions applied on this run, in order. */
91
+ versions: string[];
92
+ }
93
+ /**
94
+ * Apply all pending SDK migrations to the host-supplied pool, idempotently.
95
+ *
96
+ * Each file runs inside its own transaction (BEGIN/COMMIT, ROLLBACK on error) so a partial DDL
97
+ * failure rolls back atomically and never half-applies a migration. The tracking insert is part
98
+ * of the same transaction, so a file is recorded applied only if its DDL committed.
99
+ *
100
+ * Returns the count + versions applied — derived from work actually done, not a driver `rowCount`.
101
+ */
102
+ declare function migrate(pool: SdkPool, options?: MigrateOptions): Promise<MigrateResult>;
103
+
104
+ /**
105
+ * A lazily-constructed Resend client bound to one API key. Constructed at most once on first
106
+ * `client()` call. When the key is empty/undefined the handle is permanently disabled: `enabled`
107
+ * is `false` and `client()` returns `null` so callers no-op rather than throw.
108
+ */
109
+ interface ResendClientHandle {
110
+ /** True when an API key was supplied — i.e. Resend calls will actually be attempted. */
111
+ readonly enabled: boolean;
112
+ /**
113
+ * The underlying Resend client, constructed lazily on first call. Returns `null` when disabled
114
+ * (no key) so callers can `if (!c) return;` to no-op. Never throws for a missing key — that
115
+ * decision is surfaced as `enabled === false`, not an exception.
116
+ */
117
+ client(): Resend | null;
118
+ }
119
+ /**
120
+ * Build a lazy Resend handle. `apiKey` is read once here and never logged or stored anywhere it
121
+ * could be serialized (it lives only inside the closure / the constructed client). Pass the raw
122
+ * `resendApiKey` from `createEnvoy` config (which itself comes from an env secret per R43).
123
+ */
124
+ declare function createResendClientHandle(apiKey: string | undefined): ResendClientHandle;
125
+
126
+ /**
127
+ * Managed-Agents configuration (R24). Agent id + environment are SDK-level config supplied by the
128
+ * host from env secrets — never per-tenant DB state. Optional: a pure broadcast/digest host that
129
+ * runs no AI drip lane needs none of it.
130
+ */
131
+ interface EnvoyAgentConfig {
132
+ /** The Claude Managed Agent id that writes per-recipient drip copy. */
133
+ agentId: string;
134
+ /** The Managed-Agents environment id. */
135
+ environmentId: string;
136
+ }
137
+ /**
138
+ * Per-stream defaults. A "stream" is a type-of-email lane (e.g. `digest`, `alert`) — it scopes the
139
+ * `List-Unsubscribe` token (R33/R46) and the Topic granularity (R27). The map keys are stream
140
+ * names; for now the only declared default is the `from` address used when a send omits one.
141
+ */
142
+ interface EnvoyStreamConfig {
143
+ /** Default From address for sends on this stream (host may still override per send). */
144
+ from?: string;
145
+ }
146
+ /**
147
+ * The config a host passes to `createEnvoy`. Secrets here originate from env (R43) and are never
148
+ * logged or serialized by the SDK.
149
+ */
150
+ interface EnvoyConfig {
151
+ /**
152
+ * The host-supplied `pg`-compatible pool. The SDK never opens its own connection (R5); all DB
153
+ * access goes through the namespaced wrapper built from this.
154
+ */
155
+ db: SdkPool;
156
+ /**
157
+ * Install namespace (R38/KTD7). Prefixes every program/subject/contact key and is
158
+ * fingerprint-checked. A staging/prod split on one database is two namespaces (two installs).
159
+ * Must be a non-empty string with no `:` (the namespace key separator).
160
+ */
161
+ installNamespace: string;
162
+ /**
163
+ * Resend API key (R43). Unlike the other secrets this is NOT required: when unset the Resend
164
+ * client is a no-op (mirrors the app mailer; lets a host run in dev/CI without a key). Compliance
165
+ * secrets below ARE required because an unset one is a silent compliance hole, not a dev no-op.
166
+ */
167
+ resendApiKey?: string;
168
+ /** Svix/Resend webhook signing secret (R41). Required — an unset secret is an unverified webhook. */
169
+ webhookSecret: string;
170
+ /** Cron secret (R40). Required — an unset secret is an unauthenticated send + generation trigger. */
171
+ cronSecret: string;
172
+ /** Unsubscribe-token HMAC secret (R33). Required — an unset secret is an unsigned opt-out link. */
173
+ unsubscribeSecret: string;
174
+ /** The base Resend Segment id every enrolled contact joins (R10). Required broadcast target (R17). */
175
+ baseSegmentId: string;
176
+ /** Optional Managed-Agents config (R24). Omit for a host that runs no AI drip lane. */
177
+ agent?: EnvoyAgentConfig;
178
+ /**
179
+ * Allow-list of contact `data` fields projected into the agent personalization payload (R44).
180
+ * The SDK forwards ONLY these fields to Anthropic — never the whole mirror `data` verbatim.
181
+ * Defaults to an empty list (forward nothing) so the safe default is the privacy-preserving one.
182
+ */
183
+ aiFieldAllowList?: string[];
184
+ /** Per-stream defaults keyed by stream name (R33/R27). Optional. */
185
+ streams?: Record<string, EnvoyStreamConfig>;
186
+ }
187
+ /** The validated, normalized config the rest of the SDK reads. Secrets are present but the handle
188
+ * that wraps this never serializes them (see `Envoy.toJSON`). */
189
+ interface ResolvedEnvoyConfig {
190
+ installNamespace: string;
191
+ resendApiKey?: string;
192
+ webhookSecret: string;
193
+ cronSecret: string;
194
+ unsubscribeSecret: string;
195
+ baseSegmentId: string;
196
+ agent?: EnvoyAgentConfig;
197
+ /** Frozen, de-duplicated allow-list. Empty array = forward nothing. */
198
+ aiFieldAllowList: readonly string[];
199
+ /** Frozen stream-defaults map (empty object when none supplied). */
200
+ streams: Readonly<Record<string, EnvoyStreamConfig>>;
201
+ }
202
+ /**
203
+ * The root SDK handle returned by `createEnvoy`. Later units hang their server functions off this
204
+ * (enroll, sequences, broadcast, send.transactional, …). U3 ships the foundation: the resolved
205
+ * config, the namespaced DB, the lazy Resend handle, the namespace guard, and the redaction helper.
206
+ */
207
+ interface Envoy {
208
+ /** Validated config (defaults applied). Reading secrets off this is intentional for internal
209
+ * units; the handle's own `toJSON`/inspect output redacts them. */
210
+ readonly config: ResolvedEnvoyConfig;
211
+ /** Namespaced DB wrapper bound to `installNamespace`. The single DB boundary for the SDK. */
212
+ readonly db: NamespacedDb;
213
+ /** Lazy Resend client handle (no-op when `resendApiKey` is unset). */
214
+ readonly resend: ResendClientHandle;
215
+ /**
216
+ * Verify (and, on first run, write) this install's namespace fingerprint in the host DB (R38).
217
+ * Idempotent: re-running with the same namespace + identity is a no-op; a namespace already
218
+ * fingerprinted with a DIFFERENT config identity throws (another install detected). Call this
219
+ * from the host's init/deploy step; SDK server fns also call it lazily before first DB write.
220
+ */
221
+ assertNamespaceFingerprint(): Promise<void>;
222
+ /**
223
+ * Redact a secret-bearing or PII-bearing string for logs (R43). Emails are reduced to a
224
+ * non-reversible hint (`a***@example.com`); any other value is fully masked. Never returns the
225
+ * original. Use this at every log site that might otherwise emit a secret or full address.
226
+ */
227
+ redact(value: unknown): string;
228
+ }
229
+ /** A configuration error thrown by `createEnvoy` at INIT time. Carries no secret values. */
230
+ declare class EnvoyConfigError extends Error {
231
+ constructor(message: string);
232
+ }
233
+ /** Thrown by `assertNamespaceFingerprint` when the host DB is already owned by an install whose
234
+ * config identity differs from this one (R38 cross-install guard). */
235
+ declare class EnvoyNamespaceError extends Error {
236
+ constructor(message: string);
237
+ }
238
+ /**
239
+ * Reduce an email to a non-reversible hint for logs: `marko@example.com` -> `m***@example.com`.
240
+ * A malformed / non-email string is fully masked. No full local-part ever appears (R43: "no full
241
+ * email addresses … appear in logs").
242
+ */
243
+ declare function redactEmail(value: string): string;
244
+ /**
245
+ * Best-effort redaction for an arbitrary log value. If it looks like an email, hint it; otherwise
246
+ * mask it entirely. This is intentionally conservative — when in doubt, mask.
247
+ */
248
+ declare function redactValue(value: unknown): string;
249
+ /**
250
+ * Validate + normalize raw host config into a resolved config. Throws `EnvoyConfigError` on the
251
+ * first problem. Pure (no I/O) so config errors are guaranteed to precede any DB/Resend contact.
252
+ */
253
+ declare function resolveConfig(cfg: EnvoyConfig): ResolvedEnvoyConfig;
254
+ /**
255
+ * Derive the install's config-identity fingerprint. It is a hash of the namespace and the stable,
256
+ * non-secret config identity (`baseSegmentId`) — NOT of any secret (so the stored value leaks
257
+ * nothing). Two installs that (mis)use the same namespace but target different base Segments produce
258
+ * different fingerprints and trip the guard; the same install re-running produces the same value
259
+ * (idempotent). Secrets are intentionally excluded: rotating a key must not trip the guard.
260
+ */
261
+ declare function computeNamespaceFingerprint(config: ResolvedEnvoyConfig): string;
262
+ /**
263
+ * Build the root SDK handle. Validates config synchronously (errors thrown here, never at send
264
+ * time). The namespace fingerprint is checked lazily — the first `assertNamespaceFingerprint()`
265
+ * call performs the DB write/verify and memoizes the result, so repeated calls cost one round trip.
266
+ */
267
+ declare function createEnvoy(cfg: EnvoyConfig): Envoy;
268
+
269
+ /**
270
+ * The two delivery streams a topic can carry. A "stream" is a type-of-email lane: `digest` is the
271
+ * recurring/marketing cadence, `alert` is event-triggered. They share a topic row but have
272
+ * independent consent so opting out of one never silences the other (R27/R33).
273
+ */
274
+ type Stream = "digest" | "alert";
275
+ /** All streams, in a stable order. Used when an `unsubscribed` write must touch every stream. */
276
+ declare const STREAMS: readonly Stream[];
277
+ /**
278
+ * Per-stream consent state, mirroring Resend's `'opt_in' | 'opt_out'` subscription plus the SDK's
279
+ * own terminal `'unsubscribed'`.
280
+ *
281
+ * - `opt_in` — receiving this stream of this topic (the default; topics are created public + opt_in).
282
+ * - `opt_out` — topic-scoped opt-out for this stream; the recipient still receives OTHER topics.
283
+ * - `unsubscribed` — terminal: the recipient asked to stop everything. Dominates all streams of
284
+ * this topic and is mirrored into the contact's global `unsubscribed` flag (R26 suppress-all).
285
+ */
286
+ type ConsentStatus = "opt_in" | "opt_out" | "unsubscribed";
287
+ /**
288
+ * A row of the consent mirror for one `(contact, topic)`. `digest`/`alert` are the per-stream
289
+ * states; `topicId` is the cached Resend Topic id (null until provisioned, U7); `dirty` is true
290
+ * when the mirror and Resend may have diverged and the reconcile sweep should repair this row.
291
+ */
292
+ interface ConsentRow {
293
+ contact: string;
294
+ topicKey: string;
295
+ topicId: string | null;
296
+ digest: ConsentStatus;
297
+ alert: ConsentStatus;
298
+ dirty: boolean;
299
+ }
300
+ /** Arguments to `consent.set` — the single write path into the mirror (origin R26/R28). */
301
+ interface ConsentSetInput {
302
+ /** Recipient email (the logical contact key; namespace-prefixed at the DB boundary). */
303
+ email: string;
304
+ /** The topic this consent applies to (host-meaningful key, e.g. `weekly-digest`). */
305
+ topicKey: string;
306
+ /** Which stream of the topic to set. */
307
+ stream: Stream;
308
+ /** Target consent. `unsubscribed` dominates BOTH streams + the global flag (R26). */
309
+ status: ConsentStatus;
310
+ /**
311
+ * Cached Resend Topic id, if known by the caller (U7 provisioning). When provided it is stored
312
+ * so the push + reconcile can address the topic; when omitted the push is skipped (no topic id =
313
+ * nothing to push) and the row is marked dirty for the reconcile sweep to resolve.
314
+ */
315
+ topicId?: string | null;
316
+ }
317
+ /** Outcome of a `consent.set` — whether the mirror changed and whether the Resend push confirmed. */
318
+ interface ConsentSetResult {
319
+ /** The resulting mirror row (post-merge). */
320
+ row: ConsentRow;
321
+ /** True when the write actually changed stored state (false = a stale/no-op merge). */
322
+ changed: boolean;
323
+ /**
324
+ * `confirmed` — the Resend `contacts.topics.update` push succeeded and the row is clean.
325
+ * `skipped` — no Resend (key unset) or no topic id; nothing pushed, row left clean (digest path)
326
+ * or dirty (no topic id, needs reconcile).
327
+ * `dirty` — the push was attempted and FAILED; the row is marked reconcile-dirty (R28: never
328
+ * throw into the caller on a push failure; mark dirty and let reconcile repair).
329
+ */
330
+ push: "confirmed" | "skipped" | "dirty";
331
+ }
332
+ /**
333
+ * The consent mirror, bound to one install's namespaced DB + Resend handle. Build via `createConsentMirror`.
334
+ */
335
+ declare class ConsentMirror {
336
+ private readonly db;
337
+ private readonly resend;
338
+ constructor(db: NamespacedDb, resend: ResendClientHandle);
339
+ /**
340
+ * Read the mirror row for `(email, topicKey)`, or `null` if the contact has never been seen for
341
+ * this topic. This is a pure read — it does NOT create a default row (a missing row means the
342
+ * topic was never provisioned for this contact; the gate treats that as deny-by-default).
343
+ */
344
+ read(email: string, topicKey: string): Promise<ConsentRow | null>;
345
+ /**
346
+ * Read the contact-level GLOBAL suppression flag (`sdk_contacts.unsubscribed`), case-insensitively
347
+ * on the bare email (matches the webhook/`set` convention). A bounce, complaint, GDPR delete, or
348
+ * hosted-page unsubscribe sets this flag; the gate must honor it on EVERY topic/stream — including
349
+ * topics for which no per-topic consent row exists — so a globally-suppressed contact can never be
350
+ * re-addressed on any lane (R22/R26 suppress-all). Returns true when the contact is suppressed.
351
+ */
352
+ private isGloballySuppressed;
353
+ /**
354
+ * Authoritative send gate (R26). Returns `true` only when this exact stream of this topic is
355
+ * allowed to send to this contact. Denies when:
356
+ * - the contact is GLOBALLY suppressed (`sdk_contacts.unsubscribed = TRUE` — bounce, complaint,
357
+ * GDPR delete, or hosted-page unsubscribe), regardless of any per-topic consent, or
358
+ * - the contact has no mirror row for the topic (never provisioned → deny-by-default), or
359
+ * - the requested stream is `opt_out` or `unsubscribed`, or
360
+ * - EITHER stream is `unsubscribed` (the global "everything" suppress dominates both streams).
361
+ *
362
+ * The gate reads the local mirror only — never Resend — so it is cheap and deterministic.
363
+ * Reconcile (U14) is what keeps the mirror honest against Resend's hosted page.
364
+ */
365
+ gate(email: string, topicKey: string, stream: Stream): Promise<boolean>;
366
+ /**
367
+ * The single consent write path (origin R26/R28). Writes the mirror FIRST (monotonic-merge
368
+ * upsert), THEN awaits the Resend `contacts.topics.update` push so an unsubscribe is confirmed
369
+ * before the caller proceeds. A push failure marks the row reconcile-dirty and is reported in
370
+ * the result — it never throws into the caller (fail-soft external sync).
371
+ *
372
+ * Monotonic merge: the stored stream value only moves toward MORE suppression. A stale `opt_in`
373
+ * against a stored `unsubscribed`/`opt_out` is a no-op (`changed: false`). An `unsubscribed`
374
+ * write dominates BOTH streams and sets the contact's global suppression flag (R26 suppress-all).
375
+ */
376
+ set(input: ConsentSetInput): Promise<ConsentSetResult>;
377
+ }
378
+ /** Construct a consent mirror bound to a namespaced DB + Resend handle. */
379
+ declare function createConsentMirror(db: NamespacedDb, resend: ResendClientHandle): ConsentMirror;
380
+ declare const CONSENT_RANK: Readonly<Record<ConsentStatus, number>>;
381
+
382
+ /** One step of a drip sequence. */
383
+ interface SequenceStep {
384
+ /** Saved Resend Template id this step sends (`emails.send({ template: { id } })`, R12). */
385
+ templateId: string;
386
+ /**
387
+ * Time-based wait before this step is eligible, in days, resolved against the cron clock (R15).
388
+ * `0` ⇒ eligible immediately on reaching the step. Fractional values are allowed (e.g. `0.5` =
389
+ * 12h). Must be ≥ 0.
390
+ */
391
+ waitDays: number;
392
+ /**
393
+ * The Template variable names the AI fills at send time (R12/R14). Each must exist as a variable
394
+ * on the referenced Template — verified by `envoy.validate()` (U18), not here. May be empty for a
395
+ * non-AI step (a fully static Template).
396
+ */
397
+ aiSlots: readonly string[];
398
+ /** The per-step personalization brief the agent is given (R12). May be empty when `aiSlots` is. */
399
+ brief: string;
400
+ }
401
+ /** A defined, validated drip sequence. Immutable. */
402
+ interface Sequence {
403
+ /** Stable sequence key (the `sequence_key` an enrollment is scoped to). */
404
+ readonly key: string;
405
+ /** The ordered steps. Index is the step's position (`sdk_steps.step_index`). */
406
+ readonly steps: readonly Readonly<SequenceStep>[];
407
+ }
408
+ /** Inputs to {@link defineSequence}. */
409
+ interface DefineSequenceInput {
410
+ key: string;
411
+ steps: SequenceStep[];
412
+ }
413
+ /** Raised when a sequence definition is malformed (fail loud at definition time). */
414
+ declare class SequenceDefinitionError extends Error {
415
+ constructor(message: string);
416
+ }
417
+ /**
418
+ * Define a drip sequence (R12/R13/R15). Validates loud: a missing key, an empty step list, a bad
419
+ * templateId, a negative wait, or a malformed slot declaration throws `SequenceDefinitionError`.
420
+ * Returns a frozen `Sequence` whose steps are positionally indexed (`step_index`).
421
+ */
422
+ declare function defineSequence(input: DefineSequenceInput): Sequence;
423
+
424
+ /** A claimed due step the engine acts on (the cron tick joins enrollment + step and passes this). */
425
+ interface DueStep {
426
+ /** `sdk_enrollments.id`. */
427
+ enrollmentId: number | string;
428
+ /** `sdk_steps.id` for the current step row (created/looked-up by the tick). */
429
+ stepId: number | string;
430
+ /** The recipient email (bare; namespaced only at the DB boundary). */
431
+ email: string;
432
+ /** The sequence key the enrollment is scoped to. */
433
+ sequenceKey: string;
434
+ /** The 0-based index of the current step (`sdk_enrollments.current_step` / `sdk_steps.step_index`). */
435
+ stepIndex: number;
436
+ /** The contact's host `data` snapshot (`sdk_enrollments.data`) — allow-list-filtered before the agent. */
437
+ data: Record<string, unknown>;
438
+ /**
439
+ * Inflight crash-resume marker (`sdk_steps.agent_session_id`). Non-null ⇒ a prior tick started a
440
+ * session for this exact step — harvest it, never fork a second billed one.
441
+ */
442
+ agentSessionId: string | null;
443
+ /** When the current step became eligible (`sdk_enrollments.next_run_at`). Null ⇒ eligible now. */
444
+ nextRunAt: Date | string | null;
445
+ }
446
+ /** Why a step did not send (when `sent` is false). */
447
+ type DripSkipReason = "not_due" | "suppressed" | "resend_disabled" | "deferred" | "generation_failed" | "send_failed";
448
+ /** Outcome of {@link runDripStep}. */
449
+ type DripStepResult = {
450
+ sent: true;
451
+ emailId: string;
452
+ advancedTo: number;
453
+ completed: boolean;
454
+ } | {
455
+ sent: false;
456
+ reason: DripSkipReason;
457
+ detail?: string;
458
+ };
459
+ /** Config the engine needs beyond the Envoy handle. */
460
+ interface DripEngineConfig {
461
+ /** The consent mirror to gate against (U6). */
462
+ mirror: ConsentMirror;
463
+ /** Absolute https landing URL the List-Unsubscribe header points at (R33). */
464
+ unsubscribeBaseUrl: string;
465
+ /**
466
+ * The stream drip steps send on. Defaults to `"digest"` — drip sequences are opt-in nurture, not
467
+ * transactional alerts. Host can override per program.
468
+ */
469
+ stream?: Stream;
470
+ /** Per-call agent timeout override. */
471
+ agentTimeoutMs?: number;
472
+ }
473
+ /**
474
+ * Run one due drip step (R12–R16, R23). Order is load-bearing:
475
+ *
476
+ * 1. Resolve the current step from the sequence; an out-of-range index ⇒ complete the enrollment.
477
+ * 2. Honor the wait (R15) — a not-yet-eligible step is skipped (`not_due`), nothing touched.
478
+ * 3. GATE against the mirror (R26) — a suppressed contact is never sent (`suppressed`).
479
+ * 4. Resolve From — fail loud (caller's fail-soft wraps this) if neither default is configured.
480
+ * 5. Generate-or-harvest the declared slots (R14/R23). A re-claim with a `running` prior session
481
+ * DEFERS (no second billed session); a `completed` one is harvested. A failure leaves the step
482
+ * due (`generation_failed`) — NOTHING is sent.
483
+ * 6. No Resend key ⇒ silent no-op (`resend_disabled`, R43) — the step stays due.
484
+ * 7. `emails.send({ template: { id, variables }, headers: List-Unsubscribe }, { idempotencyKey })`
485
+ * — the idempotency key is the REQUEST OPTION (`Idempotency-Key` header), never a body field.
486
+ * 8. Only on a confirmed send: mark the step sent + advance the enrollment (R16). A send failure
487
+ * leaves the step due (`send_failed`).
488
+ */
489
+ declare function runDripStep(envoy: Envoy, sequence: Sequence, due: DueStep, config: DripEngineConfig, now?: Date): Promise<DripStepResult>;
490
+ /**
491
+ * Resolves a sequence definition by key. The host registers every `defineSequence(...)` it runs and
492
+ * passes this lookup to the tick — the SDK never persists sequence definitions (they live in host
493
+ * code, R12), only enrollment/step STATE. An enrollment whose `sequence_key` is not registered is
494
+ * skipped (`unknown_sequence`) rather than silently dropped — a deploy that removed a sequence still
495
+ * in flight is a host bug we surface, not bury.
496
+ */
497
+ type SequenceRegistry = ReadonlyMap<string, Sequence> | ((sequenceKey: string) => Sequence | undefined);
498
+ /** Per-enrollment outcome the tick collects (one entry per CLAIMED enrollment). */
499
+ interface DripTickItem {
500
+ enrollmentId: number | string;
501
+ email: string;
502
+ sequenceKey: string;
503
+ stepIndex: number;
504
+ /** The engine outcome, or a tick-level skip the engine never sees. */
505
+ result: DripStepResult | {
506
+ sent: false;
507
+ reason: "unknown_sequence" | "tick_error";
508
+ detail?: string;
509
+ };
510
+ }
511
+ /** Aggregate result of one cron tick. Counts are derived from the per-item outcomes. */
512
+ interface DripTickResult {
513
+ /** How many due enrollments this tick claimed (0 ⇒ nothing was due / all were locked by a peer). */
514
+ claimed: number;
515
+ /** How many claimed steps actually sent an email. */
516
+ sent: number;
517
+ /** How many were skipped (not_due / suppressed / deferred / unknown_sequence / resend_disabled). */
518
+ skipped: number;
519
+ /** How many failed (generation_failed / send_failed / tick_error) — left due for a later tick. */
520
+ failed: number;
521
+ /** Per-enrollment detail (bounded by `limit`). */
522
+ items: DripTickItem[];
523
+ }
524
+ /** Options for {@link tickDrip}. */
525
+ interface DripTickConfig extends DripEngineConfig {
526
+ /** Max due enrollments to claim per tick (bounds one invocation's work / `maxDuration`). */
527
+ limit?: number;
528
+ }
529
+ /**
530
+ * Run one cron tick of the drip lane (R20, R21). The mounted cron sub-path (U9 handler) calls this
531
+ * after CRON_SECRET auth (U4). It:
532
+ *
533
+ * 1. Atomically claims up to `limit` due enrollments (SKIP LOCKED) — the SELECTION guard.
534
+ * 2. For each, resolves the sequence from the host registry (unknown ⇒ skip, never drop).
535
+ * 3. Ensures the current step's row exists (carrying any inflight marker) and builds a `DueStep`.
536
+ * 4. Runs `runDripStep` — generate-or-harvest → gate → send → advance, all fail-safe (R16).
537
+ *
538
+ * PER-CONTACT FAIL-SOFT (R21): one enrollment's thrown error (a registry callback that throws, a
539
+ * step-row write that errors) is caught, recorded as a `tick_error` item, and the tick CONTINUES —
540
+ * one bad contact never aborts the others. The enrollment is left due (untouched), so it retries.
541
+ */
542
+ declare function tickDrip(envoy: Envoy, registry: SequenceRegistry, config: DripTickConfig, now?: Date): Promise<DripTickResult>;
543
+
544
+ /**
545
+ * Result a sub-handler must return: an App-Router-compatible `Response`. Sub-handlers receive the
546
+ * raw `Request` (already authenticated by the factory) plus the parsed sub-path tail.
547
+ */
548
+ type SubHandler = (request: Request) => Response | Promise<Response>;
549
+ /**
550
+ * Host `authorize(req)` callback (R6). The host owns identity; the SDK ships no login/session.
551
+ *
552
+ * CONTRACT — the return value is interpreted strictly:
553
+ * - `true` ⇒ authorized; the request proceeds to the sub-handler. The boolean `true` is the
554
+ * ONLY value that grants access. Nothing else does.
555
+ * - a `Response` ⇒ a DENIAL channel ONLY. A non-2xx `Response` (e.g. a custom 401/403/redirect)
556
+ * is returned to the client verbatim. A 2xx `Response` is a host CONTRACT ERROR — an
557
+ * `authorize` callback must never signal "allowed" by returning a success Response —
558
+ * so the factory treats it as unauthorized (a generic 401), NEVER as authorized. This
559
+ * fail-closed reading means a host that accidentally returns `new Response("ok")` from
560
+ * authorize cannot open its entire API surface (an ambiguous-host-return admit).
561
+ * - any other falsy value (`false`, `undefined`, `null`) ⇒ a generic 401.
562
+ */
563
+ type Authorize = (request: Request) => AuthorizeResult | Promise<AuthorizeResult>;
564
+ type AuthorizeResult = boolean | Response;
565
+ /**
566
+ * Config for `createEnvoyHandler`. Every authenticated sub-path is optional EXCEPT the auth
567
+ * mechanism that guards it. A sub-path with no handler still authenticates first, then returns 501
568
+ * — so an attacker can never tell "unimplemented" from "unauthorized" without first passing auth.
569
+ */
570
+ interface EnvoyHandlerConfig {
571
+ /** The root SDK handle (supplies `config.cronSecret`, `config.webhookSecret`, DB, redaction). */
572
+ envoy: Envoy;
573
+ /**
574
+ * Host authorization for the `/api` and `/read` sub-paths (R6). Required: the API surface must
575
+ * not be open. cron/webhook/unsubscribe/mcp do NOT use this — they carry no host session.
576
+ */
577
+ authorize: Authorize;
578
+ /**
579
+ * Dedicated MCP credential (R42). The `/mcp` sub-path is independently authenticated against
580
+ * this secret with a constant-time compare. When omitted, `/mcp` fails closed (401) — it is
581
+ * NEVER open.
582
+ */
583
+ mcpSecret?: string;
584
+ /**
585
+ * `"dev"` relaxes the unset-`CRON_SECRET` guard to allow unauthenticated cron locally (mirrors
586
+ * the app's dev-only allowance). In any other environment an unset cron secret fails closed.
587
+ * Defaults to `"prod"` (fail-closed) when omitted — safe by default.
588
+ */
589
+ environment?: string;
590
+ /** Handler for `/api/*` (authenticated by `authorize`). */
591
+ api?: SubHandler;
592
+ /** Handler for `/read/*` (authenticated by `authorize`). Read-only host endpoints (hooks, U17). */
593
+ read?: SubHandler;
594
+ /** Handler for `/cron/*` (authenticated by `CRON_SECRET`). The drip/broadcast tick driver. */
595
+ cron?: SubHandler;
596
+ /** Handler for `/webhook/*` (authenticated by Svix). The Resend event ingest (U5). */
597
+ webhook?: SubHandler;
598
+ /** Handler for `/unsubscribe/*` (self-authenticating signed token, U6). */
599
+ unsubscribe?: SubHandler;
600
+ /** Handler for `/mcp/*` (authenticated by `mcpSecret`). The MCP endpoint (U16). */
601
+ mcp?: SubHandler;
602
+ }
603
+ /** App Router route module shape: a `{ GET, POST }` pair of request handlers. */
604
+ interface EnvoyRouteHandlers {
605
+ GET: SubHandler;
606
+ POST: SubHandler;
607
+ }
608
+ /** Known sub-paths. Anything else is a 404 (we never leak which unknown paths exist). */
609
+ declare const KNOWN_SUBPATHS: readonly ["api", "read", "cron", "webhook", "unsubscribe", "mcp"];
610
+ type KnownSubpath = (typeof KNOWN_SUBPATHS)[number];
611
+ /**
612
+ * Extract the dispatch sub-path segment from a request URL. The factory is mount-agnostic: the host
613
+ * mounts the catch-all anywhere (`/api/envoy/...`, `/envoy/...`, etc.), so the SDK cannot know the
614
+ * base length. The dispatch segment is the one the host appended AFTER the mount base, so we scan
615
+ * for the LAST segment that matches a known sub-path — the deepest match is the action segment,
616
+ * never a coincidental `api` in the mount base (e.g. `/api/envoy/cron/tick` → `cron`, and
617
+ * `/api/envoy/api/enroll` → the second `api`). If no known segment appears, the sub-path is
618
+ * `null` ⇒ 404. A trailing extra path after the sub-path (`/webhook/resend`) still resolves to
619
+ * `webhook` because that is the last KNOWN segment.
620
+ */
621
+ declare function resolveSubpath(url: string): KnownSubpath | null;
622
+ /**
623
+ * Build the mounted route handlers. The host wires the returned `{ GET, POST }` into a single
624
+ * catch-all App Router route. Both verbs share one dispatcher; each sub-handler decides which
625
+ * methods it accepts. Auth is enforced per sub-path before any handler body runs (KTD8).
626
+ */
627
+ declare function createEnvoyHandler(config: EnvoyHandlerConfig): EnvoyRouteHandlers;
628
+ /** Config for {@link createDripCronHandler}. */
629
+ interface DripCronHandlerConfig {
630
+ /** The root SDK handle (DB, Resend, agent, redaction). */
631
+ envoy: Envoy;
632
+ /**
633
+ * How the tick resolves a sequence definition by key — a `Map` of `key → Sequence`, or a lookup
634
+ * function. Sequence definitions live in host code (`defineSequence`), never in the DB, so the
635
+ * host must register every sequence it runs. An enrollment whose key is not registered is skipped,
636
+ * not dropped.
637
+ */
638
+ registry: SequenceRegistry;
639
+ /** Engine config (consent mirror, unsubscribe base URL, stream, per-tick limit). */
640
+ tick: DripTickConfig;
641
+ }
642
+ /**
643
+ * The JSON body the drip cron handler returns on a successful tick. This is the WIRE shape the host
644
+ * types its cron route against — deliberately distinct from the engine's {@link DripTickResult}, which
645
+ * additionally carries the bounded per-enrollment `items[]`. The handler intentionally returns ONLY the
646
+ * aggregate counts (no `items`), so hosts must not be forced to satisfy a required `items` field that the
647
+ * response never includes.
648
+ */
649
+ interface CronTickResponse {
650
+ /** Always `true` on the 200 path (a thrown tick surfaces `{ ok: false, error }` with a 500). */
651
+ ok: true;
652
+ /** How many due enrollments this tick claimed. */
653
+ claimed: number;
654
+ /** How many claimed steps actually sent an email. */
655
+ sent: number;
656
+ /** How many were skipped (not_due / suppressed / deferred / unknown_sequence / resend_disabled). */
657
+ skipped: number;
658
+ /** How many failed (generation_failed / send_failed / tick_error) — left due for a later tick. */
659
+ failed: number;
660
+ }
661
+ /**
662
+ * Build the `/cron/drip` handler. Returns a {@link SubHandler} — `(request) => Promise<Response>` —
663
+ * the host passes to `createEnvoyHandler({ ..., cron })`. The factory already gated the request on
664
+ * `CRON_SECRET`; this handler runs one tick and returns a JSON summary (claimed/sent/skipped/failed).
665
+ *
666
+ * It NEVER throws to the caller: a claim/DB error is caught, redacted, logged, and surfaced as a 500
667
+ * so the host's cron platform retries — but a single contact's failure inside the tick is already
668
+ * fail-soft (R21) and reported in the body, not raised. A 2xx is returned even when some items
669
+ * failed (they are left due and retried next tick); the body carries the breakdown for host alerting
670
+ * (e.g. `lastFiredAt`-style health, R36 spirit).
671
+ */
672
+ declare function createDripCronHandler(config: DripCronHandlerConfig): (request: Request) => Promise<Response>;
673
+
674
+ /** Minimum token lifetime: 60 days. CAN-SPAM requires an opt-out mechanism that stays live for at
675
+ * least 30 days post-send; RFC 8058 one-click links are long-lived. We floor at 60d and reject any
676
+ * caller-supplied TTL below it (a too-short link is a compliance hole, fail loud at build time). */
677
+ declare const MIN_UNSUBSCRIBE_TTL_SECONDS: number;
678
+ /** The signed claims inside an unsubscribe token. `exp` is a Unix epoch SECONDS expiry. */
679
+ interface UnsubscribeClaims {
680
+ /** Bare recipient email (the contact key). */
681
+ contact: string;
682
+ /** Topic the opt-out applies to. */
683
+ topicKey: string;
684
+ /** Stream of the topic the opt-out applies to. */
685
+ stream: Stream;
686
+ /** Expiry, Unix epoch seconds. */
687
+ exp: number;
688
+ }
689
+ /** Result of verifying a token: the claims on success, or a reason on failure. Callers MUST NOT
690
+ * surface the reason to the client (uniform responses / no oracle) — it is for internal logging
691
+ * only, and even then the contact is redacted at the log site. */
692
+ type VerifyResult = {
693
+ ok: true;
694
+ claims: UnsubscribeClaims;
695
+ } | {
696
+ ok: false;
697
+ reason: "malformed" | "bad_signature" | "expired";
698
+ };
699
+ /**
700
+ * Verify a token against the secret with a constant-time signature compare and an expiry check.
701
+ * Returns the decoded claims on success. NEVER throws on attacker-controlled input — a malformed
702
+ * token is a typed failure, not an exception.
703
+ */
704
+ declare function verifyUnsubscribeToken(token: string, secret: string, nowSeconds?: number): VerifyResult;
705
+ /** Options for minting an unsubscribe token (drip/transactional lane). */
706
+ interface CreateTokenInput {
707
+ email: string;
708
+ topicKey: string;
709
+ stream: Stream;
710
+ /** Token lifetime in seconds. Defaults to and floored at `MIN_UNSUBSCRIBE_TTL_SECONDS` (60d). */
711
+ ttlSeconds?: number;
712
+ }
713
+ /**
714
+ * Mint a signed, expiring, topic+stream-scoped unsubscribe token. Throws if the requested TTL is
715
+ * below the 60-day compliance floor (fail loud at build time, not silently shorten).
716
+ */
717
+ declare function createUnsubscribeToken(input: CreateTokenInput, secret: string, nowSeconds?: number): string;
718
+ /** Built RFC 8058 one-click headers for a single `emails.send` call. */
719
+ interface ListUnsubscribeHeaders {
720
+ "List-Unsubscribe": string;
721
+ "List-Unsubscribe-Post": string;
722
+ }
723
+ /**
724
+ * Build the `List-Unsubscribe` + `List-Unsubscribe-Post` headers for a drip/transactional send
725
+ * (R33). The URL points at the SDK-owned landing under the host's mounted base path. `baseUrl` is
726
+ * the absolute, already-mounted unsubscribe endpoint (e.g. `https://app.example.com/api/envoy/unsubscribe`);
727
+ * the token is appended as a query param.
728
+ *
729
+ * RFC 8058: the presence of `List-Unsubscribe-Post: List-Unsubscribe=One-Click` tells the MUA the
730
+ * `List-Unsubscribe` URL accepts a POST one-click. The URL MUST be `https`.
731
+ */
732
+ declare function buildListUnsubscribeHeaders(input: CreateTokenInput, secret: string, baseUrl: string, nowSeconds?: number): ListUnsubscribeHeaders;
733
+ /** Default unsubscribe-landing rate limit: 20 requests / 60s per client IP. Generous enough for a
734
+ * real MUA's prefetch + the human's click, tight enough to blunt token-guessing fan-out. */
735
+ declare const DEFAULT_UNSUB_RATE_LIMIT = 20;
736
+ declare const DEFAULT_UNSUB_RATE_WINDOW_SECONDS = 60;
737
+ interface RateLimitResult {
738
+ allowed: boolean;
739
+ remaining: number;
740
+ retryAfterSeconds: number;
741
+ }
742
+ /**
743
+ * Atomic fixed-window limiter over `sdk_rate_limits`. The window resets when `window_start` ages
744
+ * past `windowSeconds`, otherwise the counter increments. Allowed while the post-increment count
745
+ * is within `limit`. FAILS OPEN on a DB error: a limiter outage must not lock every recipient out
746
+ * of unsubscribing (an unreachable opt-out is itself a compliance failure).
747
+ */
748
+ declare function checkRateLimit(db: NamespacedDb, bareKey: string, limit: number, windowSeconds: number): Promise<RateLimitResult>;
749
+ /** Best-effort client IP from proxy headers — rate-limit bucket key only, never authorization. */
750
+ declare function clientIp(request: Request): string;
751
+ /** Config the landing handler needs: the verifying secret, the mirror to write the opt-out into,
752
+ * the namespaced DB for rate-limiting, and optional limiter tunables. */
753
+ interface UnsubscribeLandingConfig {
754
+ secret: string;
755
+ mirror: ConsentMirror;
756
+ db: NamespacedDb;
757
+ rateLimit?: {
758
+ limit?: number;
759
+ windowSeconds?: number;
760
+ };
761
+ }
762
+ /**
763
+ * Handle a one-click unsubscribe request (RFC 8058). The token may arrive in the `token` query
764
+ * param (the `List-Unsubscribe` URL) or, on the POST, in a form-encoded `token` body field (the
765
+ * interstitial's hidden input).
766
+ *
767
+ * Method semantics (the security boundary):
768
+ * - GET performs NO write. It renders a small interstitial confirmation page whose button POSTs
769
+ * the token back. This makes the SDK safe against link prefetchers, link-unfurlers, and
770
+ * security scanners that GET every URL in an email — none of which should ever be able to
771
+ * unsubscribe a recipient. The interstitial is identical regardless of token validity (no
772
+ * oracle).
773
+ * - POST is the mutating one-click action. It verifies the token and, on success, writes a
774
+ * TOPIC-SCOPED `opt_out` via the mirror (NOT a global unsubscribe). Forged/expired/malformed →
775
+ * uniform 200 blank, NO state change. Already-opted-out is the same response (monotonic no-op).
776
+ *
777
+ * Other invariants:
778
+ * - Rate-limit by client IP first; over the limit → 429 (uniform, no body detail).
779
+ * - Never 500 on attacker input; never redirect.
780
+ */
781
+ declare function handleUnsubscribe(request: Request, config: UnsubscribeLandingConfig): Promise<Response>;
782
+
783
+ /** The envelope every Resend webhook shares: a discriminating `type` and a `data` object. */
784
+ interface ResendWebhookEvent {
785
+ type?: string;
786
+ created_at?: string;
787
+ data?: Record<string, unknown>;
788
+ }
789
+ /** Outcome of ingesting one event — returned for assertions/observability; serialized to the body. */
790
+ interface WebhookIngestResult {
791
+ /** The dispatch branch the event took. */
792
+ kind: "contact" | "suppression" | "analytics" | "ignored";
793
+ /** The discriminator that was seen (echoed for diagnostics; never includes PII). */
794
+ type: string;
795
+ /** True when a reconcile was enqueued (contact change signal). */
796
+ reconcileEnqueued: boolean;
797
+ /** True when a global suppression flag was written. */
798
+ suppressed: boolean;
799
+ /** True when the referenced contact existed and was resolved. */
800
+ contactMatched: boolean;
801
+ }
802
+ /**
803
+ * Pull a recipient email out of an event's `data`. Contact events carry `data.email`; email events
804
+ * carry `data.to` (Resend sends an array; we also tolerate a bare string). Returns the FIRST valid
805
+ * recipient, lowercased+trimmed (so resolution is case-insensitive), or null when none is present.
806
+ */
807
+ declare function extractRecipientEmail(data: Record<string, unknown> | undefined): string | null;
808
+ /**
809
+ * Ingest one already-verified, already-parsed Resend webhook event. Pure dispatch + DB writes; it
810
+ * never throws on an unknown / foreign / unmatched event (R41 ack-and-ignore). Returns a structured
811
+ * result the route layer serializes into the 200 body.
812
+ */
813
+ declare function ingestEvent(envoy: Envoy, event: ResendWebhookEvent): Promise<WebhookIngestResult>;
814
+ /**
815
+ * Build the `/webhook` sub-handler. Wire the returned function as
816
+ * `createEnvoyHandler({ ..., webhook: createWebhookReceiver(envoy) })`.
817
+ *
818
+ * The route factory has already Svix-verified the request and re-exposed the verified raw body, so
819
+ * this receiver parses + dispatches only. It ALWAYS returns 2xx for a processable or ignorable
820
+ * event (R41 ack-and-ignore) and never 500s on a malformed body — a 5xx would make Resend retry a
821
+ * payload we will never accept.
822
+ */
823
+ declare function createWebhookReceiver(envoy: Envoy): (request: Request) => Promise<Response>;
824
+
825
+ /**
826
+ * Canonical topic key for a `(stream, subject)` pair. This is the host-meaningful key stored on
827
+ * `sdk_topic_consent.topic_key` AND the `sdk_program_state.subject_key` of the provisioning cache,
828
+ * so the consent mirror and the provisioning cache agree on one identity. `:` is allowed here (it
829
+ * is only forbidden in the install namespace, not in topic keys).
830
+ */
831
+ declare function topicKeyFor(stream: Stream, subject: string): string;
832
+ /** Outcome of a provisioning call. `created` distinguishes a fresh Resend Topic from a cache hit. */
833
+ interface ProvisionTopicResult {
834
+ /** The host-meaningful topic key (`stream:subject`). */
835
+ topicKey: string;
836
+ /** The cached Resend Topic id (always present on success). */
837
+ topicId: string;
838
+ /** True when this call created the Resend Topic; false when it returned a cached id. */
839
+ created: boolean;
840
+ }
841
+ /** Inputs to {@link provisionTopic}. */
842
+ interface ProvisionTopicInput {
843
+ stream: Stream;
844
+ subject: string;
845
+ }
846
+ /**
847
+ * Provision (idempotently) the Resend Topic for a `(stream, subject)` pair and cache its id.
848
+ *
849
+ * Ordering — cache FIRST, create only on a miss:
850
+ * 1. Read the cache. A hit returns the cached id with `created: false`, creating nothing (the
851
+ * idempotent fast path — the second `provision` of the same pair is a pure read).
852
+ * 2. On a miss, create the Resend Topic (`opt_in`, public-by-intent), then claim-or-read the
853
+ * cache row. If we lost the claim to a concurrent provision, we adopt the winner's id and the
854
+ * Topic we created is a harmless duplicate-free no-op (we never persisted its id) — the cache
855
+ * holds exactly one id per topic key.
856
+ *
857
+ * When Resend is unset (no key) provisioning cannot create a Topic; it returns a cache hit if one
858
+ * exists, otherwise throws (a topic id is required to address the topic for opt-state pushes — a
859
+ * silent no-op here would hide a real misconfiguration, unlike a send which fails soft).
860
+ */
861
+ declare function provisionTopic(db: NamespacedDb, resend: ResendClientHandle, input: ProvisionTopicInput): Promise<ProvisionTopicResult>;
862
+
863
+ /** Outcome of a segment membership mutation. `ok: false` ⇒ caller should mark the row dirty. */
864
+ interface SegmentOpResult {
865
+ /** True when Resend confirmed the mutation; false on a Resend error, throw, or unset key. */
866
+ ok: boolean;
867
+ /** Present when the op was a no-op because Resend is unset (key absent) — distinct from a failure. */
868
+ skipped?: boolean;
869
+ /** A short, non-PII reason on failure (Resend error message or "threw"). Never the email. */
870
+ reason?: string;
871
+ }
872
+ /**
873
+ * Add a contact (by email) to a Segment. Fail-soft: a Resend error or thrown transport error
874
+ * returns `{ ok: false }` rather than throwing into the caller. An unset Resend key returns
875
+ * `{ ok: false, skipped: true }` (nothing to push; the caller leaves the row dirty for reconcile).
876
+ */
877
+ declare function addToSegment(resend: ResendClientHandle, email: string, segmentId: string): Promise<SegmentOpResult>;
878
+ /**
879
+ * Remove a contact (by email) from a Segment. Same fail-soft contract as {@link addToSegment}. Used
880
+ * by right-to-erasure (R34) best-effort membership teardown.
881
+ */
882
+ declare function removeFromSegment(resend: ResendClientHandle, email: string, segmentId: string): Promise<SegmentOpResult>;
883
+
884
+ /** Host-supplied contact: an email plus arbitrary JSON `data` Envoy mirrors verbatim (R9). */
885
+ interface ContactInput {
886
+ email: string;
887
+ data?: Record<string, unknown>;
888
+ }
889
+ /** A topic to reflect during a push: identified by `(stream, subject)`, with the opt-state to set. */
890
+ interface SyncTopic {
891
+ stream: Stream;
892
+ subject: string;
893
+ /** Subscription to push for this topic. Defaults to `opt_in` (topics are subscribe-by-default). */
894
+ subscription?: "opt_in" | "opt_out";
895
+ }
896
+ /** Inputs to a single `sync.push`. */
897
+ interface SyncPushInput {
898
+ email: string;
899
+ /** Optional topic to provision + push opt-state for. Omit for a Contact + Segment only push. */
900
+ topic?: SyncTopic;
901
+ }
902
+ /** Result of a `sync.push`. `ok` is true only when EVERY awaited step confirmed. */
903
+ interface SyncPushResult {
904
+ ok: boolean;
905
+ /** True when any step failed and the contact row was marked reconcile-dirty. */
906
+ dirty: boolean;
907
+ /** Per-step outcomes for observability (no PII). */
908
+ steps: {
909
+ contact: "confirmed" | "failed" | "skipped";
910
+ segment: "confirmed" | "failed" | "skipped";
911
+ topic: "confirmed" | "failed" | "skipped" | "none";
912
+ };
913
+ }
914
+ /**
915
+ * The push-on-write SegmentSync primitive (R37). Build one per install via {@link createSegmentSync}.
916
+ * Every `push` upserts the global Contact, adds the contact to the base Segment, and (when a topic
917
+ * is given) provisions the Topic + pushes its opt-state — ALL AWAITED, fail-soft.
918
+ */
919
+ declare class SegmentSync {
920
+ private readonly envoy;
921
+ constructor(envoy: Envoy);
922
+ /**
923
+ * Push a contact's Resend reflection. Order: global Contact upsert → base Segment add → Topic
924
+ * opt-state. Each step is awaited; a Resend-unset key makes the whole push a silent no-op
925
+ * (`ok: false`, dirty left for reconcile). Any partial failure marks the contact row dirty and
926
+ * returns `{ ok: false, dirty: true }` WITHOUT throwing (R37).
927
+ */
928
+ push(input: SyncPushInput): Promise<SyncPushResult>;
929
+ }
930
+ /** Construct a SegmentSync bound to an Envoy install. */
931
+ declare function createSegmentSync(envoy: Envoy): SegmentSync;
932
+ /** Result of an {@link enroll}. `created: false` ⇒ an idempotent no-op re-enroll (R11). */
933
+ interface EnrollResult {
934
+ /** The (bare) contact email. */
935
+ email: string;
936
+ /** The sequence the contact is enrolled in. */
937
+ sequenceKey: string;
938
+ /** Enrollment status (`active` for a fresh or already-active enrollment). */
939
+ status: string;
940
+ /** True when this call created the enrollment; false when it already existed (no-op, R11). */
941
+ created: boolean;
942
+ /** True when the contact is globally suppressed — enrollment is recorded but no sync/send occurs. */
943
+ suppressed: boolean;
944
+ /** The SegmentSync push outcome, or `null` when skipped (already active, or suppressed). */
945
+ sync: SyncPushResult | null;
946
+ }
947
+ /** Options for {@link enroll}. */
948
+ interface EnrollOptions {
949
+ /** Topic to reflect into Resend for this enrollment (provision + opt-state push). */
950
+ topic?: SyncTopic;
951
+ /**
952
+ * The stream the drip lane will send this sequence on (R27). Defaults to `"digest"` — drip
953
+ * sequences are opt-in nurture, matching the drip engine's `stream` default. Used to seed the
954
+ * LOCAL consent row the send gate reads, so the gate passes without a separate `consent.set`.
955
+ */
956
+ stream?: Stream;
957
+ }
958
+ /**
959
+ * Enroll a contact into a sequence (R8). Steps:
960
+ * 1. Upsert the mirror contact (R9). A globally-suppressed contact still records the enrollment
961
+ * but performs NO Resend sync and is reported `suppressed: true` (the send gate denies later).
962
+ * 2. Claim the enrollment row (R11). A FRESH claim (`created: true`) proceeds to sync; an
963
+ * already-ACTIVE enrollment is an idempotent no-op (`created: false`, `sync: null`) — nothing
964
+ * new is sent (R11).
965
+ * 3. On a fresh enrollment, run `sync.push` (Contact → base Segment → Topic opt-state), awaited
966
+ * and fail-soft (R10/R37).
967
+ *
968
+ * Never throws on a Resend failure — the sync result carries the dirty flag. Throws only on a hard
969
+ * mirror-write failure (a contract violation, not an external-service hiccup).
970
+ *
971
+ * Consent seeding (drip-lane correctness): a fresh, non-suppressed enrollment ALSO seeds a LOCAL
972
+ * `opt_in` consent row for `(email, sequenceKey)` on the drip stream via `mirror.set`. The drip
973
+ * send gate (U6) reads that local mirror and denies-by-default when no row exists — without this
974
+ * seed every drip step would be suppressed until the host separately called `consent.set`. The seed
975
+ * is a monotonic `opt_in`, so it never resurrects a recipient who already unsubscribed.
976
+ */
977
+ declare function enroll(envoy: Envoy, contact: ContactInput, sequenceKey: string, options?: EnrollOptions): Promise<EnrollResult>;
978
+ /** Result of a {@link deleteContact}. Each best-effort Resend teardown is reported independently. */
979
+ interface DeleteContactResult {
980
+ email: string;
981
+ /** True once the mirror was suppressed (always attempted first; throws only on a hard DB failure). */
982
+ suppressed: boolean;
983
+ /** The captured Resend contact id (or null when the contact was never reflected to Resend). */
984
+ resendContactId: string | null;
985
+ /** Best-effort teardown outcomes. `skipped` ⇒ Resend unset or nothing to delete. */
986
+ resendContactDeleted: "deleted" | "failed" | "skipped";
987
+ segmentMembershipRemoved: "removed" | "failed" | "skipped";
988
+ topicMembershipCleared: "cleared" | "failed" | "skipped";
989
+ /** True once the contact's enrollment/step PII columns were purged (R34 GDPR erasure). */
990
+ piiPurged: boolean;
991
+ }
992
+ /**
993
+ * Host-invoked right-to-erasure (R34). Order is load-bearing:
994
+ * 1. SUPPRESS THE MIRROR FIRST. This guarantees the next reconcile excludes the contact and a
995
+ * stale `topics.list` read cannot reconcile a deleted contact back to active (suppress-before-
996
+ * delete). This step is the only one that may throw (a hard DB failure) — everything after is
997
+ * best-effort and fail-soft.
998
+ * 2. Capture the Resend contact id from the mirror (before the row is anything but suppressed).
999
+ * 3. Best-effort delete the Resend Contact + Segment/Topic membership. Each is independent and
1000
+ * fail-soft: a Resend error on one does not abort the others, and NONE throw (R34). An already-
1001
+ * accepted broadcast cannot be recalled — that residual is acknowledged, not handled here.
1002
+ *
1003
+ * Note: the local mirror row is intentionally LEFT in place (suppressed), not hard-deleted — the
1004
+ * SDK never hard-deletes rows (the suppressed mirror is what keeps the contact excluded across both
1005
+ * lanes). The host's own data-retention policy governs purging the mirror row itself.
1006
+ */
1007
+ declare function deleteContact(envoy: Envoy, rawEmail: string, options?: {
1008
+ segmentIds?: string[];
1009
+ topicIds?: string[];
1010
+ }): Promise<DeleteContactResult>;
1011
+
1012
+ /**
1013
+ * Merge variables injected into the Resend Template. resend@6.14.0's `template.variables` is typed
1014
+ * `Record<string, string | number>`; we accept the same so the value passes straight through.
1015
+ */
1016
+ type TransactionalVariables = Record<string, string | number>;
1017
+ /** Inputs to {@link sendTransactional} (origin R46). */
1018
+ interface TransactionalSendInput {
1019
+ /** Recipient email (the contact key; namespace-prefixed only at the DB boundary, not on the wire). */
1020
+ email: string;
1021
+ /** Saved Resend Template id whose variables this send fills (`emails.send({ template: { id } })`). */
1022
+ templateId: string;
1023
+ /**
1024
+ * Template variables to inject. The referenced Template owns all visual structure; these fill its
1025
+ * declared variables. Optional — a Template with no variables needs none.
1026
+ */
1027
+ variables?: TransactionalVariables;
1028
+ /**
1029
+ * Stream this send belongs to (`digest` | `alert`). REQUIRED — it scopes the `List-Unsubscribe`
1030
+ * token (R33/R46). A missing/empty stream is rejected before any Resend contact (R45).
1031
+ */
1032
+ stream: Stream;
1033
+ /**
1034
+ * Topic this send belongs to. Scopes the suppression gate AND the unsubscribe token to a single
1035
+ * `(contact, topic, stream)` so a one-click opt-out leaves the recipient's other topics intact
1036
+ * (R33). Required for the same reason the stream is: a transactional email with no topic has no
1037
+ * place to scope its opt-out.
1038
+ */
1039
+ topicKey: string;
1040
+ /**
1041
+ * Idempotency key forwarded to Resend as the `Idempotency-Key` request HEADER (NOT a body field)
1042
+ * for exactly-once delivery on retry (R46). Optional — a one-shot send may forgo it, but a host
1043
+ * that may retry should always supply a stable key.
1044
+ */
1045
+ idempotencyKey?: string;
1046
+ /**
1047
+ * Sender address. Falls back to the stream's configured `from` default (`createEnvoy`'s
1048
+ * `streams[stream].from`) when omitted. A send with neither is rejected (R45-style fail-loud:
1049
+ * Resend requires a verified From).
1050
+ */
1051
+ from?: string;
1052
+ /** Optional subject override. When omitted the Resend Template's own subject is used. */
1053
+ subject?: string;
1054
+ /** Optional reply-to address(es). */
1055
+ replyTo?: string | string[];
1056
+ }
1057
+ /** Why a transactional send did not dispatch (when `sent` is false). */
1058
+ type TransactionalSkipReason = "suppressed" | "resend_disabled";
1059
+ /** Outcome of a {@link sendTransactional}. */
1060
+ type TransactionalSendResult = {
1061
+ /** True when Resend accepted the email. */
1062
+ sent: true;
1063
+ /** The Resend email id returned by `emails.send`. */
1064
+ emailId: string;
1065
+ } | {
1066
+ sent: false;
1067
+ /** Why nothing was sent. */
1068
+ reason: TransactionalSkipReason;
1069
+ };
1070
+ /**
1071
+ * Error thrown by {@link sendTransactional} for a HOST-CONTRACT violation it must fail loud on
1072
+ * (missing stream/topic/template/from, or a hard Resend error) — distinct from the fail-soft
1073
+ * `{ sent: false }` outcomes (suppression, no key) which are normal control flow, not errors.
1074
+ */
1075
+ declare class TransactionalSendError extends Error {
1076
+ constructor(message: string);
1077
+ }
1078
+ /** Config the transactional sender needs beyond the Envoy handle. */
1079
+ interface TransactionalSendConfig {
1080
+ /** The consent mirror to gate against (U6). */
1081
+ mirror: ConsentMirror;
1082
+ /**
1083
+ * Absolute, already-mounted, `https` unsubscribe landing URL (e.g.
1084
+ * `https://app.example.com/api/envoy/unsubscribe`). The signed token is appended as `?token=…`.
1085
+ * Required — without it there is no place for the `List-Unsubscribe` header to point (R33).
1086
+ */
1087
+ unsubscribeBaseUrl: string;
1088
+ }
1089
+ /**
1090
+ * Send one transactional (non-AI) templated email through Resend (R46). Order is load-bearing:
1091
+ *
1092
+ * 1. Validate inputs — fail loud on a missing stream/topic/template/email (R45). NOTHING touches
1093
+ * Resend or the contact before this passes.
1094
+ * 2. Resolve the From address (explicit or stream default) — fail loud if neither.
1095
+ * 3. GATE against the mirror (R26). A suppressed contact returns `{ sent: false, reason:
1096
+ * "suppressed" }` — no Resend call. The gate reads the mirror only (cheap, deterministic).
1097
+ * 4. If Resend is unset, silent no-op `{ sent: false, reason: "resend_disabled" }` (R43).
1098
+ * 5. Build the RFC 8058 `List-Unsubscribe` headers pointing at the SDK-owned landing (R33).
1099
+ * 6. `emails.send({ template: { id, variables }, to, from, headers, subject? }, { idempotencyKey })`
1100
+ * — the idempotency key is the REQUEST OPTION (`Idempotency-Key` header), never a body field.
1101
+ * 7. A Resend in-band `error` is a fail-loud `TransactionalSendError` (the host asked to send a
1102
+ * one-shot email and Resend refused — unlike the drip lane there is no later tick to retry it).
1103
+ */
1104
+ declare function sendTransactional(envoy: Envoy, input: TransactionalSendInput, config: TransactionalSendConfig): Promise<TransactionalSendResult>;
1105
+
1106
+ /** Error raised by the agent flow. Carries an HTTP-ish status and an optional sanitized detail. */
1107
+ declare class AgentError extends Error {
1108
+ readonly status: number;
1109
+ readonly detail?: string;
1110
+ constructor(message: string, status: number, detail?: string);
1111
+ }
1112
+ /** Per-call options. */
1113
+ interface AgentCallOpts {
1114
+ /** Invocation timeout. Defaults to 10 minutes — matches the app's run timeout. */
1115
+ timeoutMs?: number;
1116
+ /**
1117
+ * Invoked with the new session id immediately after `sessions.create` and BEFORE the billed
1118
+ * `events.send` turn. The caller persists it as an inflight crash-resume marker that always
1119
+ * precedes any billed work. If it throws, the un-sent (unbilled) session is archived and the
1120
+ * call fails — we never start a billed turn we cannot track.
1121
+ */
1122
+ onSessionCreated?: (sessionId: string) => void | Promise<void>;
1123
+ }
1124
+ interface AgentSessionResult {
1125
+ /** The chosen (content-seek) output text. */
1126
+ output: string;
1127
+ /** The session id (already persisted via `onSessionCreated` if supplied). */
1128
+ sessionId: string;
1129
+ }
1130
+ /**
1131
+ * Lazy Anthropic client singleton. Reads `ANTHROPIC_API_KEY` from env (the deployment-wide key for
1132
+ * the account that owns the Managed Agents). `maxRetries` covers 429 / transient 5xx with the
1133
+ * SDK's built-in exponential backoff. Allows injecting a client for tests.
1134
+ */
1135
+ declare function getAgentClient(): Anthropic;
1136
+ /** Override the client (tests only). Passing `null` restores lazy construction. */
1137
+ declare function setAgentClient(client: Anthropic | null): void;
1138
+ /**
1139
+ * Drive one Managed Agents session to completion and return the chosen output text plus the session
1140
+ * id. Persists the marker via `onSessionCreated` BEFORE the billed turn. Throws `AgentError` on
1141
+ * session error (502) or timeout (504).
1142
+ */
1143
+ declare function runAgentSession(agentId: string, environmentId: string, userMessage: string, opts?: AgentCallOpts): Promise<AgentSessionResult>;
1144
+ /**
1145
+ * Outcome of a crash-resume harvest:
1146
+ * - `completed`: the prior session finished (`end_turn`) with usable output — use it.
1147
+ * - `running`: still in progress — the caller MUST defer (leave the marker, retry next tick), NOT
1148
+ * create a second billed session.
1149
+ * - `unavailable`: gone / terminated / ended without usable output — the caller runs fresh.
1150
+ */
1151
+ type HarvestResult = {
1152
+ state: "completed";
1153
+ output: string;
1154
+ } | {
1155
+ state: "running";
1156
+ } | {
1157
+ state: "unavailable";
1158
+ };
1159
+ /**
1160
+ * Crash-resume harvest: given a previously-created session id, decide whether it already produced
1161
+ * usable output, is still running, or is unavailable — WITHOUT creating a new session or sending a
1162
+ * new (billed) turn. Distinguishing `running` matters: returning "no result" for a still-running
1163
+ * session would make the caller fork a second billed session (the timeout window can equal the cron
1164
+ * re-claim window). Only an `idle` session that ended with `stop_reason=end_turn` and non-empty
1165
+ * output counts as completed.
1166
+ */
1167
+ declare function harvestAgentSession(sessionId: string): Promise<HarvestResult>;
1168
+ /** The values the agent produced for the step's declared slots. */
1169
+ type GeneratedSlots = Record<string, string>;
1170
+ /** Inputs to {@link generateSlots}. */
1171
+ interface GenerateSlotsInput {
1172
+ agentId: string;
1173
+ environmentId: string;
1174
+ /** The slot names the step declares the agent must fill (R12/R14). */
1175
+ aiSlots: readonly string[];
1176
+ /** The per-step personalization brief (R12). */
1177
+ brief: string;
1178
+ /** The contact's raw host `data` — only allow-listed fields reach the agent (R44). */
1179
+ contactData: Record<string, unknown>;
1180
+ /** Host-declared allow-list of `data` fields the agent may see (R44). Empty ⇒ none. */
1181
+ aiFieldAllowList: readonly string[];
1182
+ }
1183
+ /**
1184
+ * Reduce arbitrary contact `data` to the allow-listed, value-clamped subset safe to send to the
1185
+ * agent (R44). The recipient email is never included unless the host explicitly allow-lists it. Any
1186
+ * field not on the list is dropped. Mirrors `lib/agent-sanitize.ts`, but driven by host config
1187
+ * rather than a hardcoded list.
1188
+ */
1189
+ declare function sanitizeContactForAgent(data: Record<string, unknown>, allowList: readonly string[]): Record<string, unknown>;
1190
+ /** Build the structured goal message the agent receives. The contact data is explicitly framed as
1191
+ * untrusted data, not instructions (prompt-injection defense-in-depth). */
1192
+ declare function buildSlotGoal(input: GenerateSlotsInput): string;
1193
+ /**
1194
+ * Content-seek the declared slot values out of the agent output. Returns `null` when the output is
1195
+ * not a JSON object or is missing any declared slot — the caller treats `null` as a generation
1196
+ * failure (leave the step due, never send empty/partial). Extra keys are ignored; non-string slot
1197
+ * values are coerced to strings.
1198
+ */
1199
+ declare function extractSlots(output: string, aiSlots: readonly string[]): GeneratedSlots | null;
1200
+ /**
1201
+ * Generate (or harvest) the declared slots for a drip step. When `resumeSessionId` is set this is a
1202
+ * re-claimed step: harvest the prior session and DEFER on `running` (never fork a second billed
1203
+ * session). Otherwise run a fresh session, persisting the marker first.
1204
+ *
1205
+ * Returns a discriminated result so the engine can react without exceptions for the deferral path:
1206
+ * - `generated`: slots filled (fresh session); carries the new `sessionId` (already persisted).
1207
+ * - `harvested`: slots filled from a prior `completed` session — no new bill, no resend.
1208
+ * - `deferred`: a prior session is still `running` — leave the step due, retry next tick.
1209
+ * - `failed`: generation produced no usable slots, or the session errored. Leave the step due.
1210
+ */
1211
+ type SlotGenerationResult = {
1212
+ kind: "generated";
1213
+ slots: GeneratedSlots;
1214
+ sessionId: string;
1215
+ } | {
1216
+ kind: "harvested";
1217
+ slots: GeneratedSlots;
1218
+ } | {
1219
+ kind: "deferred";
1220
+ } | {
1221
+ kind: "failed";
1222
+ reason: string;
1223
+ };
1224
+ interface GenerateOrHarvestInput extends GenerateSlotsInput {
1225
+ /** A non-null inflight marker means re-claim — harvest the prior session instead of forking. */
1226
+ resumeSessionId?: string | null;
1227
+ /** Persist the new session id as the inflight marker BEFORE the billed turn (fresh path only). */
1228
+ onSessionCreated?: (sessionId: string) => void | Promise<void>;
1229
+ /** Per-call timeout override. */
1230
+ timeoutMs?: number;
1231
+ }
1232
+ declare function generateOrHarvestSlots(input: GenerateOrHarvestInput): Promise<SlotGenerationResult>;
1233
+
1234
+ /** Default ceiling on `broadcasts.list` pages walked during a crash-resume precheck. A real host
1235
+ * persists the id on the common path, so the precheck only runs after a crash in the narrow
1236
+ * persist gap; this budget bounds the cost AND is the fail-loud tripwire that prevents a
1237
+ * blind re-create at high volume. */
1238
+ declare const DEFAULT_PRECHECK_MAX_PAGES = 20;
1239
+ /** Per-page size for the `broadcasts.list` precheck (Resend allows 1–100; default 20). */
1240
+ declare const DEFAULT_PRECHECK_PAGE_SIZE = 100;
1241
+ /** Default number of extra precheck attempts after an empty/no-match first pass, to absorb
1242
+ * read-replica lag between `broadcasts.create` accepting and the new broadcast becoming
1243
+ * listable. Each retry waits `retryDelayMs`. */
1244
+ declare const DEFAULT_PRECHECK_RETRIES = 2;
1245
+ /** Default delay (ms) between precheck retries. Small — replication lag, not a backoff. */
1246
+ declare const DEFAULT_PRECHECK_RETRY_DELAY_MS = 250;
1247
+ /**
1248
+ * A broadcast claim row as stored. Times are ISO strings (Postgres TIMESTAMPTZ); the bare
1249
+ * `broadcast_key` is the host key (namespace stripping is the caller's concern via the db wrapper).
1250
+ */
1251
+ interface BroadcastClaimRow {
1252
+ /** Host-supplied broadcast key (bare; one per broadcast issue). */
1253
+ broadcastKey: string;
1254
+ /** The Resend broadcast id, once `broadcasts.create` returned and it was persisted. Null in the
1255
+ * crash gap between accept and persist. */
1256
+ resendBroadcastId: string | null;
1257
+ /** Host content item ids included in this issue (provenance / cursor advance). */
1258
+ itemIds: string[];
1259
+ /** When the broadcast was marked sent. Null ⇒ unsent ⇒ resumable. */
1260
+ sentAt: string | null;
1261
+ /** When the claim row was created (the `broadcasts.list` precheck lower bound). */
1262
+ createdAt: string;
1263
+ }
1264
+ /** Outcome of {@link claim}. */
1265
+ interface ClaimResult {
1266
+ /** True when THIS caller won a fresh claim (the INSERT landed a row). Only a winner may send. */
1267
+ won: boolean;
1268
+ /** True when the (pre-existing) claim is a resumable prior attempt — `won === false` and the
1269
+ * existing row has `sent_at IS NULL`. A loser that is not resumable already sent (sent_at set)
1270
+ * and must do nothing. */
1271
+ resumable: boolean;
1272
+ /** The claim row (the freshly-inserted one on a win, the pre-existing one on a loss). Always
1273
+ * present after a claim — the INSERT … RETURNING wins return the new row; a loss reads the row
1274
+ * back (it must exist: the conflict implies a row). */
1275
+ row: BroadcastClaimRow;
1276
+ }
1277
+ /**
1278
+ * Atomically claim the right to send the broadcast for `broadcastKey`.
1279
+ *
1280
+ * `INSERT … ON CONFLICT DO NOTHING RETURNING` against `sdk_broadcast_claims`:
1281
+ * - WON (1 returned row): `{ won: true, resumable: false, row }`. The caller proceeds to render
1282
+ * + `broadcasts.create`, then calls {@link persistBroadcastId} and {@link markSent}.
1283
+ * - LOST (0 returned rows): a row already exists. We read it back to classify:
1284
+ * - `sent_at IS NULL` → `{ won: false, resumable: true, row }` (a crashed prior attempt — the
1285
+ * caller may resume via {@link resolveResumeBroadcastId}).
1286
+ * - `sent_at` set → `{ won: false, resumable: false, row }` (already sent — do nothing).
1287
+ *
1288
+ * Success is read from `rows.length` (the won/lost signal), never `rowCount`.
1289
+ *
1290
+ * @throws if a lost claim cannot be read back (a row MUST exist after a conflict — its absence is a
1291
+ * torn write or a namespace mismatch, a fail-loud condition, not a silent re-send).
1292
+ */
1293
+ declare function claim(db: NamespacedDb, broadcastKey: string, opts?: {
1294
+ itemIds?: ReadonlyArray<string>;
1295
+ }): Promise<ClaimResult>;
1296
+ /**
1297
+ * Persist the Resend broadcast id into the claim row, immediately after `broadcasts.create`
1298
+ * returns. This is what lets the COMMON resume path read the id directly and never scan Resend.
1299
+ * Idempotent: re-persisting the same id is a no-op-shaped UPDATE. Returns the updated row.
1300
+ *
1301
+ * @throws if no claim row exists for the key (persisting an id without a held claim is a contract
1302
+ * violation — the caller must `claim()` first).
1303
+ */
1304
+ declare function persistBroadcastId(db: NamespacedDb, broadcastKey: string, resendBroadcastId: string): Promise<BroadcastClaimRow>;
1305
+ /**
1306
+ * Mark the broadcast sent: set `sent_at = NOW()` and record the included item ids. After this, a
1307
+ * future claim for the same key is a non-resumable loss (`sent_at` set ⇒ do nothing). Idempotent on
1308
+ * `sent_at` (a second call refreshes the timestamp but the claim is already terminal). Returns the
1309
+ * updated row.
1310
+ */
1311
+ declare function markSent(db: NamespacedDb, broadcastKey: string, opts?: {
1312
+ itemIds?: ReadonlyArray<string>;
1313
+ }): Promise<BroadcastClaimRow>;
1314
+ /** Knobs for the crash-resume precheck. All have safe defaults; tests override them. */
1315
+ interface ResumePrecheckOptions {
1316
+ /** Max `broadcasts.list` pages to walk before failing loud. Default {@link DEFAULT_PRECHECK_MAX_PAGES}. */
1317
+ maxPages?: number;
1318
+ /** Page size for `broadcasts.list`. Default {@link DEFAULT_PRECHECK_PAGE_SIZE}. */
1319
+ pageSize?: number;
1320
+ /** Extra attempts after a no-match pass (replication lag). Default {@link DEFAULT_PRECHECK_RETRIES}. */
1321
+ retries?: number;
1322
+ /** Delay (ms) between retries. Default {@link DEFAULT_PRECHECK_RETRY_DELAY_MS}. */
1323
+ retryDelayMs?: number;
1324
+ /** Injectable sleep (tests pass a no-op). Defaults to a real `setTimeout` promise. */
1325
+ sleep?: (ms: number) => Promise<void>;
1326
+ }
1327
+ /** Outcome of {@link resolveResumeBroadcastId}. */
1328
+ type ResumeResolution =
1329
+ /** The broadcast already exists in Resend (found by name+created_at). Resume reads it; do NOT
1330
+ * re-create. `id` is the existing Resend broadcast id (from the persisted row or the precheck). */
1331
+ {
1332
+ status: "exists";
1333
+ broadcastId: string;
1334
+ source: "persisted" | "precheck";
1335
+ }
1336
+ /** No matching broadcast exists after a bounded, retried precheck — it is SAFE to (re-)create.
1337
+ * This is only returned when the precheck completed within budget and found nothing. */
1338
+ | {
1339
+ status: "absent";
1340
+ };
1341
+ /**
1342
+ * Resolve, for a resumable (`sent_at IS NULL`) claim, whether the broadcast already exists in
1343
+ * Resend — so the caller can resume rather than blind-re-create (the double-blast R30 forbids).
1344
+ *
1345
+ * COMMON PATH: the persisted `resend_broadcast_id` is present → `{ status: "exists", source:
1346
+ * "persisted" }` with no Resend call at all.
1347
+ *
1348
+ * CRASH GAP: the id is absent (crash after `broadcasts.create` accepted, before persist) → precheck
1349
+ * `broadcasts.list` for the deterministic `name === broadcastKey`. Since the list endpoint has no
1350
+ * name filter and no `created_at` filter param, we page (cursor `after`) and filter client-side,
1351
+ * stopping a page early once `created_at < claim.createdAt` (results are newest-first; older pages
1352
+ * cannot contain our broadcast). We retry the whole walk a few times to absorb read-replica lag.
1353
+ * - A name+created_at match → `{ status: "exists", source: "precheck" }` (resume; never re-create).
1354
+ * - No match within budget → `{ status: "absent" }` (safe to create).
1355
+ * - Budget (maxPages) exhausted on ANY attempt → THROW (fail loud — operator confirmation; never
1356
+ * blind-re-create at high volume).
1357
+ *
1358
+ * `name === broadcastKey` is the deterministic name the broadcast lane sets on `broadcasts.create`
1359
+ * (U12). It carries no server-side uniqueness — the LIST match, not the name, is the dedup.
1360
+ *
1361
+ * @throws when the precheck cannot complete within `maxPages` (fail loud), or when Resend is
1362
+ * unset/disabled (a resumable id-absent claim cannot be resolved without listing — surfacing it
1363
+ * beats a blind re-create), or on a Resend list error.
1364
+ */
1365
+ declare function resolveResumeBroadcastId(resend: ResendClientHandle, claimRow: Pick<BroadcastClaimRow, "broadcastKey" | "resendBroadcastId" | "createdAt">, opts?: ResumePrecheckOptions): Promise<ResumeResolution>;
1366
+
1367
+ /**
1368
+ * The cursor identity: a program (a `defineBroadcastProgram` key) and a subject (the unit the
1369
+ * watermark advances over — often a single global "default" subject for a simple newsletter, or a
1370
+ * per-locale / per-segment subject for a fan-out program). Both are bare host keys; the db wrapper
1371
+ * namespaces them so two installs on one Postgres never collide (R38).
1372
+ */
1373
+ interface CursorKey {
1374
+ /** Host program key (bare; namespaced by the db wrapper on write/read). */
1375
+ programKey: string;
1376
+ /** Host subject key (bare; the watermark advances per subject). */
1377
+ subjectKey: string;
1378
+ }
1379
+ /** The cursor state as surfaced to the host. */
1380
+ interface CursorState {
1381
+ /** The high-water mark over the host's ordering column, or null when the program has never sent
1382
+ * for this subject (a never-seen key reads as null without writing a row). */
1383
+ watermark: string | null;
1384
+ /** Monotonic issue sequence — how many issues have been sent for this (program, subject). 0 for a
1385
+ * never-seen key. The host may use it to label issues; `advance` records the host-supplied next
1386
+ * value (it does not auto-increment, so the host stays the source of truth). */
1387
+ issueSeq: number;
1388
+ /** When the cursor last advanced (a real send). Null for a never-seen key. Exposed as a HEALTH
1389
+ * signal: a stale lastFiredAt means the host's cron may have stopped (R36). */
1390
+ lastFiredAt: string | null;
1391
+ /** Whether the host has paused this (program, subject). A paused cursor is never `due`. */
1392
+ paused: boolean;
1393
+ }
1394
+ /**
1395
+ * Read the cursor state for `key`. A never-seen key reads as the lazy default
1396
+ * (`{ watermark: null, issueSeq: 0, lastFiredAt: null, paused: false }`) WITHOUT writing a row — a
1397
+ * pure read has no side effects, so the cursor row is materialized only on the first `advance`.
1398
+ */
1399
+ declare function read(db: NamespacedDb, key: CursorKey): Promise<CursorState>;
1400
+ /** Options for {@link due}. */
1401
+ interface DueOptions {
1402
+ /** The cadence window in days — `due` is true once this many days have elapsed since the last
1403
+ * send. Must be a finite, positive number. */
1404
+ cadenceDays: number;
1405
+ /** Injectable clock (tests pass a fixed instant). Defaults to `Date.now()`. */
1406
+ now?: () => number;
1407
+ }
1408
+ /**
1409
+ * The N-day timer. Returns whether a send is DUE for the given cursor state and cadence:
1410
+ * - paused → false (a paused cursor never fires)
1411
+ * - never fired (lastFiredAt null) → true (the first issue is always due)
1412
+ * - lastFiredAt unparseable → true (fail toward firing rather than silently stalling; a bad
1413
+ * stored timestamp should surface as a send, not an indefinite gap)
1414
+ * - otherwise → (now - lastFiredAt) >= cadenceDays
1415
+ *
1416
+ * `due` is a pure predicate over the passed state — it never reads the db. The caller pairs it with
1417
+ * {@link read}.
1418
+ *
1419
+ * @throws on a non-finite or non-positive `cadenceDays` (a zero/negative cadence is a config bug:
1420
+ * it would fire every tick — fail loud rather than blast).
1421
+ */
1422
+ declare function due(state: CursorState, opts: DueOptions): boolean;
1423
+ /** Options for {@link advance}. */
1424
+ interface AdvanceOptions {
1425
+ /** The new high-water mark — the ordering-column value of the newest item included in THIS send.
1426
+ * Must be a non-null, non-empty string that is strictly greater than the stored watermark. A
1427
+ * null/empty value is rejected (R45: the host's nullable ordering-column mistake surfaces here). */
1428
+ watermark: string;
1429
+ /** The issue sequence this send represents (host-supplied; the host owns issue numbering). When
1430
+ * omitted, the stored `issue_seq` is incremented by 1. */
1431
+ issueSeq?: number;
1432
+ /** Provenance: the host content item ids included in this issue. Stored on the row for audit; not
1433
+ * part of the watermark compare. (Reserved for parity with the claim row; currently advisory.) */
1434
+ itemIds?: ReadonlyArray<string>;
1435
+ /** Injectable clock for `last_fired_at` in tests. Defaults to DB `NOW()` when omitted. */
1436
+ firedAt?: string;
1437
+ }
1438
+ /** Outcome of {@link advance}. */
1439
+ interface AdvanceResult {
1440
+ /** True iff the watermark actually moved (the strictly-greater compare passed and the row was
1441
+ * written). False only via {@link tryAdvance} when the incoming watermark was not greater (a
1442
+ * skip-zero / only-if-new tick). `advance` itself throws on a non-monotonic watermark rather than
1443
+ * returning `advanced: false`. */
1444
+ advanced: boolean;
1445
+ /** The cursor state after the operation (the new state on an advance; the unchanged stored state
1446
+ * on a no-op skip). */
1447
+ state: CursorState;
1448
+ }
1449
+ /**
1450
+ * Advance the cursor for `key` — called ONLY on a real send (R36). Writes the new watermark, issue
1451
+ * sequence, and `last_fired_at` iff the incoming watermark is STRICTLY GREATER than the stored one.
1452
+ *
1453
+ * Rejects (throws), never silently advancing:
1454
+ * - a null / non-string / empty `watermark` (R45 — the nullable ordering-column mistake), and
1455
+ * - a non-monotonic `watermark` (<= the stored value: a same-instant duplicate or clock skew /
1456
+ * replay that would re-send already-sent content).
1457
+ *
1458
+ * The write is a single upsert (`INSERT … ON CONFLICT … DO UPDATE`) guarded in its `WHERE` by the
1459
+ * strictly-greater compare, so two concurrent ticks racing the same key cannot both advance — the
1460
+ * loser's UPDATE matches no row and it re-reads the (advanced) state. Materializes the row on first
1461
+ * advance.
1462
+ *
1463
+ * For the skip-zero / only-if-new path (no new content ⇒ DO NOT advance), use {@link tryAdvance},
1464
+ * which returns `{ advanced: false }` instead of throwing.
1465
+ */
1466
+ declare function advance(db: NamespacedDb, key: CursorKey, opts: AdvanceOptions): Promise<CursorState>;
1467
+ /**
1468
+ * The skip-tolerant sibling of {@link advance}. Identical watermark validation (a null/empty
1469
+ * watermark still throws — that is a config bug, not a skip), but a NON-MONOTONIC watermark returns
1470
+ * `{ advanced: false, state: <unchanged stored state> }` instead of throwing. Use this on the
1471
+ * only-if-new / skip-zero path where "nothing newer to send" is an expected no-op, not an error.
1472
+ */
1473
+ declare function tryAdvance(db: NamespacedDb, key: CursorKey, opts: AdvanceOptions, cfg?: {
1474
+ rejectNonMonotonic?: boolean;
1475
+ }): Promise<AdvanceResult>;
1476
+ /**
1477
+ * Set the paused flag for `key` (a host kill-switch independent of the watermark). Materializes the
1478
+ * row if absent. A paused cursor is never {@link due}. Returns the post-update state.
1479
+ */
1480
+ declare function setPaused(db: NamespacedDb, key: CursorKey, paused: boolean): Promise<CursorState>;
1481
+
1482
+ /**
1483
+ * A declared variable on a Resend Template. The SDK fills these in code for the broadcast lane.
1484
+ * `key` is the bare variable name (the `{{key}}` slot), `fallback` is the Template's own default
1485
+ * when the host supplies no value, `type` is Resend's declared scalar type.
1486
+ */
1487
+ interface TemplateVariableSpec {
1488
+ key: string;
1489
+ fallback: string | number | null;
1490
+ type: "string" | "number";
1491
+ }
1492
+ /**
1493
+ * The fields of a fetched Resend Template the broadcast renderer needs: the raw `html`/`text`
1494
+ * bodies (pre-substitution) and the declared variable specs. Everything else on the Resend
1495
+ * `Template` (status, timestamps, versioning) is irrelevant to rendering and dropped.
1496
+ */
1497
+ interface FetchedTemplate {
1498
+ id: string;
1499
+ html: string;
1500
+ text: string | null;
1501
+ variables: readonly TemplateVariableSpec[];
1502
+ }
1503
+ /** Raised when the Template cannot be fetched (Resend unset, not-found, or an upstream error). */
1504
+ declare class TemplateFetchError extends Error {
1505
+ constructor(message: string);
1506
+ }
1507
+ /** Drop the cache (tests; or a host that knows a Template was edited upstream mid-process). */
1508
+ declare function clearTemplateCache(): void;
1509
+ /**
1510
+ * Fetch a Resend Template by id and return its render-relevant fields, caching the result.
1511
+ *
1512
+ * - A cache hit returns immediately and does NOT call Resend (satisfies "second send does not
1513
+ * re-fetch"). Pass `{ refresh: true }` to force a re-fetch.
1514
+ * - Resend unset (no key) is a hard error here, not a no-op: the broadcast lane cannot render
1515
+ * without the Template's bodies, so silently producing an empty broadcast would be a bug. This
1516
+ * mirrors `provisionTopic`, which also refuses to no-op when a real upstream id is required.
1517
+ * - An upstream error or a missing Template (`data === null`) fails loud.
1518
+ */
1519
+ declare function getTemplate(resend: ResendClientHandle, id: string, opts?: {
1520
+ refresh?: boolean;
1521
+ }): Promise<FetchedTemplate>;
1522
+
1523
+ /** Host-supplied values for the Template's declared variables. Scalars only (Resend's model). */
1524
+ type BroadcastVariables = Record<string, string | number | boolean | null | undefined>;
1525
+ /** Raised when render or dispatch cannot proceed. Carries a stable, named contract message. */
1526
+ declare class BroadcastRenderError extends Error {
1527
+ constructor(message: string);
1528
+ }
1529
+ interface RenderBroadcastInput {
1530
+ /** Saved Resend Template id to render from. */
1531
+ templateId: string;
1532
+ /** Values for the Template's declared `{{key}}` variables. Missing keys use the Template fallback. */
1533
+ variables?: BroadcastVariables;
1534
+ }
1535
+ interface RenderedBroadcast {
1536
+ templateId: string;
1537
+ /** `html` body with declared variables filled and merge tags left verbatim. */
1538
+ html: string;
1539
+ /** `text` body, same substitution rules. `null` when the Template has no text part. */
1540
+ text: string | null;
1541
+ }
1542
+ /**
1543
+ * Fetch the (cached) Resend Template and fill its declared variables in code, preserving merge
1544
+ * tags verbatim. Returns broadcast-ready `{ html, text }`. Does NOT call `broadcasts.create` —
1545
+ * `sendBroadcast` composes this with dispatch; expose the pure render for hosts that want it.
1546
+ */
1547
+ declare function renderBroadcast(resend: ResendClientHandle, input: RenderBroadcastInput): Promise<RenderedBroadcast>;
1548
+ interface SendBroadcastInput extends RenderBroadcastInput {
1549
+ /** Target Resend Segment (canonical broadcast target; `audienceId` is deprecated, R17). */
1550
+ segmentId: string;
1551
+ /** Topic to scope delivery + consent to (the unsubscribe gate, KTD9). */
1552
+ topicId: string;
1553
+ /** Verified sender address. */
1554
+ from: string;
1555
+ subject: string;
1556
+ /** Broadcast name — the SDK passes the send-once `broadcastKey` here so listings can find it (U11). */
1557
+ name?: string;
1558
+ replyTo?: string | string[];
1559
+ previewText?: string;
1560
+ /**
1561
+ * Dispatch immediately (`send: true`, the default) vs create-only. When `scheduledAt` is set,
1562
+ * Resend schedules instead of sending now.
1563
+ */
1564
+ send?: boolean;
1565
+ /** ISO timestamp (or Resend natural-language) to schedule the broadcast instead of sending now. */
1566
+ scheduledAt?: string;
1567
+ }
1568
+ interface SendBroadcastResult {
1569
+ /** The Resend broadcast id returned by `broadcasts.create`. */
1570
+ broadcastId: string;
1571
+ html: string;
1572
+ text: string | null;
1573
+ }
1574
+ /**
1575
+ * Render a Resend Template and dispatch it as a Broadcast in a single call (origin R31/R32):
1576
+ * `templates.get` → fill in code → `broadcasts.create({ segmentId, topicId, html, text, send })`.
1577
+ *
1578
+ * No `templateId` and no headers are passed to `broadcasts.create` — broadcasts accept neither
1579
+ * (verified against resend@6.14.0). The Topic id carries the unsubscribe gate; the rendered html
1580
+ * still contains `{{{RESEND_UNSUBSCRIBE_URL}}}` for Resend to resolve per-contact.
1581
+ *
1582
+ * Fails loud when Resend is unset (rendering already requires the Template) or when
1583
+ * `broadcasts.create` errors — a broadcast that silently did not dispatch would be a compliance bug.
1584
+ */
1585
+ declare function sendBroadcast(resend: ResendClientHandle, input: SendBroadcastInput): Promise<SendBroadcastResult>;
1586
+
1587
+ /**
1588
+ * One resolved topic-cache entry: the host-meaningful `(stream, subject)` for a Resend topic id.
1589
+ * `topicKey` is the canonical `stream:subject` string the consent mirror stores on `topic_key`.
1590
+ */
1591
+ interface ResolvedTopic {
1592
+ topicId: string;
1593
+ topicKey: string;
1594
+ stream: Stream;
1595
+ subject: string;
1596
+ }
1597
+ /** Why a single reconcile ended the way it did. */
1598
+ type ReconcileOutcome =
1599
+ /** The diff + segment repair completed; the contact row was cleared dirty. */
1600
+ "reconciled"
1601
+ /** Resend is unset (no key) — nothing to diff; the contact stays dirty for a later run. */
1602
+ | "skipped"
1603
+ /** A 429 was hit; the caller backed off. The contact stays dirty (retried next tick). */
1604
+ | "rate_limited"
1605
+ /** A `topics.list` entry id was absent from the provisioning cache — fail loud. The contact stays
1606
+ * dirty and is SURFACED (never silently ignored — an unmapped opt_out is a consent leak). */
1607
+ | "unmapped"
1608
+ /** A non-rate-limit Resend error occurred; the contact stays dirty (retried). */
1609
+ | "error";
1610
+ /** Result of {@link reconcileContact}. Carries the observable changes (no PII beyond the email the
1611
+ * caller already holds) so a sweep can summarize what it repaired. */
1612
+ interface ReconcileContactResult {
1613
+ email: string;
1614
+ outcome: ReconcileOutcome;
1615
+ /** Topic keys whose mirror state reconcile flipped to `opt_out` (drift caught from Resend). */
1616
+ optedOut: string[];
1617
+ /** True when the base-Segment membership was (re-)asserted for this contact. */
1618
+ segmentRepaired: boolean;
1619
+ /** Topic ids on the contact in Resend that the provisioning cache could not map — the fail-loud
1620
+ * signal. Non-empty ⇒ `outcome === "unmapped"`. */
1621
+ unmappedTopicIds: string[];
1622
+ }
1623
+ /** Inputs to {@link reconcileContact}. */
1624
+ interface ReconcileContactInput {
1625
+ email: string;
1626
+ /** Pre-loaded `topicId → (stream, subject)` map (built once per sweep via {@link loadTopicCache}).
1627
+ * Pass it in to avoid re-querying the cache per contact. */
1628
+ topicCache: Map<string, ResolvedTopic>;
1629
+ /** Backoff before resume on a 429 (ms). Defaults to {@link DEFAULT_BACKOFF_MS}. Tests pass 0. */
1630
+ backoffMs?: number;
1631
+ /** Injectable sleep (tests stub it). Defaults to a real timer. */
1632
+ sleepFn?: (ms: number) => Promise<void>;
1633
+ }
1634
+ /**
1635
+ * Reconcile ONE contact: diff `contacts.topics.list` against the mirror and repair base-Segment
1636
+ * membership. The load-bearing steps, in order:
1637
+ *
1638
+ * 1. List the contact's topics from Resend (`contacts.topics.list`). A 429 here backs off and
1639
+ * returns `rate_limited` WITHOUT clearing dirty (the next tick retries). A non-429 error
1640
+ * returns `error`, also leaving dirty.
1641
+ * 2. For every listed topic, map its `id` to `(stream, subject)` via the provisioning cache. An
1642
+ * id ABSENT from the cache is fail-loud: it is collected into `unmappedTopicIds`, the contact
1643
+ * stays dirty, and the outcome is `unmapped` — we NEVER silently ignore it (a topic Resend
1644
+ * reports opted-out that we cannot map is a consent leak we must surface).
1645
+ * 3. For every MAPPED topic Resend reports `opt_out`, write `opt_out` into the mirror (monotonic —
1646
+ * `consent.set`-equivalent merge in SQL only moves toward MORE suppression).
1647
+ * 4. Repair base-Segment membership: re-assert the contact in the base Segment (idempotent add) so
1648
+ * an opted-in contact missing from the Segment still receives the issue (intersection target).
1649
+ * 5. On a clean pass (no unmapped ids, no rate-limit/error), CLEAR the contact's dirty flag.
1650
+ *
1651
+ * Never throws on a Resend hiccup — every failure is folded into the typed outcome (fail-soft),
1652
+ * EXCEPT a hard DB write failure, which propagates (a mirror we cannot write is a contract
1653
+ * violation, not an external-service blip).
1654
+ */
1655
+ declare function reconcileContact(envoy: Envoy, input: ReconcileContactInput): Promise<ReconcileContactResult>;
1656
+ /** Options for {@link reconcile}. */
1657
+ interface ReconcileOptions {
1658
+ /**
1659
+ * Sweep mode:
1660
+ * - `"dirty"` (default): visit only contacts with `dirty_since IS NOT NULL` (the cheap,
1661
+ * narrowed per-tick sweep). Bounded by `maxContacts`.
1662
+ * - `"full"`: visit EVERY contact, resuming from the persisted full-sweep cursor; advances the
1663
+ * cursor as it goes so a later tick continues where this one stopped.
1664
+ */
1665
+ mode?: "dirty" | "full";
1666
+ /** Max contacts to process this tick (the per-tick budget / fan-out window). Default 200. */
1667
+ maxContacts?: number;
1668
+ /** Backoff before resume on a 429 (ms). Default {@link DEFAULT_BACKOFF_MS}. Tests pass 0. */
1669
+ backoffMs?: number;
1670
+ /** Injectable sleep (tests stub it). */
1671
+ sleepFn?: (ms: number) => Promise<void>;
1672
+ }
1673
+ /** Result of a {@link reconcile} sweep. */
1674
+ interface ReconcileSweepResult {
1675
+ mode: "dirty" | "full";
1676
+ /** Contacts visited this tick. */
1677
+ processed: number;
1678
+ /** Contacts whose diff landed clean (dirty cleared). */
1679
+ reconciled: number;
1680
+ /** Contacts whose Resend topic ids could not all be mapped (fail-loud; surfaced, still dirty). */
1681
+ unmapped: ReconcileContactResult[];
1682
+ /** True when a 429 paused the sweep mid-tick (it will resume next tick). */
1683
+ rateLimited: boolean;
1684
+ /** For a full sweep: the cursor (last contact id processed) to resume from next tick; null when
1685
+ * the full sweep reached the end and the cursor was reset. Undefined for a dirty sweep. */
1686
+ resumeCursor?: string | null;
1687
+ }
1688
+ /**
1689
+ * The reconcile sweep — the broadcast lane's pre-send consistency pass (R29). Runs as the LAST step
1690
+ * before `broadcasts.create` (see U15's `runIssue` ordering). Two modes:
1691
+ *
1692
+ * - DIRTY (default): processes the dirty-set (`sdk_contacts.dirty_since IS NOT NULL`) up to the
1693
+ * per-tick budget. This is the cheap, narrowed path the broadcast loop calls each issue.
1694
+ * - FULL: a periodic safety net that walks EVERY contact, resumable across ticks via a persisted
1695
+ * cursor (`sdk_program_state` under `__envoy_reconcile_sweep__`). A tick that exhausts its
1696
+ * budget persists the last id; the next FULL tick resumes after it. When the walk reaches the
1697
+ * end, the cursor resets to null (the next full sweep starts over).
1698
+ *
1699
+ * Per-contact fail-soft: one contact's Resend error never aborts the sweep — it leaves that contact
1700
+ * dirty and moves on. A 429 pauses the sweep for the rest of THIS tick (so we don't hammer a
1701
+ * rate-limited account) and resumes next tick; the paused contact stays dirty.
1702
+ */
1703
+ declare function reconcile(envoy: Envoy, options?: ReconcileOptions): Promise<ReconcileSweepResult>;
1704
+
1705
+ /** Raised when a program DEFINITION is malformed (fail loud at definition time, mirroring
1706
+ * {@link SequenceDefinitionError} in the drip lane). A bad `render`, a non-positive cadence, or a
1707
+ * missing segment is a config bug surfaced at `defineBroadcastProgram` time, not at first send. */
1708
+ declare class BroadcastProgramError extends Error {
1709
+ constructor(message: string);
1710
+ }
1711
+ /** A `(stream, subject)` topic identity for a program subject — the unit a recipient leaves on
1712
+ * Resend's hosted preference page (R27). `topicKeyFor(subjectKey)` returns this so a program over
1713
+ * per-locale subjects (`"IT"`, `"FR"`) provisions one Topic per subject. */
1714
+ interface ProgramTopic {
1715
+ stream: Stream;
1716
+ subject: string;
1717
+ }
1718
+ /**
1719
+ * The context handed to a program's `render` for one issue of one subject. The host's `render` owns
1720
+ * the CONTENT decision (what Template, what variables, what subject line) given the items it was
1721
+ * passed and the cursor position; the SDK owns the mechanics around it.
1722
+ */
1723
+ interface RenderContext {
1724
+ /** The subject this issue is for (bare host key; e.g. `"default"`, `"IT"`). */
1725
+ subjectKey: string;
1726
+ /** The host content items the host decided are NEW for this issue (the host owns the content query
1727
+ * — R35). May be empty; a `render` that returns `null` for an empty batch is the skip path. */
1728
+ items: ReadonlyArray<unknown>;
1729
+ /** The cursor state read just before render — `{ watermark, issueSeq, lastFiredAt, paused }`. The
1730
+ * `render` reads `issueSeq` to label the issue and `watermark` to know the prior high-water mark. */
1731
+ cursor: CursorState;
1732
+ /** The provisioned Resend Topic id for this subject (the unsubscribe gate, KTD9). */
1733
+ topicId: string;
1734
+ }
1735
+ /**
1736
+ * What a program's `render` returns for one issue. Mirrors {@link SendBroadcastInput} minus the
1737
+ * mechanics the SDK fills in (`segmentId`, `topicId`, `name` are supplied by `runIssue`), PLUS the
1738
+ * `advance` payload (`watermark`/`issueSeq`/`itemIds`) so the host names the new high-water mark for
1739
+ * the SAME issue it rendered. Returning `null`/`undefined` is the explicit SKIP signal (nothing new
1740
+ * to send) — `runIssue` then neither sends nor advances.
1741
+ */
1742
+ interface RenderedIssue {
1743
+ /** Saved Resend Template id to render this issue from. */
1744
+ templateId: string;
1745
+ /** Values for the Template's declared `{{key}}` variables (merge tags stay verbatim). */
1746
+ variables?: BroadcastVariables;
1747
+ /** Sender address. Falls back to the program's `from`, then the SDK has no default (it throws). */
1748
+ from?: string;
1749
+ /** Subject line for this issue. */
1750
+ subject: string;
1751
+ replyTo?: string | string[];
1752
+ previewText?: string;
1753
+ /** Schedule instead of sending now (Resend ISO/natural-language). */
1754
+ scheduledAt?: string;
1755
+ /**
1756
+ * The new high-water mark for THIS issue — the ordering-column value of the newest item included.
1757
+ * Advanced ONLY after the send is accepted, strictly-greater (R36). A null/empty value is a host
1758
+ * contract bug (a nullable ordering column) and is rejected by `cursor.advance` (R45).
1759
+ */
1760
+ watermark: string;
1761
+ /** Issue sequence to record (host owns numbering). Defaults to `cursor.issueSeq + 1`. */
1762
+ issueSeq?: number;
1763
+ /** Content item ids included (provenance, recorded on the claim + cursor rows). */
1764
+ itemIds?: ReadonlyArray<string>;
1765
+ }
1766
+ /** A program's `render` callback. Async-allowed. Returns a {@link RenderedIssue}, or `null`/
1767
+ * `undefined` to SKIP (no new content → no send, no advance). */
1768
+ type ProgramRender = (ctx: RenderContext) => RenderedIssue | null | undefined | Promise<RenderedIssue | null | undefined>;
1769
+ /** Inputs to {@link defineBroadcastProgram}. */
1770
+ interface DefineBroadcastProgramInput {
1771
+ /** Stable program key (the cursor + claim rows are scoped to it; namespaced by the db wrapper). */
1772
+ key: string;
1773
+ /** Target Resend Segment id (the canonical broadcast target; intersected with the Topic). */
1774
+ segmentId: string;
1775
+ /** Map a subject key to its `(stream, subject)` Topic identity. Defaults to `{ stream: "digest",
1776
+ * subject: subjectKey }` — a single-stream newsletter. */
1777
+ topicKeyFor?: (subjectKey: string) => ProgramTopic;
1778
+ /** The N-day cadence for `due` (R36). Must be finite and positive. */
1779
+ cadenceDays: number;
1780
+ /** Default sender address used when a `render` omits `from`. */
1781
+ from?: string;
1782
+ /** The host content/subject renderer (see {@link ProgramRender}). */
1783
+ render: ProgramRender;
1784
+ }
1785
+ /** Why a {@link runIssue} call did NOT send (a non-error, expected no-op). */
1786
+ type IssueSkipReason =
1787
+ /** The cadence window has not elapsed since the last send (and the caller did not `force`). */
1788
+ "not_due"
1789
+ /** This (program, subject) cursor is paused (a host kill-switch). */
1790
+ | "paused"
1791
+ /** `render` returned `null`/`undefined` — the host had nothing new to send. */
1792
+ | "empty"
1793
+ /** The claim was lost to a concurrent tick that already sent (send-once: this caller does NOT
1794
+ * re-send). The other tick owns this issue. */
1795
+ | "claim_lost"
1796
+ /** The claim was already marked sent (a duplicate trigger after a completed issue). */
1797
+ | "already_sent";
1798
+ /** The outcome of one {@link runIssue} call. Exactly one of `sent` / `skipped` / `failed` is the
1799
+ * dominant state; `failed` is the per-subject fail-soft capture (the host loop continues). */
1800
+ interface RunIssueResult {
1801
+ /** The bare program key. */
1802
+ programKey: string;
1803
+ /** The bare subject key this issue was for. */
1804
+ subjectKey: string;
1805
+ /** The broadcast key (`programKey:subjectKey:issueSeq`) used as the claim id + Resend broadcast
1806
+ * name. Present whenever a claim was attempted. */
1807
+ broadcastKey?: string;
1808
+ /** True iff a broadcast was accepted by Resend this call (`broadcasts.create` returned an id) OR a
1809
+ * resumable prior attempt was resolved as already-existing and finalized. */
1810
+ sent: boolean;
1811
+ /** The Resend broadcast id, when `sent`. */
1812
+ broadcastId?: string;
1813
+ /** Set when the call was a deliberate no-op (not an error). */
1814
+ skipped?: IssueSkipReason;
1815
+ /** Set when a fail-soft error was captured (a Resend hiccup on THIS subject). The host loop must
1816
+ * continue to the next subject; this subject retries next tick. The message is redacted (R43). */
1817
+ failed?: string;
1818
+ /** The reconcile sweep summary run before the send (present whenever reconcile ran). */
1819
+ reconcile?: ReconcileSweepResult;
1820
+ /** The cursor state after the call (advanced on a send; unchanged on a skip/fail). */
1821
+ cursor?: CursorState;
1822
+ }
1823
+ /**
1824
+ * A defined broadcast program — the declarative handle. Exposes:
1825
+ * - the program's static config (`key`, `segmentId`, `cadenceDays`, …) for introspection (U16 MCP),
1826
+ * - `runIssue(envoy, { subjectKey, items })`: the bundled, per-subject fail-soft ordering, and
1827
+ * - the RAW primitives bound to this program's keys (`reconcile`, `claim`, `render`/`send`,
1828
+ * `cursor.read/due/advance`) for hosts that need a custom ordering. The raw module-level
1829
+ * primitives stay exported from the package root too (this is sugar, not a replacement).
1830
+ */
1831
+ interface BroadcastProgram {
1832
+ readonly key: string;
1833
+ readonly segmentId: string;
1834
+ readonly cadenceDays: number;
1835
+ readonly from?: string;
1836
+ /** Resolve a subject's `(stream, subject)` Topic identity. */
1837
+ topicFor(subjectKey: string): ProgramTopic;
1838
+ /** The cursor key for a subject (`{ programKey: key, subjectKey }`). */
1839
+ cursorKey(subjectKey: string): {
1840
+ programKey: string;
1841
+ subjectKey: string;
1842
+ };
1843
+ /** The deterministic broadcast key for a subject + issue sequence (`key:subjectKey:issueSeq`). */
1844
+ broadcastKey(subjectKey: string, issueSeq: number): string;
1845
+ /** Run ONE issue for ONE subject with the canonical ordering (per-subject fail-soft). */
1846
+ runIssue(envoy: Envoy, input: RunIssueInput): Promise<RunIssueResult>;
1847
+ }
1848
+ /** Inputs to {@link BroadcastProgram.runIssue}. */
1849
+ interface RunIssueInput {
1850
+ /** The subject to run (defaults to `"default"` — a single-subject newsletter). */
1851
+ subjectKey?: string;
1852
+ /** The host content items the host decided are new for this issue (handed to `render`). */
1853
+ items?: ReadonlyArray<unknown>;
1854
+ /** Bypass the cadence `due` check (a host-forced manual issue). The send-once claim still guards
1855
+ * against a double-send — `force` only skips the timer, never the claim. */
1856
+ force?: boolean;
1857
+ /** Override the reconcile sweep options for this issue (mode, budget, backoff). */
1858
+ reconcile?: ReconcileOptions;
1859
+ /** Override the crash-resume precheck knobs (max pages, retries) for this issue. */
1860
+ resume?: ResumePrecheckOptions;
1861
+ /** Injectable clock for the `due` check (tests). */
1862
+ now?: () => number;
1863
+ }
1864
+ /**
1865
+ * Define a broadcast program (R35). Validates loud at definition time: a missing/empty `key` or
1866
+ * `segmentId`, a non-positive `cadenceDays`, or a non-function `render` throws
1867
+ * {@link BroadcastProgramError}. Returns a frozen {@link BroadcastProgram} handle.
1868
+ *
1869
+ * The handle is pure config + bound methods — it touches no network or DB at definition time (so a
1870
+ * module that defines programs at import has no Resend/DB dependency, preserving the unset-key no-op).
1871
+ */
1872
+ declare function defineBroadcastProgram(input: DefineBroadcastProgramInput): BroadcastProgram;
1873
+
1874
+ /** Resolve a {@link Sequence} by key — a `Map` of `key → Sequence`, or a lookup function. Mirrors
1875
+ * the drip cron's `SequenceRegistry` so the same host registration drives both surfaces. */
1876
+ type McpSequenceRegistry = ReadonlyMap<string, Sequence> | ((key: string) => Sequence | undefined);
1877
+ /** Resolve a {@link BroadcastProgram} by key — a `Map`, or a lookup function. */
1878
+ type McpProgramRegistry = ReadonlyMap<string, BroadcastProgram> | ((key: string) => BroadcastProgram | undefined);
1879
+ /**
1880
+ * Verify an MCP bearer token, returning an {@link AuthInfo} when valid or `undefined` to reject.
1881
+ * The default ({@link defaultVerifyMcpToken}) is a constant-time compare against the configured
1882
+ * `mcpSecret`; a host that wants its `authorize(req)` to recognize an agent token can inject its own.
1883
+ */
1884
+ type McpVerifyToken = (request: Request, bearerToken: string | undefined) => AuthInfo | undefined | Promise<AuthInfo | undefined>;
1885
+ /** Config for {@link createMcpRouteHandler}. */
1886
+ interface McpRouteConfig {
1887
+ /** The root SDK handle (DB, Resend, agent, redaction). */
1888
+ envoy: Envoy;
1889
+ /**
1890
+ * The dedicated MCP credential (R42). Used by the default token verifier. The route factory (U4)
1891
+ * also gates `/mcp` against this; this module re-checks it so a standalone mount is never open.
1892
+ * When omitted (and no custom `verifyToken`), the MCP handler fails closed (every call rejected).
1893
+ */
1894
+ mcpSecret?: string;
1895
+ /** Custom token verifier (overrides the default constant-time `mcpSecret` compare). */
1896
+ verifyToken?: McpVerifyToken;
1897
+ /** Sequences an agent may enroll into / inspect (host `defineSequence` definitions). */
1898
+ sequences?: McpSequenceRegistry;
1899
+ /** Broadcast programs an agent may trigger / inspect (host `defineBroadcastProgram` handles). */
1900
+ programs?: McpProgramRegistry;
1901
+ /** Absolute https landing URL the drip List-Unsubscribe header points at — passed through for
1902
+ * parity with the engine config; unused by the current read/enroll tools. */
1903
+ unsubscribeBaseUrl?: string;
1904
+ /** Max MCP request duration (seconds). Mirrors the app's `{ maxDuration: 60 }`. */
1905
+ maxDuration?: number;
1906
+ }
1907
+ /**
1908
+ * The default MCP token verifier: a constant-time compare of the bearer token against `mcpSecret`.
1909
+ * Returns an {@link AuthInfo} on a match, `undefined` otherwise. An unset/empty `mcpSecret` or a
1910
+ * missing bearer token always rejects (never open, R42).
1911
+ */
1912
+ declare function defaultVerifyMcpToken(mcpSecret: string | undefined): McpVerifyToken;
1913
+ /**
1914
+ * Register the single-tenant lifecycle tools on an {@link McpServer}. Exposed standalone (in addition
1915
+ * to being wired by {@link createMcpRouteHandler}) so a host that builds its own MCP server can reuse
1916
+ * the exact tool set, and so tests can register against an in-memory server.
1917
+ *
1918
+ * Every tool that WRITES goes through the same server fn the host calls directly, so the suppression
1919
+ * mirror, the send-once claim, and the per-topic consent gate all apply unchanged.
1920
+ */
1921
+ declare function registerEnvoyTools(server: McpServer, config: McpRouteConfig): void;
1922
+ /** Instructions surfaced to the connecting agent (the app's `SERVER_INSTRUCTIONS`, single-tenant). */
1923
+ declare const SERVER_INSTRUCTIONS: string;
1924
+ /**
1925
+ * Build the `/mcp` {@link SubHandler}. Constructs the MCP server with `createMcpHandler`, registers
1926
+ * the single-tenant lifecycle tools, and wraps it with `withMcpAuth({ required: true })` so the MCP
1927
+ * server itself is never open (R42) — independent of the route factory's outer `mcpSecret` gate.
1928
+ *
1929
+ * Returns a Web-standard `(Request) => Promise<Response>`, so it slots directly into
1930
+ * `createEnvoyHandler({ ..., mcp })` and stays App-Router compatible.
1931
+ */
1932
+ declare function createMcpRouteHandler(config: McpRouteConfig): SubHandler;
1933
+
1934
+ /**
1935
+ * Raised when a host-contract validation fails loud at config time (R45). Carries no secret values;
1936
+ * the message names the offending field/slot/column and what to fix. Distinct from
1937
+ * `SequenceDefinitionError` (shape) and `cursor.advance`'s runtime guard (the last-line defense): a
1938
+ * `ValidationError` is the EARLY, actionable surfacing of the same class of mistake.
1939
+ */
1940
+ declare class ValidationError extends Error {
1941
+ constructor(message: string);
1942
+ }
1943
+ /**
1944
+ * Assert a transactional send names a valid `stream` (R45/R46). A transactional email's
1945
+ * `List-Unsubscribe` token is stream-scoped (R33), so a send with no stream cannot carry a working
1946
+ * one-click opt-out — it must be rejected at CONFIG time, never sent malformed.
1947
+ *
1948
+ * Callable wherever a stream is first declared (a host can run it at wiring to fail before any send).
1949
+ * `drip/transactional.ts` also re-checks at call time; this is the early surfacing of the same rule.
1950
+ *
1951
+ * @throws {ValidationError} on a missing / non-string / unknown stream.
1952
+ */
1953
+ declare function assertTransactionalStream(stream: unknown, context?: string): asserts stream is Stream;
1954
+ /**
1955
+ * The host's declaration of the ordering column that backs a broadcast program's monotonic cursor.
1956
+ * The SDK cannot read the host's content tables (R38/R45), so the host DECLARES the column it
1957
+ * advances the watermark over, and the SDK validates the declaration is sound at setup.
1958
+ */
1959
+ interface WatermarkColumnDeclaration {
1960
+ /** The host column name backing the watermark (e.g. `created_at`, `id`). Informational + surfaced
1961
+ * in error messages; must be non-empty. */
1962
+ column: string;
1963
+ /** The column's scalar type — a timestamp/id ordering column. Both sort monotonically (timestamps
1964
+ * lexicographically as ISO-8601, ids numerically) — matching `cursor.advance`'s compare. */
1965
+ type: "timestamptz" | "timestamp" | "bigint" | "integer" | "text" | "uuid";
1966
+ /** Whether the host column is NULLABLE. MUST be `false`: a nullable ordering column cannot back a
1967
+ * strictly-greater watermark (a null row has no position), so a `true` here is rejected at setup
1968
+ * rather than surfacing as a `cursor.advance` throw on the first real send (R36/R45). */
1969
+ nullable: boolean;
1970
+ }
1971
+ /**
1972
+ * Assert a broadcast program's declared watermark column is non-nullable (R45). A nullable ordering
1973
+ * column cannot back the monotonic cursor: `cursor.advance` rejects a null watermark at runtime, but
1974
+ * that is the LAST-line defense (it would fail at the first send). This is the EARLY surfacing — a
1975
+ * host that declares `nullable: true` at `defineBroadcastProgram` setup fails immediately, before any
1976
+ * cron is wired.
1977
+ *
1978
+ * @throws {ValidationError} on a `nullable: true` declaration, an empty column, or an unknown type.
1979
+ */
1980
+ declare function assertWatermarkColumnType(decl: WatermarkColumnDeclaration, context?: string): void;
1981
+ /**
1982
+ * The outcome of validating ONE sequence step's `aiSlots` against its Template. Exactly one of
1983
+ * `ok` / `warned` / `missing` is the dominant state per step:
1984
+ * - `ok: true` — every declared slot exists on the Template's concrete variable list.
1985
+ * - `warned` — the Template returned `variables: null` (a draft / variable-less Template):
1986
+ * we CANNOT CONFIRM the slots, so this is a warning, not a failure.
1987
+ * - `missing` (≥ 1) — the Template has a concrete variable list and one or more declared slots are
1988
+ * absent from it: a hard error (collected, then thrown together).
1989
+ */
1990
+ interface StepSlotCheck {
1991
+ stepIndex: number;
1992
+ templateId: string;
1993
+ /** Declared slots that do NOT exist on the Template's concrete variable list. */
1994
+ missing: readonly string[];
1995
+ /** True when the Template's variables came back `null` (cannot confirm — a warning). */
1996
+ warned: boolean;
1997
+ }
1998
+ /** The full result of {@link validateSequenceSlots} — a per-step breakdown plus rolled-up warnings. */
1999
+ interface SequenceValidationResult {
2000
+ sequenceKey: string;
2001
+ steps: readonly StepSlotCheck[];
2002
+ /** Human-readable warnings (one per draft/variable-less Template a slot could not be confirmed
2003
+ * against). Surfaced to the host (R39) — never swallowed, never fatal. */
2004
+ warnings: readonly string[];
2005
+ }
2006
+ /** Drop the slot-check cache (tests; or a host that knows a Template was edited upstream). */
2007
+ declare function clearValidationCache(): void;
2008
+ /**
2009
+ * Validate ONE sequence's declared `aiSlots` against its steps' Resend Templates (the lazy, network
2010
+ * arm of R45). Fetches each step's Template (cached, deduped), then for each step:
2011
+ * - concrete variable list present → every declared slot must exist on it, else `missing`.
2012
+ * - `variables: null` (draft) → cannot confirm → `warned: true`, surfaced as a warning.
2013
+ *
2014
+ * Collects ALL missing slots across ALL steps and throws ONE `ValidationError` listing every offender
2015
+ * (so a host fixes them in a single pass, not one error per redeploy). When nothing is missing it
2016
+ * returns the per-step breakdown plus any warnings (never throws on a warning).
2017
+ *
2018
+ * @throws {ValidationError} when one or more declared slots are absent from a concrete Template list,
2019
+ * or when a Template cannot be fetched (Resend unset / not found / upstream error).
2020
+ */
2021
+ declare function validateSequenceSlots(resend: ResendClientHandle, sequence: Sequence, opts?: {
2022
+ refresh?: boolean;
2023
+ }): Promise<SequenceValidationResult>;
2024
+ /**
2025
+ * Validate MANY sequences in one pass (the shape `envoy.validate()` drives). Runs each sequence's
2026
+ * slot check (sharing the per-Template cache so a Template referenced by two sequences is fetched
2027
+ * once), accumulates warnings, and throws on the FIRST sequence that has missing slots (its
2028
+ * `ValidationError` already lists every offender within that sequence).
2029
+ *
2030
+ * Returns the aggregate result (all per-sequence breakdowns + all warnings) when every sequence is OK
2031
+ * or only warns. This is the function a host calls explicitly at deploy time; it is also what the
2032
+ * drip engine can call lazily on a sequence's first tick (cached, so subsequent ticks are free).
2033
+ */
2034
+ declare function validateSequences(resend: ResendClientHandle, sequences: readonly Sequence[], opts?: {
2035
+ refresh?: boolean;
2036
+ }): Promise<{
2037
+ sequences: readonly SequenceValidationResult[];
2038
+ warnings: readonly string[];
2039
+ }>;
2040
+ /** Inputs to {@link validateConfig} / `envoy.validate()`. */
2041
+ interface ValidateInput {
2042
+ /** Sequences whose declared `aiSlots` are checked against their Templates (the network arm). */
2043
+ sequences?: readonly Sequence[];
2044
+ /** Per-program watermark column declarations checked for non-nullability (the sync arm). */
2045
+ watermarks?: readonly WatermarkColumnDeclaration[];
2046
+ /** Force a re-fetch of every Template (ignore the slot-check cache). */
2047
+ refresh?: boolean;
2048
+ }
2049
+ /** The aggregate result of a full {@link validateConfig} pass. */
2050
+ interface ValidateResult {
2051
+ sequences: readonly SequenceValidationResult[];
2052
+ /** All accumulated warnings (draft Templates that could not be confirmed) — surfaced, not fatal. */
2053
+ warnings: readonly string[];
2054
+ }
2055
+ /**
2056
+ * The full config-time validation entry point — the function `envoy.validate()` wraps (U18 / R45).
2057
+ * Runs, in order:
2058
+ * 1. the SYNCHRONOUS watermark-column checks (no network — fails loud on a nullable declaration), then
2059
+ * 2. the LAZY slot⇄Template network checks for every passed sequence (fails loud on a missing slot).
2060
+ *
2061
+ * Synchronous checks run FIRST so a nullable-column or bad-type mistake fails without spending a
2062
+ * Resend round-trip. Never runs at module load — the host calls it explicitly (or the engine calls it
2063
+ * lazily on first tick), preserving U3's unset-key no-op for installs that never validate.
2064
+ *
2065
+ * @throws {ValidationError} on the first hard failure (nullable watermark, unknown stream/type, or a
2066
+ * missing slot). Warnings (draft Templates) are returned, never thrown.
2067
+ */
2068
+ declare function validateConfig(envoy: Envoy, input: ValidateInput): Promise<ValidateResult>;
2069
+
2070
+ declare const SDK_VERSION = "0.0.0";
2071
+
2072
+ export { type AdvanceOptions, type AdvanceResult, type AgentCallOpts, AgentError, type AgentSessionResult, type Authorize, type AuthorizeResult, type BroadcastClaimRow, type BroadcastProgram, BroadcastProgramError, BroadcastRenderError, type BroadcastVariables, CONSENT_RANK, type ClaimResult, ConsentMirror, type ConsentRow, type ConsentSetInput, type ConsentSetResult, type ConsentStatus, type ContactInput, type CreateTokenInput, type CronTickResponse, type CursorKey, type CursorState, DEFAULT_PRECHECK_MAX_PAGES, DEFAULT_PRECHECK_PAGE_SIZE, DEFAULT_PRECHECK_RETRIES, DEFAULT_PRECHECK_RETRY_DELAY_MS, DEFAULT_UNSUB_RATE_LIMIT, DEFAULT_UNSUB_RATE_WINDOW_SECONDS, type DefineBroadcastProgramInput, type DefineSequenceInput, type DeleteContactResult, type DripCronHandlerConfig, type DripEngineConfig, type DripSkipReason, type DripStepResult, type DripTickConfig, type DripTickItem, type DripTickResult, type DueOptions, type DueStep, type EnrollOptions, type EnrollResult, type Envoy, type EnvoyAgentConfig, type EnvoyConfig, EnvoyConfigError, type EnvoyHandlerConfig, EnvoyNamespaceError, type EnvoyRouteHandlers, type EnvoyStreamConfig, type FetchedTemplate, type GenerateOrHarvestInput, type GenerateSlotsInput, type GeneratedSlots, type HarvestResult, type IssueSkipReason, type ListUnsubscribeHeaders, SERVER_INSTRUCTIONS as MCP_SERVER_INSTRUCTIONS, MIN_UNSUBSCRIBE_TTL_SECONDS, type McpProgramRegistry, type McpRouteConfig, type McpSequenceRegistry, type McpVerifyToken, type MigrateOptions, type MigrateResult, NamespacedDb, type ProgramRender, type ProgramTopic, type ProvisionTopicInput, type ProvisionTopicResult, type RateLimitResult, type ReconcileContactInput, type ReconcileContactResult, type ReconcileOptions, type ReconcileOutcome, type ReconcileSweepResult, type RenderBroadcastInput, type RenderContext, type RenderedBroadcast, type RenderedIssue, type ResendClientHandle, type ResendWebhookEvent, type ResolvedEnvoyConfig, type ResumePrecheckOptions, type ResumeResolution, type RunIssueInput, type RunIssueResult, SDK_VERSION, STREAMS, type SdkPool, type SdkQueryResult, type SegmentOpResult, SegmentSync, type SendBroadcastInput, type SendBroadcastResult, type Sequence, SequenceDefinitionError, type SequenceRegistry, type SequenceStep, type SequenceValidationResult, type SlotGenerationResult, type StepSlotCheck, type Stream, type SubHandler, type SyncPushInput, type SyncPushResult, type SyncTopic, TemplateFetchError, type TemplateVariableSpec, type TransactionalSendConfig, TransactionalSendError, type TransactionalSendInput, type TransactionalSendResult, type TransactionalSkipReason, type TransactionalVariables, type UnsubscribeClaims, type UnsubscribeLandingConfig, type ValidateInput, type ValidateResult, ValidationError, type VerifyResult, type WatermarkColumnDeclaration, type WebhookIngestResult, addToSegment, advance as advanceCursor, assertTransactionalStream, assertWatermarkColumnType, buildListUnsubscribeHeaders, buildSlotGoal, checkRateLimit, claim, clearTemplateCache, clearValidationCache, clientIp, computeNamespaceFingerprint, createConsentMirror, createDb, createDripCronHandler, createEnvoy, createEnvoyHandler, createMcpRouteHandler as createEnvoyMcpHandler, createMcpRouteHandler, createResendClientHandle, createSegmentSync, createUnsubscribeToken, createWebhookReceiver, due as cursorDue, defaultVerifyMcpToken, defineBroadcastProgram, defineSequence, deleteContact, enroll, extractRecipientEmail, extractSlots, generateOrHarvestSlots, getAgentClient, getTemplate, handleUnsubscribe, harvestAgentSession, ingestEvent, markSent, migrate, normalizeEmail, persistBroadcastId, provisionTopic, read as readCursor, reconcile, reconcileContact, redactEmail, redactValue, registerEnvoyTools, removeFromSegment, renderBroadcast, resolveConfig, resolveResumeBroadcastId, resolveSubpath, runAgentSession, runDripStep, sanitizeContactForAgent, sendBroadcast, sendTransactional, setAgentClient, setPaused as setCursorPaused, tickDrip, topicKeyFor, tryAdvance as tryAdvanceCursor, validateConfig, validateSequenceSlots, validateSequences, verifyUnsubscribeToken };