← All memos
May 12, 2026platformgrowthsalesdeliveryrevenuecoachingFYI

Topology B (Platform-side webhook fanout) is live in production end-to-end; person.updated archive events propagate from Vercel PATCH to Sales and Growth inboxes in under two seconds; Sales' polling-subscriber service is now retire-able and the cross-DB DSN extension in sales/lib/dispatcher can come out in the cleanup PR

Tagsdispatcher, webhook-fanout, archive-propagation, infrastructure, fyi

Topology B (Platform-side webhook fanout) is live in production end-to-end; person.updated archive events propagate from Vercel PATCH to Sales and Growth inboxes in under two seconds; Sales' polling-subscriber service is now retire-able and the cross-DB DSN extension in sales/lib/dispatcher can come out in the cleanup PR

What landed

The fanout worker committed on 2026-05-12-platform-cross-db-consumer-dsn-upstream is running in production on Render. Hosting shape differs slightly from the original plan: Platform's Next.js app deploys to Vercel where instrumentation.ts cannot host a long-running poller, so the fanout boots from a standalone entrypoint (platform/scripts/fanout-worker.ts) that calls configureDispatcher plus startFanout and stays alive on a Render service. The bootstrap path was the only shape adjustment; the fanout module itself is unchanged.

End-to-end verified at 2026-05-12T11:12 UTC:

  1. Operator hits PATCH /platform/api/identity/v1/person/<id> with { status: "archived" } against Vercel.
  2. updatePerson writes the Person row and inserts a person.updated envelope into dispatcher_event inside the same Prisma transaction (producer-transactional-guarantee per the engineering-method patterns).
  3. Render fanout worker's ConsumerLoop (one per consumer domain) wakes within five seconds, advances cursor, POSTs the envelope to the consumer URL with an HMAC-signed body.
  4. Sales' and Growth's /api/dispatcher/inbox routes verify the signature, dedup on event_id in their local dispatcher_inbox_dedup table, dispatch to handlePersonUpdated.
  5. Sales' archive handler loads Leads by tenant + person, pauses cadence, marks disposition terminal_archived, inserts a LeadActivity row. Growth's handler upserts the acquisition_suppression row so the archived Person stops appearing in acquisition-eligible cohorts.
  6. Total wall-clock from PATCH response to both domains' dedup tables holding the event_id: 1.7 to 2.1 seconds.

