@sguild/dispatcher 2.0.0 → 2.0.1

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 (96) hide show
  1. package/README.md +4 -1
  2. package/contracts/README.md +30 -0
  3. package/contracts/coach-availability/README.md +355 -0
  4. package/contracts/coach-availability/README.v2.md +263 -0
  5. package/contracts/coach-availability/schema/payloads/coach.assigned-v1.json +91 -0
  6. package/contracts/coach-availability/validation/delivery-assignment.md +89 -0
  7. package/contracts/coach-availability/validation/sales-offer-construction.md +76 -0
  8. package/contracts/coaching-confirmation/README.md +96 -0
  9. package/contracts/coaching-confirmation/schema/payloads/coaching.lesson.confirmation_decided-v1.json +142 -0
  10. package/contracts/coaching-confirmation/schema/payloads/lead.coach.confirmation.requested-v1.json +124 -0
  11. package/contracts/credit-reservation-funding-state/README.md +147 -0
  12. package/contracts/credit-reservation-lock/README.md +433 -0
  13. package/contracts/credit-reservation-lock/delivery-state-vocabulary.md +73 -0
  14. package/contracts/credit-reservation-lock/reservation-create-api.md +191 -0
  15. package/contracts/credit-reservation-lock/reservation-release-api.md +171 -0
  16. package/contracts/credit-reservation-lock/schema/payloads/credit.locked-v1.json +1 -1
  17. package/contracts/credit-reservation-lock/schema/payloads/credit.reserved-v1.json +2 -3
  18. package/contracts/credit-reservation-lock/validation/lesson-lifecycle.md +318 -0
  19. package/contracts/event-envelope/README.md +205 -0
  20. package/contracts/event-envelope/schema/envelope-v1.json +2 -2
  21. package/contracts/event-envelope/validation/event-vocabulary.md +270 -0
  22. package/contracts/event-types-registry.json +337 -24
  23. package/contracts/external-actions/README.md +338 -0
  24. package/contracts/finance-mart/README.md +238 -0
  25. package/contracts/finance-mart/cac-payback.md +113 -0
  26. package/contracts/finance-mart/cash-position.md +98 -0
  27. package/contracts/finance-mart/cohort-summary.md +72 -0
  28. package/contracts/finance-mart/customer-journey-audit.md +92 -0
  29. package/contracts/finance-mart/ltv.md +92 -0
  30. package/contracts/finance-mart/margin.md +87 -0
  31. package/contracts/finance-mart/pnl.md +83 -0
  32. package/contracts/finance-mart/reconciliation.md +98 -0
  33. package/contracts/finance-mart/revenue-recognition-rollup.md +87 -0
  34. package/contracts/finance-mart/unit-economics.md +94 -0
  35. package/contracts/growth-warehouse-api/README.md +162 -0
  36. package/contracts/identity/README.md +184 -0
  37. package/contracts/identity/person-canonical-fields.md +120 -0
  38. package/contracts/identity/person-externals.md +267 -0
  39. package/contracts/identity/person-resolution-semantics.md +144 -0
  40. package/contracts/identity/person-role-taxonomy.md +120 -0
  41. package/contracts/identity/schema/payloads/intake.captured-v2.json +60 -0
  42. package/contracts/identity/schema/payloads/intake.matched-v2.json +123 -0
  43. package/contracts/identity/schema/payloads/person.updated-v1.json +8 -2
  44. package/contracts/identity/schema/payloads/role.assigned-v1.json +50 -0
  45. package/contracts/identity/schema/payloads/role.retired-v1.json +54 -0
  46. package/contracts/identity/validation/client-table.md +131 -0
  47. package/contracts/identity/validation/coach-handling.md +100 -0
  48. package/contracts/identity/validation/person-graph.md +140 -0
  49. package/contracts/lead-lifecycle/README.md +187 -0
  50. package/contracts/lead-lifecycle/schema/payloads/lead.handoff.context.recorded-v1.json +108 -0
  51. package/contracts/lead-lifecycle/schema/payloads/lead.qualified-v1.json +54 -0
  52. package/contracts/lead-lifecycle/schema/payloads/sales.lead.onboarded-v1.json +120 -0
  53. package/contracts/lesson-lifecycle/README.md +118 -0
  54. package/contracts/lesson-lifecycle/schema/payloads/lesson.cancelled-v1.json +30 -0
  55. package/contracts/lesson-lifecycle/schema/payloads/lesson.delivered-v1.json +29 -0
  56. package/contracts/lesson-lifecycle/schema/payloads/lesson.rescheduled-v1.json +157 -0
  57. package/contracts/lesson-lifecycle/schema/payloads/lesson.scheduled-v1.json +107 -0
  58. package/contracts/lesson-lifecycle/validation/README.md +5 -0
  59. package/contracts/mart-consumer-api/README.md +108 -0
  60. package/contracts/order-flow/README.md +106 -0
  61. package/contracts/order-flow/schema/payloads/order.created-v1.json +58 -0
  62. package/contracts/order-flow/schema/payloads/order.updated-v1.json +63 -0
  63. package/contracts/payment-flow/README.md +157 -0
  64. package/contracts/platform-comms/README.md +84 -0
  65. package/contracts/platform-comms/schema/payloads/platform.comms.inbound-v1.json +83 -0
  66. package/contracts/platform-geography-snapshot/README.md +205 -0
  67. package/contracts/platform-geography-snapshot/schema/payloads/geography.market.archived-v1.json +36 -0
  68. package/contracts/platform-geography-snapshot/schema/payloads/geography.market.upserted-v1.json +59 -0
  69. package/contracts/platform-geography-snapshot/schema/payloads/geography.service-area.archived-v1.json +36 -0
  70. package/contracts/platform-geography-snapshot/schema/payloads/geography.service-area.upserted-v1.json +65 -0
  71. package/contracts/portfolio-mart/README.md +133 -0
  72. package/contracts/portfolio-mart/cohort-funnel-panel.md +76 -0
  73. package/contracts/portfolio-mart/cross-discipline-performance.md +91 -0
  74. package/contracts/portfolio-mart/cross-market-performance.md +84 -0
  75. package/contracts/portfolio-mart/health-composites.md +88 -0
  76. package/contracts/portfolio-mart/org-topology.md +70 -0
  77. package/contracts/portfolio-mart/portfolio-level-funnel-health.md +92 -0
  78. package/contracts/portfolio-mart/validation/consumer-isolation.md +33 -0
  79. package/contracts/portfolio-mart/validation/decoupling-discipline.md +34 -0
  80. package/contracts/refund-flow/README.md +136 -0
  81. package/contracts/refund-flow/sales-callable-refund-initiation-api.md +218 -0
  82. package/contracts/sales-scheduling-surface/README.md +532 -0
  83. package/contracts/sales-scheduling-surface/schema/payloads/delivery.lesson-hold.cancelled-v1.json +42 -0
  84. package/contracts/sales-scheduling-surface/schema/payloads/delivery.lesson-hold.created-v1.json +115 -0
  85. package/contracts/sales-scheduling-surface/validation/composite-hold-create.md +97 -0
  86. package/contracts/sales-scheduling-surface/validation/lock-state-machine-conformance.md +84 -0
  87. package/contracts/sales-scheduling-surface/validation/sales-close-orchestration.md +77 -0
  88. package/contracts/warehouse-silver/README.md +118 -0
  89. package/contracts/warehouse-silver/coaching-utilization-columns.md +105 -0
  90. package/dist/events.d.ts +63 -0
  91. package/dist/events.js +293 -0
  92. package/dist/index.d.ts +2 -0
  93. package/dist/index.js +7 -1
  94. package/dist/postgres-consumer.js +2 -1
  95. package/dist/validator.js +1 -0
  96. package/package.json +1 -1
