@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,131 @@
1
+ # Validation: Current Clients table against Identity Contract v0.1
2
+
3
+ **Status:** Passed with a follow-up list; no contract changes required, migration work identified
4
+ **Date:** 2026-04-24 (amended same day for Lifecycles dissolution rename pass)
5
+ **Owner:** Platform
6
+ **Validates:** `platform/contracts/identity/contract.md` v0.1.0
7
+ **Sibling validations:** `coach-handling.md` (passed), `person-graph.md` (passed)
8
+ **Source:** Live Airtable, base `app1K2d9GZVsE7vWh` (Delivery), table `tbl53WJLnUPde5auP` (Clients), 42 fields, as of 2026-04-24. `Schema.docx` in Google Drive is not authoritative; it is lightly behind the live base (missing `Display Name` formula field, uses "Activation Organization" where live is `Activation Org`, misses `Client Activation Summaries` link). Live Airtable is the source of truth.
9
+
10
+ ## Purpose
11
+
12
+ Walk the existing Airtable Clients schema against the v0.1 Identity Contract. Identify what is compatible as-is, what needs renaming, and what needs a migration. Produce a follow-up list, not a blocker; none of the gaps below require a contract change.
13
+
14
+ ## Current Clients schema (from live Airtable)
15
+
16
+ The existing Clients table is the global/shared payer identity record, not org-scoped. Org participation lives on Client Profiles (out of scope for this validation). 42 fields total.
17
+
18
+ **Identity/core text:** First Name, Last Name, Phone, Phone Normalized (formula), Email, Zip, Lead Key, Notes.
19
+
20
+ **Primary formula field:** `Client Name = TRIM(First Name + " " + Last Name)`. This is the Airtable table primary field.
21
+
22
+ **Additional display helper:** `Display Name = First Name + " " + LEFT(Last Name, 1)` (e.g., "Jack A"). Short-form variant for UI contexts; operational convenience, not an identity fact.
23
+
24
+ **Status (singleSelect):** `Active`, `Inactive`, `Merged`, `Do Not Contact` (confirmed live choice IDs).
25
+
26
+ **Booleans/operational:** Activation Ready (checkbox).
27
+
28
+ **Record links:** Activation Org, Client Profiles, Students, Lesson Sites (Private), Client Externals, Client Activation Summaries.
29
+
30
+ **Lookups:** Organizations (from Client Profiles).
31
+
32
+ **Rollups over links:** Client External Count, Synced Client External Count, Failed Client External Count, Missing External ID Synced Client External Count, Usable Synced Client External Count, Usable External Card Count, Client Profile Count.
33
+
34
+ **Formula helpers:** Has Contact Info, Primary Contact Method, Setup Status, Primary Org, Has Active Profiles.
35
+
36
+ **Exception system (formulas):** Missing Client Name, Missing Contact Info, No Client Profiles, Has Identity Problem, Identity Problem, Has Setup Problem, Setup Problem, Has Exception, Exception Reason.
37
+
38
+ **Audit:** Created At (createdTime), Modified At (lastModifiedTime).
39
+
40
+ ## Field-by-field mapping to Identity Contract v0.1
41
+
42
+ | v0.1 field | Current Clients source | Mapping action | Notes |
43
+ |------------|------------------------|----------------|-------|
44
+ | `person_id` | (none today; Airtable record ID is used) | **Migration:** mint `per_<UUID v7>` per row | One-shot mint per ADR-0003 Action Item 5. Mint is performed by Platform's identity service. |
45
+ | `status` | `Status` (Active/Inactive/Merged/Do Not Contact) | **Rename + remap values** | Active → `active`; Inactive → `archived`; Merged → `merged`; Do Not Contact → `archived` with a Platform-internal `do_not_contact` flag (NOT on the canonical contract). |
46
+ | `alias_of` | (none today) | **New column** | Nullable text with CHECK `like 'per\_%'`. Populate only when Status transitions to Merged; today's Merged rows without a target point need manual resolution during migration. |
47
+ | `given_name` | `First Name` | **Rename** | Straight rename, max 200. |
48
+ | `family_name` | `Last Name` | **Rename** | Straight rename, max 200. |
49
+ | `display_name` | `Client Name` (formula, full form) | **Rename + semantics shift** | Current `Client Name` always computes full "First Last". New semantics: `display_name` is nullable; when null, Platform's identity service computes `given_name + " " + family_name`. Existing values stay null on migration (the computation matches). The live `Display Name` formula (short-form "First L") is a separate operational helper; it stays in a Platform or Delivery view layer, NOT on the canonical contract. |
50
+ | `is_minor` | (not on Clients; lives on Students) | **New column** | Clients today represent adult buyers, so `is_minor=false` for all migrated Clients rows. Minor Persons come from Students table, a separate ADR-0003 migration path. |
51
+ | `created_at` | `Created At` | **Keep** | Rename to snake_case if desired for schema consistency; value preserved. |
52
+ | `updated_at` | `Modified At` | **Keep + semantics tightening** | Rename to snake_case. Semantics in v0.1 require `updated_at` to bump only on canonical-field changes. Airtable's `Modified At` bumps on any field edit. After migration, the Platform identity service SHOULD enforce the tighter semantics; Airtable interim keeps looser semantics since it is being replaced. |
53
+
54
+ ## Fields that stay internal to the Platform identity service or to Delivery (NOT on canonical contract)
55
+
56
+ These exist on Clients today and SHOULD remain off the canonical contract per v0.1 §5.2. Their owning home depends on whether the field is identity-shaped (Platform) or operational/relationship-shaped (Delivery).
57
+
58
+ - `Phone`, `Phone Normalized` (formula) — Platform-internal contact data; outbound comms route through Platform's Guardian-aware comms-routing endpoint
59
+ - `Email` — Platform-internal contact data; same rule
60
+ - `Zip` — Platform-internal demographic data
61
+ - `Notes` — relationship-shaped; lives in Delivery (post-handoff) or Sales (pre-handoff) depending on context
62
+ - `Activation Ready` — Delivery-internal operational helper
63
+ - `Display Name` (short-form formula, "First L") — view-layer helper; can live in Platform's identity service or in Delivery' UI layer
64
+ - `Activation Org` link — Delivery-internal operational link
65
+ - `Client Activation Summaries` link — Delivery-internal operational summary
66
+ - All helpers (Has Contact Info, Primary Contact Method, Setup Status, Primary Org, Has Active Profiles) — Delivery-internal
67
+ - All checks (Missing Client Name, Missing Contact Info, No Client Profiles) — Delivery-internal
68
+ - Exception system (Has Identity Problem, Identity Problem, Has Setup Problem, Setup Problem, Has Exception, Exception Reason) — Delivery-internal
69
+ - All rollups over Client Externals (Client External Count, Synced Client External Count, Failed Client External Count, Missing External ID Synced Client External Count, Usable Synced Client External Count, Usable External Card Count, Client Profile Count) — Delivery-internal (Client Externals owner is Revenue or Delivery depending on linkage purpose)
70
+
71
+ None of these are contract gaps; they are internal operational convenience that domains hold for themselves.
72
+
73
+ ## Fields that move off Clients entirely (per ADR-0003)
74
+
75
+ - `Lead Key` and any lead-pipeline fields (Source, Stage, Last Attempt At, Attempt Count, Last Reach At, Last Reached Outcome, Next Callback At, Next Callback Notes) — **move to Sales' Lead table** per amended ADR-0003. A Client may have many Lead records across time; the per-Client singleton Lead Key was a symptom of the conflation the new model fixes.
76
+
77
+ ## Links to other entities
78
+
79
+ - `Students` link → preserved as Guardian relationship (`gdn_` records in Platform's identity service) and/or Participant references in Delivery. ADR-0003 specifies the mapping: child Students become Person+Participant with an inbound Guardian relationship from the adult Client's Person.
80
+ - `Client Profiles` link → preserved; Client Profiles are per-Org Client participation records, outside this contract's scope. They continue to reference Person by `person_id` post-migration.
81
+ - `Client Externals` link → preserved; Client Externals continues to hold the external provider's identifier for the Person via `External Person ID` (renamed per ADR-0004). Owning domain is Revenue (for payment-side externals) or Delivery (for non-payment externals).
82
+ - `Activation Org` link → preserved; org reference, not an identity concern.
83
+
84
+ ## Status enum migration detail
85
+
86
+ The Airtable Status enum has four values; the v0.1 contract enum has three. Mapping:
87
+
88
+ | Current | v0.1 | Additional internal state |
89
+ |---------|------|---------------------------|
90
+ | Active | `active` | none |
91
+ | Inactive | `archived` | none |
92
+ | Merged | `merged` | `alias_of` MUST be populated; rows without a known target need manual resolution |
93
+ | Do Not Contact | `archived` | Platform-internal `do_not_contact: true` flag set; the comms-routing endpoint respects the flag |
94
+
95
+ Rationale for collapsing Do Not Contact into `archived`: the v0.1 contract deliberately keeps the status enum small (§5.1), and Do Not Contact is a communications-layer concern, not an identity-lifecycle concern. Keeping the flag in Platform-internal state means the comms-routing endpoint can honor it without exposing it on every cross-domain event.
96
+
97
+ ## Merged rows without a target
98
+
99
+ Current Clients rows with Status=Merged may not have a pointer to the canonical Person they were merged into, because `alias_of` does not exist today. Two options during migration:
100
+
101
+ 1. If an operator can identify the canonical Person by inspection, populate `alias_of` at migration time.
102
+ 2. If the canonical is unknown, mint a fresh `per_` ID for the merged row and leave `alias_of` null. The row's status stays `merged` but consumers cannot resolve it. This is data-debt, not a contract bug; cleanup is an ops task.
103
+
104
+ Neither option requires a contract change.
105
+
106
+ ## Gaps relative to v0.1
107
+
108
+ No gaps that require contract changes. The migration surface is:
109
+
110
+ 1. **Mint `per_` for every Clients row** (ADR-0003 Action Item 5; performed by Platform's identity service).
111
+ 2. **Rename:** First Name → given_name, Last Name → family_name. Client Name formula → display_name (nullable, with Platform's identity service computing the default).
112
+ 3. **Add columns:** `alias_of` (nullable), `is_minor` (boolean, default false for Clients-derived Persons).
113
+ 4. **Remap Status enum:** 4-value → 3-value with Platform-internal `do_not_contact` flag preserving the Do Not Contact semantics.
114
+ 5. **Move fields off Clients:** Lead Key and any acquisition-pipeline fields migrate to Sales' Lead table.
115
+ 6. **Semantics tightening:** `updated_at` bumps only on canonical-field changes in the new Platform identity service implementation (Airtable interim keeps looser semantics since it is being replaced).
116
+
117
+ ## Findings
118
+
119
+ 1. The live Clients table (42 fields, base `app1K2d9GZVsE7vWh`, table `tbl53WJLnUPde5auP`) is compatible with v0.1 under a well-defined migration. No contract changes required.
120
+ 2. The four-value Status enum (Active, Inactive, Merged, Do Not Contact) maps cleanly onto the three-value v0.1 enum with one Platform-internal flag (`do_not_contact`) carrying the lost semantics.
121
+ 3. `Client Name` (full "First Last") as a computed formula aligns with v0.1's `display_name` fallback rule; no semantic loss, just a rename and a relaxation (display_name becomes nullable and overridable). The separate `Display Name` formula (short-form "First L") is an operational view-layer helper that stays Platform-internal or Delivery-internal and does NOT map to the canonical `display_name` field.
122
+ 4. All non-canonical fields (Phone, Email, Zip, Notes, operational links, helpers, checks, exceptions, rollups) remain in their owning domain's internal schema and do not surface on the canonical contract per v0.1 §5.2. Identity-shaped fields stay with Platform; operational and relationship-shaped fields move to Delivery (or stay with Sales for pre-handoff data).
123
+ 5. Lead Key and any acquisition-pipeline fields need to move to Sales' Lead table per amended ADR-0003. Already tracked as ADR-0003 Action Item 3.
124
+ 6. Merged rows without a known target ID are a data-debt item that ops needs to address during migration; not a contract bug.
125
+ 7. `Schema.docx` is lightly behind the live Airtable; live Airtable (accessed via the Airtable MCP) is the source of truth going forward. Schema.docx should be updated or retired to match live, as a separate housekeeping task.
126
+
127
+ ## Recommendation
128
+
129
+ No changes to Identity Contract v0.1 required for the Clients-to-Person migration. This validation feeds into `Revise to v1.0.0` alongside `coach-handling` (passed) and `person-graph` (passed). All three validations complete; the contract is ready to advance to v1.0.0 pending the three blockers named in §12 of the contract (event envelope contract, Auth SDK tenant_id resolution, at least one consumer domain sandbox integration).
130
+
131
+ The migration-surface list in §Gaps above is the follow-up list the task description asked for; it belongs in the one-shot migration script scoping (ADR-0003 Action Item 5) rather than as a contract change.
@@ -0,0 +1,100 @@
1
+ # Validation: Coach handling against Identity Contract v0.1
2
+
3
+ **Status:** Passed, no contract changes required
4
+ **Date:** 2026-04-24 (amended same day for Lifecycles dissolution rename pass)
5
+ **Owner:** Platform
6
+ **Validates:** `platform/contracts/identity/contract.md` v0.1.0
7
+ **Sibling validations:** `client-table.md` (passed), `person-graph.md` (passed)
8
+
9
+ ## Purpose
10
+
11
+ Confirm that the v0.1 identity contract supports the Coach case without gaps. Specifically: a Coach is representable as one canonical Person plus one or more `coa_` role records; the canonical Person fields are sufficient for Delivery to render and reason about a Coach; the hard overlap cases (Coach-who-is-also-Participant, Coach-who-is-paying-parent, former-student-now-Coach) resolve cleanly through `person_id`; and external provider linkage (including payroll reconciliation) works through existing mechanisms.
12
+
13
+ ## Background
14
+
15
+ The original task framing referenced a "thin Coach participation record in Lifecycles plus a full Coach Profile in Service Delivery." Two superseding decisions made this framing obsolete: ADR-0003 collapsed the dual-record model into a single Coach role record, and the 2026-04-24 amendment dissolved the Customer Lifecycles domain entirely (Person and Guardian moved to Platform's identity service; relationship-shaped work merged into Delivery, which is the renamed-and-expanded former Service Delivery). Under the current canonical model, Coach is exclusively an Delivery role record, the canonical Person record lives in Platform, and there is no Lifecycles. Validation reframes to: does the contract handle Coach under this simplified model cleanly?
16
+
17
+ ## Cases validated
18
+
19
+ ### Case 1: baseline Coach who is only a Coach
20
+
21
+ A human who teaches at one Organization and has no other relationship to Sguild.
22
+
23
+ - Canonical Person in Platform's identity service: one, with name fields and `is_minor=false` (Coaches are adults by operational policy).
24
+ - Delivery Coach role record: one `coa_` entry, keyed by `person_id` + `organization_id`, carrying bio, rates, skill levels, assignment location.
25
+ - Rendering in Delivery UIs: the canonical identity fields (`display_name`, `given_name`, `family_name`, `status`, `is_minor`) give Delivery everything it needs to render a Coach in schedules, coach lists, and assignment views. No additional identity lookup required.
26
+ - Contract lookup path: `GET /identity/person/:id` from Platform returns the canonical Person shape; Delivery joins its `coa_` record to the Person view in its own UI layer.
27
+
28
+ **Result:** passes. No gaps.
29
+
30
+ ### Case 2: Coach who also takes lessons
31
+
32
+ A coach at one Sguild facility who takes adult lessons at the same or another facility. (Real at Sguild.)
33
+
34
+ - Canonical Person: one.
35
+ - Delivery role records: one `coa_` (for the Org where they teach) AND one `par_` (for the Org where they take lessons). Both keyed by the same `person_id`.
36
+ - The `per_`/`coa_`/`par_` keys are distinct namespaces per ADR-0002's canonical-entity-ID template, so no collision is possible.
37
+ - Per-Organization uniqueness on `coa_` and `par_` (one of each per Person per Org) continues to hold; a Coach who teaches at Org A and takes lessons at Org B has no conflict.
38
+ - If the coach teaches and takes lessons at the *same* Org, ADR-0003's "one record per Person per Org" for each of Coach and Participant still holds (they are different role types; no per-Org collision between them).
39
+
40
+ **Result:** passes. No gaps.
41
+
42
+ ### Case 3: Coach who is also a paying parent
43
+
44
+ A Coach who buys lessons for their own child at a Sguild facility.
45
+
46
+ - Canonical Person (Coach): one.
47
+ - Canonical Person (child): one, with `is_minor=true`.
48
+ - Guardian relationship: one `gdn_` in Platform's identity service, directed from parent-Person to child-Person.
49
+ - Delivery role records: `coa_` on the parent (where they teach) and `par_` on the child (where they take lessons).
50
+ - Revenue: Credit Account, Orders, Invoices key by the parent-Person's `person_id` (the parent is the buyer).
51
+ - Comms routing for lesson reminders about the child: Delivery calls Platform's Guardian-aware comms-routing endpoint, which walks Guardian and returns the parent's contact. Direct comms to the child are never attempted because `is_minor=true` gates this path.
52
+
53
+ **Result:** passes. No gaps. This is exactly the case ADR-0003 was designed for.
54
+
55
+ ### Case 4: Former student who became a Coach
56
+
57
+ A human who started as a minor Participant at age 10, aged out at 18 (`is_minor` invariant flipped to false by Platform's daily identity-service job), and years later was hired as a Coach.
58
+
59
+ - Canonical Person: the same `person_id` from age 10 onward. Unchanged.
60
+ - Delivery role records over time: `par_` from the early years (may be archived via its own status field, or remain active if they still take occasional lessons), `coa_` added when hired.
61
+ - Historical continuity: the Person's `created_at` preserves the age-10 capture date. Growth's tenure and cohort analytics see this as a long-tenure Person. Delivery comms history spans the entire post-handoff relationship.
62
+ - Guardian relationships: at age 18, Platform's identity service flips `is_minor` to false; the Guardian relationship (to the parent who signed them up) may remain as a historical record or be closed per operational policy, but the canonical Person identity is unchanged.
63
+
64
+ **Result:** passes. No gaps. The single-Person-across-time model is a first-class feature, not an edge case.
65
+
66
+ ### Case 5: Coach who teaches at multiple Organizations
67
+
68
+ A Coach who works at two Sguild-affiliated facilities (e.g., weekend contractor at one, weekday staff at another).
69
+
70
+ - Canonical Person: one.
71
+ - Delivery role records: two `coa_` records, one per Organization, each keyed by the same `person_id`.
72
+ - Per-Org uniqueness preserved; the Coach has distinct bio, rate, and assignment data per Org because those are per-Org concerns.
73
+
74
+ **Result:** passes. Matches ADR-0003 directly.
75
+
76
+ ## External provider linkage, including payroll
77
+
78
+ The v0.1 contract says external provider IDs are NOT on the canonical Person record (§5.2). They live in domain-owned external-link tables keyed by `person_id` plus an Org Integration (which carries the provider discriminator).
79
+
80
+ For Coach payroll reconciliation, the flow is:
81
+
82
+ 1. Delivery stands up an Org Integration for the payroll provider (Gusto, Rippling, ADP, etc.), scoped to the Sguild business Organization.
83
+ 2. Coach Externals (per ADR-0004, renamed from Instructor Externals) holds a row per Coach per payroll provider: `person_id`, `org_integration_id`, `External Person ID` (the payroll provider's employee ID), plus snapshot and sync fields.
84
+ 3. Delivery' payroll reconciliation job reads Coach Externals to map each Coach's `person_id` to their payroll provider employee ID, then pulls timesheet and payment records from the provider API.
85
+ 4. Revenue is not involved. Payroll is an operational fact, not a commercial-ledger fact, so it stays in Delivery.
86
+
87
+ **Result:** supported, no contract changes. Existing Coach Externals table provides the mechanism; the contract's prohibition on external IDs in the canonical Person record is the correct behavior (it keeps payroll data out of the four-domain event stream).
88
+
89
+ A small note worth capturing: the parallel structure between `Client Externals` (Person-to-external-provider for customer/payment side) and `Coach Externals` (Person-to-external-provider for coach/operational side) is intentional and matches domain ownership. Revenue owns Client Externals. Delivery owns Coach Externals. A single human who is both a customer and a coach will have rows in both tables. This is correct under one-fact-one-owner.
90
+
91
+ ## Findings
92
+
93
+ 1. The contract handles all five Coach cases cleanly. No changes required for v1.0.0.
94
+ 2. The canonical Person fields are sufficient for Delivery to render and reason about Coaches. No additional Coach-specific fields need to be added to the contract.
95
+ 3. External provider linkage for Coaches (including payroll) works through the existing Coach Externals mechanism without any contract surface. This validates §5.2's decision to keep external IDs off the canonical record.
96
+ 4. The original task framing referenced a "thin Coach participation record in Lifecycles" that does not exist under the current model. The task description can be closed with this note rather than by implementing the older design; ADR-0003's simplification (and the subsequent dissolution of Lifecycles into Platform's identity service plus Delivery) is the correct path.
97
+
98
+ ## Recommendation
99
+
100
+ No changes to Identity Contract v0.1 required for Coach handling. Ready to feed into `Revise to v1.0.0` alongside the other two validation passes (both also passed).
@@ -0,0 +1,140 @@
1
+ # Validation: Person graph pattern against Identity Contract v0.1
2
+
3
+ **Status:** Passed, no contract changes required
4
+ **Date:** 2026-04-24 (amended same day for Lifecycles dissolution rename pass)
5
+ **Owner:** Platform
6
+ **Validates:** `platform/contracts/identity/contract.md` v0.1.0
7
+ **Sibling validations:** `coach-handling.md` (passed), `client-table.md` (passed)
8
+
9
+ ## Purpose
10
+
11
+ Confirm that the v0.1 identity contract supports the Person graph pattern without forcing denormalization. Specifically: one canonical Person per human across all four user-facing domains; role records owned by the domain that cares about the role, keyed by `person_id`; references from Sales, Delivery, and Revenue reach a human through the same `person_id` without requiring any domain to keep its own copy of Person identity data.
12
+
13
+ ## The Person graph pattern
14
+
15
+ Stated plainly: one Person (a human) has one canonical identity (one `person_id`, one Person record in Platform's identity service). Domains that care about what this human does in their part of the business own role records, not Persons. A Lead is a Sales role record. A Participant and a Coach are Delivery role records. Guardian is a Platform-identity-service relationship record connecting two Persons. Revenue owns transactional records (Credit Account, Order, Invoice, Credit Reservation) keyed by `person_id`.
16
+
17
+ No domain stores its own Person entity. No domain denormalizes Person fields as a shadow copy that must be kept in sync. Cross-domain reads resolve through `person_id` and the Platform identity service Person-by-id API, or through event-driven caches invalidated by `person.updated` and `person.merged`.
18
+
19
+ ## Reference patterns validated
20
+
21
+ ### Revenue references to Person
22
+
23
+ - `Credit Account` keyed by `person_id` (one per Person per currency or credit pool)
24
+ - `Order` references `person_id` as buyer
25
+ - `Invoice` references `person_id` as bill-to
26
+ - `Credit Reservation` (the lock) references `person_id` plus `credit_id` plus `lesson_id`
27
+ - `Payment` methods linked via Client Externals (`person_id` to external provider's customer ID)
28
+ - No Revenue-owned "customer" or "person" entity duplicating identity
29
+
30
+ **Result:** passes. Revenue only needs `person_id` to key its records. Display rendering (on invoice PDFs, dashboards) fetches Person from Platform's identity service or uses snapshotted name for historical records (see Denormalization analysis below).
31
+
32
+ ### Delivery references to Person
33
+
34
+ - `Participant` role record keyed by `person_id` + `organization_id`
35
+ - `Coach` role record keyed by `person_id` + `organization_id`
36
+ - `Lesson` references `participant_person_id` and `coach_person_id`
37
+ - `Attendance` references `person_id`
38
+ - Schedule rows reference `person_id` plus a soft pointer to the `credit_id` that will be locked
39
+ - Delivery-internal customer-tracking records (created on `customer.handoff`) reference `person_id`
40
+ - No Delivery-owned Person entity duplicating identity
41
+
42
+ **Result:** passes. Schedule rendering, coach rosters, attendance reports, and post-handoff customer-relationship work all operate on `person_id` and fetch display-layer Person data from Platform as needed.
43
+
44
+ ### Sales references to Person
45
+
46
+ - `Lead` role record keyed by `lead_id`, references `person_id` (nullable until matched)
47
+ - Pipeline state, attempt history, callback scheduling, and notes all hang off `Lead`, not Person
48
+ - Sales portal renders Persons via the Platform identity service `GET /identity/person/:id` endpoint
49
+ - No Sales-owned Person entity duplicating identity
50
+
51
+ **Result:** passes. Sales' view of a human is the Lead record plus the Person reference; identity itself is fetched from Platform.
52
+
53
+ ### Growth references to Person
54
+
55
+ - `form_submission` records reference `person_id` once Platform matches or mints; nullable before match
56
+ - Attribution records join `person_id` to campaign and source metadata
57
+ - Funnel events carry `person_id` plus `lead_id` on the envelope
58
+ - No Growth-owned Person entity
59
+
60
+ **Result:** passes. Growth's view of a human is purely attribution and source; no identity duplication.
61
+
62
+ ### Platform identity service internal references
63
+
64
+ - `Person` is the canonical entity (owner)
65
+ - `Guardian` relationship keyed by `gdn_id`, references two `person_id`s
66
+ - Comms-routing endpoint resolves `person_id` plus `is_minor` to a recipient `person_id` (walks Guardian for minors)
67
+
68
+ **Result:** passes. Platform is the only domain that "knows" the full Person, as specified.
69
+
70
+ ## Multi-role cases (one Person, many role records)
71
+
72
+ Validated in `coach-handling.md` specifically for Coach overlap, but restating here for the graph pattern:
73
+
74
+ - A human can hold any combination of `lead_` (Sales), `par_` (Delivery), `coa_` (Delivery), Guardian (Platform, as guardian or as ward), plus implicit Buyer (by virtue of having Revenue records) simultaneously.
75
+ - Each role record lives in its owning domain; no domain stores the others.
76
+ - Cross-role queries happen in the warehouse (with events flattened out) or through per-domain APIs (ask Delivery for Participant role, ask Platform for Guardian relationships, ask Revenue for buyer status, ask Sales for Lead history).
77
+
78
+ **Result:** passes. The pattern is first-class in ADR-0003 and fully supported by the contract.
79
+
80
+ ## Denormalization analysis
81
+
82
+ The task specifically asks whether the contract forces denormalization. Sharpening the definition: denormalization means storing Person identity data redundantly across domains in a way that requires sync-maintenance to stay correct. Caching (refreshed on events) and historical snapshots (pinned at a point in time for integrity) are NOT denormalization; they are normal patterns.
83
+
84
+ ### Does the contract force denormalization? No.
85
+
86
+ - Every domain can reference Person by `person_id` only, with the full identity record fetched from Platform on demand.
87
+ - Caching is optional, implemented per-consumer based on performance needs, and invalidated by `person.updated` and `person.merged` events.
88
+ - Historical snapshots (e.g., invoice line showing "John Smith" because that was the customer's display name when invoice 4521 issued, even though the Person later renamed to "John Smyth") are correct behavior for historical records, not denormalization.
89
+
90
+ ### Historical snapshots worth calling out
91
+
92
+ Three places where a domain legitimately snapshots a Person field for historical integrity:
93
+
94
+ - Revenue invoices: `customer_name_at_issue` on each invoice record pins the display_name at issue time. Required for legal/tax integrity; the invoice is a point-in-time legal document.
95
+ - Delivery attendance: `participant_display_name_at_attendance` may be captured on attendance rows if regulatory or program-completion reporting requires the name as-attended.
96
+ - Revenue ledger entries: all ledger entries are append-only and point-in-time; any denormalized Person field is an intentional snapshot.
97
+
98
+ None of these is forced by the contract. Each is a domain-internal historical-integrity decision, and none conflicts with Platform's ownership of the live Person identity. Live reads fetch from Platform; historical reads use the snapshotted field. The invoice PDF shows "John Smith" even after the rename because that is what the invoice recorded at issue time, which is what the law requires.
99
+
100
+ ### Anti-pattern to avoid (not forced by contract)
101
+
102
+ If Revenue built its own "customers" table that duplicated Person fields and tried to keep them in sync with Platform via periodic polling, that would be denormalization and would violate one-fact-one-owner. The contract does not force this; it in fact makes it unnecessary because the Platform identity service Person-by-id API and `person.updated` events give Revenue a cleaner path.
103
+
104
+ ## Edge cases
105
+
106
+ ### Consistency under eventual-consistency assumptions
107
+
108
+ If Platform updates a Person's `display_name` and emits `person.updated`, there is a brief window where Revenue's cache is stale. During that window, a newly rendered invoice might show the old name. This is acceptable for the overwhelming majority of use cases. Strict cross-domain consistency is not a v0.1 contract concern; distributed transactions are out of scope. Consumers design around eventual consistency at the per-feature level.
109
+
110
+ ### High-frequency reads
111
+
112
+ If every event processed triggers a Person fetch, load on the Platform identity service can become a bottleneck. Mitigation: per-consumer caching with event-driven invalidation. The contract supports this directly through `person.updated` and `updated_at` comparison. Not a contract gap.
113
+
114
+ ### Cross-domain analytical queries
115
+
116
+ Questions like "show me all customers with unpaid invoices who are scheduled for lessons this week" span Delivery and Revenue. The contract does not provide a cross-domain query surface, and should not. Such queries run against the warehouse, where events from all four user-facing domains land and can be joined freely by `person_id`. Real-time operational queries go through the domain that owns the primary fact in the question.
117
+
118
+ ### Merge during active domain state
119
+
120
+ If Person A merges into Person B while Person A has active Lessons scheduled in Delivery and unpaid Invoices in Revenue, the merge must not break those records. Under §6.4 of the contract, role records reconcile (Lead records in Sales union, Participant/Coach per-Org conflicts resolve to oldest-wins). Revenue records (Credit Accounts, Orders, Invoices) key by `person_id`; on merge, they re-key to the canonical `person_id` and emit their own reconciliation events. Delivery records (Lessons, Attendance) similarly re-key.
121
+
122
+ Not all of this is spelled out in the Identity Contract (it is cross-domain mechanics), but the contract provides the primitives (`person.merged` event, `alias_of` field, chained-merge one-hop resolution) that let each domain do the right thing. Specifying the full cross-domain merge workflow is a Revenue, Delivery, and Sales concern, not Platform.
123
+
124
+ ### Alias resolution at scale
125
+
126
+ A consumer holding many `person_id` references that belong to merged Persons will resolve each through `alias_of` on next fetch. This is linear in the number of lookups, not exponential. The `person.merged` event allows proactive reconciliation so stale references do not accumulate. Not a contract gap.
127
+
128
+ ## Findings
129
+
130
+ 1. The contract supports the one-Person-many-roles pattern without forcing any domain to duplicate Person identity data.
131
+ 2. Sales, Delivery, and Revenue all reference Person cleanly by `person_id` and fetch identity through Platform identity service APIs or caches; none needs a local Person table.
132
+ 3. Historical snapshots (invoice names, attendance records) are correct behavior, not denormalization. The contract does not prohibit them; domains own their historical integrity.
133
+ 4. Cross-domain merge mechanics (Sales, Delivery, and Revenue re-keying on `person.merged`) are not specified in the Identity Contract. This is intentional; the contract provides the primitives, and per-domain mechanics belong to those domains. The `Revise to v1.0.0` task may want to add a short section to the contract or a sibling note specifying that consumers are expected to honor `person.merged` for their own record re-keying.
134
+ 5. No forced denormalization, no missing primitives, no gaps against the graph pattern.
135
+
136
+ ## Recommendation
137
+
138
+ No changes to Identity Contract v0.1 required for the Person graph pattern. Ready to feed into `Revise to v1.0.0` alongside the other validations (both also passed).
139
+
140
+ One minor suggestion for v1.0.0 publication: add a short paragraph (either in the contract's §9 Consumer responsibilities, or as a sibling `person-merged-reconciliation.md` spec) naming the consumer expectation that domains honor `person.merged` events by re-keying their role records and historical references to the canonical `person_id`. This is not a v0.1 change; it is a v1.0.0 clarification so the expectation is stated explicitly rather than implicit.
@@ -0,0 +1,187 @@
1
+ # Lead Lifecycle
2
+
3
+ **Status:** v1.2.0
4
+ **Date:** 2026-05-19
5
+ **Owner:** sales (lead-pipeline)
6
+ **Consumers:** platform-warehouse (analytics ingestion); growth (lead-intake-correlation + qualified-intake, active as of 2026-05-12); delivery (handoff context, active for lead.handoff.context.recorded as of v1.2.0)
7
+ **Related ADRs:** ADR-0001 (tenant_id), ADR-0002 (canonical entity ID template; `lead_` prefix), ADR-0003 (Person canonical, Sales does not own Person), ADR-0005 (event envelope), ADR-0009 (dispatcher transport)
8
+ **Related contracts:** Identity Contract (`../identity/README.md`), Event Envelope Contract (`../event-envelope/README.md`), Credit Reservation Lock Contract (`../credit-reservation-lock/README.md`)
9
+ **Sub-specs (authoritative):** `schema/payloads/lead.created-v1.json`, `schema/payloads/lead.stage.changed-v1.json`, `schema/payloads/lead.reached-v1.json`, `schema/payloads/lead.callback.scheduled-v1.json`, `schema/payloads/lead.attempt.exhausted-v1.json`, `schema/payloads/lead.qualified-v1.json`, `schema/payloads/lead.handoff.context.recorded-v1.json`
10
+ **Validations:** none yet
11
+
12
+ ## 1. Purpose and scope
13
+
14
+ This contract specifies the Lead lifecycle event family produced by Sales as the Lead progresses through the cadence from intake to handoff or attempt-exhausted close. Five `lead.*` event types are registered at v1.0.0: `lead.created`, `lead.stage.changed`, `lead.reached`, `lead.callback.scheduled`, `lead.attempt.exhausted`. Later minors add qualification and handoff-context events. Each event is named in `coordination/domains/sales.md` §"Primary interfaces" or in a follow-up coordination memo as a Sales-produced event; this contract pins the payload shapes, the producer responsibilities, the subscriber expectations, and the relationship between the broad state-transition event (`lead.stage.changed`) and the operational specialized events that may co-fire alongside it.
15
+
16
+ Out of scope: the Lead schema itself (Sales-internal, lives in the Sales repo's Prisma model), the cadence runtime's specific scheduling primitives (Sales-internal scoping), the operator-facing Lead inbox prose and presentation logic (Sales-internal), the funnel-reporting aggregation logic at the warehouse level (Platform-warehouse loader's downstream surface), and any specific Person identity-resolution behavior (lives in the Identity Contract and ADR-0003).
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
+ - **Lead.** A Sales work item per `coordination/domains/sales.md`. One Person may have many Leads across time; reactivation creates a new Lead rather than mutating the original. Identified by `lead_<UUID v7 canonical>` per ADR-0002.
25
+ - **Stage.** The Lead's position in the cadence. Stage values are Sales-internal at v1.0.0 (carried as flexible strings in the event payloads with examples in the schema descriptions), allowing Sales to extend stage taxonomy without contract version bumps. Future tightening to a closed enum is a v1.1 candidate per §8.
26
+ - **Cadence.** The four-attempt outbound call sequence per `coordination/domains/sales.md`. After four unsuccessful attempts the Lead transitions to `attempt_exhausted` (a terminal stage absent operator override).
27
+ - **Reach.** A successful call connection where a real conversation occurs. Voicemail, busy signal, no-answer, and disconnected number are not reaches; they are unsuccessful attempts that increment the cadence counter without firing `lead.reached`.
28
+ - **Callback.** A human-driven scheduled call-back action per `coordination/domains/sales.md` ("no auto-callback queueing without explicit operator action"). Scheduling a callback fires `lead.callback.scheduled` and may co-fire `lead.stage.changed` if the Lead's stage transitions in the same operation.
29
+ - **Reactivation.** A new Lead opens for a Person whose prior Lead reached a terminal stage. The new Lead carries `reactivated_from` pointing at the prior Lead's id per `2026-05-02-growth-sales-lead-reactivation-confirmed`. Reactivation does not mutate the prior Lead.
30
+
31
+ ## 4. Event types
32
+
33
+ ### 4.1 lead.created
34
+
35
+ Fires when a new Lead opens. Sales emits one of two cases: opening from `intake.captured` (Growth's form-submission event; the Lead opens before Person mint completes, so `person_id` is null) or opening from `intake.matched` (Platform's Person-attachment event; the Lead opens with `person_id` populated). The `originating_intake_event_id` carries the correlation back to whichever intake event opened the Lead.
36
+
37
+ A reactivation case also fires `lead.created` with `reactivated_from` populated. The reactivated Lead is a fresh Lead, not a state transition on the prior one; the lineage pointer is the only structural connection.
38
+
39
+ Producer SHALL: emit exactly once per Lead creation, inside the same Prisma transaction that creates the Lead row, per the producer-transactional-guarantee shape in ADR-0009.
40
+
41
+ Subscribers MAY: warehouse-load for funnel reporting; future Sales tooling MAY subscribe for operator notification. Growth DOES subscribe (active as of 2026-05-12) to populate `growth.lead_intake_correlation`, mapping `lead_id` → `form_submission_id` so downstream qualification events can resolve attribution without cross-DB lookups.
42
+
43
+ Payload schema: `schema/payloads/lead.created-v1.json`.
44
+
45
+ ### 4.2 lead.stage.changed
46
+
47
+ Fires on every Lead stage transition. The broad state-transition event; subscribers tracking the Lead's stage progression read this rather than synthesizing it from the specialized events.
48
+
49
+ The `transition_reason` enum identifies what drove the transition: `operator_action` (a human operator did something that moved the stage), `cadence_runtime` (Sales' cadence runtime drove the transition, e.g., incrementing attempt count or hitting the four-attempt cap), or `inbound_event` (a subscribed event drove the transition, e.g., `customer.handoff` arriving and closing the Lead, `intake.matched` arriving with `person_id`).
50
+
51
+ `from_stage` and `to_stage` are flexible strings at v1.0.0. Sales reserves the right to tighten these to a closed enum in v1.1 once the stage taxonomy stabilizes.
52
+
53
+ Producer SHALL: emit exactly once per stage transition, inside the same Prisma transaction that updates the Lead row.
54
+
55
+ Producer MAY: co-emit alongside a specialized event when both apply. For example, scheduling a callback that also moves the Lead's stage emits both `lead.callback.scheduled` and `lead.stage.changed`; both fire from the same transaction.
56
+
57
+ Subscribers MAY: warehouse-load; future Sales tooling MAY subscribe for stage-progression dashboards. Growth DOES subscribe (active as of 2026-05-12) to detect qualifying stage transitions and upsert `growth.qualified_intake`.
58
+
59
+ Payload schema: `schema/payloads/lead.stage.changed-v1.json`.
60
+
61
+ ### 4.3 lead.reached
62
+
63
+ Fires when an outbound call attempt connects to a real conversation with the Person (or with an authorized Guardian per the comms-routing rule in `coordination/domains/sales.md`). Voicemail, busy signal, no-answer, and disconnected number do not fire `lead.reached`; they increment the cadence counter without producing this event.
64
+
65
+ The `attempt_number` field carries which attempt out of the four-attempt cap connected. The `outcome` is a flexible string at v1.0.0 carrying the post-reach disposition (e.g., qualified, callback_requested, not_interested, wrong_number, other). Sales reserves the right to tighten outcome to a closed enum in v1.1 once the disposition taxonomy stabilizes.
66
+
67
+ Producer SHALL: emit exactly once per successful reach, inside the same Prisma transaction that records the reach in the Lead's reach history.
68
+
69
+ Subscribers MAY: warehouse-load. Growth DOES subscribe (active as of 2026-05-12) to detect `outcome == "qualified"` reaches and upsert `growth.qualified_intake.firstQualifiedAt` on first qualification, bumping `reQualifyCount` on subsequent ones.
70
+
71
+ Payload schema: `schema/payloads/lead.reached-v1.json`.
72
+
73
+ ### 4.4 lead.callback.scheduled
74
+
75
+ Fires when an operator explicitly schedules a callback for a Lead. Per `coordination/domains/sales.md`'s "no auto-callback queueing without explicit operator action" rule, this event always carries an operator identity in `scheduled_by`; system-driven scheduling is not a valid producer path.
76
+
77
+ Producer SHALL: emit exactly once per callback scheduling action, inside the same Prisma transaction that creates the callback record. If the callback scheduling also moves the Lead's stage, `lead.stage.changed` co-fires from the same transaction with `transition_reason: operator_action`.
78
+
79
+ Subscribers MAY: warehouse-load.
80
+
81
+ Payload schema: `schema/payloads/lead.callback.scheduled-v1.json`.
82
+
83
+ ### 4.5 lead.attempt.exhausted
84
+
85
+ Fires when the cadence's four-attempt cap is hit and the Lead transitions to the `attempt_exhausted` terminal stage. The event is the explicit-decision artifact required by the `coordination/domains/sales.md` quality bar ("no Lead sits in the cadence past the four-attempt cap without an explicit decision"); the cadence runtime fires this event as the transition lands.
86
+
87
+ `final_attempt_at` is the timestamp of the fourth unsuccessful attempt (the one that triggered the exhaustion); `exhausted_at` is when the transition fired (typically very close in time but distinct in principle).
88
+
89
+ Producer SHALL: emit exactly once per exhaustion event, inside the same Prisma transaction that transitions the Lead to `attempt_exhausted`. `lead.stage.changed` co-fires from the same transaction with `transition_reason: cadence_runtime`, `from_stage` reflecting whatever the prior stage was, `to_stage` set to `attempt_exhausted`.
90
+
91
+ Subscribers MAY: warehouse-load; future Growth tooling MAY subscribe to surface unreached-Lead patterns back to acquisition channels.
92
+
93
+ Payload schema: `schema/payloads/lead.attempt.exhausted-v1.json`.
94
+
95
+ ### 4.6 lead.qualified
96
+
97
+ Fires when a sales rep marks a Lead as qualified, meaning the Person has been reached, expressed intent, and is ready for scheduling handoff to Delivery. This event is the explicit qualification artifact; it is not a stage transition in the same sense as `lead.stage.changed`, though `lead.stage.changed` MAY co-fire in the same transaction if the Lead's stage advances as part of the qualification action.
98
+
99
+ The critical payload fields are `organization_id`, `market_id`, and `org_market_id`. These carry the confirmed (org, market) pair for the Person being qualified. Growth uses these to close the provisional → confirmed attribution loop: Growth's provisional market inference (from campaign metadata or zip lookup) is superseded by the confirmed values on this event. Sales MUST confirm the org and market with the rep at qualification time; these fields MUST NOT be inferred from campaign metadata on the Sales side.
100
+
101
+ `qualification_signal` is a closed enum at v1.0.0: `connected` (live call with expressed intent), `replied` (replied to outbound message with expressed intent), `inbound_request` (Person self-initiated contact). Sales SHALL tighten this enum only at v1.1 or later; new values require a contract minor bump and consumer notification.
102
+
103
+ `requalification_count` is the number of times this Lead has been qualified including this event. Sales SHOULD populate this once requalification tracking is implemented; Growth uses it to maintain `growth.qualified_intake.reQualifyCount`.
104
+
105
+ Producer SHALL: emit exactly once per qualification action, inside the same Prisma transaction that records the qualification on the Lead row. The same transaction MAY also emit `lead.stage.changed` if the qualification advances the stage.
106
+
107
+ Producer MUST: confirm `organization_id` and `market_id` with the sales rep at qualification time. These MUST NOT be inferred from campaign metadata or zip on the Sales side; they are the authoritative confirmed pair.
108
+
109
+ Subscribers: Growth MUST consume this event to update `growth.qualified_intake` with the confirmed (org, market) pair. Platform-warehouse SHOULD consume for attribution funnel analytics. Delivery MAY consume in the future to pre-warm scheduling context.
110
+
111
+ Payload schema: `schema/payloads/lead.qualified-v1.json`.
112
+
113
+ ### 4.7 lead.handoff.context.recorded
114
+
115
+ Fires when Sales records the curated relationship-start context Delivery needs after close orchestration. The event is Sales-owned context beside, not inside, Revenue's first-lock `customer.handoff` event. `customer.handoff` remains the authoritative signal that Delivery owns the daily-touch relationship; this event carries the curated Sales context Delivery can attach to customer tracking.
116
+
117
+ Sales emits this event after close orchestration has a durable success to correlate against. For a single-lesson close, the payload SHOULD carry `first_lesson_id` and `credit_reservation_id`. For a composite-offer close, the payload SHOULD carry `originating_offer_id`; per-lesson operational notes remain on the `sales-scheduling-surface` hold-create or atomic-multi-create item `notes` fields.
118
+
119
+ Producer SHALL: emit inside the same Sales transaction as the Sales-owned handoff-context snapshot write or audit write that records the curated context. The event cannot share a transaction with Revenue or Delivery writes, but the Sales write and dispatcher publish SHALL follow ADR-0009's producer-transactional-guarantee shape.
120
+
121
+ Producer SHALL NOT: emit raw Sales note dumps, raw SMS bodies, call transcripts, phone numbers, email addresses, Guardian relationship facts, DOB, payment details, opt-out truth, or internal audit noise in this payload. The curated text fields are relationship-start annotations only. Consent, opt-out state, contact addresses, and Guardian-aware routing remain in Platform or comms-owned surfaces. A communication preference on this event does not authorize direct messaging to a minor; consumers still route outbound contact through Platform's Guardian-aware comms-routing endpoint when required.
122
+
123
+ Subscribers: Delivery consumes this event to upsert pending Sales handoff context and attach it to customer tracking when `customer.handoff` has arrived or later arrives. Platform-warehouse ingests the event for handoff-timeliness, context-coverage, and repeat-question analytics.
124
+
125
+ Payload schema: `schema/payloads/lead.handoff.context.recorded-v1.json`.
126
+
127
+ ## 5. Producer responsibilities
128
+
129
+ Sales SHALL: register every emit in this contract's event family against the dispatcher SDK's startup-time registry validation per Phase 0's CI gate. Unregistered emits SHALL fail validation at emit time per the dispatcher SDK's runtime check.
130
+
131
+ Sales SHALL: include the canonical envelope fields per the event-envelope contract (`event_id`, `event_type`, `occurred_at`, `tenant_id`, `producer`, `schema_version`) on every emit. The payload schemas in this contract specify only the event-specific data; envelope-level fields are not duplicated in payloads.
132
+
133
+ Sales SHALL: emit each event inside the same Prisma transaction that performs the corresponding state mutation (per ADR-0009's producer-transactional-guarantee shape). The dispatcher SDK's `dispatcher.publish` integrates with Prisma transactions per the SDK's documented integration pattern.
134
+
135
+ Sales SHALL: handle redelivery from the dispatcher SDK gracefully. The SDK provides per-(consumer, event_id) dedup at the consumer side; producers do not need to implement dedup themselves but MUST treat `dispatcher.publish` as at-least-once and design state-transition idempotency so that retried emits do not corrupt Lead state. (In practice, the same-Prisma-transaction pattern means a failed emit rolls back the state mutation, and retry re-runs both; the idempotency property is preserved by the transactional shape, not by application-level retry logic.)
136
+
137
+ Sales SHALL NOT: emit an event without the corresponding state mutation having committed. Speculative emits, dry-run emits, or "we might do this" emits violate the contract and the producer-transactional-guarantee shape.
138
+
139
+ Sales SHOULD: include the producing operator's identity in events where the action is operator-driven (`lead.callback.scheduled` REQUIRED; `lead.stage.changed` and `lead.reached` OPTIONAL but recommended). The audit trail benefits from operator attribution.
140
+
141
+ ## 6. Consumer responsibilities
142
+
143
+ Per the event-envelope contract §9 generally, consumers of this event family SHALL: dedup at the SDK-provided per-(consumer, event_id) layer; tolerate at-least-once delivery; tolerate event-type and schema-version values they do not recognize per §9.5 of the event-envelope contract; treat Lead state derivation as eventually consistent (the Lead's authoritative state lives in the Sales database; the events are the change-log surface).
144
+
145
+ Consumers SHOULD NOT: treat `lead.stage.changed` and the specialized events (`lead.reached`, `lead.callback.scheduled`, `lead.attempt.exhausted`) as exclusive alternatives. They co-fire when both apply; consumers tracking stage progression should subscribe to `lead.stage.changed` and accept that specialized events may also be visible at the same logical moment.
146
+
147
+ Consumers MUST NOT: synthesize Lead state from the event stream as if it were a CRDT or event-sourced projection without coordinating with Sales. The Sales database is the source of truth; the event stream is the change-log surface.
148
+
149
+ ## 7. Idempotency and retry
150
+
151
+ Producer-side idempotency: per §5, the same-Prisma-transaction pattern provides idempotency by construction. A `dispatcher.publish` failure rolls back the state mutation; retry re-attempts the transaction.
152
+
153
+ Consumer-side idempotency: the SDK provides per-(consumer, event_id) dedup. Consumer handlers SHOULD be additionally idempotent at the application level (designed so that re-processing the same event yields the same end state) for resilience against the SDK's at-least-once guarantee and against handler-level retries that occur before dedup tracking commits.
154
+
155
+ Replay: the Postgres-queue transport's SELECT-from-cursor primitive supports replay per ADR-0009. Sales' lead.* events are subject to whatever replay policy the dispatcher SDK exposes; specifically, replays during projection-rebuild are expected and consumer handlers SHOULD be designed for them.
156
+
157
+ ## 8. Versioning
158
+
159
+ Per CONVENTIONS.md §"Contract conventions" and the event-envelope contract §8: additive changes (new optional fields, new enum values where the field is documented as flexible string) are minor versions on this contract (v1.x patches). Breaking changes (removed fields, tightened types, narrowed enums where the prior shape was load-bearing for a known consumer) are major versions (v2.x).
160
+
161
+ Specifically additive at minor cost: tightening `from_stage` and `to_stage` on `lead.stage.changed` from flexible string to a closed enum, when the stage taxonomy stabilizes; tightening `outcome` on `lead.reached` from flexible string to a closed enum, when the disposition taxonomy stabilizes; adding `tenant_id` validation on the consumer side beyond what the envelope already provides; adding `notes` or other optional fields where consumers benefit from richer context; adding optional structured handoff-context categories to `lead.handoff.context.recorded` if Sales and Delivery agree that a typed context array should replace or supplement the flat v1 fields.
162
+
163
+ A consumer running against v1.0.x continues to validate against v1.1 and v1.2 payloads under the additive-friendly framing; the registry's per-event schema_version mechanism allows multiple versions to be active simultaneously per `event-envelope` §8.1.
164
+
165
+ ## 9. Action items
166
+
167
+ Each action item is tracked against its owning domain's commitments rather than carried in this contract README's frontmatter.
168
+
169
+ 1. [ ] Sales: register the five event types in `coordination/contracts/event-types-registry.json` with v1 payload schemas pointing at the sub-specs in this contract's `schema/payloads/` directory. (Lands in the same filing pass as this contract README per `2026-05-14-sales-lead-lifecycle-contract-v1-and-event-types-registered`.)
170
+
171
+ 2. [ ] Sales: implement the producer-side emit wiring at the Lead state-transition call sites per Sales' Q2 directive scoping commitment (`2026-05-02-sales-q2-airtable-sunset-scoping`, dated 2026-06-22 against Phase 2 ship). The wiring lands inside the same Prisma transactions as the corresponding state mutations per the producer-transactional-guarantee shape.
172
+
173
+ 3. [ ] Sales (forward-looking, v1.1 candidate): consider tightening `from_stage` and `to_stage` to a closed enum when the cadence's stage taxonomy stabilizes in production. Tightening `outcome` on `lead.reached` similarly. Both are additive minor bumps, not breaking changes.
174
+
175
+ ## 10. Trigger to revisit
176
+
177
+ Revisit this contract when any of the following fires: a non-Sales domain proposes consuming the lead.* event family for a load-bearing use case beyond warehouse analytics (NOTE: this trigger fired 2026-05-12, Growth now subscribes to `lead.created`, `lead.reached`, and `lead.stage.changed` for qualification attribution; contract updated accordingly at v1.0.1); a third Lead-creation case emerges beyond the two named in §4.1 (a manual-capture path, an operator-initiated Lead creation that does not flow from intake.*, etc.); the cadence's four-attempt cap changes (which would shift `lead.attempt.exhausted`'s semantics); or the producer-transactional-guarantee pattern in ADR-0009 is amended in a way that changes the §5 emit-inside-transaction obligation.
178
+
179
+ ## Change log
180
+
181
+ **v1.2.0** (2026-05-19). Added `lead.handoff.context.recorded` event type (§4.7). New event carries curated Sales-owned handoff context for Delivery customer tracking without expanding Revenue's `customer.handoff` payload. Delivery added as consumer for this event only. Payload schema at `schema/payloads/lead.handoff.context.recorded-v1.json`. Platform memo `2026-05-19-platform-sales-delivery-handoff-context-proposal` proposed the boundary; Sales accepted producer ownership in `2026-05-19-sales-handoff-context-event-accepted`; Delivery accepted consumer ownership in `2026-05-19-delivery-handoff-context-consumer-ack`.
182
+
183
+ **v1.1.0** (2026-05-17). Added `lead.qualified` event type (§4.6). New event carries confirmed `organization_id`, `market_id`, and `org_market_id` on qualification, required by ADR-0019 to close the provisional → confirmed attribution loop between Sales and Growth. Payload schema at `schema/payloads/lead.qualified-v1.json`. Growth added as MUST consumer of this event. Platform memo `2026-05-17-platform-lead-qualified-contract-v1` filed notifying Sales (producer) and Growth (consumer) of the new event type and the confirmed-pair requirement.
184
+
185
+ **v1.0.1** (2026-05-12). Growth added as active consumer of `lead.created`, `lead.reached`, and `lead.stage.changed`. Growth's inbox handler (Growth repo `src/app/api/dispatcher/inbox/route.ts`, commit a1a627e) populates `growth.lead_intake_correlation` on `lead.created` and upserts `growth.qualified_intake` on qualifying `lead.reached` / `lead.stage.changed` events. §4.1, §4.2, §4.3 subscriber notes updated; frontmatter Consumers line updated; §10 trigger noted.
186
+
187
+ **v1.0.0** (2026-05-14). Initial contract. Five event types registered with v1 payload schemas. Producer-side emit wiring tracked against Sales' Q2 directive scoping commitment for 2026-06-22 ship; consumer-side use is platform-warehouse only at v1.0.0 with the forward-compatibility reservation per §6.