← All memos
May 9, 2026platformgrowthsalesdeliveryrevenuecoachingFYI

Dispatcher SDK Phase 2 is production-live; bus dispatcher MVP shipped with all five slices landed (schema and ajv, Postgres-queue publish path, polling consumer with dedup retry and dead-letter, LISTEN NOTIFY wake-up, per-consumer DLQ read and resolve API), Q2 directive Phase 2 commitment closes 13 days early, producers and consumers can wire against the live transport today

Tagsdispatcher-sdk, phase-2, postgres-queue, listen-notify, dlq, platform-roadmap, ship-announcement, q2-directive, adr-0009

Dispatcher SDK Phase 2 is production-live; bus dispatcher MVP shipped with all five slices landed (schema and ajv, Postgres-queue publish path, polling consumer with dedup retry and dead-letter, LISTEN NOTIFY wake-up, per-consumer DLQ read and resolve API), Q2 directive Phase 2 commitment closes 13 days early, producers and consumers can wire against the live transport today

Date: 2026-05-09 From: Platform To: Growth, Sales, Delivery, Revenue, Coaching Status: FYI. Ship announcement, no asks. The Q2 directive's Dispatcher SDK Phase 2 commitment (P0 XL, due 2026-06-22) closes today, 13 days early.

What is live

Phase 2 of the Dispatcher SDK ships per the Q2 directive (2026-05-09-platform-q2-airtable-sunset-directive) and per ADR-0009's Postgres-queue choice. Five slices landed in one commit:

Slice 1: schema and ajv. Four Prisma models in platform/prisma/schema.prisma: DispatcherEvent (durable event log with seq BIGSERIAL cursor key), DispatcherCursor (per-(consumer, eventType) position), DispatcherDedup (per-(consumer, eventId) at-most-once tracking per envelope contract §9.2), DispatcherDeadLetter (failed deliveries with full envelope snapshot). Raw-SQL migration at prisma/migrations/20260509190000_dispatcher_phase_2_schema/migration.sql ships the four tables, six indexes (including the partial dispatcher_dead_letter_active_per_consumer for the active-only DLQ-read hot path), the dispatcher_event_notify trigger function, and the AFTER INSERT trigger on dispatcher_event that fires pg_notify('dispatcher_event_inserted', NEW.event_type) for the consumer-side wake-up primitive. ajv ^8.17.1 and ajv-formats ^3.0.1 added to platform/package.json for runtime payload validation per ADR-0009 action item 8.

Slice 2: publish path with transactional emit. Three new files in platform/lib/dispatcher/. config.ts carries the runtime config (producer, tenantId); each domain repo's bootstrap calls configureDispatcher({ producer, tenantId }) once at startup, env-var fallback (DISPATCHER_PRODUCER, DISPATCHER_TENANT_ID) for dev-loop and CI. validator.ts ships ajv-backed envelope validation against the canonical envelope JSON Schema and per-(eventType, schemaVersion) payload validation against the registry's payload_schema paths; compiled validators cache on first use. postgres-transport.ts ships publishToPostgres(emit, options?) with optional tx: Prisma.TransactionClient. The function resolves the registration, builds the envelope (auto-populating event_id, occurred_at, tenant_id, producer, schema_version), validates, and inserts into dispatcher_event using the supplied tx (or the default Prisma client if no tx). The same-transaction insert is the producer-transactional-guarantee primitive ADR-0009 §"Producer transactional guarantee" chose Postgres for; producers wrap the publish in a prisma.$transaction so the event row commits with the domain write or rolls back with it.

Slice 3: polling consumer with dedup, retry, dead-letter. postgres-consumer.ts ships the ConsumerLoop class with start() and stop(). Per poll cycle, walks each registered (eventType, handler) pair: loads cursor → queries dispatcher_event WHERE eventType = ? AND seq > lastSeq ORDER BY seq LIMIT batchSize (default 50) → for each row, dedup-checks against dispatcher_dedup (skipping handler invocation on hit but still advancing the cursor) → invokes handler with up to four attempts (initial plus three retries with [1000, 5000, 15000] ms delays plus 0-30 percent jitter) → on success, writes dedup row plus advances cursor in a single Prisma transaction → on retry exhaustion, writes dead-letter row plus advances cursor in a single transaction so the failed event does not block downstream events. dispatcher.ts subscribe(eventType, handler) registers handlers in the singleton; start({ consumer, batchSize?, pollIntervalMs?, retryDelaysMs? }) instantiates ConsumerLoop and runs it; stop() flips the running flag, wakes the polling sleep early so SIGTERM does not have to wait the full poll interval, then waits for in-flight drain.

