ZLT-3008: email send→deliver fan-out (DB lens primitives) + durable jobs overflow buffer - #62
Conversation
- add lib/audience.ts: channel-agnostic resolveAudience(Audience[]) -> Recipient{user,context}, dedup by (user, context)
- move recipient targeting out of @template/email (pure composition) to the resolution layer
- split monolithic sendEmail into planner (sendEmail) + per-recipient deliverEmail (idempotency-keyed)
- compose once per context with the render-error policy; fan out one deliverEmail per recipient
- cc/bcc are resolved roles, single-message-only (warn + drop on fan-out)
Reference implementation for ZLT-3008.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Framing this PR rests on, capturing the cc/bcc discussion: The deliverable is a pure function of three inputs:
So This PR draws that boundary (resolve → group by context → settle template → combine with user per fanned deliver). The stubbed items above are outputs of the same function — named seams to fill, not new dimensions. |
Addresses review feedback on the send→deliver pipeline: - to/cc/bcc are all `Audience` now. `Audience` carries `as?: 'to'|'cc'|'bcc'` and `resolveAudience` partitions them into disposition-tagged `Recipient`s (dedup by (user,context); strongest disposition wins on collision). One `audience: Audience[]` input replaces the three parallel arrays. - `data` everywhere, never `variables` — matches the appEvent payload key. - The planner routes the template *name* + context, not pre-composed subject/mjml strings. deliverEmail owns the whole resolve-and-render step. - `settleTemplate` (compose + owner-cascade fallback + render-error policy) pulled out of the handler into lib/emailTemplate.ts so the deliver job calls it — fallback is no longer trapped inside the planner. - from-address resolves in deliverEmail (per-context), not the planner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ience
db primitives (@template/db, channel-agnostic):
- fetchLens: lens → rows (toPrisma → requireWhere fail-closed → include → check)
- includeFromLens: Prisma include from a lens's declared relations (projectByPath,
not exposedSurface — respects pick/omit, no off-path re-exposure or explosion)
- prune: project row/row[] to the lens's available surface
- requireWhere: fail-closed guard against an empty where
email pipeline (apps/api):
- EmailHandoff slimmed to { template, data } — emitters emit references only
- sendEmail: registry → fetchLens(source) → senders → recipients(source, sender)
→ prune → fan out (channel-agnostic resolution)
- deliverEmail: settleTemplate @ sender → interpolate → render → send (email-specific)
- registry (lib/email/registry.ts): per-template { source, senders, recipients, data? };
data defaults to the pruned source
- removed audience.ts + audienceLens.ts; ReachContext + contextKey now in lib/email
inquiry-invite-organization-user (renamed from org-invitation):
- data is the inquiry (source) projected — sourceOrganization included, no sourceUser
- button composed in the template from {{sender.webUrl}}/invitations/{{data.id}}
render: interpolate now resolves nested paths ({{data.sourceOrganization.name}}) via lodash get
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Name the data ref by what it does (verification-url), not the UI element that happens to render it. The email-verification button moves inline like inquiry-invite did, dropping the now-orphaned generic system-button component. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Idempotency keyed on the logical event, not the random job id:
- planner (sendEmail) id = {eventName}:{template}:{hash(event.data)}
- deliver (deliverEmail) = {eventName}:{template}:{contextKey(sender)}:{to}:{hash(contents)}
Shared ordered prefix, hash last. A duplicate emit collapses at the planner;
a re-fanned send collapses at delivery; a changed payload re-sends. The bridge
threads event.name + sets the planner id; minimal refs in event.data make the
anchor stable. New stableHash util (safe-stable-stringify + sha256).
cc/bcc return as optional per-recipient lenses on the registry entry, resolved
inside the fan-out loop (manager-cc pattern) and passed through to the client.
Tests: stableHash (5, run green), idempotency key shape (pure, logic verified),
sendEmail fan-out + cc resolution (integration, needs the test DB to run).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…onJob → createLock - Design doc for a durable overflow buffer (transactional outbox + leaky-bucket drain) in front of BullMQ at the enqueueJob chokepoint — bounds Redis depth on large fan-outs while keeping per-job idempotent retries (no chunking). - INFRA-021 ticket + kanban-aron entry. - Fold makeSingletonJob onto createLock (fenced compare-and-delete release) instead of the bespoke SET NX + unconditional del. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- JobOutbox model: jobId unique, dedupeKey indexed, data/options Json. - outbox.ts: global overflow flag + cached fleet-global depth probe; coalescing accumulator (size|time trigger, serialized flushes, resolve-on-commit); spillToOutbox (superseding → deleteMany+create; fan-out → createMany skipDuplicates); flushOutbox for shutdown. - enqueue.ts: adhoc-only guard, bypass option, flag check → spill, tripIfFull. - drainOutbox handler: singleton (createLock), tops queue up to cap, re-enqueues with stored jobId, clears flag at low-water; 15s 6-field cron. - Wire flushOutbox into worker shutdown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Exercises spillToOutbox/drainOutbox directly (enqueueJob short-circuits under isTest): fan-out createMany coalescing, skipDuplicates dedup, superseding deleteMany-first latest-wins, drain admit+clear+flag-clear, idempotent re-enqueue by stored jobId. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Correct the exactly-once claim → at-least-once: removeOnComplete evicts the jobId, so a drain crash-replay can double-run; handlers must be idempotent (doc + ticket fixed). - API process now flushes the buffer on shutdown (was worker-only); flush runs after intake stops; flushOutbox awaits + observes + logs the in-flight flush (no swallowed failure). - Drain: per-row try/catch (one poison row can't strand the batch); superseding rows re-signal + add without a fixed jobId, matching the direct path. - Superseding spill is an atomic upsert on @@unique([handlerName, dedupeKey]) — race-safe. - Depth probe counts waiting+active, not delayed (excludes scheduled cron repeats). - Lazy-read config (cap/triggers) → cap-trip + partial-drain now testable; closing guard flushes inline during shutdown. - Extract shouldSpill (pure) + move signalSupersededJobs to outbox.ts (breaks an import cycle). - Tests: route table, cap-trip, partial drain, cross-flush dedup, superseding latest-wins. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Everything (fan-out + superseding) routes through ONE accumulator — no per-spill upsert (which round-trips the DB per superseding job and falls over under many at once). The flush dedups in a txn: latest-per-lane within the batch, deleteMany OR'd over the batch's lanes, then createMany(skipDuplicates). - Flush and drain share one mutex (runOnOutboxQueue) so they never touch JobOutbox concurrently — no stale re-enqueue racing a lane delete. - Durable shutdown: flushOutbox clears the timer, settles in-flight, loops to catch late arrivals, and retries each batch (bounded) before surfacing a failure loudly. - Tests: cross-flush + within-batch superseding collapse; reset FLUSH_MAX_ROWS per test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…registry) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+alert, signal on direct only - #1 cross-process silent-drop: superseding lanes now write via upsert (last-writer-wins), not deleteMany+createMany(skipDuplicates) which kept first-commit and dropped the latest. Plain fan-out rows still createMany. Both still one batched txn. - #2 closing latch: flushOutbox resets closing in finally + re-arms mid-drain stragglers. - #5 tripIfFull probes fresh depth (cached read tripped the flag late → overshoot). - #6 supersede signal moved to the direct path only (spill path supersedes at drain). - Overflow flag is now a start-timestamp with a drain-renewed TTL (survives between ticks, self-clears if the drain dies) + a stuck-overflow alert when it won't clear. - #3 (drain holds mutex across the add loop) kept by design — bounded, self-recovering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…down re-arm timer - Drain renews the overflow flag at the START of the tick too, so a long admit pass can't let the TTL lapse mid-tick (which would briefly drop the flag + reset the stuck-alert age). - flushOutbox no longer re-arms a setTimeout in its finally: during a real shutdown that timer could fire after Redis/DB are torn down and lose the job. The drain loop already persists everything present; an aborted shutdown re-arms on its next spill. - Pre-existing follow-up (not fixed): signalSupersededJobs scans the full queue per call, now per-row in the drain loop — batch the scan per drain pass if superseding volume grows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
withOverflowRenew wraps the drain with a recursive setTimeout that renews the flag's TTL (createLock heartbeat pattern), with a final renew + clearTimeout in the finally. This holds the flag open for an arbitrarily long admit loop instead of relying on TTL > tick duration — fully closing the round-3 'flag lapses mid-long-drain' finding. EXPIRE no-ops on an absent key, so the final renew never resurrects a flag the pass cleared. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
renewMs = floor(TTL/3) — a fraction is always below the TTL by construction. The previous Math.max(1000, …) floor could exceed the TTL for small TTLs (== TTL at the 1s minimum), so the heartbeat would fire at/after expiry and the flag could lapse. No TTL clamp needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ot per row signalSupersededLanes(Set) does a single getJobs scan for all the batch's superseding lanes; signalSupersededJobs(dedupeKey) delegates to it for the direct path. The drain collects its lanes and signals once, removing the O(rows × queueSize) scan under the mutex. No scan at all when a batch has no superseding rows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review notes on the jobs overflow buffer (we're adopting it for Zealot)Read through What's especially well done — resolve-on-commit (a crash mid-flush tells the caller "not accepted" rather than silently dropping); the plain- 1. Design-doc ↔ code drift
2. MySQL port note (for the Zealot / Tribe ports)
3. Sustained-overload failure mode (accepted ceiling, just naming it)Under arrival-rate > drain-throughput, 4. Drain holds the shared mutex across the whole
|
- add resolveSender(ReachContext) returning the platform env fallback; deliverEmail resolves {{sender.*}} through it. Real per-sender/DKIM address resolution is deferred to its own ticket behind this seam.
- remove all postal-address references from the email template path (sender.address footer token + senderVars).
- alias escape->escapeHtml in interpolate to satisfy noShadowRestrictedNames.
- biome auto-format (line-wrapping) across touched job/lens/cache files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ubscribe, template versioning - Sender DU + scope-first cascade; planner/deliver split with find-or-create fence - CommunicationLog per-recipient ledger + at-most-once idempotency; CAS-guarded lifecycle - Auto email Contact + canDeliver gating; bouncer pre-flight cached on Contact - HMAC unsubscribe capability + RFC 8058 one-click endpoint + List-Unsubscribe headers - Template/component versioning: audit-log snapshot graph (emailComponentAuditLogIds) + backprop walk; deliver pins emailTemplateAuditLogId; recompose reader; seeds audited via apps/api seed wrapper - Docs (COMMUNICATIONS.md); tickets COMM-003/004/005 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…→ Prisma push-down + per-candidate loop for cross-source lenses) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… predicate loop is cheap on the small set) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iagnostics - emailVersioning: skip no-op updates (no audit snapshot written → don't rewrite an older immutable snapshot's pins); handle soft-delete (deletedAt via update) by re-pinning ancestors without stamping the tombstone - seed wrapper: log the seed error instead of swallowing it - COMM-006: ticket the open versioning/infra items (send-pin fidelity, async backprop fan-out, recompose guards, lazy queue/Redis init) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Pin the template's latest snapshot in the template lookup itself (auditLogs take:1 desc), surfaced through compose → settle → deliver, so render and pin are one read (no drift). Drops deliverEmail's separate findFirst. - Tests: no-op re-save rewrites nothing; latest snapshot recomposes to exactly the live composition before and after an edit (no-drift invariant). - Seed wrapper fails loud (no try/catch). - COMM-006: V2 resolved (one-read pin), V4 acknowledged intended; notes for version restore (cross-ownership / degraded list) and component-scoped degrade + error attribution. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nv knobs resolveSender reads PROJECT_NAME (the init-script project-name key) instead of the parallel PLATFORM_NAME. Document the overflow-buffer tuning knobs in apps/api/.env.local.example with their in-code defaults.
|
@stevenolay thanks for the close read on the overflow buffer — glad it's a fit for Zealot's backpressure. Walking your five points with where the code stands: 1. Design-doc ↔ code drift — you're right, and the schema is the canonical artifact. For porters copying this, trust the shipped
The design doc is the proposal that predates those final calls; reconciling it is the concrete follow-up from your note (now flagged in the PR description's Known follow-ups). 2. MySQL port (app-side uuidv7) — agreed, contained. 3. Sustained-overload row growth — yes, that's the intended ceiling. The trade is explicit: move unboundedness off Redis (OOM, ungraceful) onto a cheap-to-grow Postgres table; 4. Drain holds the mutex across the admit loop — accepted, self-recovering stall. It's documented as such at the top of 5. Test gaps — fair, all three are real. At-least-once replay is timing-hard; the shutdown-drain ( Net: 2–4 are intended design with the rationale above; 1 (doc reconciliation) and 5 (tests) are genuine follow-ups, not yet in the code. Generated by Claude Code |
…reatedAt createdAt is identical for all audit rows written in one transaction (Postgres now() is the txn clock), so findFirst/findMany ordered by createdAt picks an arbitrary row on a multi-write txn and can pin a stale snapshot. uuidv7 id is monotonic, so ordering by id desc is the correct latest-first order.
…ark recompose @wip Template authors control template text, so {{data.constructor.name}} / {{data.__proto__}} walked the prototype chain via lodash get, and {{data.toString}} resolved an inherited function. Reject __proto__/prototype/constructor path segments and skip function values. recomposeCommunication is an intentional @wip seam (resend/preview consumer is a COMM follow-up), marked so it isn't mistaken for dead code.
…delete) The email Contact mirroring the user's login (valueKey === user.email) must stay in sync with auth and never be edited directly. AND a json-rules notEquals rule (field valueKey, path user.email) into contact.manage — the action both update and delete gate on. hydrate() loads the owner user, so the path resolves in-memory; the rule is a no-op for every other contact (phone/org/space/second-email all differ). A server-side email-change flow updates the contact outside the permix gate.
…here fetchLens re-checks the full condition in memory after toPrisma; with only the projection hydrated, a where filtering on an un-projected relation left that relation absent, so the check threw/dropped. Walk the lens whereClauses (fields, field-to-field `path` ValueSource, aggregate/array sub-conditions, all/any/if), resolve the leading `object`-relation run via the lens field map, and merge into the projection include. prune still strips these from output, so consumers are unchanged; scalar wheres (all current usage) produce the same include as before.
… commit-reject CommunicationLog: index every FK column (the onDelete: SetNull cascades and recompose/lookup paths scan them). Adds tests for flushOutbox persisting rows buffered at shutdown, and spillToOutbox rejecting when the commit fails (resolve-on-commit, not on accumulation) — both run green against the test DB.
Doc was the stale side of the drift @stevenolay flagged: §9 drain pseudocode counted waiting+delayed (code counts waiting+active), and §12's schema sketch showed collapseKey + @@index([id]) + a dedupeKey ?? options.id semantic that shipped as dedupeKey, no separate index (uuidv7 PK is the FIFO order). Align the sketch to packages/db/prisma/schema/jobOutbox.prisma and mark status Implemented.
verify-before-assert (a relayed/subagent claim is unverified until reproduced), no-deferral-as-escape-hatch, exhaust-validation-before-caveating.
… cleanups Owner model: - EmailTemplate/EmailComponent gain a userId owner FK + User/OrganizationUser/SpaceUser tiers (EmailOwnerModel), denormalized parent FKs, per-tier partial uniques, and registry entries mirroring Token. AuditLog.contextUserId records owner provenance on snapshots. Cascade (two separate chains, converging at default): - user: SpaceUser -> OrganizationUser -> User -> default - org: Space -> Organization -> default - ownerScope(sender) maps each sender to its own tier; explicit platform -> default bridge. - lookupTemplate/lookupCascade/lookup + parentOwner walk the chains. Versioning hook review cleanups: - one shared VersionedRecord type (Pick of the Prisma models, no redeclare) - inline subjectWhere, drop all selects, isEqual instead of sameIds - drop dead hard-delete branch (preventHardDelete already enforces soft-delete) - userId flows through resolveChildAuditLogIds + the snapshot owner context. Upsert as the idempotent pattern: - add factory.upsert; userEmailContact + the contact login-email test now upsert - Contact's redundant partial uniques -> plain uniques (valid ON CONFLICT targets; NULLS DISTINCT already left the null-FK branch unconstrained). Also: seed is its own atlas concept (infrastructure:seed); COMM-003 reconciled to the built two-chain design (no longer deferred) + COMM-007 tracks app-wide orgId denormalization; COMMUNICATIONS.md cascade docs updated; repo biome formatting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dex projection
Evacuation:
- cascade lookups filter deletedAt:null; per-owner email uniques are deletedAt-partial.
Soft-deleting an override now fails over to the parent tier AND frees the slug slot to recreate.
Version map (supersedes flat emailComponentAuditLogIds String[]):
- AuditLog.componentVersions Json = { slug: auditLogId | null } — the version's resolved dep
tree, null = ref unresolved (broken). recompose follows it slug-keyed (no brace-naive regex).
- resolveChildAuditLogIds -> resolveComponentVersions (returns the map).
Degraded index projection:
- degradedComponentRefs String[] on EmailTemplate/EmailComponent — the null-keyed slugs, an
audit-ignored live projection (ignoreFields) maintained by the versioning hook, for the
index-list 'which have errors'. Render-error attribution stays a send-time CommunicationLog concern.
Upsert as the idempotent pattern:
- factory.upsert; userEmailContact upserts; Contact per-owner uniques made non-partial
(partial uniques aren't valid ON CONFLICT targets -> Postgres 42P10).
Versioning hook stays '*' (must run after the global audit hook it stamps; model hooks run
before global hooks, so per-model registration would run before audit and breaks it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- stampOwnChildIds -> snapshotChildVersions (augments the audit snapshot with the child-version map; comment reworded off 'stamp'). - resolveComponentVersions: distinct ['subjectEmailComponentId'] + id desc so Postgres returns the latest snapshot per child (one row each) instead of loading the full version history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…model - sendEmail: replace the per-recipient contact.findFirst + communicationLog.upsert loop with batched lookups — one contact.findMany -> userId dict, one communicationLog.createManyAndReturn (skipDuplicates, idempotent on idempotencyKey) + read-back for ids. cc/bcc left per-recipient (speculative). Removes ~3N round-trips per fan-out. - COMM-008: ticket the email-change gap (login email is creation-time-only; re-sync the login contact via a User-update hook / background job if/when we support changing it). - communicationLog.prisma: enums moved below the model (convention). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
outbox.ts (264 lines) -> outbox/ folder, one concern per file: - config.ts env knobs + constants + FLAG_KEY - types.ts OutboxRow + toCreateInput + shouldSpill - queueDepth.ts the cached depth probe - flag.ts overflow flag lifecycle (is/set/renew/clear/tripIfFull/warnIfOverflowStuck/withOverflowRenew) - mutex.ts the shared serialized queue (runOnOutboxQueue) — used by accumulator AND drain - supersede.ts signalSupersededLanes/Jobs - accumulator.ts the write-behind buffer (accumulate/flush/writeBatch/dedupeLatestPerLane) + shutdown flushOutbox - index.ts barrel — keeps the '#/jobs/outbox' public surface identical No behavior change (820/820, typecheck green, atlas-clean). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
executeHooks ordering is implicit (model-before-global, registration order); a hook that must run after another (emailVersioning after auditLog) relies on it and per-model re-registration can break it. Document the requirement + the cycle-check need, mechanism left open. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@stevenolay — capturing where we landed on the email/comms direction after going through this PR against your two in-flight Zealot PRs (#1455 delivery, #1461 Decided
Still working through
Still unbuilt in this PR (don't treat as reference-complete)
|
No code writes or reads it and there's no `delivered` status in CommunicationStatus — it advertised a delivery-confirmation model nothing implements. Per the canonical comms design, delivery-receipt fields (deliveredAt/delivered/bounced/complained) land in the same change that wires provider webhook ingestion, not before. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reference implementation for ZLT-3008 — upgrading the template's email send path so the downstream Zealot/Tribe ports copy a correct example. The work landed as several composable pieces rather than the single "audience resolver" this description originally described: recipient targeting is now expressed through generic DB lens primitives instead of an email-specific
resolveAudience/audience.ts(those were prototyped and then dropped — see commit history), and the send job was split into a planner + per-recipient delivery fan-out.The pieces
1. Email send → deliver fan-out —
apps/api/src/jobs/handlers/{sendEmail,deliverEmail}.tssendEmailis now a planner: resolve entity + recipients via a lens, compute asendKey+ per-recipientidempotencyKey, upsert oneCommunicationLogrow per recipient, enqueue onedeliverEmaileach.deliverEmailis one recipient, one message: load the log row,settleTemplate(compose + render-error policy), suppression/deliverability check, claim the row (status: sendingvia conditional update), send, recordsent/failed/suppressed/undeliverable.CommunicationLog.idempotencyKey @unique+ thestatusguard), not the BullMQ jobId.2. DB lens primitives —
packages/db/src/lens/*,packages/db/src/hydrate/*,packages/shared/.../stableHash.tsfetchLens/includeFromLens/prune— a small, generic projection + hydration layer (a lens narrows the query, hydrates only the projected relations, prunes the result to the projection). Channel-agnostic; email is the first consumer.requireWherefail-closed guard;stableHashbacks the idempotency keys.3. Durable jobs overflow buffer —
apps/api/src/jobs/{outbox,handlers/drainOutbox}.ts,JobOutboxmodel,docs/design/jobs-overflow-buffer.mdenqueueJobchokepoint: once Redis queue depth crosses a cap, adhoc enqueues spill to Postgres and a singleton cron meters them back. Keeps one-job-per-recipient (clean idempotent retries) without a Redis-memory blowup. Generic for all job types; the email fan-out is the first consumer.4. Communication record + template versioning —
CommunicationLog,apps/api/src/hooks/{emailVersioning,userEmailContact}/CommunicationLogis the per-recipient delivery ledger (status, sender polymorphism, provider id, deliverability cache).userEmailContactkeeps an emailContactin sync on user create.5. Unsubscribe —
apps/api/src/routes/unsubscribe.ts,lib/email/unsubscribe.tsBETTER_AUTH_SECRET) one-click unsubscribe, POST-only, RFC 8058List-Unsubscribe/List-Unsubscribe-Postheaders on non-system mail.Removed —
packages/email/src/targeting/andapps/api/src/lib/resolveTargets.ts(replaced by the lens path; no remaining references).Decisions worth a look
deliverEmailpayload — fine at small N; a send-keyed short-TTL cache is the follow-up for large blasts (no cache primitive here yet).CommunicationLogstatus guard is the delivery-level dedup.Known follow-ups
docs/design/jobs-overflow-buffer.mdis the original proposal and has drifted from the shipped schema (dedupeKeynotcollapseKey; no@@index([id])— the uuidv7 PK is already the FIFO order; depth probe iswaiting+active). The schema/code is canonical; doc to be reconciled (per @stevenolay's review note).inScope(rebac scope gate),resolveSender/resolveFromAddress(per-brand Mailgun, Phase 5) are intentional@wipseams (COMM-005), not wired yet.Validation
apps/api,packages/email,packages/dbtypecheck clean; the DB-free unit suites (render,stableHash, lens/hydrate, idempotency, unsubscribe) pass.🤖 Generated with Claude Code