@@ -0,0 +1,318 @@
1
+ # Validation: Credit reservation lock against lesson lifecycle scenarios
2
+
3
+ **Status:** Passed, no contract changes required
4
+ **Date:** 2026-04-25
5
+ **Owner:** Platform
6
+ **Validates:** `platform/contracts/credit-reservation-lock/README.md` v1.0.0
7
+
8
+ ## Purpose
9
+
10
+ Confirm that the v1.0.0 credit reservation lock state machine handles the full lesson lifecycle and the operationally meaningful exception flows without ambiguous states, missing transitions, or ledger inconsistencies. Each scenario below walks the state machine, names the events fired, and reconstructs the ledger footprint.
11
+
12
+ The reference scenarios are: regular lesson with credits on hand, regular lesson via invoice payment, auto-cancel at T-24h for unpaid, customer cancel before lock, customer cancel after lock, no-show, Sguild-side cancel after lock (coach unavailable), Sguild-side cancel before lock (weather), administrative void after lock, refund of unused credits, mid-pack cancellation, trial lesson, group lesson, first-lock vs subsequent-lock customer.handoff cases, reschedule of locked lesson, reschedule of pre-lock reservation.
13
+
14
+ ## Notation
15
+
16
+ In every scenario `N` is the credit count for the lesson (e.g., 6 for a 30-min lesson, 12 for a 60-min lesson, smaller for a trial). Ledger sign convention: positive entries add to balance, negative entries debit. "Net account impact" sums all ledger entries for the reservation.
17
+
18
+ ---
19
+
20
+ ## Scenario 1: Happy path, customer has credits
21
+
22
+ **Setup.** Customer has N credits in their account. Schedules a regular lesson.
23
+
24
+ **Flow.**
25
+
26
+ 1. Schedule: claim recorded (no ledger entry). State `→ reserved`, funding_status=`funded` (credits cover). `credit.reserved` fires. `credit.funded` fires immediately because credits cover.
27
+ 2. T-24h: Reservation Job runs, reservation is `funded`. Reservation Lock Debit (−N, Created Via=Reservation Job) posts. State `reserved → locked`. `credit.locked` fires. If first lock for this Person: `customer.handoff` fires.
28
+ 3. T-0: Lesson delivered. Lesson Completion Job runs. Adjustment (+N, reason=Credits Consumed, Created Via=Lesson Completion Job) posts. Lesson Debit (−N, Created Via=Lesson Completion Job) posts. State `locked → consumed`. `credit.consumed` fires.
29
+
30
+ **Ledger footprint.** Lock −N + Reversal +N + Lesson Debit −N = **net −N**. The customer paid for one lesson.
31
+
32
+ **Result:** passes. Three ledger entries reconcile. State machine progressed cleanly from start to terminal. customer.handoff fires once at lock if first.
33
+
34
+ ---
35
+
36
+ ## Scenario 2: Happy path, customer pays via invoice
37
+
38
+ **Setup.** Customer schedules a regular lesson but has 0 credits. Invoice will be sent.
39
+
40
+ **Flow.**
41
+
42
+ 1. Schedule: claim recorded. State `→ reserved`, funding_status=`pending`. `credit.reserved` fires. `credit.funded` does NOT fire yet.
43
+ 2. T-7 days: Revenue's invoicing layer sends an invoice (operational, not a contract event in this contract).
44
+ 3. T-3 days (customer pays): Payment Job posts Purchase Credit (+N, Created Via=Payment Job). `credit.purchased` fires. Funding flips to funded. `credit.funded` fires.
45
+ 4. T-24h: Reservation Job runs, reservation is `funded`. Reservation Lock Debit (−N) posts. State `reserved → locked`. `credit.locked` and (if first lock) `customer.handoff` fire.
46
+ 5. T-0: Delivery flow as Scenario 1.
47
+
48
+ **Ledger footprint.** Purchase +N + Lock −N + Reversal +N + Lesson Debit −N = **net 0**. Customer paid cash and received one lesson; no leftover credits.
49
+
50
+ **Result:** passes. The funding sub-state correctly gates the lock until payment arrives. credit.purchased and credit.funded are distinct events firing at different times if invoice payment is the funding source.
51
+
52
+ ---
53
+
54
+ ## Scenario 3: Auto-cancel at T-24h for unpaid
55
+
56
+ **Setup.** Customer schedules a lesson, no credits, invoice unpaid by T-24h.
57
+
58
+ **Flow.**
59
+
60
+ 1. Schedule: state `→ reserved`, funding_status=`pending`. `credit.reserved` fires.
61
+ 2. Invoice sent at T-7. Customer does not pay.
62
+ 3. T-24h: Reservation Job runs, reservation is still `pending`. State `reserved → released` with `initiator=system_unpaid`, `reversal_reason=Credits Released`. **No ledger entry posts** because the lock never fired. `credit.released` fires.
63
+
64
+ **Ledger footprint.** Empty. No ledger entries posted at all.
65
+
66
+ **Result:** passes. customer.handoff did NOT fire because the lock never fired. The Person remains a Sales-managed Lead, which is the intended business outcome (Sales did not yet earn the handoff).
67
+
68
+ ---
69
+
70
+ ## Scenario 4: Customer cancels before lock (T-3 days)
71
+
72
+ **Setup.** Customer with N credits scheduled a lesson. State `reserved`, funding=`funded`. Customer cancels 3 days before lesson.
73
+
74
+ **Flow.**
75
+
76
+ 1. Customer initiates cancellation at T-3 days. Initiator=customer, cancelled_at >> 24h before lesson.
77
+ 2. Cancellation policy returns `released, reversal_reason=Credits Released`.
78
+ 3. State `reserved → released`. **No ledger entry posts** (no lock to reverse, no debit to apply). `credit.released` fires.
79
+ 4. Claim removed; available balance returns.
80
+
81
+ **Ledger footprint.** Empty.
82
+
83
+ **Result:** passes. Pre-lock cancellation is zero-cost to the customer; their credits are restored to available balance immediately. Only a state machine event fires.
84
+
85
+ ---
86
+
87
+ ## Scenario 5: Customer cancels after lock (T-12h)
88
+
89
+ **Setup.** State `locked` (lock fired at T-24h). Customer cancels at T-12h.
90
+
91
+ **Flow.**
92
+
93
+ 1. Customer initiates cancellation at T-12h. Initiator=customer, cancelled_at < 24h before lesson.
94
+ 2. Cancellation policy returns `forfeited, reversal_reason=Credits Forfeited`.
95
+ 3. Forfeit Job runs. Adjustment (+N, reason=Credits Forfeited, Created Via=Forfeit Job) posts. Credit Forfeit (−N, forfeiture_reason=late_cancel, Created Via=Forfeit Job) posts.
96
+ 4. State `locked → forfeited`. `credit.forfeited` fires.
97
+
98
+ **Ledger footprint.** Lock −N + Reversal +N + Forfeit −N = **net −N**. Customer forfeits the credits.
99
+
100
+ **Result:** passes. Lock dissolution invariant holds: Adjustment posts at every locked-to-terminal transition. Paired Credit Forfeit captures the customer's loss.
101
+
102
+ ---
103
+
104
+ ## Scenario 6: No-show
105
+
106
+ **Setup.** State `locked`. Lesson time arrives. Customer does not show up.
107
+
108
+ **Flow.**
109
+
110
+ 1. Delivery records a no-show event for the lesson at or after T-0.
111
+ 2. Forfeit Job runs (triggered by Delivery' no-show signal). Adjustment (+N, reason=Credits Forfeited, Created Via=Forfeit Job) posts. Credit Forfeit (−N, forfeiture_reason=no_show, Created Via=Forfeit Job) posts.
112
+ 3. State `locked → forfeited`. `credit.forfeited` fires with `forfeiture_reason=no_show`.
113
+
114
+ **Ledger footprint.** Same as Scenario 5: net −N.
115
+
116
+ **Result:** passes. The forfeiture_reason enum cleanly distinguishes no-show from late cancel; both produce the same ledger footprint and the same state transition. Delivery' no-show event is the trigger; the forfeiture itself is Revenue's job.
117
+
118
+ ---
119
+
120
+ ## Scenario 7: Sguild cancels after lock (coach unavailable)
121
+
122
+ **Setup.** State `locked`. At T-12h, the assigned coach has an emergency. Sguild cancels.
123
+
124
+ **Flow.**
125
+
126
+ 1. Admin or system records cancellation at T-12h. Initiator=coach (or admin), reason=coach_unavailable.
127
+ 2. Cancellation policy returns `released, reversal_reason=Credits Released`. Sguild-side cancellations always release.
128
+ 3. Adjustment (+N, reason=Credits Released, Created Via=Manual or Reservation Job depending on automation) posts. **No paired debit.**
129
+ 4. State `locked → released`. `credit.released` fires with `initiator=coach`, `reversal_reason=Credits Released`, `reason_code=coach_unavailable`.
130
+
131
+ **Ledger footprint.** Lock −N + Reversal +N = **net 0**. Customer keeps their credits.
132
+
133
+ **Result:** passes. Sguild-side cancellation post-lock cleanly returns the customer's credits via the reversal alone.
134
+
135
+ ---
136
+
137
+ ## Scenario 8: Sguild cancels before lock (weather forecast)
138
+
139
+ **Setup.** State `reserved`, funding=`funded`. At T-3 days, bad weather forecast forces cancellation.
140
+
141
+ **Flow.**
142
+
143
+ 1. Admin records cancellation at T-3 days with initiator=system_weather.
144
+ 2. Cancellation policy returns `released, reversal_reason=Credits Released`.
145
+ 3. State `reserved → released`. **No ledger entry** (no lock to reverse). `credit.released` fires.
146
+ 4. Claim removed; credits return to available balance.
147
+
148
+ **Ledger footprint.** Empty.
149
+
150
+ **Result:** passes. Pre-lock Sguild-side cancellation is identical to pre-lock customer cancellation in ledger impact (zero), distinguished only by the `initiator` and `reason_code` on the released event for audit.
151
+
152
+ ---
153
+
154
+ ## Scenario 9: Administrative void after lock
155
+
156
+ **Setup.** State `locked`. Admin discovers the lock was applied to the wrong lesson (data error) or chooses to void the lock as a goodwill gesture.
157
+
158
+ **Flow.**
159
+
160
+ 1. Admin issues a void. Initiator=admin (or system_other), reason=admin_override (or whatever justification).
161
+ 2. Cancellation policy returns `released, reversal_reason=Administrative Void`.
162
+ 3. Adjustment (+N, reason=Administrative Void, Created Via=Manual) posts. The Adjustment entry SHALL include an operator identity and justification text in audit fields. **No paired debit.**
163
+ 4. State `locked → released`. `credit.released` fires with `reversal_reason=Administrative Void`.
164
+
165
+ **Ledger footprint.** Lock −N + Reversal +N = **net 0**.
166
+
167
+ **Result:** passes. Administrative Void is a flavor of `released` distinguished only by the Adjustment reason code on the reversal entry, supporting audit and compliance review.
168
+
169
+ ---
170
+
171
+ ## Scenario 10: Refund of paid but unused credits
172
+
173
+ **Setup.** Customer purchased a 12-credit pack. Used 0. Requests a refund.
174
+
175
+ **Flow.**
176
+
177
+ 1. Customer requests refund. Refund Job runs.
178
+ 2. Refund Debit (−12, Created Via=Refund Job) posts to ledger. Cash refunded externally to customer.
179
+ 3. No state machine event because no reservation is involved.
180
+
181
+ **Ledger footprint.** Purchase +12 + Refund Debit −12 = **net 0**.
182
+
183
+ **Result:** passes (in scope for the credit account ledger, out of scope for the lock state machine). The contract notes this scenario for completeness; Refund Debit is a separate ledger entry type that doesn't interact with the state machine.
184
+
185
+ ---
186
+
187
+ ## Scenario 11: Mid-pack cancellation
188
+
189
+ **Setup.** Customer purchased a 12-credit pack. Used 6 credits across two lessons. 6 remain. Wants a refund of the unused 6.
190
+
191
+ **Flow.**
192
+
193
+ 1. Refund Job posts Refund Debit (−6, Created Via=Refund Job).
194
+ 2. Any future reservations against the customer's account need to be cancelled first; this is operationally enforced before the refund posts.
195
+ 3. The state machine itself isn't directly involved (any active reservations were terminated separately before the refund).
196
+
197
+ **Ledger footprint** (just the refund step, prior lessons accounted separately). Refund Debit −6.
198
+
199
+ **Result:** passes. Mid-pack refunds work through the existing Refund Debit entry type. Care is required operationally to ensure no reservations exist against the credits being refunded.
200
+
201
+ ---
202
+
203
+ ## Scenario 12: Trial lesson with reduced N
204
+
205
+ **Setup.** Customer purchases a trial offering at N=2 credits (vs N=6 for a regular 30-min lesson). Schedules a trial lesson.
206
+
207
+ **Flow.**
208
+
209
+ 1. Purchase: Purchase Credit (+2) posts. `credit.purchased` fires.
210
+ 2. Schedule trial: claim recorded with N=2. State `reserved`, funding=`funded`. `credit.reserved` fires with `reserved_credits=2`. `credit.funded` fires.
211
+ 3. T-24h: Reservation Lock Debit (−2) posts. State `→ locked`. `credit.locked` fires with `locked_credits=2`. If first lock for Person: `customer.handoff` fires.
212
+ 4. Delivery: Adjustment (+2, reason=Credits Consumed) and Lesson Debit (−2). State `→ consumed`.
213
+
214
+ **Ledger footprint.** Purchase +2 + Lock −2 + Reversal +2 + Lesson Debit −2 = **net 0**. Customer paid for one trial.
215
+
216
+ **Result:** passes. The N parameter cleanly handles trial pricing without state machine special cases. customer.handoff fires at first lock regardless of whether the first lesson is a trial or a regular lesson, which matches the business intent.
217
+
218
+ ---
219
+
220
+ ## Scenario 13: Group lesson with multiple participants
221
+
222
+ **Setup.** Buyer schedules a 60-minute group lesson (N=12). Brings 3 swimmers (siblings).
223
+
224
+ **Flow.**
225
+
226
+ 1. Buyer's account is the only one charged. Reservation made against buyer's `credit_account_id` with N=12.
227
+ 2. Lesson record in Delivery references all three participant_ids (siblings) but only one credit_reservation_id.
228
+ 3. State machine flow proceeds normally for the buyer's reservation.
229
+ 4. At delivery, Adjustment (+12) and Lesson Debit (−12) post against the buyer's account. The other participants are operational fact only; they don't affect the ledger.
230
+
231
+ **Ledger footprint.** Same as a regular 60-min lesson: net −N for the buyer.
232
+
233
+ **Result:** passes. The contract's "one credit account per lesson regardless of participants" rule means group lessons are zero special-casing in the state machine.
234
+
235
+ ---
236
+
237
+ ## Scenario 14: customer.handoff first-lock-only behavior
238
+
239
+ **Setup.** Person A has never had a lock fire. Schedules and proceeds through two lessons over time.
240
+
241
+ **Flow.**
242
+
243
+ 1. First lesson reaches T-24h, funded. State `→ locked`. `credit.locked` fires. Revenue checks first-lock state for Person A: no prior lock. Emit `customer.handoff` immediately. Mark Person A's first-lock state as fired.
244
+ 2. First lesson completes (or terminates in any way). Customer.handoff already fired; not re-emitted.
245
+ 3. Second lesson reaches T-24h, funded. State `→ locked` for the second reservation. `credit.locked` fires. Revenue checks first-lock state: already fired. **`customer.handoff` does NOT fire.**
246
+
247
+ **Result:** passes. customer.handoff is idempotent on first-lock per Person. Subsequent locks fire only `credit.locked`.
248
+
249
+ ---
250
+
251
+ ## Scenario 15: First lock that releases doesn't retract handoff
252
+
253
+ **Setup.** Person A's first lock fires (customer.handoff sent to Delivery). The lesson is then cancelled by Sguild for weather.
254
+
255
+ **Flow.**
256
+
257
+ 1. State `reserved → locked`. `credit.locked`. `customer.handoff` fires.
258
+ 2. T-12h, weather closure. State `locked → released` (Sguild-side). Adjustment (+N, reason=Credits Released). `credit.released` fires.
259
+ 3. Person A is now a customer who never had a delivered lesson. **Delivery still owns the relationship.** customer.handoff is not retracted.
260
+
261
+ **Result:** passes per the "handoff is irrevocable" rule in §5.1 of the contract. Delivery is responsible for re-engagement (rescheduling, retention) of this customer.
262
+
263
+ ---
264
+
265
+ ## Scenario 16: Reschedule of locked lesson
266
+
267
+ **Setup.** State `locked` for lesson X. Customer wants to reschedule to lesson Y (later date).
268
+
269
+ **Flow.**
270
+
271
+ 1. Per contract policy, customer cannot reschedule a locked lesson. They must accept forfeit + rebook.
272
+ 2. Customer initiates "cancellation" of lesson X (effectively a forfeit since past T-24h). Forfeit Job runs. Adjustment +N (Credits Forfeited), Credit Forfeit −N. State `locked → forfeited` for lesson X.
273
+ 3. Customer (or staff on customer's behalf) creates a new reservation for lesson Y. State `→ reserved` for new reservation. Funding sub-state determined by current credit balance.
274
+ 4. New reservation proceeds through state machine independently.
275
+
276
+ **Ledger footprint.** Lock_X −N + Reversal +N + Forfeit −N = net −N for lesson X. Plus whatever the new reservation produces (potentially another N debit if the customer pays again or has credits).
277
+
278
+ **Result:** passes. Reschedule of locked is by design more punitive than reschedule of unlocked, which matches the contract's stated policy. Operationally, this should be visible in customer-facing UIs as a forfeit warning.
279
+
280
+ ---
281
+
282
+ ## Scenario 17: Reschedule of pre-lock reservation
283
+
284
+ **Setup.** State `reserved` (pre-lock) for lesson X. Customer wants to reschedule to lesson Y.
285
+
286
+ **Flow.**
287
+
288
+ 1. Customer initiates cancellation of lesson X. Pre-lock cancellation, initiator=customer. State `reserved → released`. No ledger entries. `credit.released` fires. Claim removed.
289
+ 2. New reservation for lesson Y. State `→ reserved`. Claim recorded against the same credit account.
290
+ 3. Both reservations may share funding sub-state if credits are still in account.
291
+
292
+ **Ledger footprint.** Empty for the cancellation step. Future state machine flow for lesson Y's reservation is independent.
293
+
294
+ **Result:** passes. Reschedule of pre-lock is cheap; the customer keeps their credits and gets a fresh reservation.
295
+
296
+ ---
297
+
298
+ ## Findings
299
+
300
+ 1. The state machine handles all 17 reference scenarios cleanly. No ambiguous states, no missing transitions, no ledger inconsistencies.
301
+ 2. The lock dissolution invariant (Adjustment +N posts at every `locked → terminal` transition) is consistent across all six terminal flavors that originate from `locked` (Consumed, Forfeited via late cancel, Forfeited via no-show, Released via Sguild operational, Released via Administrative Void, plus the pre-lock paths that don't involve lock dissolution at all).
302
+ 3. The funding sub-state cleanly separates pre-lock cancellation (no ledger impact) from auto-cancel-for-unpaid (no ledger impact, no handoff fired) from normal lock progression (full ledger trail).
303
+ 4. The N parameterization handles trial lessons and regular lessons identically; only the magnitude of N differs.
304
+ 5. customer.handoff fires exactly once per Person on first lock, irrevocable thereafter, including across cancellation and forfeiture of the first lock. This matches the late-handoff business intent.
305
+ 6. Group lessons charge one credit account regardless of participant count; the state machine has no participant-count special case.
306
+ 7. Reschedule policy (forfeit + rebook for locked, free reschedule for pre-lock) flows out of the cancellation policy without a separate transition.
307
+ 8. Refund flows operate via the Refund Debit entry type entirely outside the state machine, which is correct.
308
+ 9. Delivery' role-record creation on `customer.handoff` and Revenue's first-lock detection are the two non-trivial cross-domain dependencies; both are documented in the contract's producer responsibilities and consumer responsibilities sections.
309
+
310
+ ## Recommendation
311
+
312
+ No changes to v1.0.0 of the Credit Reservation Lock Contract are required based on this validation. Ready to feed into the tag-and-register steps (T7, T8) once you confirm.
313
+
314
+ One minor implementation note worth carrying forward (not a contract change): the Forfeit Job and the Lesson Completion Job both need idempotency keys on `credit_reservation_id` plus target terminal state, because both jobs post two paired ledger entries (Adjustment + paired debit) and partial completion would produce inconsistent ledger state. The contract's §13.7 requires this implicitly; calling it out in implementation review is worth doing.
315
+
316
+ ---
317
+
318
+ **Editorial note, 2026-04-27.** Every "Operations" reference in this validation note has been replaced with "Delivery" to match the renamed domain. The validation findings against v1.0.0 are unchanged in substance; the contract was bumped to v1.0.1 in the same sweep for the same vocabulary reason.
@@ -0,0 +1,205 @@
1
+ # Event Envelope Contract
2
+
3
+ **Status:** v1.0.2
4
+ **Date:** 2026-05-01
5
+ **Owner:** Platform
6
+ **Consumers:** Growth, Sales, Delivery, Revenue (and Platform itself)
7
+ **Related ADRs:** ADR-0001 (tenant_id), ADR-0002 (canonical entity ID template), ADR-0005 (event envelope decision)
8
+ **Related contracts:** Identity Contract v1.0.0 (`../identity/README.md`)
9
+
10
+ ## 1. Purpose and scope
11
+
12
+ Every cross-domain event in the Sguild platform rides on a shared envelope. This contract specifies that envelope: required fields, optional fields, validation, transport assumptions, versioning, and the responsibilities of producers and consumers.
13
+
14
+ The envelope is transport-agnostic. The shape is the same whether the event is delivered in-process via a dispatcher or over a message bus. Bus choice is deferred and lives in a separate decision.
15
+
16
+ Out of scope: per-event-type payload schemas (those live alongside this contract under `schema/payloads/` and version independently per event_type), authentication and tenant resolution (separate Auth contract), bus selection.
17
+
18
+ ## 2. Normative language
19
+
20
+ The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted per RFC 2119.
21
+
22
+ ## 3. Terminology
23
+
24
+ - **Event.** A single occurrence of something that happened in a domain, intended for cross-domain consumption.
25
+ - **Envelope.** The shared shape every event takes; the metadata around the payload.
26
+ - **Payload.** The event-specific data inside the envelope. Shape determined by `event_type` plus `schema_version`.
27
+ - **Producer.** The domain that emits an event.
28
+ - **Consumer.** A domain that subscribes to and processes events.
29
+ - **Dispatcher.** The Platform-provided SDK that constructs, validates, publishes, and (on the consumer side) receives, validates, and routes events. Operates in-process or over a bus depending on deployment.
30
+
31
+ ## 4. Envelope fields
32
+
33
+ ### 4.1 Required fields
34
+
35
+ Every event MUST have all of the following:
36
+
37
+ | Field | Type | Description |
38
+ |-------|------|-------------|
39
+ | `event_id` | text, `evt_<UUID v7 canonical>` per ADR-0002 | Globally unique event identifier. Used for idempotency and dedup. |
40
+ | `event_type` | text, dotted lowercase noun.verb | Hierarchical event-type identifier (e.g., `intake.captured`, `person.created`, `customer.handoff`). |
41
+ | `occurred_at` | ISO 8601 datetime in UTC | Producer's wall-clock at the moment the event happened. |
42
+ | `tenant_id` | text | Tenant scoping per ADR-0001. Today a fixed singleton. |
43
+ | `producer` | enum (`platform`, `growth`, `sales`, `delivery`, `revenue`) | Which domain emitted the event. (`operations` is the legacy value during the 2026-04-27 to 2026-05-11 deprecation window; consumers MUST accept both.) |
44
+ | `schema_version` | integer | Versions the payload shape for this event_type. Starts at 1. |
45
+ | `payload` | object | Event-specific data; shape determined by `event_type` + `schema_version`. Required but contents vary. |
46
+
47
+ ### 4.2 Optional fields
48
+
49
+ The following SHOULD be present where applicable:
50
+
51
+ | Field | Type | Description |
52
+ |-------|------|-------------|
53
+ | `subject` | text (canonical entity ID) | The primary entity the event is about. Usually a `person_id`; may be any canonical entity ID (`lead_*`, `par_*`, `coa_*`, `crd_*`, etc.). |
54
+ | `actor` | text (`person_id` or `system:<domain>`) | The human or system that caused the event. |
55
+ | `correlation_id` | text, `evt_<UUID v7 canonical>` | The originating event in a chain. Lets consumers reconstruct cross-domain workflows. |
56
+
57
+ ### 4.3 Field rules
58
+
59
+ `event_id` MUST be unique within a tenant. Collisions are a producer bug.
60
+
61
+ `event_type` SHALL follow the dotted noun.verb convention. The noun identifies the entity or domain concept; the verb identifies what happened. New event_types MUST be registered in the event-type registry (alongside this contract) before first use, with an initial payload schema at `schema_version=1`.
62
+
63
+ `occurred_at` is the time the event happened in the domain, NOT the time it was received or processed by a consumer. Producers MUST NOT backdate or postdate events.
64
+
65
+ `tenant_id` MUST come from request context (auth-resolved), NOT from payload. Producers SHALL NOT accept tenant_id from untrusted input.
66
+
67
+ `producer` is an enum. Adding a new producer value (e.g., a new domain) is a v1.x additive change. Renaming an existing producer is a v2.0 breaking change.
68
+
69
+ `schema_version` is per-event_type. `intake.captured` v1 is independent of `person.created` v1. A producer evolves a single event_type's payload from v1 to v2 by emitting events with `schema_version=2`; consumers opt in by handling both v1 and v2 during a transition window.
70
+
71
+ `payload` is required, but its shape is governed by the per-event-type schema, not by this envelope.
72
+
73
+ `subject`, `actor`, `correlation_id` are optional but SHOULD be set where they make sense. An event whose subject is a Person SHALL set `subject` to the relevant `person_id`. An event triggered by a human SHALL set `actor` to that human's `person_id`; an event triggered by automation SHALL set `actor` to `system:<domain>` (e.g., `system:platform`, `system:revenue`).
74
+
75
+ ## 5. Validation
76
+
77
+ The envelope schema is published as JSON Schema at `schema/envelope-v1.json`. Per-event-type payload schemas live at `schema/payloads/<event_type>-v<schema_version>.json`.
78
+
79
+ Producers SHALL validate the envelope and payload before emit. Consumers SHALL validate the envelope and payload before processing. Validation failures SHALL be logged with full envelope context and the event SHALL be sent to a dead-letter queue rather than processed.
80
+
81
+ Validation failure modes:
82
+
83
+ - Missing required envelope field: dead-letter, alert.
84
+ - Invalid envelope field type or format: dead-letter, alert.
85
+ - `event_type` not in the registry: dead-letter, alert.
86
+ - Payload does not match the registered schema for `(event_type, schema_version)`: dead-letter, alert.
87
+ - Unknown enum value (e.g., new `producer` value an old consumer does not recognize): consumers SHALL ignore the unknown value gracefully and SHOULD log a warning, NOT dead-letter, because additive changes are expected.
88
+
89
+ ## 6. Transport
90
+
91
+ The envelope is transport-agnostic. The Platform-provided dispatcher SDK operates in dual mode:
92
+
93
+ **In-process mode (today).** Events are delivered synchronously or asynchronously within a single process via the dispatcher. The producer calls `dispatcher.publish(event)`; subscribed consumers receive the event in-process.
94
+
95
+ **Bus mode (future).** When domains extract into separate deployables, the dispatcher writes to a message bus (NATS, Kafka, SNS+SQS, Redis Streams, or other; choice deferred). The dispatcher API is unchanged from the producer's perspective.
96
+
97
+ The dispatcher SDK is the only supported way to publish events once it ships. Hand-constructing envelopes and posting to the bus directly is prohibited because it bypasses validation, idempotency tracking, and observability.
98
+
99
+ **Interim transport during the SDK gap (added in v1.0.2).** The dispatcher SDK described above does not yet exist; the platform repo carries no implementation as of this contract version. During the SDK gap window (which begins at v1.0.0 publication on 2026-04-25 and ends when the SDK is consumer-ready, tracked separately in `memos/2026/2026-05-01-platform-dispatcher-sdk-build-plan.md`), the following are contract-compliant in place of the SDK-mediated transports above:
100
+
101
+ - Cross-deployable consumer reads MAY synchronously query the producer's authoritative API at request time instead of subscribing to events through the SDK. The producer-authoritative read returns the same logical state the SDK-delivered events would have made eventually consistent in a consumer-side projection. Coaching's `coach-availability` v1.0.0 read endpoints are the canonical example.
102
+ - Producers and consumers colocated in a single Node process MAY call each other directly during the gap. Direct calls are not envelope-validated and bypass the audit trail described in §11.3, which is an acknowledged loss; the SDK build closes this gap.
103
+ - Hand-constructing envelopes and posting to a message bus directly remains prohibited even during the gap, because there is no Platform-managed bus to post to.
104
+
105
+ The interim shape is contract-compliant in the sense that it does not violate this contract; it is a relaxation of the producer/consumer SHALL clauses in §9.1 and §10.1, scoped to the SDK gap window and described again where those clauses appear. When the SDK ships consumer-ready, the relaxation ends and §9.1 and §10.1 read as originally written.
106
+
107
+ ## 7. Delivery semantics
108
+
109
+ Delivery is at-least-once. Consumers MUST be idempotent over `event_id`. The dispatcher tracks delivered events per consumer; transient failures cause redelivery, and the consumer SHALL recognize the duplicate by its `event_id` and process it as a no-op.
110
+
111
+ The envelope makes no ordering guarantee across event types or producers. Consumers that need ordering SHALL use `occurred_at` plus `event_id` as a sort key, OR SHALL subscribe to a single event_type whose producer guarantees in-stream order.
112
+
113
+ Consumers MAY assume that consecutive events with the same `subject` from the same `producer` arrive in `occurred_at` order; the dispatcher SHOULD preserve this ordering when possible but does not guarantee it across a bus migration.
114
+
115
+ ## 8. Versioning policy
116
+
117
+ ### 8.1 Semantic versioning of the envelope
118
+
119
+ - **Patch** (1.0.0 → 1.0.1): editorial clarifications.
120
+ - **Minor** (1.0.x → 1.1.0): additive changes. New optional envelope fields, new `producer` enum values, new `event_type` registrations, new payload `schema_version` registrations.
121
+ - **Major** (1.x.y → 2.0.0): breaking changes. Removal of envelope fields, renaming of `producer` enum values, breaking changes to envelope structure.
122
+
123
+ ### 8.2 Per-event-type payload versioning
124
+
125
+ Each event_type maintains its own `schema_version` sequence, independent of the envelope version. `intake.captured` v1 evolving to v2 is a producer-and-consumer coordination separate from envelope evolution.
126
+
127
+ Adding optional fields to a payload is a `schema_version` bump for that event_type. Removing or changing existing fields requires a new event_type (e.g., `intake.captured.v2` as a distinct event_type) so existing consumers continue to work.
128
+
129
+ ### 8.3 Deprecation policy
130
+
131
+ When a major envelope version is published, the previous major enters a two-week deprecation window per the Q2 2026 contract-currency metric. Consumers running deprecated versions after the window contribute to drift.
132
+
133
+ When a payload `schema_version` is deprecated for an event_type, the event-type registry SHALL note the deprecation and a sunset date; consumers have until the sunset to migrate.
134
+
135
+ ### 8.4 Registry
136
+
137
+ The envelope contract version is registered in the Platform Contracts Registry (Notion). Each event_type and its current `schema_version` is registered in the event-type registry adjacent to this contract.
138
+
139
+ ## 9. Consumer responsibilities
140
+
141
+ ### 9.1 Use the dispatcher SDK
142
+ Consumers SHALL subscribe to events via the dispatcher SDK, not by reading directly from the bus. The SDK handles validation, dedup, and tenant routing.
143
+
144
+ During the SDK gap window described in §6, consumers MAY satisfy this clause by synchronously querying the producer's authoritative API at request time instead of subscribing through the SDK. The exception ends when the SDK is consumer-ready.
145
+
146
+ ### 9.2 Idempotency
147
+ Consumers MUST be idempotent over `event_id`. A duplicate event with the same `event_id` SHALL produce no observable side effects beyond what the first delivery produced.
148
+
149
+ ### 9.3 Tenant scoping
150
+ Consumers MUST scope all event-driven work by `tenant_id` from the envelope. Cross-tenant processing is prohibited.
151
+
152
+ ### 9.4 Validation
153
+ Consumers SHALL validate the envelope and payload on receipt. Invalid events go to dead-letter, not silent discard.
154
+
155
+ ### 9.5 Unknown enum values
156
+ Consumers SHALL handle unknown enum values (e.g., a new `producer` value, a new `reason_code`) gracefully. Forward-compatibility is built into the additive-discipline rule; consumers that fail on unknown values block the contract from evolving.
157
+
158
+ ### 9.6 Schema_version handling
159
+ Consumers SHOULD support the current and previous `schema_version` of every event_type they subscribe to during transition windows. The event-type registry documents currently-supported versions per type.
160
+
161
+ ## 10. Producer responsibilities
162
+
163
+ ### 10.1 Use the dispatcher SDK
164
+ Producers SHALL emit events via the dispatcher SDK. Hand-constructed envelopes are prohibited.
165
+
166
+ During the SDK gap window described in §6, producers colocated in a single Node process with their consumers MAY call those consumers directly instead of emitting through the SDK. Cross-deployable producer-consumer pairs continue to need the SDK; until it ships, the consumer's interim sync-query under §9.1 covers the gap. The exception ends when the SDK is consumer-ready.
167
+
168
+ ### 10.2 Required field population
169
+ Producers MUST populate all required envelope fields correctly on every emit. The SDK handles `event_id`, `occurred_at`, `tenant_id`, `producer`, and `schema_version` automatically; producers populate `event_type`, `subject`, `actor`, and `payload`.
170
+
171
+ ### 10.3 Pre-emit validation
172
+ The dispatcher SDK validates before publishing. Producers SHALL NOT bypass this validation.
173
+
174
+ ### 10.4 Event-type registration
175
+ Producers SHALL register every new event_type in the event-type registry before first use, with an initial payload schema at `schema_version=1`.
176
+
177
+ ### 10.5 Payload evolution discipline
178
+ Producers SHALL increment `schema_version` for additive payload changes within an event_type, and SHALL NOT change existing fields' shape or semantics within a `schema_version`. Breaking changes require a new event_type.
179
+
180
+ ### 10.6 Idempotent emission
181
+ Producers SHALL NOT emit the same logical event twice with different `event_id`s. If an emit operation might be retried (e.g., network failure), the producer SHALL reuse the same `event_id` on retry so consumers' dedup works.
182
+
183
+ ## 11. Security and privacy
184
+
185
+ ### 11.1 PII in payloads
186
+ Event payloads MAY carry PII consistent with each consumer's need-to-know. The envelope itself is not a PII vehicle; envelope fields are identifiers and metadata.
187
+
188
+ ### 11.2 Cross-tenant isolation
189
+ The dispatcher SHALL route events strictly within tenant boundaries. A consumer subscribed in tenant A SHALL NOT receive events from tenant B, regardless of how the bus is configured.
190
+
191
+ ### 11.3 Audit trail
192
+ Every event is implicitly an audit trail entry. The combination of `event_id`, `actor`, `subject`, `occurred_at`, `producer`, and `event_type` answers "who did what to whom when, from where" for every cross-domain action. Platform retains the event stream indefinitely (or per data retention policy when established).
193
+
194
+ ## 12. Future work
195
+
196
+ - **Bus choice and migration plan.** Specific bus selection (NATS, Kafka, SNS+SQS, Redis Streams, etc.) when single-process delivery becomes insufficient. Will get its own ADR.
197
+ - **Stronger ordering guarantees.** If a use case requires exactly-once or strict cross-producer ordering, the envelope may need additional fields (sequence number, partition key). Defer until the use case appears.
198
+ - **Cross-tenant resolution.** When tenant two is committed, the dispatcher's tenant routing logic needs explicit specification; the envelope already carries `tenant_id` so the contract surface is forward-compatible.
199
+ - **CloudEvents bridge.** If Sguild needs to send events to or receive events from external systems that speak CloudEvents, a bridging layer translates between Sguild envelope and CloudEvents. Defer until the external integration appears.
200
+
201
+ ## 13. Change log
202
+
203
+ - **v1.0.2** (2026-05-01) — Acknowledged that the dispatcher SDK described in §3, §6, §9.1, and §10.1 has not yet been implemented as of this version's date. Added an "Interim transport during the SDK gap" subsection to §6 and corresponding scoped relaxations to §9.1 (consumer SHALL use the SDK) and §10.1 (producer SHALL use the SDK). The interim shape allows cross-deployable consumer reads to synchronously query the producer's authoritative API at request time, and allows colocated producers and consumers in a single Node process to call each other directly, with both relaxations ending when the SDK is consumer-ready. The SDK build is tracked separately in `memos/2026/2026-05-01-platform-dispatcher-sdk-build-plan.md`. No envelope structural change, no field added or removed; consumers running against v1.0.0 or v1.0.1 are interface-compatible with v1.0.2.
204
+ - **v1.0.1** (2026-04-27) — `producer` enum renamed `operations` to `delivery` to match the renamed domain. Two-week deprecation window from 2026-04-27 to 2026-05-11: producers SHOULD emit `delivery`, consumers MUST accept both `operations` and `delivery` and treat them as the same producer. Other vocabulary references to "Operations" in this README replaced with "Delivery". No structural change to the envelope.
205
+ - **v1.0.0** (2026-04-25) — Initial release. Seven required envelope fields, three optional, JSON Schema validation, dual-mode dispatcher, at-least-once delivery, no ordering guarantee.
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "event_type": {
24
24
  "type": "string",
25
- "description": "Hierarchical event-type identifier; dotted lowercase noun.verb. Must be registered in the event-type registry before first use per §10.4.",
26
- "pattern": "^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$",
25
+ "description": "Hierarchical event-type identifier; dotted lowercase noun.verb. Hyphenated segments are allowed for compound nouns such as service-area. Must be registered in the event-type registry before first use per §10.4.",
26
+ "pattern": "^[a-z][a-z0-9_]*(?:-[a-z0-9_]+)*(\\.[a-z][a-z0-9_]*(?:-[a-z0-9_]+)*)+$",
27
27
  "minLength": 3,
28
28
  "maxLength": 128
29
29
  },