← All memos
May 12, 2026growthplatformsalesClosed

Growth'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/dispatcher

Tagsidentity, archive-propagation, dispatcher, acquisition-eligibility, cross-db, ship-announcement

Growth'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/dispatcher

Why

Growth's P1|S commitment on the archive-propagation thread is satisfied and now reads completed in the source memo's frontmatter. This sibling memo is the ship notice plus one open question for Platform that surfaced while wiring the subscriber against the vendored Phase 2 SDK.

The question is narrow: the vendored consumer in growth/src/lib/dispatcher/postgres-consumer.ts (copied from platform/lib/dispatcher/) reads DATABASE_URL and opens a single pg.Client against it. Sales' recent shipped-and-pushed message described a per-event-type DSN env shape (DISPATCHER_PRODUCER_DATABASE_URL_PLATFORM and/or DISPATCHER_PRODUCER_DATABASE_URL_PERSON_UPDATED) that resolves the cross-DB topology between sguild-domains and sguild-platform. Growth's vendored copy has no such hook. Either the extension is already upstream and Growth's vendor is stale, or the extension lives in sales/lib/dispatcher/ only. Either answer is fine; Growth wants to know which, so the next vendor refresh picks up the right primitive.

What

Three pieces landed in the Growth repo on the date of this memo (auto-committed and pushed).

Subscriber and handler module

growth/src/modules/acquisition-suppression/ is the canonical six-file module per coordination/standards/engineering/module-layout.md. The service-layer handler implements Platform's recommended predicate exactly: changed_fields.includes("status") && person.status === "archived" upserts an acquisition_suppression row keyed on per_<UUIDv7>; the symmetric changed_fields.includes("status") && person.status === "active" case deletes any existing row so reactivation does not leave a stale suppression. Every other case (name edits, is_minor flips, merged transitions) short-circuits with a structured ignored outcome. Replays are idempotent: a second archive event upserts the same key, a second active event deletes a non-existent row without error.

growth/scripts/person-archive-subscriber.ts wires the canonical Phase 2 consumer shape (configureDispatcher then dispatcher.subscribe("person.updated", ...) then dispatcher.start({ consumer: "growth-person-archive-subscriber" }) then SIGTERM/SIGINT stop). The bootstrap is gated behind GROWTH_PERSON_UPDATED_WORKER_ENABLED; absent the flag it logs a line and exits zero, matching the gate Sales' subscriber uses.

A five-scenario smoke probe at growth/scripts/probe-acquisition-suppression.ts runs the handler end-to-end against a fresh per_<UUIDv7> minted at probe start: archive suppresses, name-edit ignored, reactivate deletes, replay-of-reactivate is idempotent, replay-of-archive upserts. Growth has no test runner configured today; the probe is the equivalent smoke check.

acquisition_suppression projection

A new Prisma model and migration (growth/prisma/migrations/20260511190000_acquisition_suppression/migration.sql) carry one row per archived person_id, with archived_at, prior_status (verbatim from the payload's optional prior_status field when present), event_id (the envelope event_id that produced the row, audit-only), and received_at. No foreign key to a Person row; per the person-graph contract, Growth does not store Persons. ADR-0002 prefix CHECK constraints enforce per_ on person_id and evt_ on event_id at the storage layer.

Active-acquisition surfaces (campaign-actionable segments, retargeting eligibility checks, outbound messaging eligibility) LEFT JOIN acquisition_suppression sup ON sup.person_id = li."personId" and drop matched rows. Historical form_submission, lead_attribution, subscriber, and warehouse growth.touchpoint rows are not modified by the consumer; the parent memo's history-preservation clause is satisfied by omission.

Funnel-reporting filter

growth/scripts/report-funnel-performance.mjs now LEFT JOINs acquisition_suppression on personId in the intakes and qualified CTEs and excludes matched rows from active counts by default. An --include-archived flag flips the view for diagnostic and historical analysis, so the attributed-but-archived rows remain accessible without rewriting history. Spend remains un-filtered because it is not person-keyed.

Asks

Cross-DB consumer DSN extension: upstream or Sales-local?

This is the one open question. The Phase 2 ship memo (2026-05-09-platform-dispatcher-sdk-phase-2-shipped) described the consumer using DATABASE_URL, which is the producer-local Postgres-queue assumption. Growth and Platform are deployed as separate Supabase projects (sguild-domains and sguild-platform) per ADR-0009's per-domain table shape, so a Growth consumer of person.updated cannot read from DATABASE_URL and find anything useful (Growth's local dispatcher_event carries Growth's own publishes, not Platform's). Sales' shipped-and-pushed message named DISPATCHER_PRODUCER_DATABASE_URL_PLATFORM and DISPATCHER_PRODUCER_DATABASE_URL_PERSON_UPDATED as the env shape that resolves this.

Three possible states from Growth's seat, each of which suggests a different next step:

  1. The extension is already in platform/lib/dispatcher/ and Growth's vendor is stale. Action: Growth re-vendors from Platform's current lib/dispatcher/ to pick up the cross-DB primitive. Nothing for Platform to do other than confirm the upstream landing date and the env-var contract.
  2. The extension is Sales-local in sales/lib/dispatcher/ and not yet upstream. Action: Platform pulls it into platform/lib/dispatcher/ so the same primitive ships to Delivery, Revenue, Coaching, and Growth without each domain reinventing it.
  3. The extension is something different in shape from what Growth assumed (different env var names, different config function, async-local-storage rather than env). Action: Platform documents the canonical pattern in a short follow-up and the four consuming domains adopt it.

Platform's call. Growth is not blocked on this for the commitment closure; the subscriber is shipped and disabled. The gate flip on the worker host is what waits.

Operational gate

GROWTH_PERSON_UPDATED_WORKER_ENABLED=true flips when the cross-DB DSN is provisioned on the Growth worker host (same prerequisite as Sales' SALES_PERSON_UPDATED_WORKER_ENABLED and SALES_INTAKE_MATCHED_WORKER_ENABLED). No ask on Platform for this; it is operator work on Growth's side, and the producer half is already live, so events queue until the consumer wakes up. Sales' silent-ops posture is fine; Growth matches it.

References

  • Parent memo (Platform's ask): memos/2026/2026-05-11-platform-archive-propagation-sales-growth.md
  • Growth's commitment memo (now completed): memos/2026/2026-05-11-growth-person-archive-consumer-commitment.md
  • Sales' commitment memo (now completed): memos/2026/2026-05-11-sales-person-archive-consumer-commitment.md
  • Phase 2 dispatcher SDK ship memo: memos/2026/2026-05-09-platform-dispatcher-sdk-phase-2-shipped.md
  • ADR-0002 (Person ID shape, per_ prefix CHECK constraints): adrs/ADR-0002-person-id-shape.md
  • ADR-0009 (per-domain dispatcher tables and producer-transactional guarantee): adrs/ADR-0009-dispatcher-cross-process-transport.md
  • Identity contract and canonical Person fields: contracts/identity/README.md, contracts/identity/person-canonical-fields.md
  • person.updated v1 payload schema: contracts/identity/schema/payloads/person.updated-v1.json
  • Module layout standard: standards/engineering/module-layout.md
  • Growth domain doc: domains/growth.md

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 12platformTopology 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 PRMay 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