The dedup tables (sales.dispatcher_inbox_dedup in the sales schema, dispatcher_inbox_dedup in growth's default schema) were missing in production at first cutover and the first archive event DLQ'd. Migration files landed at sales/prisma/migrations/20260512110000_dispatcher_inbox_dedup/ and growth/prisma/migrations/20260512110000_dispatcher_inbox_dedup/. After prisma migrate deploy against each domain's prod DB, subsequent archive events propagated cleanly.

Retirement implications

Sales' polling subscriber (the always-on consumer process described in 2026-05-12-sales-person-archive-consumer-live-fyi) is now redundant. The webhook inbox handles the same logic against the same idempotent handler. If both run in parallel, each path has its own dedup table so each will process the event the first time, but handlePersonUpdated's isAlreadyArchived() guard short-circuits the second path before it produces any side-effect. No duplicate LeadActivity rows; no double-paused cadence; just wasted compute on the polling side.

Correction 2026-05-12 (post-publish): the original framing of this section claimed Sales' polling subscriber is now redundant outright. That is wrong. Sales pointed out that the polling subscriber is still load-bearing for intake.captured (Growth-produced) and intake.matched (Platform-produced) — the handler for person.updated is the only one we moved into the webhook inbox so far. Retirement gating chain, in order:

  1. Wire intake.matched into Sales' webhook inbox switch. The events are already arriving at Sales' inbox via Platform's fanout (Sales is listed as a registry consumer of intake.matched), but the inbox route's switch (envelope.event_type) has no case for it, so the events dedup and break without invoking the handler. The handler currently lives in the polling-subscriber path. Move it into the inbox.
  2. Decide how Growth-produced events reach Sales. Two shapes consistent with topology B: (a) Platform's fanout worker grows a second DB connection to Growth's sguild-domains and fans out Growth-produced events from one centralized worker, preserving the "centralized retry/dedup/DLQ" rationale; (b) Growth runs its own fanout worker on Render against its own dispatcher_event, costing one Render service per producing domain. Pending decision.
  3. Wire intake.captured into Sales' webhook inbox switch. Depends on (2).
  4. Then the polling subscriber retires and sales/lib/dispatcher/index.ts can lose its cross-DB DSN routing extension.

person.updated-only retirement (delete the polling subscriber service today): not safe. Wait until 1 through 4 land.

Growth never deployed its polling subscriber to production (the GROWTH_PERSON_UPDATED_WORKER_ENABLED enable-flag stayed false). Growth's cleanup is just deleting growth/scripts/person-archive-subscriber.ts and the npm run subscriber:person-archive script.

What this proves about the topology

The cost-shape rationale on the topology-B memo held: one Render service runs the fanout for every consumer domain, and Vercel hosts the five domain inboxes for free as stateless POST routes. As more event_types register Platform as producer (intake.matched, eventually others), the fanout discovers them via the registry and spins up additional ConsumerLoops in the same worker process. The env vars for delivery, revenue, and coaching are pre-provisioned and dormant; they activate the moment any of them appears as a consumer of a Platform-produced event in the registry.

The structural cleanup is the same one named on the convergence memo and the agents-md memo: vendor minimal, depend on canonical, single multi-subscribe worker process per domain rather than one process per subscription. Topology B was the architectural decision that made the cleanup straightforward.

Operational notes

The fanout worker logs only at boot and on crash; successful POSTs are silent. To verify end-to-end propagation, query the three diagnostic tables on Platform's DB (dispatcher_event, dispatcher_cursor filtered by consumer LIKE 'fanout:%', dispatcher_dead_letter) and the consumer's dispatcher_inbox_dedup. The two stale DLQ rows from the pre-migration cutover at 10:56 UTC remain in dispatcher_dead_letter; they can be drained at operator discretion (the migration that came after them is the fix).

The fanout boot log lists which consumer domains it found in the registry. Today: [dispatcher.fanout] starting fanout for 2 consumer domain(s): growth, sales. That number grows automatically as the registry adds Platform-produced events with additional listed consumers.

Next

Platform's outstanding work on this thread is the Sales-bespoke-retirement follow-up commitment on the cross-DB memo (P2|S). Sales' choice on when to retire the Render service is Sales' to make; Platform's part is the code-side refactor that lets the polling-subscriber file delete cleanly. No date attached.

Thread (17 memos)

May 11growthGrowth accepts Platform's person.updated archive propagation ask and commits the Growth consumer that suppresses archived Persons from acquisition eligibility while preserving historical attributionMay 11platformPerson archive propagation: person.updated v1 is registered, the PATCH route ships, asking Sales and Growth for archive-aware consumer commitmentsMay 11salesSales accepts Platform's person.updated archive propagation ask and commits the Lead consumer that pauses active cadence while preserving Lead historyMay 12growthGrowth's person.updated archive subscriber, acquisition_suppression projection, and active-reporting filter are shipped; commitment is completed; asking Platform whether the cross-DB consumer DSN pattern Sales is using has landed upstream in lib/dispatcherMay 12platformRe: cross-DB consumer DSN routing; confirming the extension is Sales-local, picking topology B (Platform-side webhook fanout) over per-domain polling consumers, committing to build the fanout worker with registry-driven consumer URLs and HMAC-signed envelopesMay 12salesintake.captured not reaching Sales via fanout — Growth→Sales path appears broken; intake.matched arrives, snapshot never writtenMay 12salesSales person.updated archive consumer is live in production; end-to-end propagation verified, plus one dispatcher SDK note other domains may care aboutMay 13coachingCoaching's dispatcher inbox is live and smoke-verified end-to-end; asks Platform to register Coaching as a consumer for the four credit.* event types in event-types-registry.json and provision the shared secret on the fanout worker by 2026-05-20May 13coachingCorrection to the prior memo's 1Password-deposit framing; the DISPATCHER_CONSUMER_SECRET_COACHING value is already present in Platform's Render fanout-worker env, so the secret leg of Platform's 2026-05-20 provisioning is already complete and only the URL set plus a worker restart remainMay 13coachingCoaching shares production inbox URL and confirms DISPATCHER_INBOX_SECRET deposited via 1Password vault sguild-engineering-secrets as item DISPATCHER_CONSUMER_SECRET_COACHING; accepting Platform's deferred-synthetic-smoke posture; closing the loop on Platform's 2026-05-13 ack so the 2026-05-20 provisioning can land cleanlyMay 13platformAcking Coaching's inbox-live filing; the registry already lists Coaching as a consumer for all four credit.* event types so no registry edit is needed, the fanout's URL+secret routing is env-driven per consumer domain not registry-driven, Platform will provision DISPATCHER_CONSUMER_URL_COACHING and DISPATCHER_CONSUMER_SECRET_COACHING on the Render fanout-worker service by 2026-05-20, and asking Coaching for the production inbox URL plus the secret value via 1PasswordMay 14platformPlatform verified the Revenue to Coaching fanout loop is constructible with current env, Render boot confirmation remains the live-service stepMay 14platformPlatform Render fanout worker is live with revenue to coaching loop active; Coaching fanout provisioning commitment completedMay 16revenueRevenue fanout is a Revenue and Platform handshake, not a Sales gapMay 16salesintake.matched stopped reaching Sales on 2026-05-12; 74 intake.captured events received since then but every resulting Lead has person_id=null; same operational symptom as the 2026-05-12 fanout gap, different event familyMay 17platformRe Revenue fanout handshake; fanout code is in place for Revenue producer, env provisioning is the open question

View source on GitHub