Skip to content

ZLT-3008: email send→deliver fan-out (DB lens primitives) + durable jobs overflow buffer - #62

Merged
agreenspan merged 39 commits into
mainfrom
ZLT-3008
Jun 22, 2026
Merged

ZLT-3008: email send→deliver fan-out (DB lens primitives) + durable jobs overflow buffer#62
agreenspan merged 39 commits into
mainfrom
ZLT-3008

Conversation

@agreenspan

@agreenspan agreenspan commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

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.

Note: this PR's scope grew past email. It also lands a generic jobs overflow buffer, the DB lens/hydrate primitives, email-template versioning, a CommunicationLog model, and an unsubscribe route. They're related (the email pipeline is the first consumer of each) but independently reviewable — the map below is by piece.

The pieces

1. Email send → deliver fan-outapps/api/src/jobs/handlers/{sendEmail,deliverEmail}.ts

  • sendEmail is now a planner: resolve entity + recipients via a lens, compute a sendKey + per-recipient idempotencyKey, upsert one CommunicationLog row per recipient, enqueue one deliverEmail each.
  • deliverEmail is one recipient, one message: load the log row, settleTemplate (compose + render-error policy), suppression/deliverability check, claim the row (status: sending via conditional update), send, record sent/failed/suppressed/undeliverable.
  • Idempotency authority is the DB (CommunicationLog.idempotencyKey @unique + the status guard), not the BullMQ jobId.

