@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.
- package/README.md +4 -1
- package/contracts/README.md +30 -0
- package/contracts/coach-availability/README.md +355 -0
- package/contracts/coach-availability/README.v2.md +263 -0
- package/contracts/coach-availability/schema/payloads/coach.assigned-v1.json +91 -0
- package/contracts/coach-availability/validation/delivery-assignment.md +89 -0
- package/contracts/coach-availability/validation/sales-offer-construction.md +76 -0
- package/contracts/coaching-confirmation/README.md +96 -0
- package/contracts/coaching-confirmation/schema/payloads/coaching.lesson.confirmation_decided-v1.json +142 -0
- package/contracts/coaching-confirmation/schema/payloads/lead.coach.confirmation.requested-v1.json +124 -0
- package/contracts/credit-reservation-funding-state/README.md +147 -0
- package/contracts/credit-reservation-lock/README.md +433 -0
- package/contracts/credit-reservation-lock/delivery-state-vocabulary.md +73 -0
- package/contracts/credit-reservation-lock/reservation-create-api.md +191 -0
- package/contracts/credit-reservation-lock/reservation-release-api.md +171 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.locked-v1.json +1 -1
- package/contracts/credit-reservation-lock/schema/payloads/credit.reserved-v1.json +2 -3
- package/contracts/credit-reservation-lock/validation/lesson-lifecycle.md +318 -0
- package/contracts/event-envelope/README.md +205 -0
- package/contracts/event-envelope/schema/envelope-v1.json +2 -2
- package/contracts/event-envelope/validation/event-vocabulary.md +270 -0
- package/contracts/event-types-registry.json +337 -24
- package/contracts/external-actions/README.md +338 -0
- package/contracts/finance-mart/README.md +238 -0
- package/contracts/finance-mart/cac-payback.md +113 -0
- package/contracts/finance-mart/cash-position.md +98 -0
- package/contracts/finance-mart/cohort-summary.md +72 -0
- package/contracts/finance-mart/customer-journey-audit.md +92 -0
- package/contracts/finance-mart/ltv.md +92 -0
- package/contracts/finance-mart/margin.md +87 -0
- package/contracts/finance-mart/pnl.md +83 -0
- package/contracts/finance-mart/reconciliation.md +98 -0
- package/contracts/finance-mart/revenue-recognition-rollup.md +87 -0
- package/contracts/finance-mart/unit-economics.md +94 -0
- package/contracts/growth-warehouse-api/README.md +162 -0
- package/contracts/identity/README.md +184 -0
- package/contracts/identity/person-canonical-fields.md +120 -0
- package/contracts/identity/person-externals.md +267 -0
- package/contracts/identity/person-resolution-semantics.md +144 -0
- package/contracts/identity/person-role-taxonomy.md +120 -0
- package/contracts/identity/schema/payloads/intake.captured-v2.json +60 -0
- package/contracts/identity/schema/payloads/intake.matched-v2.json +123 -0
- package/contracts/identity/schema/payloads/person.updated-v1.json +8 -2
- package/contracts/identity/schema/payloads/role.assigned-v1.json +50 -0
- package/contracts/identity/schema/payloads/role.retired-v1.json +54 -0
- package/contracts/identity/validation/client-table.md +131 -0
- package/contracts/identity/validation/coach-handling.md +100 -0
- package/contracts/identity/validation/person-graph.md +140 -0
- package/contracts/lead-lifecycle/README.md +187 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.handoff.context.recorded-v1.json +108 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.qualified-v1.json +54 -0
- package/contracts/lead-lifecycle/schema/payloads/sales.lead.onboarded-v1.json +120 -0
- package/contracts/lesson-lifecycle/README.md +118 -0
- package/contracts/lesson-lifecycle/schema/payloads/lesson.cancelled-v1.json +30 -0
- package/contracts/lesson-lifecycle/schema/payloads/lesson.delivered-v1.json +29 -0
- package/contracts/lesson-lifecycle/schema/payloads/lesson.rescheduled-v1.json +157 -0
- package/contracts/lesson-lifecycle/schema/payloads/lesson.scheduled-v1.json +107 -0
- package/contracts/lesson-lifecycle/validation/README.md +5 -0
- package/contracts/mart-consumer-api/README.md +108 -0
- package/contracts/order-flow/README.md +106 -0
- package/contracts/order-flow/schema/payloads/order.created-v1.json +58 -0
- package/contracts/order-flow/schema/payloads/order.updated-v1.json +63 -0
- package/contracts/payment-flow/README.md +157 -0
- package/contracts/platform-comms/README.md +84 -0
- package/contracts/platform-comms/schema/payloads/platform.comms.inbound-v1.json +83 -0
- package/contracts/platform-geography-snapshot/README.md +205 -0
- package/contracts/platform-geography-snapshot/schema/payloads/geography.market.archived-v1.json +36 -0
- package/contracts/platform-geography-snapshot/schema/payloads/geography.market.upserted-v1.json +59 -0
- package/contracts/platform-geography-snapshot/schema/payloads/geography.service-area.archived-v1.json +36 -0
- package/contracts/platform-geography-snapshot/schema/payloads/geography.service-area.upserted-v1.json +65 -0
- package/contracts/portfolio-mart/README.md +133 -0
- package/contracts/portfolio-mart/cohort-funnel-panel.md +76 -0
- package/contracts/portfolio-mart/cross-discipline-performance.md +91 -0
- package/contracts/portfolio-mart/cross-market-performance.md +84 -0
- package/contracts/portfolio-mart/health-composites.md +88 -0
- package/contracts/portfolio-mart/org-topology.md +70 -0
- package/contracts/portfolio-mart/portfolio-level-funnel-health.md +92 -0
- package/contracts/portfolio-mart/validation/consumer-isolation.md +33 -0
- package/contracts/portfolio-mart/validation/decoupling-discipline.md +34 -0
- package/contracts/refund-flow/README.md +136 -0
- package/contracts/refund-flow/sales-callable-refund-initiation-api.md +218 -0
- package/contracts/sales-scheduling-surface/README.md +532 -0
- package/contracts/sales-scheduling-surface/schema/payloads/delivery.lesson-hold.cancelled-v1.json +42 -0
- package/contracts/sales-scheduling-surface/schema/payloads/delivery.lesson-hold.created-v1.json +115 -0
- package/contracts/sales-scheduling-surface/validation/composite-hold-create.md +97 -0
- package/contracts/sales-scheduling-surface/validation/lock-state-machine-conformance.md +84 -0
- package/contracts/sales-scheduling-surface/validation/sales-close-orchestration.md +77 -0
- package/contracts/warehouse-silver/README.md +118 -0
- package/contracts/warehouse-silver/coaching-utilization-columns.md +105 -0
- package/dist/events.d.ts +63 -0
- package/dist/events.js +293 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -1
- package/dist/postgres-consumer.js +2 -1
- package/dist/validator.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Person resolution semantics
|
|
2
|
+
|
|
3
|
+
**Status:** v1.0.0
|
|
4
|
+
**Date:** 2026-04-24 (initial draft same day as v0.1; amended same day for Lifecycles dissolution; promoted to v1.0.0 same day after three validations passed)
|
|
5
|
+
**Owner:** Platform (identity service)
|
|
6
|
+
**Feeds:** Identity contract v1.0.0 (see `README.md`)
|
|
7
|
+
**Related:** ADR-0002 (Person ID shape), ADR-0003 (Person canonical with role records per domain), person-canonical-fields.md
|
|
8
|
+
|
|
9
|
+
## Scope
|
|
10
|
+
|
|
11
|
+
How Platform's identity service decides, for any incoming signal, whether the signal refers to an existing Person or warrants minting a new one. How two Persons merge when they are discovered to be the same human. How consumers resolve references after a merge. This is the section that makes or breaks the identity contract, because wrong rules here either silently merge different humans into the same record (invisible until something breaks) or fragment a single human across duplicates (expensive to clean up later).
|
|
12
|
+
|
|
13
|
+
Out of scope: business logic for the Lead cadence (lives in Sales), communications routing through Guardian (a Platform identity service endpoint that consumer domains call), role-record field definitions (in each domain's schema).
|
|
14
|
+
|
|
15
|
+
## Normalization
|
|
16
|
+
|
|
17
|
+
Every match decision runs against normalized forms, not raw input. Normalization rules:
|
|
18
|
+
|
|
19
|
+
- **Phone:** E.164 format. Leading `+` and country code required; non-digit characters stripped. `(555) 123-4567` and `555-123-4567` and `5551234567` all normalize to `+15551234567` assuming US default. Intake capture layer is responsible for country-code resolution before the event is published.
|
|
20
|
+
- **Email:** lowercased and whitespace-trimmed. Subaddressing is preserved: `foo+tag@bar.com` is distinct from `foo@bar.com` for match purposes. We do not treat `foo+anything@bar.com` as aliases of `foo@bar.com` because users genuinely distinguish them.
|
|
21
|
+
- **Name:** case-preserved for display. Match uses a normalized form: lowercase, whitespace collapsed to single space, diacritics stripped, common titles and suffixes (Mr, Mrs, Dr, Jr, Sr, I, II, III) removed. Match is exact-on-normalized-form in v0.1; fuzzy scoring is out of scope for v1 and can be added additively to Tier 2 later.
|
|
22
|
+
|
|
23
|
+
## Minting rule
|
|
24
|
+
|
|
25
|
+
A canonical Person is minted when a signal arrives that does not auto-match an existing Person AND the signal includes a phone number. Phone is required; email alone is not sufficient. Email-only signals (newsletter subscribers, content-marketing sign-ups) do not mint a canonical Person; they live in a Growth-owned subscriber store until the contact provides a phone and enters the intake funnel.
|
|
26
|
+
|
|
27
|
+
## Match decision tiers
|
|
28
|
+
|
|
29
|
+
Every incoming signal runs through a three-tier decision.
|
|
30
|
+
|
|
31
|
+
### Tier 1: Auto-match
|
|
32
|
+
|
|
33
|
+
The signal attaches to an existing Person without human review. Auto-match triggers:
|
|
34
|
+
|
|
35
|
+
- Exact match on phone AND compatible name (either an exact match on normalized family name, an exact match on normalized given name, or at least one side has both names null). Null-on-both-sides counts as compatible for this purpose.
|
|
36
|
+
- Exact match on phone AND email simultaneously, regardless of name.
|
|
37
|
+
- Exact match on an external provider ID (Square customer ID, etc.) that is already linked to a canonical Person via a Client Externals row.
|
|
38
|
+
|
|
39
|
+
### Tier 2: Manual review
|
|
40
|
+
|
|
41
|
+
The signal goes to a review queue surfaced by Platform's identity service. The queue is worked by operators in the consumer domain that surfaced the signal: Sales operators handle reviews triggered by intake or lead activity, Delivery operators handle reviews triggered by customer-side activity (manual entry, external-provider sync). A human decides whether to mint new, attach to existing, or trigger a merge. Manual review triggers:
|
|
42
|
+
|
|
43
|
+
- Phone match but incompatible name (possible sibling sharing a household phone, possible phone reuse over time, or possible typo requiring merge).
|
|
44
|
+
- Email match without phone match.
|
|
45
|
+
- Name match (exact normalized) without phone or email match.
|
|
46
|
+
- External provider ID match that does not resolve to a canonical Person (data-integrity edge case).
|
|
47
|
+
|
|
48
|
+
### Tier 3: Mint new
|
|
49
|
+
|
|
50
|
+
No signals match. A fresh Person is minted per the minting rule (phone required, ADR-0002 ID shape, status `active`, name fields nullable).
|
|
51
|
+
|
|
52
|
+
## Merge workflow
|
|
53
|
+
|
|
54
|
+
When two Person records are confirmed to be the same human, one becomes canonical and the other becomes an alias. Confirmation comes from manual review, from an ops-initiated correction, or from a Tier 1 auto-match that surfaces a pre-existing duplicate.
|
|
55
|
+
|
|
56
|
+
### Canonical selection
|
|
57
|
+
|
|
58
|
+
The Person with the oldest `created_at` wins. This is mechanical and non-negotiable; no tiebreakers required because `created_at` has sub-second resolution. Oldest wins because tenure, cohort, and funnel analytics all depend on the earliest capture date for the human, and promoting fresher data into the older record preserves that earliest date without losing information.
|
|
59
|
+
|
|
60
|
+
### Field promotion
|
|
61
|
+
|
|
62
|
+
Non-null fields on the canonical (older) record are preserved as-is. Null fields on the canonical are populated from the non-canonical record if that record has a value. Example: older Person has phone but null given_name; newer Person has phone plus given_name "Jamie"; after merge, the canonical record has phone plus given_name "Jamie". If both records have non-null values for the same field and they differ, the canonical's value wins and the merge log records the discarded value for audit.
|
|
63
|
+
|
|
64
|
+
### Role record reconciliation
|
|
65
|
+
|
|
66
|
+
- **Lead records (Sales):** union. Both Persons' Lead records attach to the canonical. Multiple Leads per Person is expected per ADR-0003 and preserves multi-touch acquisition history for Growth reporting.
|
|
67
|
+
- **Participant records (Delivery):** per-Organization uniqueness applies. If both Persons hold a Participant in the same Organization, the Participant with the oldest `created_at` wins; the other archives with an `alias_of` pointer to the surviving Participant and emits `participant.merged`. If the Persons' Participants are in different Organizations, union.
|
|
68
|
+
- **Coach records (Delivery):** same rule as Participant.
|
|
69
|
+
- **Guardian relationships (Platform identity service):** union. If A was guardian of X, and A merges into B, B is now guardian of X. If A was the ward of Y, and A merges into B, B is the ward of Y.
|
|
70
|
+
- **Client Externals rows (external provider links):** union, keyed by provider. If both Persons have a Client Externals row pointing to Square (same provider), the case is rare and requires manual resolution because Square itself does not know the two external IDs refer to one human. Flag, do not auto-resolve.
|
|
71
|
+
|
|
72
|
+
### ID and status changes on merge
|
|
73
|
+
|
|
74
|
+
- Non-canonical Person: `status` transitions to `merged`, `alias_of` populated with the canonical `person_id`.
|
|
75
|
+
- Canonical Person: `updated_at` bumped; any promoted fields applied.
|
|
76
|
+
- Events: `person.merged` emitted carrying `old_person_id`, `canonical_person_id`, `reason_code` (one of: `auto-phone-plus-email`, `auto-external-id`, `manual-operator-confirmed`, `ops-correction`), and the full merge context (promoted fields, archived role records).
|
|
77
|
+
|
|
78
|
+
### Merge is not reversible
|
|
79
|
+
|
|
80
|
+
If a merge is wrong, the remedy is to mint a new Person and manually reattach role records. We do not carry bidirectional merge state, because supporting un-merge would force every consumer to handle a reverse of every merge event, which is complexity without a clear benefit at Sguild's scale. Manual cleanup is expensive per incident but cheap overall because the error rate is low when Tier 2 review is strict.
|
|
81
|
+
|
|
82
|
+
### Chained merges
|
|
83
|
+
|
|
84
|
+
When a canonical Person is later merged into a third Person, the earliest Person's `alias_of` is updated to point at the terminal canonical. Consumers resolving a lookup on any merged ID reach the current canonical in a single hop, not through a chain. The `person.merged` event for the second merge includes the updated aliases in its payload so consumers can reconcile all affected references in one pass.
|
|
85
|
+
|
|
86
|
+
## Consumer resolution protocol
|
|
87
|
+
|
|
88
|
+
When a consumer looks up a Person by `person_id`:
|
|
89
|
+
|
|
90
|
+
1. If the record has `status='merged'`, the Platform identity service API returns the canonical record (at `alias_of`) with the queried ID surfaced as an alias in the response.
|
|
91
|
+
2. The consumer is expected to update its stored reference to the canonical `person_id` going forward. Leaving stale references in place is permitted in the short term (the API continues to resolve them), but accumulation of stale references across the four domains is a drift signal that surfaces in the contract-currency metric.
|
|
92
|
+
3. `person.merged` events are the proactive reconciliation path: consumers subscribing to them can update references without waiting for a lookup to force it.
|
|
93
|
+
|
|
94
|
+
## Specific cases
|
|
95
|
+
|
|
96
|
+
### Lead converts to active customer
|
|
97
|
+
|
|
98
|
+
Not a resolution problem. A Lead's pipeline state transitions inside Sales as the human progresses (qualified, scheduled, paid). The Sales-to-Delivery handoff fires when the first credit reservation lock confirms (`customer.handoff` event); Delivery begins owning the relationship at that moment. The Person record itself does not change; it gains an Delivery-internal customer-tracking record on handoff. No merge or identity work is required.
|
|
99
|
+
|
|
100
|
+
### Coach is also a Customer
|
|
101
|
+
|
|
102
|
+
Same Person, multiple role records simultaneously: Coach (Delivery, scoped per Org), possibly Participant (Delivery, scoped per Org) if the coach also takes lessons, Guardian relationships (Platform) if they have children in the program, plus implicit customer status by virtue of having Revenue records (Credit Account, Order, Invoice) keyed by `person_id`. ADR-0003 specifies this is supported and expected. No merge is needed; resolution ensures all signals pointing at this human route to the same `person_id`.
|
|
103
|
+
|
|
104
|
+
### Two Leads turn out to be the same human
|
|
105
|
+
|
|
106
|
+
Discovery path: two intakes months apart, same phone, incompatible names (typo, form filled differently by the same person, or partner filling out the form on their behalf). Tier 2 review flags the conflict; operator confirms same human and triggers merge. Both Lead records attach to the canonical Person, preserving multi-touch acquisition history for Growth's cost-per-conversion reporting.
|
|
107
|
+
|
|
108
|
+
## Edge cases
|
|
109
|
+
|
|
110
|
+
### Household phone shared across family members
|
|
111
|
+
|
|
112
|
+
Common at Sguild. First intake mints Person A with the household phone. A second intake with the same phone but a different name drops into Tier 2 manual review. The operator either mints a new Person (sibling, with a Guardian relationship to the parent Person) or attaches to the existing Person (same human, different name variant). This friction is intentional because silent auto-merge across family members is the worst failure mode in this domain.
|
|
113
|
+
|
|
114
|
+
### Phone number reuse over time
|
|
115
|
+
|
|
116
|
+
Carrier reassigns a phone from Person A to Person B months or years later. Sguild does not attempt to auto-detect this. The case surfaces when B's intake hits Tier 2 (phone match, incompatible name). Operator recognizes the situation, mints a new Person for B; the old phone value on A is cleared or marked historical via a manual identity-service correction.
|
|
117
|
+
|
|
118
|
+
### Anonymous intake with phone only
|
|
119
|
+
|
|
120
|
+
Phone is required for mint; name is nullable. A Person exists with display_name null until intake cadence or subsequent contact fills in the name. Rendering consumers fall back to a placeholder ("Unknown (+15551234567)" or similar) until name is known.
|
|
121
|
+
|
|
122
|
+
### Double-submission within a short window
|
|
123
|
+
|
|
124
|
+
Two intake submissions from the same phone in a few minutes (double-click, two browser tabs, form retry after network error). This is deduped at the Growth form layer by the tuple (phone, form_id, submission window). Platform's identity service sees a single `intake.captured` event and does not need a resolution decision.
|
|
125
|
+
|
|
126
|
+
### External-provider ID match without canonical link
|
|
127
|
+
|
|
128
|
+
Square customer ID appears in a signal and matches a Client Externals row that is NOT linked to a canonical Person (data-integrity lag or prior bug). Tier 2 review. Operator establishes or corrects the link, then proceeds with the rest of the resolution.
|
|
129
|
+
|
|
130
|
+
## Versioning
|
|
131
|
+
|
|
132
|
+
Resolution rules are Platform-internal and can evolve freely; they are not part of the identity contract surface. What IS in the contract and follows its versioning discipline:
|
|
133
|
+
|
|
134
|
+
- `alias_of` field semantics (additive-only within v1)
|
|
135
|
+
- `person.merged` event shape and reason codes (additive for new reason codes; removal requires v2)
|
|
136
|
+
- The consumer resolution protocol (behavior of the Platform identity service API when `status='merged'`)
|
|
137
|
+
|
|
138
|
+
Changes to Tier 1 / Tier 2 / Tier 3 thresholds, addition of fuzzy matching, changes to normalization rules, or changes to the field-promotion logic are internal improvements and do not trigger contract version bumps.
|
|
139
|
+
|
|
140
|
+
## Out of scope for v0.1
|
|
141
|
+
|
|
142
|
+
- **Fuzzy name matching with a confidence score.** V1 is exact-on-normalized-form. Fuzzy matching can be added to Tier 2 in v1.x additively without changing the contract.
|
|
143
|
+
- **Cross-tenant resolution.** Sguild is single-tenant today per ADR-0001. When tenant two arrives, resolution scope will need to be explicitly tenant-bounded; the implementation today does not need to handle the case but should accept `tenant_id` on every resolution call for forward-compatibility.
|
|
144
|
+
- **Background merge job.** Periodic batch detection of duplicates that slipped through intake-time Tier 1 (e.g., a Person whose email was added later and now matches an older Person's email). Deferred to v1.x. Manual operator-initiated merge is the only path in v0.1.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Person role taxonomy
|
|
2
|
+
|
|
3
|
+
**Status:** v1.0.0
|
|
4
|
+
**Date:** 2026-05-12
|
|
5
|
+
**Owner:** Platform (identity service)
|
|
6
|
+
**Feeds:** Identity contract v1.0.0 (see `README.md`)
|
|
7
|
+
**Related:** ADR-0003 (Person as canonical entity with role records per domain), `person-canonical-fields.md`
|
|
8
|
+
|
|
9
|
+
## Scope
|
|
10
|
+
|
|
11
|
+
This document enumerates the **roles** a Person can hold across Sguild's domains and defines the closed taxonomy that the `role.assigned` and `role.retired` event types carry. It is the source-of-truth list that Platform's `person_role` projection consumes; producers SHALL emit only the role values listed here.
|
|
12
|
+
|
|
13
|
+
A Person's role is the relationship that Person holds with a specific domain entity. The same human, represented by one canonical Person, can hold many roles simultaneously (a Coach who is also a Customer who used to be a Lead). Each role's lifecycle is owned by the domain whose entities back that role; Platform aggregates the projection but does not author the underlying state transitions.
|
|
14
|
+
|
|
15
|
+
This document answers "what kinds of relationships does the org track between Persons and domain entities, and who owns each one." It does not answer "what stage is this Lead in" or "what does this Coach charge per hour" — those are domain-internal questions on the role-bearing entity.
|
|
16
|
+
|
|
17
|
+
## Role enum
|
|
18
|
+
|
|
19
|
+
Closed set. Adding a new role is a v1.x minor change per envelope contract §8.1; producers SHALL NOT emit values outside this set without a contract bump.
|
|
20
|
+
|
|
21
|
+
### lead
|
|
22
|
+
|
|
23
|
+
- **Source domain:** sales
|
|
24
|
+
- **Source entity:** `sales.Lead`
|
|
25
|
+
- **Assigned at:** Lead row creation (`Lead.createdAt`)
|
|
26
|
+
- **Retired at:** Lead reaches a terminal stage (`lost`, `won`, or `archived` per Sales' lifecycle)
|
|
27
|
+
- **Meaning:** This Person is currently being nurtured by Sales toward conversion. The role exists for the lifetime of the Lead entity in non-terminal stages.
|
|
28
|
+
- **Multiplicity:** A Person can hold the `lead` role multiple times across history (each historical Lead row is its own retire/assign cycle), but only one `lead` row in the projection is active at a time per Lead entity. ADR-0003 acknowledges one Person may have many Leads over time; the projection holds them all keyed by `source_entity_id`.
|
|
29
|
+
|
|
30
|
+
### customer
|
|
31
|
+
|
|
32
|
+
- **Source domain:** revenue
|
|
33
|
+
- **Source entity:** `revenue.credit_account` (or the canonical Customer record once Revenue's Customer migration lands)
|
|
34
|
+
- **Assigned at:** First successful `credit.purchased` event for this Person
|
|
35
|
+
- **Retired at:** `customer` role is sticky — once a Person has paid for credits, they are a Customer in perpetuity. Retirement reserved for compliance scenarios (right-to-be-forgotten requests, fraud reversals) and writes a `role.retired` event with `reason = 'compliance_purge'`.
|
|
36
|
+
- **Meaning:** This Person has a paying economic relationship with Sguild. Used by Growth (to suppress acquisition-target cohorts), Sales (to know not to treat as a fresh Lead), and analytics (cohort definitions).
|
|
37
|
+
- **Multiplicity:** One per Person. Re-purchase by an already-Customer Person does not emit a new `role.assigned`.
|
|
38
|
+
|
|
39
|
+
### student
|
|
40
|
+
|
|
41
|
+
- **Source domain:** delivery
|
|
42
|
+
- **Source entity:** `delivery.participant`
|
|
43
|
+
- **Assigned at:** First `participant` row created linking this Person to a coaching engagement
|
|
44
|
+
- **Retired at:** `participant` row deactivates (lifecycle ends, customer churns out of active scheduling)
|
|
45
|
+
- **Meaning:** This Person is actively receiving lessons. Distinct from Customer (who paid) and Lead (who is being courted); Student is the operational engagement state.
|
|
46
|
+
- **Multiplicity:** One per Person at a time. A Person who churns and returns gets a new `participant` row and a fresh `role.assigned` cycle.
|
|
47
|
+
- **Source map (2026-05-11 Delivery clarification):** Participant is the Student source per `2026-05-11-delivery-person-role-filter-source-map`.
|
|
48
|
+
|
|
49
|
+
### coach
|
|
50
|
+
|
|
51
|
+
- **Source domain:** coaching
|
|
52
|
+
- **Source entity:** `coaching.coach`
|
|
53
|
+
- **Assigned at:** Coach row transitions to `status = 'active'` (from `candidate` or `onboarding`)
|
|
54
|
+
- **Retired at:** Coach row transitions to `status = 'retired'` or `status = 'inactive'`
|
|
55
|
+
- **Meaning:** This Person is currently delivering coaching services. Coach lifecycle is Coaching-owned per ADR-0008; Platform mirrors the assignment state via the role event.
|
|
56
|
+
- **Multiplicity:** One per Person. Coaches can re-activate (retire → re-onboard) which produces a new assign/retire cycle.
|
|
57
|
+
|
|
58
|
+
### guardian
|
|
59
|
+
|
|
60
|
+
- **Source domain:** platform (self-owned; no cross-domain emit needed)
|
|
61
|
+
- **Source entity:** `platform.guardian`
|
|
62
|
+
- **Assigned at:** `establishGuardianRelationship` is called
|
|
63
|
+
- **Retired at:** `terminateGuardianRelationship` is called
|
|
64
|
+
- **Meaning:** This Person is the legal/operational guardian of another Person (typically a minor). Drives comms routing through `getCommsRoutingTarget`. Platform writes its own projection entry in-process — no webhook fanout to itself for this role; the in-process write happens in the same transaction as the `guardian` row insert.
|
|
65
|
+
- **Multiplicity:** A Person can be guardian of multiple wards; each ward relationship is its own `person_role` row with `source_entity_id = <guardian row id>` and `metadata = { ward_person_id }`.
|
|
66
|
+
|
|
67
|
+
### operator
|
|
68
|
+
|
|
69
|
+
- **Source domain:** platform (self-owned via Better-Auth)
|
|
70
|
+
- **Source entity:** `platform.member` (the Better-Auth membership row joining a user to an organization)
|
|
71
|
+
- **Assigned at:** Member row is created for a Person linked to a Sguild-internal Organization
|
|
72
|
+
- **Retired at:** Member row is deleted or role downgraded out of operator-class roles
|
|
73
|
+
- **Meaning:** This Person is internal Sguild staff with operator-tier access to one of Sguild's tools. Drives auth/admin visibility decisions in the Identity Console and the per-domain operator surfaces.
|
|
74
|
+
- **Multiplicity:** A Person can be an operator for multiple orgs; each member row is its own row.
|
|
75
|
+
|
|
76
|
+
## Lifecycle invariants
|
|
77
|
+
|
|
78
|
+
- **Assignment is producer-transactional.** A `role.assigned` event SHALL be emitted in the same Prisma transaction as the underlying entity write that created the role-bearing relationship. Per ADR-0009 producer-transactional-guarantee.
|
|
79
|
+
- **Retirement is producer-transactional.** Same rule for `role.retired`.
|
|
80
|
+
- **Idempotent at the projection.** Platform's `person_role` table uses `(person_id, role, source_entity_id)` as the unique key. Re-delivery of `role.assigned` upserts the row; re-delivery of `role.retired` is a no-op if `retired_at` is already populated.
|
|
81
|
+
- **The projection is eventually consistent.** Cursor lag and inbox dedup live between producer commit and projection read. Operator-visible lag in v1 is bounded by the dispatcher's 5-second polling cadence plus webhook RTT — typically sub-2-second per tonight's verified end-to-end timing on `person.updated`.
|
|
82
|
+
- **Roles do not cascade.** Archiving a Person via `person.updated` does NOT automatically retire all their roles. Each producing domain decides whether to retire its role in response to a Person archive (Sales does — pauses Lead + emits `role.retired`; Coaching does — moves Coach to inactive + emits `role.retired`; Revenue does not — Customer is sticky).
|
|
83
|
+
|
|
84
|
+
## Read API
|
|
85
|
+
|
|
86
|
+
Platform exposes `GET /api/identity/v1/person/<person_id>/roles` returning the current active roles for a Person:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"person_id": "per_019e1957-...",
|
|
91
|
+
"roles": [
|
|
92
|
+
{
|
|
93
|
+
"role": "lead",
|
|
94
|
+
"source_domain": "sales",
|
|
95
|
+
"source_entity_id": "led_019e1...",
|
|
96
|
+
"effective_at": "2026-05-10T14:23:11Z",
|
|
97
|
+
"metadata": null
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"role": "customer",
|
|
101
|
+
"source_domain": "revenue",
|
|
102
|
+
"source_entity_id": "cac_019e1...",
|
|
103
|
+
"effective_at": "2026-04-12T09:00:00Z",
|
|
104
|
+
"metadata": null
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Filter: `?include_retired=true` returns the full role history including retired rows. Default returns only `retired_at IS NULL` rows.
|
|
111
|
+
|
|
112
|
+
## Backfill
|
|
113
|
+
|
|
114
|
+
Each producing domain runs a one-shot script populating Platform's `person_role` table from its current entity state at the moment the projection ships. Backfill bypasses the dispatcher (direct upsert into `platform.person_role`) to avoid replaying historical role-creation events through every downstream consumer. After backfill, the domain enables the `role.assigned` / `role.retired` emit and any future role transitions flow through the event stream.
|
|
115
|
+
|
|
116
|
+
## Open questions
|
|
117
|
+
|
|
118
|
+
1. **Student role multiplicity.** Today a Person has at most one active `participant` row. If Delivery later splits Participant into per-org-engagement rows (a Person attending lessons at two service areas concurrently), the projection accommodates multiple active `student` rows because `(person_id, role, source_entity_id)` is the unique key, not `(person_id, role)`. Confirm with Delivery before the Student backfill.
|
|
119
|
+
2. **Customer retirement reasons.** v1 reserves `compliance_purge` as the only retirement reason. If Revenue identifies operational reasons to retire a Customer (long-dormant accounts, account merges), add reason values additively.
|
|
120
|
+
3. **Operator role granularity.** A future Sguild org structure may want sub-roles (operator-readonly, operator-admin, operator-billing). Today the `operator` role is a binary flag; sub-roles would either become separate role values or live in the `metadata` JSON.
|
|
@@ -82,6 +82,39 @@
|
|
|
82
82
|
],
|
|
83
83
|
"format": "date",
|
|
84
84
|
"description": "Optional submitted date of birth when available. Platform treats DOB as identity input and does not redistribute it on the canonical Person contract."
|
|
85
|
+
},
|
|
86
|
+
"student_age_group": {
|
|
87
|
+
"type": [
|
|
88
|
+
"string",
|
|
89
|
+
"null"
|
|
90
|
+
],
|
|
91
|
+
"description": "Submitted age bracket of the prospective student. Flexible string by design; Growth's form defines the enum values it collects (typical values include 'infant', 'toddler', 'preschool', 'early_elementary', 'late_elementary', 'teen', 'adult'). Sales reads this to route to the right cadence + lesson-type recommendation; Platform does NOT redistribute it on the canonical Person (Platform's own age_group enum on Person is identity-resolution-internal). Null when the form omitted the question."
|
|
92
|
+
},
|
|
93
|
+
"timeline": {
|
|
94
|
+
"type": [
|
|
95
|
+
"string",
|
|
96
|
+
"null"
|
|
97
|
+
],
|
|
98
|
+
"description": "When the prospect wants to start lessons. Flexible string; typical values include 'asap', 'this_month', 'next_month', 'this_summer', 'this_fall', 'exploring', 'no_rush'. Sales prioritizes cadence sequencing on this signal. Null when the form omitted the question."
|
|
99
|
+
},
|
|
100
|
+
"lesson_setting": {
|
|
101
|
+
"type": [
|
|
102
|
+
"string",
|
|
103
|
+
"null"
|
|
104
|
+
],
|
|
105
|
+
"description": "Preferred lesson setting / format. Flexible string; typical values include 'group_class', 'semi_private', 'private', 'home_pool', 'facility_pool', 'swim_team'. Drives Sales' service-area + coach matching. Null when the form omitted the question."
|
|
106
|
+
},
|
|
107
|
+
"new_qualified": {
|
|
108
|
+
"type": [
|
|
109
|
+
"string",
|
|
110
|
+
"null"
|
|
111
|
+
],
|
|
112
|
+
"enum": [
|
|
113
|
+
"new",
|
|
114
|
+
"qualified",
|
|
115
|
+
null
|
|
116
|
+
],
|
|
117
|
+
"description": "Stage hint for Sales' Lead opener. 'new' means the prospect is a fresh inbound with no prior screening; 'qualified' means upstream pre-screening (partner referral, operator manual capture with vetting, etc.) has confirmed fit and Sales can open the Lead in a later cadence stage. Closed enum: 'new' | 'qualified' | null (null treated as 'new' for back-compat with intakes that don't carry the discriminator)."
|
|
85
118
|
}
|
|
86
119
|
}
|
|
87
120
|
},
|
|
@@ -109,6 +142,33 @@
|
|
|
109
142
|
],
|
|
110
143
|
"description": "Optional Growth subscriber row back-reference for subscriber_promotion synthetic submissions. Growth currently uses sub_<UUID v7>; earlier thread prose mentioned gws_ as a candidate before the Growth schema reserved sub_.",
|
|
111
144
|
"pattern": "^sub_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
145
|
+
},
|
|
146
|
+
"ingestion_mode": {
|
|
147
|
+
"type": [
|
|
148
|
+
"string",
|
|
149
|
+
"null"
|
|
150
|
+
],
|
|
151
|
+
"enum": [
|
|
152
|
+
"live",
|
|
153
|
+
"backfill",
|
|
154
|
+
null
|
|
155
|
+
],
|
|
156
|
+
"description": "How this event entered the system. 'live' = real-time capture from a production intake surface (web form, Meta lead webhook, partner integration). 'backfill' = historical row imported from a prior system (Airtable, exported CSV, replayed event log). Distinct from `source` which describes the original acquisition channel; the same Meta lead can be both source='meta_lead_ad' and ingestion_mode='backfill' if imported retroactively. Null treated as 'live' for back-compat with intakes emitted before this field landed. Producers emitting backfill events SHOULD also backdate `occurred_at` to the original submission timestamp so warehouse week-grouping naturally places historical events in their actual historical weeks."
|
|
157
|
+
},
|
|
158
|
+
"qualified_at": {
|
|
159
|
+
"type": [
|
|
160
|
+
"string",
|
|
161
|
+
"null"
|
|
162
|
+
],
|
|
163
|
+
"format": "date-time",
|
|
164
|
+
"description": "Wall-clock UTC at which this intake was qualified by an upstream process (partner referral marked it qualified, operator manual-capture entered the qualifying note, derived_match algorithm scored it as qualified, etc.). Populated only when submission.new_qualified === 'qualified'; null otherwise. Distinct from envelope.occurred_at (which carries the form-submission timestamp); allows analytics to measure time-to-qualification independent of time-to-capture, and lets Sales prioritize freshly-qualified leads ahead of older-qualified ones even when their original form submission dates differ."
|
|
165
|
+
},
|
|
166
|
+
"is_test_data": {
|
|
167
|
+
"type": [
|
|
168
|
+
"boolean",
|
|
169
|
+
"null"
|
|
170
|
+
],
|
|
171
|
+
"description": "Producer-side test-data discriminator. True when Growth's lead_intake row has is_test_data=true (form-submission-time heuristic match: NANP fictional phones, obvious-test names, internal-IP submits, operator manual flag). Platform's matcher reads this and mints the resulting Person with is_test_data=true so the discriminator propagates downstream to Sales' Lead.is_test_data via intake.matched without each consumer needing to re-run the heuristic. Null treated as false for back-compat. Distinct from ingestion_mode (live vs backfill) — a test intake can be live-captured, and a real customer's intake can be backfilled."
|
|
112
172
|
}
|
|
113
173
|
}
|
|
114
174
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://contracts.sguild/identity/schema/payloads/intake.matched-v2.json",
|
|
4
|
+
"title": "intake.matched payload v2",
|
|
5
|
+
"description": "Payload for the intake.matched event_type at schema_version 2. Platform emits this after running mintOrMatchPerson on an upstream intake.captured envelope. v2 adds a submission_summary block carrying through the operator-relevant fields downstream consumers (primarily Sales) need to open a Lead without a back-lookup against Growth's dispatcher_event. Adding submission_summary is a minor additive change per envelope-contract §8.1; v1 consumers continue to work on the unchanged top-level fields. v1 stays active during a transition window; producers MAY emit at v2 immediately, consumers SHOULD upgrade when they need the submission_summary fields.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"tenant_id",
|
|
10
|
+
"person_id",
|
|
11
|
+
"originating_event_id",
|
|
12
|
+
"form_submission_id",
|
|
13
|
+
"match_type",
|
|
14
|
+
"matched_at"
|
|
15
|
+
],
|
|
16
|
+
"properties": {
|
|
17
|
+
"tenant_id": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Tenant discriminator. Mirrors the event envelope tenant_id."
|
|
20
|
+
},
|
|
21
|
+
"person_id": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Canonical Person id resolved by mintOrMatchPerson. per_<UUID v7> per ADR-0002. ALWAYS populated on intake.matched (in contrast with intake.captured where person_id may be null pre-match).",
|
|
24
|
+
"pattern": "^per_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
25
|
+
},
|
|
26
|
+
"originating_event_id": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"description": "Event id of the intake.captured envelope this matched event corresponds to. Lets consumers correlate back to the captured envelope for fields not forwarded here (dob, full submission, visitor_id, subscriber_row_id).",
|
|
29
|
+
"pattern": "^evt_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
30
|
+
},
|
|
31
|
+
"form_submission_id": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "Growth's form_submission id (fsm_<UUID v7>). Same value carried as the envelope subject on intake.captured.",
|
|
34
|
+
"pattern": "^fsm_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
35
|
+
},
|
|
36
|
+
"match_type": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"enum": ["auto_minted", "auto_matched", "manual_review_resolved"],
|
|
39
|
+
"description": "How Platform resolved the Person. auto_minted = Tier 3 (no phone match; new Person minted). auto_matched = Tier 1 (single phone match; existing Person returned). manual_review_resolved = a Tier 2 multi-match that an operator resolved into a single canonical Person. Sales reads this to decide opening behavior (new mint → open fresh Lead; matched → check for active Leads first)."
|
|
40
|
+
},
|
|
41
|
+
"matched_at": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"format": "date-time",
|
|
44
|
+
"description": "Wall-clock UTC when mintOrMatchPerson resolved this Person."
|
|
45
|
+
},
|
|
46
|
+
"source": {
|
|
47
|
+
"type": ["string", "null"],
|
|
48
|
+
"description": "Growth intake source carried through from the originating intake.captured (website_form, meta_lead_ad, subscriber_promotion, partner_upload, manual_capture, derived_match). Null when the captured event omitted it. Same flexible-string discipline."
|
|
49
|
+
},
|
|
50
|
+
"campaign_code": {
|
|
51
|
+
"type": ["string", "null"],
|
|
52
|
+
"description": "Growth campaign code carried through from intake.captured. Null when no campaign attribution present."
|
|
53
|
+
},
|
|
54
|
+
"landing_url": {
|
|
55
|
+
"type": ["string", "null"],
|
|
56
|
+
"description": "Landing URL carried through from intake.captured. Null when unavailable."
|
|
57
|
+
},
|
|
58
|
+
"ingestion_mode": {
|
|
59
|
+
"type": ["string", "null"],
|
|
60
|
+
"enum": ["live", "backfill", null],
|
|
61
|
+
"description": "Carried through from intake.captured's ingestion_mode. Sales reads this to branch on backfilled vs live intakes (e.g., don't auto-call a 2-year-old historical Lead). Sales' Lead row stores the bool in `is_backfill` for hot-path filtering; warehouse models filter on the same to keep historical imports out of active-cohort metrics. Null treated as 'live' for back-compat."
|
|
62
|
+
},
|
|
63
|
+
"qualified_at": {
|
|
64
|
+
"type": ["string", "null"],
|
|
65
|
+
"format": "date-time",
|
|
66
|
+
"description": "Carried through from intake.captured's qualified_at. Populated only when submission_summary.new_qualified === 'qualified'. Distinct from matched_at (when Platform resolved the Person) and occurred_at on the envelope (when the form was originally submitted). Sales stores this in Lead.qualified_at so the operator queue can prioritize by qualification recency without conflating it with form-submission recency or backfill-arrival recency."
|
|
67
|
+
},
|
|
68
|
+
"is_test_data": {
|
|
69
|
+
"type": "boolean",
|
|
70
|
+
"description": "Snapshot of the resolved Person's is_test_data flag at match time. True for Persons created by automated tests, integration smokes, or operator QA flows (auto-flagged when phone is in the NANP fictional 1-555-0100-XXX block, or operator-flipped via PATCH). Sales stores the value in Lead.is_test_data so downstream Sales-side analytics filter test traffic out of conversion funnels and operator queues without needing to JOIN to Platform's Person table. Distinct from is_backfill (which describes the ingestion path) and from `lead` role analytics (which roll up active relationships); is_test_data describes the underlying human, is_backfill describes how the event arrived, and the lead role describes the Sales relationship state."
|
|
71
|
+
},
|
|
72
|
+
"submission_summary": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"additionalProperties": false,
|
|
75
|
+
"description": "Operator-relevant snapshot of the form submission, copied from intake.captured at match time so Sales can open a Lead without joining back to Growth's dispatcher_event. Identity-resolution-only fields (dob, email, raw phone display) stay on intake.captured and are NOT forwarded here.",
|
|
76
|
+
"required": [
|
|
77
|
+
"first_name",
|
|
78
|
+
"last_name",
|
|
79
|
+
"phone_normalized",
|
|
80
|
+
"zip"
|
|
81
|
+
],
|
|
82
|
+
"properties": {
|
|
83
|
+
"first_name": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"minLength": 1,
|
|
86
|
+
"description": "Submitted given name."
|
|
87
|
+
},
|
|
88
|
+
"last_name": {
|
|
89
|
+
"type": "string",
|
|
90
|
+
"minLength": 1,
|
|
91
|
+
"description": "Submitted family name."
|
|
92
|
+
},
|
|
93
|
+
"phone_normalized": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"minLength": 1,
|
|
96
|
+
"description": "Phone in Platform's normalized form (digits-only, no leading +, US prefix when applicable). Distinct from intake.captured's submission.phone which carries Growth's raw display format. Sales uses this for join-by-phone displays without re-normalizing."
|
|
97
|
+
},
|
|
98
|
+
"zip": {
|
|
99
|
+
"type": "string",
|
|
100
|
+
"pattern": "^[0-9]{5}$",
|
|
101
|
+
"description": "Submitted five-digit ZIP code."
|
|
102
|
+
},
|
|
103
|
+
"student_age_group": {
|
|
104
|
+
"type": ["string", "null"],
|
|
105
|
+
"description": "Submitted age bracket of the prospective student. Carried through from intake.captured's submission.student_age_group. Sales reads this to route to the right cadence + lesson-type recommendation."
|
|
106
|
+
},
|
|
107
|
+
"timeline": {
|
|
108
|
+
"type": ["string", "null"],
|
|
109
|
+
"description": "When the prospect wants to start lessons. Carried through from intake.captured's submission.timeline. Sales prioritizes cadence sequencing on this signal."
|
|
110
|
+
},
|
|
111
|
+
"lesson_setting": {
|
|
112
|
+
"type": ["string", "null"],
|
|
113
|
+
"description": "Preferred lesson setting / format. Carried through from intake.captured's submission.lesson_setting. Drives Sales' service-area + coach matching."
|
|
114
|
+
},
|
|
115
|
+
"new_qualified": {
|
|
116
|
+
"type": ["string", "null"],
|
|
117
|
+
"enum": ["new", "qualified", null],
|
|
118
|
+
"description": "Stage hint for Sales' Lead opener. Carried through from intake.captured's submission.new_qualified. 'new' opens in fresh cadence; 'qualified' opens in a later stage (pre-vetted partner referrals, operator manual capture). Null treated as 'new' for back-compat."
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -26,10 +26,11 @@
|
|
|
26
26
|
"family_name",
|
|
27
27
|
"display_name",
|
|
28
28
|
"is_minor",
|
|
29
|
+
"is_test_data",
|
|
29
30
|
"created_at",
|
|
30
31
|
"updated_at"
|
|
31
32
|
],
|
|
32
|
-
"description": "The full post-update canonical Person, exactly matching the
|
|
33
|
+
"description": "The full post-update canonical Person, exactly matching the ten-field shape from contracts/identity/person-canonical-fields.md. Consumers MAY treat this as a refresh of their local Person cache without a follow-up fetch.",
|
|
33
34
|
"properties": {
|
|
34
35
|
"person_id": {
|
|
35
36
|
"type": "string",
|
|
@@ -62,6 +63,10 @@
|
|
|
62
63
|
"type": "boolean",
|
|
63
64
|
"description": "Maintained as a Platform invariant by the daily runIsMinorInvariantJob; a flip from true to false fires its own person.updated."
|
|
64
65
|
},
|
|
66
|
+
"is_test_data": {
|
|
67
|
+
"type": "boolean",
|
|
68
|
+
"description": "Test-data discriminator. True for Persons created by automated tests, integration smokes, or operator QA flows. Warehouse models filter on this so test traffic never pollutes active-cohort analytics. Distinct from status='archived' (real-customer opt-out). Set automatically at mint when phone is in the NANP fictional 1-555-0100-XXX block; operators flip via the PATCH route otherwise. A flip emits person.updated with changed_fields including 'is_test_data'."
|
|
69
|
+
},
|
|
65
70
|
"created_at": {
|
|
66
71
|
"type": "string",
|
|
67
72
|
"format": "date-time",
|
|
@@ -86,7 +91,8 @@
|
|
|
86
91
|
"family_name",
|
|
87
92
|
"display_name",
|
|
88
93
|
"status",
|
|
89
|
-
"is_minor"
|
|
94
|
+
"is_minor",
|
|
95
|
+
"is_test_data"
|
|
90
96
|
]
|
|
91
97
|
}
|
|
92
98
|
},
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://contracts.sguild/identity/schema/payloads/role.assigned-v1.json",
|
|
4
|
+
"title": "role.assigned payload v1",
|
|
5
|
+
"description": "Payload for the role.assigned event_type at schema_version 1. Emitted by a role-owning producer-domain when a Person gains a role-bearing relationship with one of that domain's entities (Sales opens a Lead, Coaching activates a Coach, Revenue records first purchase, Delivery links a Participant, Platform establishes a Guardian or Operator). Producer SHALL emit inside the same Prisma transaction as the role-bearing entity write per ADR-0009 producer-transactional-guarantee. Platform's person_role projection consumes role.assigned + role.retired and exposes the aggregated role list at GET /api/identity/v1/person/<id>/roles. The closed enum of role values lives in contracts/identity/person-role-taxonomy.md; new values are minor additive contract changes.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"tenant_id",
|
|
10
|
+
"person_id",
|
|
11
|
+
"role",
|
|
12
|
+
"source_domain",
|
|
13
|
+
"source_entity_id",
|
|
14
|
+
"effective_at"
|
|
15
|
+
],
|
|
16
|
+
"properties": {
|
|
17
|
+
"tenant_id": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Tenant discriminator. Mirrors the event envelope tenant_id."
|
|
20
|
+
},
|
|
21
|
+
"person_id": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "The Person who has gained the role. per_<UUID v7> per ADR-0002.",
|
|
24
|
+
"pattern": "^per_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
25
|
+
},
|
|
26
|
+
"role": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"enum": ["lead", "customer", "student", "coach", "guardian", "operator"],
|
|
29
|
+
"description": "The role being assigned. Closed enum per the person-role-taxonomy contract. Each role's source domain is fixed (Sales owns lead, Coaching owns coach, Revenue owns customer, Delivery owns student, Platform owns guardian + operator). Producers SHALL emit only the role values their domain owns."
|
|
30
|
+
},
|
|
31
|
+
"source_domain": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"enum": ["sales", "growth", "delivery", "revenue", "coaching", "platform"],
|
|
34
|
+
"description": "The producer-domain that emitted this assignment. Matches the envelope's producer field; carried explicitly in the payload so Platform's projection can record source without parsing the envelope."
|
|
35
|
+
},
|
|
36
|
+
"source_entity_id": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "The id of the role-bearing entity in the producer-domain (Lead.id, Coach.id, Participant.id, CreditAccount.id, Guardian.id, Member.id). Forms part of the projection's uniqueness key so a Person who has held the same role across multiple historical entities keeps each as a distinct row."
|
|
39
|
+
},
|
|
40
|
+
"effective_at": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"format": "date-time",
|
|
43
|
+
"description": "Wall-clock UTC at which the role-bearing relationship became active. Typically equals the entity's createdAt, but producers MAY backdate when the operational meaning differs (e.g., a Lead manually reassigned from another rep keeps its original opening time)."
|
|
44
|
+
},
|
|
45
|
+
"metadata": {
|
|
46
|
+
"type": ["object", "null"],
|
|
47
|
+
"description": "Free-form domain-specific metadata. Guardian uses this to carry ward_person_id; operator uses this to carry organization_id. Most producers leave it null."
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://contracts.sguild/identity/schema/payloads/role.retired-v1.json",
|
|
4
|
+
"title": "role.retired payload v1",
|
|
5
|
+
"description": "Payload for the role.retired event_type at schema_version 1. Emitted by a role-owning producer-domain when a Person's role-bearing relationship terminates (Lead reaches a terminal stage, Coach moves to inactive/retired, Participant lifecycle ends, Guardian relationship is terminated, Member row is deleted). Producer SHALL emit inside the same Prisma transaction as the entity's terminal-state write per ADR-0009 producer-transactional-guarantee. Idempotent: re-delivery of role.retired for an already-retired projection row is a no-op. The customer role is sticky — Revenue emits role.retired only for compliance scenarios (right-to-be-forgotten, fraud reversal).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"tenant_id",
|
|
10
|
+
"person_id",
|
|
11
|
+
"role",
|
|
12
|
+
"source_domain",
|
|
13
|
+
"source_entity_id",
|
|
14
|
+
"retired_at"
|
|
15
|
+
],
|
|
16
|
+
"properties": {
|
|
17
|
+
"tenant_id": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Tenant discriminator. Mirrors the event envelope tenant_id."
|
|
20
|
+
},
|
|
21
|
+
"person_id": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "The Person whose role is retiring. per_<UUID v7> per ADR-0002.",
|
|
24
|
+
"pattern": "^per_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
25
|
+
},
|
|
26
|
+
"role": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"enum": ["lead", "customer", "student", "coach", "guardian", "operator"],
|
|
29
|
+
"description": "The role being retired. Must match the role value carried on the prior role.assigned for the same (person_id, source_entity_id) tuple."
|
|
30
|
+
},
|
|
31
|
+
"source_domain": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"enum": ["sales", "growth", "delivery", "revenue", "coaching", "platform"],
|
|
34
|
+
"description": "The producer-domain emitting the retirement. Matches the envelope's producer."
|
|
35
|
+
},
|
|
36
|
+
"source_entity_id": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "The id of the role-bearing entity that just retired. Forms the (person_id, role, source_entity_id) key used to locate the projection row to update."
|
|
39
|
+
},
|
|
40
|
+
"retired_at": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"format": "date-time",
|
|
43
|
+
"description": "Wall-clock UTC at which the role-bearing relationship terminated. Typically the entity's terminal-state-transition timestamp (Lead.lost_at, Coach.retired_at, Participant.deactivated_at, etc.)."
|
|
44
|
+
},
|
|
45
|
+
"reason": {
|
|
46
|
+
"type": ["string", "null"],
|
|
47
|
+
"description": "Optional free-form reason for the retirement. Used by analytics + by the comms-routing layer when deciding whether to treat a retired role as 'churned' or 'archived'. customer role uses reserved value 'compliance_purge' for right-to-be-forgotten retirements."
|
|
48
|
+
},
|
|
49
|
+
"metadata": {
|
|
50
|
+
"type": ["object", "null"],
|
|
51
|
+
"description": "Free-form domain-specific metadata at retirement. Most producers leave it null."
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|