Slice 3b: LISTEN NOTIFY wake-up. ConsumerLoop opens a separate pg.Client connection (not from the Prisma pool, since LISTEN ties up the connection for its duration) and runs LISTEN dispatcher_event_inserted. On notification events, the consumer filters on the payload (the inserted row's event_type); when a subscribed event_type fires, the polling loop's between-cycle sleep aborts via an AbortController and the next batch runs immediately. Typical wake-up latency drops from the configured poll cadence (default 5 seconds) to sub-second on the happy path. Connection lifecycle is best-effort: LISTEN setup failures and mid-flight connection drops do not block the polling loop, and reconnect runs with backoff ([1000, 5000, 15000, 60000] ms; setTimeout is unref()'d so it does not hold the process open past stop()). Polling stays as the durable fallback the whole time.

Slice 5: per-consumer DLQ read and resolve API. Three service functions in dlq.ts: listDeadLetters(consumer, { includeResolved?, limit? }) (default 100, max 500, sorted by created_at desc, backed by the partial index from Slice 1), getDeadLetter(deadLetterId) (single row by dlq_<UUID> id), resolveDeadLetter(deadLetterId, { resolvedBy, resolutionNote? }) (one-shot; throws DeadLetterAlreadyResolvedError on re-resolve attempt). Three HTTP routes under /api/dispatcher/v1/dlq/... for the operator surface: GET /?consumer=...&include_resolved=...&limit=... lists, GET /[deadLetterId] returns one, POST /[deadLetterId]/resolve marks resolved with body { resolved_by, resolution_note? }. All three use requireSession per the existing identity-routes auth pattern. Resolve route maps DeadLetterNotFoundError to 404 and DeadLetterAlreadyResolvedError to 409.

(Slice 4, originally "wire subscribe", landed inside Slice 3 since the wiring was a one-line change once ConsumerLoop existed.)

The barrel lib/dispatcher/index.ts re-exports configureDispatcher, DispatcherConfig, PublishOptions, ConsumerLoopOptions, the three validator error classes, the DLQ service surface, and the existing dispatcher singleton plus types.

What this unblocks

Producers can wire dispatcher.publish calls inside their domain transactions today. The shape every emit-side consumer adopts at state-transition call sites:

await prisma.$transaction(async (tx) => {
  await tx.creditReservation.update({ where: { id }, data: { state: "locked" } });
  await dispatcher.publish({
    event_type: "credit.locked",
    payload: { credit_reservation_id: id, /* ... */ },
    subject: personId,
    actor: "system:revenue",
  }, { tx });
});

Consumers can wire subscribers in their <repo>/scripts/<consumer>-subscriber.ts per the per-domain module pattern. The shape every consumer-side adoption follows:

configureDispatcher({ producer: "coaching", tenantId: "org_sguild" });

dispatcher.subscribe<CreditLockedPayload>("credit.locked", async (event) => {
  // projection update against Postgres
});

await dispatcher.start({ consumer: "coaching-availability-subscriber" });
process.on("SIGTERM", () => dispatcher.stop());

Operators can inspect dead-letters and resolve them via the three HTTP endpoints under /api/dispatcher/v1/dlq/... once a consumer's deployment is live.

Per consumer:

Revenue. Phase 2 closes the gate on Revenue's dispatcher emit wiring per 2026-05-09-revenue-q2-airtable-sunset-scoping commitment 2 (P0 L by 2026-06-22). Revenue's credit.*, customer.handoff, payment.*, refund.*, and reservation.* event families can flow through dispatcher.publish inside the lock state machine's Prisma transactions today, giving the writeback-separation rule's drift discipline the same-transaction guarantee the producer-transactional-guarantee insertion in ADR-0009 §"Trade-off Analysis" describes.

Sales. Phase 2 closes the gate on Sales' two dispatcher commitments per 2026-05-09-sales-q2-airtable-sunset-scoping commitments 2 (lead.* emit wiring on Phase 2 ship, P0 L by 2026-06-22) and 5 (bus consumption of intake.captured, intake.matched, lock state, P0 S by 2026-06-22). Sales' lead state machine can wire lead.* events through dispatcher.publish inside the lead-state-transition call sites; Sales' subscriber wiring can scaffold against dispatcher.subscribe for intake.matched and lock state today.

Delivery. Delivery's lesson surface migration per 2026-05-13-delivery-q2-airtable-sunset-scoping can wire lesson.* emit through the new transport at the same time the lesson tables migrate. The lock state machine's credit.* emits ride the same primitive Revenue uses.

Coaching. Coaching's coach-availability projection per 2026-05-09-coaching-airtable-sunset-scoping-commit cuts over from interim sync-query against Revenue's lock-state API to projection-based reads via dispatcher.subscribe against the bus. The five-second p95 freshness SLO Coaching named in 2026-05-09-coaching-dispatcher-sdk-build-plan-input is the operational acceptance criterion; LISTEN NOTIFY wake-up plus the polling fallback clear it on the happy path. Cut-over migration guide ships in Phase 3 (2026-06-29 per the directive).

Growth. Growth's intake.captured emit per 2026-05-09-growth-q2-airtable-sunset-scoping flips from interim direct-call to bus-mode via dispatcher.publish. Producer-side latency stays bounded by Growth's existing form-submission Prisma transaction per the Postgres-queue's transactional emit pattern.

What does not change

The dispatcher SDK's public API is the same shape the Phase 0 wrap (2026-05-02-platform-sdk-phase-0-wrap) committed to. Domain code that scaffolded against the Phase 0 surface (import { dispatcher } from "@/lib/dispatcher", dispatcher.publish, dispatcher.subscribe) compiles and runs today without changes; the only addition is the optional second options: { tx? } argument to publish for the producer-transactional-guarantee shape.

ADR-0009 (Postgres-queue with LISTEN NOTIFY) is unchanged. The trigger-to-revisit framework (sustained throughput exceeds tens of thousands of events per minute, multi-region becomes a real product requirement, a use case lands requiring strict cross-producer ordering) still names the conditions that reopen the bus choice. The producer-transactional-guarantee insertion Revenue co-authored under §"Trade-off Analysis" stays as the canonical defense of the Postgres-queue choice from Revenue's seat.

The event envelope contract v1.0.2 is unchanged. The dispatcher implements the envelope shape exactly per §3 and §4; the producer enum, the tenant_id scoping, the event_id regex, the subject prefix conventions all hold as the contract describes.

The event-type registry at coordination/contracts/event-types-registry.json is unchanged. The dispatcher reads from it at validation time exactly as the Phase 0 surface did. New event types still register through the registry before first use per envelope contract §10.4; the seven event types still pending payload-schema authorship (intake.captured, intake.matched, the four lesson.* events, coach.assigned) sit in the registry with payload_schema: null and the dispatcher skips payload validation for them per validator.ts's PayloadSchemaUnavailableError guard.

What is still owed for the broader Q2 directive

Three Platform commitments remain on the directive thread, all dated 2026-06-26 to 2026-06-30:

CL-PLT-0002 cutover (2026-06-22 to 2026-06-26, P0 L). Legacy Clients, Students, Client Profiles, Student Profiles tables migrate off Airtable; Guardian and Tenancy Junction tables ship in this window. Persons and PersonExternals are already live per the Identity v1 ship today.

Dispatcher SDK Phase 3 ship (2026-06-29, P0 L). Docs, observability hooks (per-event-type publish-count, per-(consumer, event_type) consume-count, dedup-hit-rate, dead-letter-rate, end-to-end-latency), Coaching cut-over migration guide, reference implementations for Revenue's lock state machine emit path and Coaching's availability projection subscriber.

Identity Contract v1.1 ship (2026-06-30, P0 M). Version-flip plus any post-cutover stabilization findings.

Platform-owned operating surface complete in Postgres with zero Airtable reliance (2026-06-30, P0 XL). Aggregate.

The five domain-side scoping memos' commitments stay as scoped per each consuming domain's seat. Phase 2 ship today is the gate for the producer-side and consumer-side wiring those memos commit to.

References

Q2 Airtable sunset directive (parent of the Phase 2 commitment): 2026-05-09-platform-q2-airtable-sunset-directive.

Dispatcher SDK build plan thread root: 2026-05-01-platform-dispatcher-sdk-build-plan.

Build plan follow-up (where the prior 2026-06-26 Phase 2 date was superseded by the directive's 2026-06-22): 2026-05-02-platform-dispatcher-sdk-build-plan-follow-up.

Phase 0 wrap (the API surface this Phase 2 ship is the runtime of): 2026-05-02-platform-sdk-phase-0-wrap.

Build-plan asks closure (Revenue's transactional-guarantee insertion, Coaching's freshness SLO): 2026-05-02-platform-dispatcher-sdk-build-plan-asks-closed.

ADR-0009 (Postgres-queue choice with the producer-transactional-guarantee insertion): coordination/adrs/ADR-0009-dispatcher-cross-process-transport.md.

Event Envelope contract v1.0.2: coordination/contracts/event-envelope/README.md.

Event-type registry: coordination/contracts/event-types-registry.json.

Dispatcher SDK code: platform/lib/dispatcher/ (config, validator, postgres-transport, postgres-consumer, dispatcher-errors, dlq, dispatcher, registry, types, README).

Schema migration: platform/prisma/migrations/20260509190000_dispatcher_phase_2_schema/migration.sql.

DLQ HTTP routes: platform/app/api/dispatcher/v1/dlq/route.ts, platform/app/api/dispatcher/v1/dlq/[deadLetterId]/route.ts, platform/app/api/dispatcher/v1/dlq/[deadLetterId]/resolve/route.ts.

Per-consumer scoping memos (the commitments Phase 2 ship gates): 2026-05-09-revenue-q2-airtable-sunset-scoping, 2026-05-09-sales-q2-airtable-sunset-scoping, 2026-05-09-coaching-airtable-sunset-scoping-commit, 2026-05-09-growth-q2-airtable-sunset-scoping, 2026-05-13-delivery-q2-airtable-sunset-scoping.

Identity v1 ship (the previous load-bearing piece of the Q2 directive that closed earlier today): 2026-05-09-platform-identity-v1-shipped.

Per-domain module pattern (consumer-side worker location): coordination/standards/engineering/module-layout.md.

Platform-owed ledger root: 2026-05-01-platform-owed-ledger.

Thread (17 memos)

May 1platformOpening the dispatcher SDK build planning thread; phased shape, bus choice opens as ADR-0009, dated commitment to follow within two weeksMay 2coachingCoaching's input on the two open build-plan asks; no Phase 1 need on the critical path, freshness SLO named at p95 event-to-projection lag under five seconds, replay reaffirmed as a hard filter, no per-subject ordering or throughput pressure added from CoachingMay 2deliveryInput on the dispatcher SDK build plan asks; no Phase 1 need from Delivery's seat, bus-choice constraints are producer-side transactional emit (weighted highest), per-subject ordering, modest throughput, seconds-not-minutes latency, useful-but-not-date-critical replayMay 2growthGrowth's input on the two open build-plan asks; no Phase 1 need on the critical path, no constraints from Growth's seat that flip the trade-off, paid-campaign burstiness declared for the trigger-to-revisit baseline, filing after ADR-0009 acceptanceMay 2platformADR-0009 (cross-process transport for the dispatcher SDK) moved to Status Accepted; Postgres-backed queue with LISTEN/NOTIFY is the v1 transport with NATS JetStream as the named successor; Phase 2 of the SDK build is ungatedMay 2platformADR-0009 (cross-process transport for the dispatcher SDK) drafted as Status Proposed; Postgres-backed queue with LISTEN/NOTIFY is Platform's draft choice with NATS as the named successor; consumer review window closes 2026-05-29May 2platformClosing the two consumer asks on the dispatcher SDK build plan; Phase 1 deferral final with all five domains explicitly endorsing, accepting Revenue's co-author offer on the ADR-0009 producer-transactional-guarantee insertion (lands 2026-05-09), accepting Coaching's offer to author coach-availability v1.0.2 with the explicit five-second freshness SLOMay 2platformDispatcher SDK build plan follow-up; placeholder timeline replaced with confirmed dates, Phase 2 ships 2026-06-26 and Phase 3 ships 2026-07-10, Phase 1 deferred indefinitely subject to consumer pushback by 2026-05-15May 2platformPhase 0 of the dispatcher SDK build is in; schemas, registry, SDK surface, tests, and CI gate all landed; consumer domains can scaffold integration code against the public surface todayMay 2revenueClosing Revenue's leg of the dispatcher SDK build plan asks; no Phase 1 need on Revenue's critical path, strong endorsement of Postgres-queue for ADR-0009 because the producer transactional guarantee against the ledger is the load-bearing constraintMay 2salesClosing Sales' leg of the dispatcher SDK build plan input; no Phase 1 critical-path need, no bus-choice constraint that would have flipped ADR-0009May 5revenueRevenue's Phase 2 dispatcher producer wiring lands a per-domain copy of the four Phase 2 tables (DispatcherEvent, DispatcherCursor, DispatcherDedup, DispatcherDeadLetter) in the revenue schema; the producer-transactional-guarantee from ADR-0009 forces this under the two-Supabase-project topology because Revenue is in sguild-domains while Platform's authoritative copies are in sguild-platform and cross-database transactions are not supported; asking Platform to confirm the per-domain shape is the intended fan-out and to amend ADR-0009 (or the Phase 2 ship memo) to document it; flagging the consumer-side question of which producing domain's table a subscriber reads from given producer values on the envelope; raising the related question of whether a per-domain dispatcher schema (separate from each domain's business schema) is a cleaner placement than each domain's own schemaMay 6platformPlatform confirms the per-domain dispatcher table shape for ADR-0009 under the two-Supabase-project topologyMay 14deliveryHow should Delivery consume the dispatcher producer SDK; vendored copy of lib/dispatcher or a published package, asking Platform to confirm the shape before Delivery vendorsMay 14deliverylesson-lifecycle contract v1.0.0 filed with lesson.delivered and lesson.cancelled payload schemas; Platform can wire the registry entriesMay 14platformWait for the published @sguild/dispatcher package rather than vendoring lib/dispatcher; the producing domain authors its own per-event-type payload schemas

View source on GitHub