2. DB lens primitivespackages/db/src/lens/*, packages/db/src/hydrate/*, packages/shared/.../stableHash.ts

  • fetchLens / 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.
  • requireWhere fail-closed guard; stableHash backs the idempotency keys.

3. Durable jobs overflow bufferapps/api/src/jobs/{outbox,handlers/drainOutbox}.ts, JobOutbox model, docs/design/jobs-overflow-buffer.md

  • Transactional-outbox + leaky-bucket drain at the enqueueJob chokepoint: 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 versioningCommunicationLog, apps/api/src/hooks/{emailVersioning,userEmailContact}/

  • CommunicationLog is the per-recipient delivery ledger (status, sender polymorphism, provider id, deliverability cache).
  • Email-template versioning hooks snapshot template/component edits into the audit log so a parent's latest snapshot pins its children's current snapshots.
  • userEmailContact keeps an email Contact in sync on user create.

5. Unsubscribeapps/api/src/routes/unsubscribe.ts, lib/email/unsubscribe.ts

  • Signed (HMAC over BETTER_AUTH_SECRET) one-click unsubscribe, POST-only, RFC 8058 List-Unsubscribe / List-Unsubscribe-Post headers on non-system mail.

Removedpackages/email/src/targeting/ and apps/api/src/lib/resolveTargets.ts (replaced by the lens path; no remaining references).

Decisions worth a look

  1. cc/bcc resolve through the same lens path and ride only single-message sends; on a fan-out they're dropped (you don't cc 40k separate sends). Flagging in case you want grouped-To semantics.
  2. Composed MJML rides in each deliverEmail payload — fine at small N; a send-keyed short-TTL cache is the follow-up for large blasts (no cache primitive here yet).
  3. Render-error policy via one probe-render per context (rule errors are template-level / recipient-independent), preserving the degrade/fallback/fail behavior without rendering the whole batch in the planner.
  4. The overflow buffer is at-least-once — handlers must be idempotent; the email consumer's CommunicationLog status guard is the delivery-level dedup.

Known follow-ups

  • docs/design/jobs-overflow-buffer.md is the original proposal and has drifted from the shipped schema (dedupeKey not collapseKey; no @@index([id]) — the uuidv7 PK is already the FIFO order; depth probe is waiting+active). The schema/code is canonical; doc to be reconciled (per @stevenolay's review note).
  • Test coverage on the new send/deliver path and the overflow shutdown-drain / resolve-on-commit-rejection paths is thin.
  • inScope (rebac scope gate), resolveSender/resolveFromAddress (per-brand Mailgun, Phase 5) are intentional @wip seams (COMM-005), not wired yet.

Validation

apps/api, packages/email, packages/db typecheck clean; the DB-free unit suites (render, stableHash, lens/hydrate, idempotency, unsubscribe) pass.

🤖 Generated with Claude Code

- 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>
@agreenspan

Copy link
Copy Markdown
Contributor Author

Framing this PR rests on, capturing the cc/bcc discussion:

The deliverable is a pure function of three inputs:

deliver = f(message/template, user, tenancy settings)
  • template variant ← (slug, tenancy) — brand→default cascade (settleTemplate)
  • sender / domain ← tenancy — per-brand Mailgun (Phase 5) — stubbed today (resolveFromAddress is global)
  • relational cc/bcc ← tenancy — org admin / account owner — not derived yet
  • suppress / send? ← (user, tenancy, message category) — notification prefs + unsubscribe — not applied yet
  • deliverable address ← user — email, never userID

So context is just the handle to tenancy settings, and Recipient { user, context } stays minimal — nothing derived is stored on it. cc/bcc is deliberately not a field: relational cc/bcc derives from context; explicit cc/bcc is the single-message case handled in the planner.

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.

agreenspan and others added 15 commits June 17, 2026 18:00
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>
@stevenolay

Copy link
Copy Markdown
Contributor

Review notes on the jobs overflow buffer (we're adopting it for Zealot)

Read through outbox.ts, handlers/drainOutbox.ts, enqueue.ts, the JobOutbox schema, and the tests. This is robust, production-grade work — we're going to adopt the overflow buffer as Zealot's queue-backpressure strategy (over chunking, with chunking + buffer as our documented fallback). A few notes for visibility.

What's especially well done — resolve-on-commit (a crash mid-flush tells the caller "not accepted" rather than silently dropping); the plain-createMany(skipDuplicates) vs keyed-upsert split with the explicit reasoning that skipDuplicates keeps first-commit-wins not latest; the flag lifecycle (NX set-once + TTL heartbeat-renew for the whole pass, EXPIRE no-op so the final renew never resurrects a cleared flag); depth = waiting + active excluding delayed (the standing cron floor); and the honest at-least-once contract. The shared flush/drain mutex + singleton drain is the right concurrency model.

1. Design-doc ↔ code drift

docs/design/jobs-overflow-buffer.md models JobOutbox with collapseKey + @@index([id]); the shipped schema (packages/db/prisma/schema/jobOutbox.prisma) uses dedupeKey and drops the index (uuidv7 id is time-ordered, so orderBy: { id: 'asc' } is the FIFO drain order). Worth reconciling the doc so the canonical example matches the code a downstream porter will copy.

2. MySQL port note (for the Zealot / Tribe ports)

@default(dbgenerated("uuidv7()")) is a Postgres-side function — MySQL has no uuidv7(), so we'll generate the id app-side (the uuidv7 npm pkg is already imported in enqueue.ts, so this is small). The @@unique([handlerName, dedupeKey]) NULL-distinct behavior (many fan-out rows coexist) and createMany({ skipDuplicates }) (→ INSERT IGNORE) both carry to MySQL. So it's a contained change, just flagging it.

3. Sustained-overload failure mode (accepted ceiling, just naming it)

Under arrival-rate > drain-throughput, JobOutbox grows between ticks — write volume stays low thanks to the write-behind batching, but row count climbs. warnIfOverflowStuck alerts on it, but that's an alert, not producer backpressure / load-shedding. This is the intended trade (move unboundedness from Redis-OOM to a cheaper-to-grow table), worth stating as the design's explicit ceiling in case a bounded-table or shed-load policy is wanted later.

4. Drain holds the shared mutex across the whole queue.add admit loop

You already flag this as "an accepted, self-recovering stall." For a large room (thousands), the sequential add-loop blocks that process's flushes for the whole pass; a chunked admit that released the mutex between sub-batches would shrink the window. Non-blocking nit.

5. Test coverage — strong, with a few gaps

Routing (adhoc/bypass/cron), resolve-on-commit, jobId dedup across flushes, superseding collapse (within-batch + cross-flush), and drain top-up/flag-clear are all covered. Not seen: the at-least-once replay (crash between queue.add and the row delete → re-admit next tick), the flushOutbox shutdown drain + retry, and the resolve-on-commit rejection path (flush throws → caller sees rejection). The first is timing-hard; the latter two look testable.

None of these are blockers — mostly doc reconciliation + port notes. Nice piece of infra.

agreenspan and others added 9 commits June 20, 2026 12:33
- 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.
@arongreenspan arongreenspan changed the title ZLT-3008: email audience/recipient resolution + send→deliver fan-out ZLT-3008: email send→deliver fan-out (DB lens primitives) + durable jobs overflow buffer Jun 21, 2026

Copy link
Copy Markdown
Contributor

@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 jobOutbox.prisma, not the doc sketch:

  • the field is dedupeKey (not collapseKey), and there is no dedupeKey ?? options.id fallback — a spilled job's lane is just dedupeKey ?? null;
  • no @@index([id]) — the uuidv7 PK is already time-ordered, so orderBy: { id: 'asc' } is the FIFO drain order;
  • depth probe is getJobCounts('waiting','active') (excludes delayed — that's the standing cron-repeat floor, not overflow pressure).

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. JobOutbox.id uses Postgres dbgenerated("uuidv7()"); for the MySQL ports, generate the id app-side at the create site — the uuidv7 pkg is already imported in enqueue.ts for the spill jobId. The @@unique([handlerName, dedupeKey]) NULL-distinct behavior and createMany({ skipDuplicates })INSERT IGNORE both carry over. No template change needed.

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; warnIfOverflowStuck is the alert, not producer backpressure. A bounded-table / shed-load policy is a deliberate non-goal here — worth naming in the doc's open-questions, as you suggest.

4. Drain holds the mutex across the admit loop — accepted, self-recovering stall. It's documented as such at the top of drainOutbox.ts. A chunked admit that releases the mutex between sub-batches is the clean lever if a large room ever makes it bite; not pulled yet since it hasn't.

5. Test gaps — fair, all three are real. At-least-once replay is timing-hard; the shutdown-drain (flushOutbox retry loop) and the resolve-on-commit rejection path are both testable and worth adding. Tracking rather than blocking (noted in the PR description).

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.
arongreenspan and others added 12 commits June 21, 2026 15:14
…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>
@agreenspan

Copy link
Copy Markdown
Contributor Author

@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 @zealot/email). Three buckets: decided, still open, and still-sketched-here.

Decided

  • The architecture is shared and validated. Per-recipient fan-out; the durable log row — not the BullMQ jobId — is the idempotency authority (jobId is evicted by removeOnComplete under load). Your #1455 already converges on this independently, so we're not asking you to change the shape.
  • Keep Zealot's NotificationLog. It is the equivalent of this PR's CommunicationLog — same per-recipient ledger. Widen it; don't add a parallel table.
  • The log-row id is the single correlation identity end-to-end: it's the idempotency key where the provider accepts one, the verify-lookup key where it doesn't, and the reconciliation key. No separate DLQ — an unconfirmed row is the work-list.
  • Per-adapter confirmation capability. An adapter is exactly-once-capable if it has either idempotentSend (provider replays the original response on a repeated key — Resend) or verify(correlationKey) → { status, providerMessageId } (Mailgun events / Twilio lookup). Absent both → at-most-once floor (Slack/SMS/webhook can't dedup a resend). Note the provider split: Zealot email is Mailgun, which has no send-idempotency key, so Zealot reaches exactly-once via verify, not via an idempotency key — don't go hunting for one.
  • Don't ship dead enum values. CommunicationStatus stays lifecycle-only (queued/sending/sent/failed/suppressed/undeliverable). delivered/bounced/complained (and a deliveredAt) land with the webhook ingestion that writes them, not before. I removed the orphaned deliveredAt column here to match (the enum was already trimmed).
  • camelCase enum values; status = lifecycle stage, reason = error. On the Zealot side this means standardizing NotificationLog's untyped VARCHAR status into a real Prisma enum, collapsing the DNSemail alias, and moving failure reasons (CONTENT_NOT_FOUND, USER_DATA_REPLACEMENT_FAILED) out of the status into errorMessage.
  • Overflow buffer is adopted as Zealot's queue-backpressure strategy (your review note); route the fan-out through it before any large-brand flag.

Still working through

  • Persisted-status migration sequencing (Zealot). Migrate NotificationLog.status VARCHAR → canonical Prisma enum and update the live Lambda's status constants now (one vocabulary from day one), vs. map at the boundary until the Lambda is retired. Leaning full-sweep.
  • #1455 ↔ #1461 are not joined. Delivery still renders with the legacy replaceUserData; the @zealot/email MJML pipeline has no consumer. This PR runs them as one path (deliverEmail → settleTemplate → compose). Open question: when does Zealot delivery switch to consuming @zealot/email? settleTemplate's render-error policy (degrade/fallback/fail) ports at that integration point, not before.
  • Ambiguous bucket representation — an explicit unconfirmed status, or a stale sending row swept by the reconciler.

Still unbuilt in this PR (don't treat as reference-complete)

  • inScopereturn true — the authorization gate (recipient ∈ scope of the entity) is a no-op pass-through, i.e. fail-open. COMM-005.
  • resolveSender / resolveFromAddress → env-default stubs — sender identity is ignored; every mail sends from one DEFAULT_FROM_EMAIL. Multi-tenant sender branding isn't real yet. COMM-003.
  • No delivery-event ingestion — no webhook consumer; delivered/bounced/complained deliberately not in the schema until that lands.
  • recomposeCommunication@wip — resend/preview replay-from-snapshot has no consumer (recomposeSnapshot, the live path, works).
  • Custom (non-registry) send path — stubbed to the base User lens until the segment builder ships.
  • sms/push/inApp channels — enum-only; email is the only built transport.
  • cc/bcc — dropped on a fan-out (single-message sends only).
  • Send-keyed MJML cache — not built; composed MJML rides in every deliverEmail payload (fine at small N).

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants