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.