@sguild/dispatcher 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +354 -0
  2. package/contracts/credit-reservation-funding-state/schema/payloads/reservation.funded-v1.json +59 -0
  3. package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunded-v1.json +80 -0
  4. package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunding-v1.json +74 -0
  5. package/contracts/credit-reservation-lock/schema/payloads/credit.consumed-v1.json +33 -0
  6. package/contracts/credit-reservation-lock/schema/payloads/credit.forfeited-v1.json +41 -0
  7. package/contracts/credit-reservation-lock/schema/payloads/credit.funded-v1.json +31 -0
  8. package/contracts/credit-reservation-lock/schema/payloads/credit.locked-v1.json +42 -0
  9. package/contracts/credit-reservation-lock/schema/payloads/credit.purchased-v1.json +39 -0
  10. package/contracts/credit-reservation-lock/schema/payloads/credit.released-v1.json +61 -0
  11. package/contracts/credit-reservation-lock/schema/payloads/credit.released-v2.json +77 -0
  12. package/contracts/credit-reservation-lock/schema/payloads/credit.reserved-v1.json +60 -0
  13. package/contracts/credit-reservation-lock/schema/payloads/customer.handoff-v1.json +35 -0
  14. package/contracts/event-envelope/schema/envelope-v1.json +79 -0
  15. package/contracts/event-types-registry.json +541 -0
  16. package/contracts/identity/schema/payloads/intake.amended-v1.json +124 -0
  17. package/contracts/identity/schema/payloads/intake.captured-v2.json +114 -0
  18. package/contracts/identity/schema/payloads/person.updated-v1.json +99 -0
  19. package/contracts/lead-lifecycle/schema/payloads/lead.attempt.exhausted-v1.json +36 -0
  20. package/contracts/lead-lifecycle/schema/payloads/lead.callback.scheduled-v1.json +39 -0
  21. package/contracts/lead-lifecycle/schema/payloads/lead.created-v1.json +50 -0
  22. package/contracts/lead-lifecycle/schema/payloads/lead.reached-v1.json +39 -0
  23. package/contracts/lead-lifecycle/schema/payloads/lead.stage.changed-v1.json +44 -0
  24. package/contracts/payment-flow/schema/payloads/payment.failed-v1.json +88 -0
  25. package/contracts/payment-flow/schema/payloads/payment.received-v1.json +69 -0
  26. package/contracts/refund-flow/schema/payloads/refund.completed-v1.json +75 -0
  27. package/contracts/refund-flow/schema/payloads/refund.initiated-v1.json +69 -0
  28. package/dist/config.d.ts +67 -0
  29. package/dist/config.js +81 -0
  30. package/dist/dispatcher-errors.d.ts +20 -0
  31. package/dist/dispatcher-errors.js +42 -0
  32. package/dist/dispatcher.d.ts +123 -0
  33. package/dist/dispatcher.js +171 -0
  34. package/dist/dlq.d.ts +173 -0
  35. package/dist/dlq.js +391 -0
  36. package/dist/fanout-drain.d.ts +11 -0
  37. package/dist/fanout-drain.js +31 -0
  38. package/dist/fanout.d.ts +144 -0
  39. package/dist/fanout.js +321 -0
  40. package/dist/inbox.d.ts +125 -0
  41. package/dist/inbox.js +120 -0
  42. package/dist/index.d.ts +36 -0
  43. package/dist/index.js +70 -0
  44. package/dist/internal/id.d.ts +38 -0
  45. package/dist/internal/id.js +78 -0
  46. package/dist/internal/pg-search-path.d.ts +34 -0
  47. package/dist/internal/pg-search-path.js +55 -0
  48. package/dist/internal/resolve-contract-path.d.ts +41 -0
  49. package/dist/internal/resolve-contract-path.js +65 -0
  50. package/dist/observability.d.ts +24 -0
  51. package/dist/observability.js +37 -0
  52. package/dist/postgres-consumer.d.ts +175 -0
  53. package/dist/postgres-consumer.js +561 -0
  54. package/dist/postgres-transport.d.ts +70 -0
  55. package/dist/postgres-transport.js +144 -0
  56. package/dist/producer-db.d.ts +80 -0
  57. package/dist/producer-db.js +115 -0
  58. package/dist/registry.d.ts +94 -0
  59. package/dist/registry.js +99 -0
  60. package/dist/signature.d.ts +44 -0
  61. package/dist/signature.js +79 -0
  62. package/dist/types.d.ts +107 -0
  63. package/dist/types.js +13 -0
  64. package/dist/validator.d.ts +60 -0
  65. package/dist/validator.js +171 -0
  66. package/package.json +48 -0
package/README.md ADDED
@@ -0,0 +1,354 @@
1
+ # Dispatcher SDK
2
+
3
+ Cross-domain event dispatcher per the Sguild Event Envelope contract
4
+ (`coordination/contracts/event-envelope/README.md`, currently v1.0.2).
5
+
6
+ This module is the runtime that producers and consumers call into to emit
7
+ and subscribe to cross-domain events. It owns envelope construction,
8
+ payload validation, dedup, and transport.
9
+
10
+ It is published as the `@sguild/dispatcher` npm package so every domain
11
+ repo consumes one shared, versioned runtime rather than vendoring a copy.
12
+ The package is built and published from this directory in the platform
13
+ repo; see "Installing and consuming" and "Where the SDK code lives" below.
14
+
15
+ ## Status: Phase 3 landed in repo
16
+
17
+ Per the Q2 Airtable sunset directive
18
+ (`coordination/memos/2026/2026-05-09-platform-q2-airtable-sunset-directive.md`)
19
+ the dispatcher SDK ship dates compressed against the original build plan.
20
+ Current state:
21
+
22
+ **Phase 0 (foundation): complete.** The public types, the registry
23
+ loader, the dispatcher API surface, and 22 unit tests landed 2026-05-02.
24
+ Producers and consumers compile against the surface; the runtime methods
25
+ still throw `DispatcherNotImplementedError` until Phase 2 Slice 2 wires
26
+ the transport implementation in.
27
+
28
+ **Phase 1 (in-process dispatcher): deferred indefinitely.** All five
29
+ non-Platform domains explicitly endorsed the Phase 0 + Phase 2 (bus-only)
30
+ shape per the build-plan asks closure
31
+ (`coordination/memos/2026/2026-05-02-platform-dispatcher-sdk-build-plan-asks-closed.md`).
32
+ Reopening requires a separate proposal memo on the build plan thread.
33
+
34
+ **Phase 2 (bus dispatcher): complete.** ADR-0009 accepted 2026-05-02
35
+ at Postgres-backed queue with LISTEN/NOTIFY wake-up
36
+ (`coordination/adrs/ADR-0009-dispatcher-cross-process-transport.md`).
37
+ Hard date for full Phase 2 ship: **2026-06-22** per the Q2 directive
38
+ (compressed 4 days from the original 2026-06-26 commitment).
39
+
40
+ Slice 1 (landed earlier 2026-05-09): the schema foundation.
41
+
42
+ - Four Prisma models: `DispatcherEvent`, `DispatcherCursor`,
43
+ `DispatcherDedup`, `DispatcherDeadLetter`. See
44
+ `prisma/schema.prisma` for the canonical definitions and the
45
+ table-by-table comment header.
46
+ - Raw-SQL migration at
47
+ `prisma/migrations/20260509190000_dispatcher_phase_2_schema/migration.sql`
48
+ with the four tables, six indexes (including the partial
49
+ `dispatcher_dead_letter_active_per_consumer` for the active-only
50
+ DLQ-read hot path), the `dispatcher_event_notify` trigger function,
51
+ and the AFTER INSERT trigger on `dispatcher_event` that fires
52
+ `pg_notify('dispatcher_event_inserted', NEW.event_type)` for the
53
+ consumer-side wake-up primitive.
54
+ - `ajv` and `ajv-formats` added to `package.json` for runtime payload
55
+ validation against the JSON Schemas in
56
+ `coordination/contracts/<contract>/schema/payloads/` per ADR-0009
57
+ action item 8.
58
+
59
+ Slice 2 (landed 2026-05-09 alongside Slice 1): the publish path.
60
+
61
+ - `lib/dispatcher/config.ts` carries the runtime config (`producer`,
62
+ `tenantId`, and the optional injected `prismaClient`) every emit needs.
63
+ Each domain repo's bootstrap calls
64
+ `configureDispatcher({ producer, tenantId, prismaClient })` once at
65
+ startup; `DISPATCHER_PRODUCER` and `DISPATCHER_TENANT_ID` env vars are
66
+ the fallback for `producer`/`tenantId` in dev-loop and CI. `prismaClient`
67
+ is the default client for publishes that do not pass a per-call `tx`;
68
+ the SDK keeps `@prisma/client` as a peer dependency and never imports a
69
+ domain's generated client directly.
70
+ - `lib/dispatcher/validator.ts` ships ajv-backed envelope validation
71
+ against `contracts/event-envelope/schema/envelope-v1.json` and
72
+ per-`(event_type, schema_version)` payload validation against the
73
+ `payload_schema` paths registered in
74
+ `contracts/event-types-registry.json`. Compiled validators cache
75
+ on first use.
76
+ - `lib/dispatcher/postgres-transport.ts` ships `publishToPostgres(emit,
77
+ options?)` with optional `tx: Prisma.TransactionClient`. The
78
+ function resolves the registration and schema version, builds the
79
+ envelope (auto-populating event_id, occurred_at, tenant_id,
80
+ producer, schema_version per envelope contract §10.2), validates,
81
+ and inserts into `dispatcher_event` using the supplied tx (or the
82
+ default Prisma client if no tx). The same-transaction insert is
83
+ the producer-transactional-guarantee primitive per ADR-0009
84
+ §"Producer transactional guarantee".
85
+ - `lib/dispatcher/dispatcher.ts` `publish` no longer throws
86
+ `DispatcherNotImplementedError`; it calls `publishToPostgres`
87
+ through to the transport. Producers can emit events today against
88
+ the live dispatcher_event table. Error classes extracted into
89
+ `lib/dispatcher/dispatcher-errors.ts` and re-exported from
90
+ `dispatcher.ts` so existing import sites continue to work.
91
+
92
+ Slice 3 (landed 2026-05-09 alongside Slices 1 and 2): the consumer
93
+ polling worker.
94
+
95
+ - `lib/dispatcher/postgres-consumer.ts` ships `ConsumerLoop`, a
96
+ long-lived process that polls `dispatcher_event` past the per-(consumer,
97
+ event_type) cursor in batches (default 50 per cycle), checks
98
+ `dispatcher_dedup` before invoking the handler (so a re-dispatched
99
+ event already delivered to this consumer skips re-invocation), retries
100
+ handler exceptions with exponential backoff plus 0-30 percent jitter
101
+ (default 3 retries on top of the initial attempt; delays
102
+ `[1000, 5000, 15000]` ms), and dead-letters into
103
+ `dispatcher_dead_letter` after retry exhaustion. Cursor advance and
104
+ dedup/dead-letter writes run in a single Prisma transaction per row
105
+ so the at-most-once promise holds across crash points.
106
+ - `lib/dispatcher/dispatcher.ts` `subscribe` now registers handlers in
107
+ the singleton's internal list (instead of throwing
108
+ `DispatcherNotImplementedError`). Two new methods: `start({ consumer,
109
+ batchSize?, pollIntervalMs?, retryDelaysMs? })` instantiates the
110
+ ConsumerLoop and runs it; `stop()` flips the running flag and waits
111
+ for the in-flight batch to drain. Subscribers typically wire
112
+ `process.on("SIGTERM", () => dispatcher.stop())` so a deploy rollover
113
+ drains cleanly.
114
+ - The barrel `lib/dispatcher/index.ts` re-exports `configureDispatcher`,
115
+ `DispatcherConfig`, `PublishOptions`, `ConsumerLoopOptions`, and the
116
+ three validator error classes alongside the existing surface.
117
+
118
+ Slice 3b (landed 2026-05-09 alongside Slices 1, 2, and 3): LISTEN/NOTIFY
119
+ wake-up.
120
+
121
+ - `lib/dispatcher/postgres-consumer.ts` `ConsumerLoop` now opens a
122
+ separate `pg.Client` connection (not from the Prisma pool, since LISTEN
123
+ ties up the connection for its duration) and runs `LISTEN
124
+ dispatcher_event_inserted`. On `notification` events, the consumer
125
+ filters on the payload (the inserted row's `event_type`); when a
126
+ subscribed event_type fires, the polling loop's between-cycle sleep
127
+ aborts via an `AbortController` and the next batch runs immediately.
128
+ Typical wake-up latency drops from the configured poll cadence
129
+ (default 5 seconds) to sub-second on the happy path.
130
+ - Connection lifecycle: best-effort startup (LISTEN failures don't
131
+ block the polling loop, just log and reconnect with backoff per
132
+ `LISTEN_RECONNECT_DELAYS_MS = [1000, 5000, 15000, 60000]`). On the
133
+ pg.Client `error` or `end` events, `scheduleListenReconnect` queues a
134
+ reconnect with `setTimeout` (unref'd so it doesn't block process exit).
135
+ Successful reconnect resets the backoff counter. `stop()` closes the
136
+ LISTEN connection cleanly with `UNLISTEN` followed by `client.end()`.
137
+ - Polling stays as the durable fallback the whole time. A dropped LISTEN
138
+ connection degrades wake-up latency to poll cadence but never blocks
139
+ delivery; missed notifications (LISTEN queue overflow, network blip,
140
+ reconnect window) get caught by the next poll cycle.
141
+
142
+ Slice 5 (landed 2026-05-09 alongside Slices 1, 2, 3, and 3b): per-consumer
143
+ DLQ read and resolve API.
144
+
145
+ - `lib/dispatcher/dlq.ts` ships three service functions:
146
+ `listDeadLetters(consumer, { includeResolved?, limit? })` returns the
147
+ active (or all) dead-letters for a consumer, sorted by `created_at`
148
+ descending; `getDeadLetter(deadLetterId)` returns one row by its
149
+ `dlq_<UUID>` id; `resolveDeadLetter(deadLetterId, { resolvedBy,
150
+ resolutionNote? })` marks a row resolved with `resolved_at = NOW()`
151
+ and the operator identifier. Resolution is one-shot; throws
152
+ `DeadLetterAlreadyResolvedError` on a re-resolve attempt.
153
+ - Three HTTP routes under `/api/dispatcher/v1/dlq/...`:
154
+ - `GET /api/dispatcher/v1/dlq?consumer=...&include_resolved=...&limit=...`
155
+ lists dead-letters for a consumer.
156
+ - `GET /api/dispatcher/v1/dlq/[deadLetterId]` returns one row.
157
+ - `POST /api/dispatcher/v1/dlq/[deadLetterId]/resolve` marks one
158
+ resolved; body is `{ "resolved_by": "<operator-id>",
159
+ "resolution_note": "<optional>" }`.
160
+ - Auth: `requireSession` on all three routes (parallel to the
161
+ identity routes' v1 surface). Tenant or role-based gating can layer
162
+ in when the DLQ moves to a superadmin-only surface.
163
+ - Index re-exports the DLQ surface alongside the rest of the dispatcher
164
+ SDK so consumer-domain admin tools can import directly:
165
+ `import { listDeadLetters, resolveDeadLetter } from "@sguild/dispatcher"`.
166
+
167
+ ## Phase 2 status: complete
168
+
169
+ All five Phase 2 slices landed. The dispatcher SDK is production-ready
170
+ on the producer side (transactional emit, envelope and payload
171
+ validation), the consumer side (polling worker with LISTEN/NOTIFY
172
+ wake-up, dedup, retry-with-jitter, dead-letter on exhaustion), and the
173
+ operator surface (DLQ read and resolve API). Phase 3 (docs, observability
174
+ hooks, Coaching cut-over migration guide, reference implementations)
175
+ ships by 2026-06-29 per the Q2 directive.
176
+
177
+ (Slice 4, originally "wire subscribe", landed inside Slice 3 since the
178
+ wiring was a one-line change once the ConsumerLoop existed.)
179
+
180
+ **Phase 3 (consumer enablement): complete in repo.** Observability hooks
181
+ and the first docs set landed in this slice:
182
+
183
+ - `lib/dispatcher/observability.ts` exposes `configureDispatcherObservability`.
184
+ - Publish path increments `dispatcher.publish.count`.
185
+ - Consumer path increments `dispatcher.consume.count`, `dispatcher.dedup_hit.count`,
186
+ and `dispatcher.dead_letter.count`.
187
+ - Consumer path observes `dispatcher.end_to_end_latency_ms` and
188
+ `dispatcher.handler_latency_ms`.
189
+ - Docs:
190
+ - `docs/dispatcher/phase-3-observability.md`
191
+ - `docs/dispatcher/coaching-cutover-guide.md`
192
+ - `docs/dispatcher/reference-implementations.md`
193
+
194
+ ## Consumer fallback
195
+
196
+ The dispatcher is now the preferred cross-domain event path. Domains may
197
+ keep synchronous producer API reads as an operational fallback during
198
+ their cutover window, but new durable cross-domain event consumption
199
+ should use the SDK so cursoring, dedup, DLQ, and observability all land
200
+ on the same rail.
201
+
202
+ ## Current deliverables
203
+
204
+ - Public SDK surface: `dispatcher.publish`, `dispatcher.subscribe`,
205
+ `dispatcher.start`, `dispatcher.stop`, config helpers, typed envelopes,
206
+ registry lookups, and error classes.
207
+ - Producer path: transactional Postgres insert, envelope construction,
208
+ envelope validation, payload validation, and LISTEN/NOTIFY wake-up.
209
+ - Consumer path: cursoring, dedup, retry with jitter, dead-letter on
210
+ retry exhaustion, and graceful process drain.
211
+ - Operator path: DLQ list, read, and resolve helpers plus HTTP routes.
212
+ - Observability path: vendor-neutral counter and histogram hooks for
213
+ publish, consume, dedup hit, dead-letter, end-to-end latency, and
214
+ handler latency.
215
+ - Enablement docs: Phase 3 metrics wiring, Coaching cutover guide,
216
+ Revenue emit reference, and Coaching projection subscriber reference.
217
+
218
+ Per-event-type payload schemas still land with the owning contract as
219
+ each event_type acquires a binding consumer.
220
+
221
+ ## Installing and consuming
222
+
223
+ The SDK ships as `@sguild/dispatcher`. A consuming domain adds it as a
224
+ dependency and imports from the package root:
225
+
226
+ ```ts
227
+ import { dispatcher, configureDispatcher } from "@sguild/dispatcher";
228
+ ```
229
+
230
+ `@prisma/client` is a **peer dependency**. The SDK does not import any
231
+ domain's generated Prisma client directly; the consuming domain injects
232
+ its own at startup (see "Usage"). Each domain owns its `DispatcherEvent`,
233
+ `DispatcherCursor`, `DispatcherDedup`, and `DispatcherDeadLetter` models
234
+ and migration per ADR-0009's per-domain table family.
235
+
236
+ The event-type registry and the JSON Schemas are bundled inside the
237
+ package (`contracts/`), so payload and envelope validation work at runtime
238
+ with no dependency on a sibling coordination repo. `registry.ts` and
239
+ `validator.ts` resolve the bundled copy relative to the module location;
240
+ a checkout that prefers a live coordination repo can still pass an
241
+ explicit path to `loadRegistry`, and a cwd-relative `../coordination/...`
242
+ fallback is tried last.
243
+
244
+ Building and publishing happen from this directory:
245
+
246
+ ```
247
+ npm run build # tsc -> dist/ (JS + .d.ts)
248
+ npm publish # prepublishOnly runs the build; ships dist/ + contracts/
249
+ ```
250
+
251
+ `dist/` is git-ignored; it is a build artifact produced by
252
+ `prepublishOnly`. The platform app itself continues to consume the SDK
253
+ from source via the `@/lib/dispatcher` path alias, so the nested
254
+ `package.json` is publish metadata and does not introduce a separate
255
+ `node_modules` for in-repo development.
256
+
257
+ ## Where the SDK code lives
258
+
259
+ The build plan named two options: a new repo
260
+ `github.com/sguild-admin/dispatcher` published to npm as
261
+ `@sguild/dispatcher`, or a module inside the platform repo at
262
+ `lib/dispatcher/`. The resolved decision (see
263
+ `coordination/memos/2026/2026-05-14-platform-dispatcher-producer-sdk-consumption.md`):
264
+ the SDK is packaged and published as `@sguild/dispatcher` **from this
265
+ directory in the platform repo**. Domains consume the published package
266
+ rather than vendoring a copy.
267
+
268
+ The directory is a self-contained, publishable package: its own
269
+ `package.json` (name, exports map, dependencies, `@prisma/client` peer
270
+ dependency) and `tsconfig.json` (CJS + `.d.ts` build to `dist/`). The
271
+ public surface in `index.ts` is the API contract; the internal file
272
+ layout is malleable. Extraction to a standalone repo later, if ever
273
+ warranted, stays a mechanical move because the package boundary is
274
+ already drawn here.
275
+
276
+ ## Usage
277
+
278
+ ```ts
279
+ import { dispatcher, configureDispatcher, type EventEnvelope } from "@sguild/dispatcher";
280
+ import { prisma } from "./db/prisma"; // the consuming domain's own client
281
+
282
+ // Startup: configure once. `prismaClient` is the default client used by
283
+ // publishes that do not pass a per-call `tx`. The SDK has @prisma/client
284
+ // as a peer dependency and never imports a domain's generated client
285
+ // directly, so the domain injects its own here.
286
+ configureDispatcher({
287
+ producer: "revenue",
288
+ tenantId: "tnt_sguild",
289
+ prismaClient: prisma,
290
+ });
291
+
292
+ // Producer, transactional: the event row inserts in the SAME transaction
293
+ // as the domain write (the producer-transactional-guarantee per ADR-0009).
294
+ await prisma.$transaction(async (tx) => {
295
+ await tx.creditReservation.update({ where: { id }, data: { state: "locked" } });
296
+ await dispatcher.publish(
297
+ {
298
+ event_type: "credit.locked",
299
+ payload: {
300
+ credit_reservation_id: "crr_...",
301
+ lesson_id: "les_...",
302
+ person_id: "per_...",
303
+ locked_credits: 6,
304
+ locked_at: new Date().toISOString(),
305
+ },
306
+ subject: "per_...",
307
+ actor: "system:revenue",
308
+ },
309
+ { tx },
310
+ );
311
+ });
312
+
313
+ // Producer, no domain write to coordinate with: omit `tx` and the publish
314
+ // runs against the injected `prismaClient` in its own transaction.
315
+ await dispatcher.publish({
316
+ event_type: "credit.locked",
317
+ payload: { /* ... */ },
318
+ subject: "per_...",
319
+ actor: "system:revenue",
320
+ });
321
+
322
+ // Consumer
323
+ type CreditLockedPayload = {
324
+ credit_reservation_id: string;
325
+ lesson_id: string;
326
+ person_id: string;
327
+ locked_credits: number;
328
+ locked_at: string;
329
+ };
330
+
331
+ dispatcher.subscribe<CreditLockedPayload>("credit.locked", async (event) => {
332
+ const { lesson_id, locked_at } = event.payload;
333
+ // ... Coaching's availability projection update, etc.
334
+ });
335
+ ```
336
+
337
+ ## Standards alignment
338
+
339
+ This module is cross-cutting infrastructure per
340
+ `coordination/standards/engineering/module-layout.md` §1, so it lives at
341
+ `platform/lib/dispatcher/` rather than `platform/modules/dispatcher/`.
342
+ The per-domain six-file module layout (dto, schema, repo, service,
343
+ route, index, optional actions) does not apply; the dispatcher does
344
+ not own a domain object. Internal file organization (types, registry,
345
+ dispatcher, transport-stubs) is dispatcher-specific.
346
+
347
+ ## Related artifacts
348
+
349
+ - Build plan: `coordination/memos/2026/2026-05-01-platform-dispatcher-sdk-build-plan.md`
350
+ - Gap memo (origin of the SDK conversation): `coordination/memos/2026/2026-05-01-platform-dispatcher-sdk-gap-and-interim-shape.md`
351
+ - Event envelope contract: `coordination/contracts/event-envelope/README.md`
352
+ - ADR-0005 (event envelope decision): `coordination/adrs/ADR-0005-event-envelope.md`
353
+ - ADR-0009 (bus choice; pending): tracked on the build plan thread
354
+ - Platform-owed ledger: `coordination/memos/2026/2026-05-01-platform-owed-ledger.md`
@@ -0,0 +1,59 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://contracts.sguild/credit-reservation-funding-state/schema/payloads/reservation.funded-v1.json",
4
+ "title": "reservation.funded payload v1",
5
+ "description": "Payload for the reservation.funded event_type per contracts/credit-reservation-funding-state/README.md §4.1. Emitted by Revenue at the writeback transaction commit when a credit reservation's funding sub-state transitions from pending_funding to funded. Producer SHALL emit inside the same Prisma transaction as the credit-reservation funding-status update per ADR-0009. Subscribers: revenue (reconciliation), platform-warehouse.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "credit_reservation_id",
10
+ "person_id",
11
+ "funding_source",
12
+ "payment_processor_provider",
13
+ "payment_processor_ref",
14
+ "funded_at"
15
+ ],
16
+ "properties": {
17
+ "credit_reservation_id": {
18
+ "type": "string",
19
+ "description": "The Revenue-owned Credit Reservation whose funding sub-state transitioned to funded. crr_<UUID v7>.",
20
+ "pattern": "^crr_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
21
+ },
22
+ "person_id": {
23
+ "type": "string",
24
+ "description": "The Person whose reservation this is. Sourced from the reservation's owning credit-account's client per the Identity canonical surface. per_<UUID v7>.",
25
+ "pattern": "^per_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
26
+ },
27
+ "funding_source": {
28
+ "type": "string",
29
+ "description": "Path through which the reservation became funded per §4.4. Consumers SHALL treat unknown values as safe-to-ignore per §8.",
30
+ "enum": [
31
+ "invoice_paid",
32
+ "active_charge",
33
+ "cash",
34
+ "check",
35
+ "credit_balance",
36
+ "refund_recovery"
37
+ ]
38
+ },
39
+ "payment_processor_provider": {
40
+ "type": "string",
41
+ "description": "Provider attribution per the funding-state external-reference rule. `manual` for sources without a provider call (cash, check, credit_balance).",
42
+ "enum": [
43
+ "square",
44
+ "stripe",
45
+ "manual"
46
+ ]
47
+ },
48
+ "payment_processor_ref": {
49
+ "type": "string",
50
+ "description": "Provider-side identifier linking this funding-state transition back to the payment provider's record. Square payment id (sqpay_<id>) for square provider; Stripe payment intent id (pi_<id>) for stripe provider; external-actions row id prefixed with ext_ for manual provider.",
51
+ "minLength": 1
52
+ },
53
+ "funded_at": {
54
+ "type": "string",
55
+ "format": "date-time",
56
+ "description": "Wall-clock UTC timestamp at the writeback transaction commit. Producer's clock."
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,80 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://contracts.sguild/credit-reservation-funding-state/schema/payloads/reservation.refunded-v1.json",
4
+ "title": "reservation.refunded payload v1",
5
+ "description": "Payload for the reservation.refunded event_type per contracts/credit-reservation-funding-state/README.md §4.3. Emitted by Revenue at the writeback transaction commit when a credit reservation's funding sub-state transitions from refunding to refunded. Producer SHALL emit inside the same Prisma transaction as the credit-reservation funding-status update per ADR-0009. Subscribers: revenue (reconciliation), platform-warehouse.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "credit_reservation_id",
10
+ "person_id",
11
+ "refund_reason",
12
+ "payment_processor_provider",
13
+ "payment_processor_ref",
14
+ "refund_amount_cents",
15
+ "currency",
16
+ "refunding_at",
17
+ "refunded_at"
18
+ ],
19
+ "properties": {
20
+ "credit_reservation_id": {
21
+ "type": "string",
22
+ "description": "The Revenue-owned Credit Reservation whose funding sub-state transitioned to refunded. crr_<UUID v7>.",
23
+ "pattern": "^crr_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
24
+ },
25
+ "person_id": {
26
+ "type": "string",
27
+ "description": "The Person whose reservation this is. Sourced from the reservation's owning credit-account's client per the Identity canonical surface. per_<UUID v7>.",
28
+ "pattern": "^per_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
29
+ },
30
+ "refund_reason": {
31
+ "type": "string",
32
+ "description": "Cancellation or operator reason that triggered the refund-flow against this reservation's funded balance. Mirrors credit.released v2 reason_code per §4.5.",
33
+ "enum": [
34
+ "site_closure",
35
+ "coach_unavailable_reschedule_failed",
36
+ "force_majeure",
37
+ "weather",
38
+ "administrative_void",
39
+ "customer_requested_in_window",
40
+ "customer_requested_exception",
41
+ "policy_exception",
42
+ "bad_debt_writeoff"
43
+ ]
44
+ },
45
+ "payment_processor_provider": {
46
+ "type": "string",
47
+ "description": "Provider attribution per the funding-state external-reference rule.",
48
+ "enum": [
49
+ "square",
50
+ "stripe",
51
+ "manual"
52
+ ]
53
+ },
54
+ "payment_processor_ref": {
55
+ "type": "string",
56
+ "description": "Provider-side identifier linking this funding-state transition back to the refund provider's record. Square refund id for square provider; Stripe refund id for stripe provider; external-actions row id prefixed with ext_ for manual provider.",
57
+ "minLength": 1
58
+ },
59
+ "refund_amount_cents": {
60
+ "type": "integer",
61
+ "minimum": 0,
62
+ "description": "Refund amount in minor currency units for this reservation's funding-state transition."
63
+ },
64
+ "currency": {
65
+ "type": "string",
66
+ "description": "ISO 4217 currency code.",
67
+ "pattern": "^[A-Z]{3}$"
68
+ },
69
+ "refunding_at": {
70
+ "type": "string",
71
+ "format": "date-time",
72
+ "description": "Wall-clock UTC timestamp from the matching reservation.refunding event. The producer preserves this value across the refunding/refunded pair."
73
+ },
74
+ "refunded_at": {
75
+ "type": "string",
76
+ "format": "date-time",
77
+ "description": "Wall-clock UTC timestamp at the refunded writeback transaction commit. Producer's clock."
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,74 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://contracts.sguild/credit-reservation-funding-state/schema/payloads/reservation.refunding-v1.json",
4
+ "title": "reservation.refunding payload v1",
5
+ "description": "Payload for the reservation.refunding event_type per contracts/credit-reservation-funding-state/README.md §4.2. Emitted by Revenue at the writeback transaction commit when a credit reservation's funding sub-state transitions from funded to refunding. Producer SHALL emit inside the same Prisma transaction as the credit-reservation funding-status update per ADR-0009. Subscribers: revenue (reconciliation), platform-warehouse.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "credit_reservation_id",
10
+ "person_id",
11
+ "refund_reason",
12
+ "payment_processor_provider",
13
+ "payment_processor_ref",
14
+ "refund_amount_cents",
15
+ "currency",
16
+ "refunding_at"
17
+ ],
18
+ "properties": {
19
+ "credit_reservation_id": {
20
+ "type": "string",
21
+ "description": "The Revenue-owned Credit Reservation whose funding sub-state transitioned to refunding. crr_<UUID v7>.",
22
+ "pattern": "^crr_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
23
+ },
24
+ "person_id": {
25
+ "type": "string",
26
+ "description": "The Person whose reservation this is. Sourced from the reservation's owning credit-account's client per the Identity canonical surface. per_<UUID v7>.",
27
+ "pattern": "^per_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
28
+ },
29
+ "refund_reason": {
30
+ "type": "string",
31
+ "description": "Cancellation or operator reason that triggered the refund-flow against this reservation's funded balance. Mirrors credit.released v2 reason_code per §4.5.",
32
+ "enum": [
33
+ "site_closure",
34
+ "coach_unavailable_reschedule_failed",
35
+ "force_majeure",
36
+ "weather",
37
+ "administrative_void",
38
+ "customer_requested_in_window",
39
+ "customer_requested_exception",
40
+ "policy_exception",
41
+ "bad_debt_writeoff"
42
+ ]
43
+ },
44
+ "payment_processor_provider": {
45
+ "type": "string",
46
+ "description": "Provider attribution per the funding-state external-reference rule.",
47
+ "enum": [
48
+ "square",
49
+ "stripe",
50
+ "manual"
51
+ ]
52
+ },
53
+ "payment_processor_ref": {
54
+ "type": "string",
55
+ "description": "Provider-side identifier linking this funding-state transition back to the refund provider's record. Square refund id for square provider; Stripe refund id for stripe provider; external-actions row id prefixed with ext_ for manual provider.",
56
+ "minLength": 1
57
+ },
58
+ "refund_amount_cents": {
59
+ "type": "integer",
60
+ "minimum": 0,
61
+ "description": "Refund amount in minor currency units for this reservation's funding-state transition."
62
+ },
63
+ "currency": {
64
+ "type": "string",
65
+ "description": "ISO 4217 currency code.",
66
+ "pattern": "^[A-Z]{3}$"
67
+ },
68
+ "refunding_at": {
69
+ "type": "string",
70
+ "format": "date-time",
71
+ "description": "Wall-clock UTC timestamp at the refunding writeback transaction commit. Producer's clock."
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://contracts.sguild/credit-reservation-lock/schema/payloads/credit.consumed-v1.json",
4
+ "title": "credit.consumed payload v1",
5
+ "description": "Payload for the credit.consumed event_type per contracts/credit-reservation-lock/README.md §9.5. Emitted by Revenue at lesson delivery (locked → consumed transition). Subscribers: Delivery (post-delivery follow-up), Revenue (revenue recognition), Platform warehouse. Coaching does NOT subscribe; consumed transitions are terminal-past and do not bear on future-window availability.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "credit_reservation_id",
10
+ "lesson_id",
11
+ "consumed_credits",
12
+ "consumed_at"
13
+ ],
14
+ "properties": {
15
+ "credit_reservation_id": {
16
+ "type": "string",
17
+ "pattern": "^crr_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
18
+ },
19
+ "lesson_id": {
20
+ "type": "string",
21
+ "pattern": "^les_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
22
+ },
23
+ "consumed_credits": {
24
+ "type": "integer",
25
+ "description": "N (positive integer): the credit count posted as the Lesson Debit ledger entry per §7.",
26
+ "minimum": 1
27
+ },
28
+ "consumed_at": {
29
+ "type": "string",
30
+ "format": "date-time"
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://contracts.sguild/credit-reservation-lock/schema/payloads/credit.forfeited-v1.json",
4
+ "title": "credit.forfeited payload v1",
5
+ "description": "Payload for the credit.forfeited event_type per contracts/credit-reservation-lock/README.md §9.7. Emitted by Revenue on locked → forfeited transition (customer-side cancellation post-lock or no-show). Subscribers: Delivery (re-engagement workflow), Revenue (recognition of forfeited credits as recognized revenue per policy), Coaching (unsubtracts the slot in the availability projection per coach-availability §4.3.2), Platform warehouse. Forfeiture is always customer-side per §9.7; Sguild-initiated cancellations emit credit.released, never credit.forfeited.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "credit_reservation_id",
10
+ "lesson_id",
11
+ "forfeited_credits",
12
+ "forfeiture_reason",
13
+ "forfeited_at"
14
+ ],
15
+ "properties": {
16
+ "credit_reservation_id": {
17
+ "type": "string",
18
+ "pattern": "^crr_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
19
+ },
20
+ "lesson_id": {
21
+ "type": "string",
22
+ "pattern": "^les_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
23
+ },
24
+ "forfeited_credits": {
25
+ "type": "integer",
26
+ "description": "N (positive integer): the credit count posted as the Credit Forfeit ledger entry per §7.",
27
+ "minimum": 1
28
+ },
29
+ "forfeiture_reason": {
30
+ "type": "string",
31
+ "enum": [
32
+ "late_cancel",
33
+ "no_show"
34
+ ]
35
+ },
36
+ "forfeited_at": {
37
+ "type": "string",
38
+ "format": "date-time"
39
+ }
40
+ }
41
+ }