@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,184 @@
1
+ # Identity Contract
2
+
3
+ **Status:** v1.1.0
4
+ **Date:** 2026-05-16
5
+ **Owner:** Platform (identity service)
6
+ **Consumers:** Growth, Sales, Delivery, Revenue, Coaching
7
+ **Related ADRs:** ADR-0001 (tenant_id), ADR-0002 (Person ID shape), ADR-0003 (Person canonical with role records, amended 2026-04-24, 2026-04-27, 2026-05-01), ADR-0004 (Airtable renames), ADR-0010 (provider externals at Platform; Accepted 2026-05-02)
8
+ **Sub-specs (authoritative):** `person-canonical-fields.md`, `person-resolution-semantics.md`
9
+ **Sub-specs (draft):** `person-externals.md` (lands with Identity Contract v1.1, gated on ADR-0010 acceptance)
10
+ **Validations:** `validation/coach-handling.md`, `validation/person-graph.md`, `validation/client-table.md`
11
+
12
+ ## 1. Purpose and scope
13
+
14
+ Identity is the shared language Sguild user-facing domains use to refer to the humans the business interacts with. This contract specifies how humans are identified across domain boundaries (Person ID), how tenancy is discriminated (`tenant_id`), what the canonical Person record looks like when it crosses a domain boundary, how signals about the same human are resolved into a single canonical record, and how the contract itself is versioned.
15
+
16
+ The two sub-specs hold the authoritative detail: `person-canonical-fields.md` for the ten fields and what is deliberately not exposed; `person-resolution-semantics.md` for normalization, the three-tier match decision, the merge workflow, and consumer resolution. This document is the index, the cross-cutting rules, and the change log.
17
+
18
+ Out of scope: communications routing through Guardian (Platform exposes a routing endpoint that consumer domains call), role-record field definitions in each domain, commercial and operational state, authentication token shape (separate Auth contract), event envelope shape (separate contract, pending ADR-0005).
19
+
20
+ ## 2. Normative language
21
+
22
+ The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted per RFC 2119.
23
+
24
+ ## 3. Terminology
25
+
26
+ - **Person.** A single human Sguild has a relationship with. The canonical identity entity.
27
+ - **Tenant.** A single Sguild business instance. Today exactly one; the contract is forward-compatible with multi-tenant without breaking changes.
28
+ - **Organization.** A customer organization (swim program, gym, facility) whose members buy lessons from Sguild. NOT a tenant.
29
+ - **Role record.** A domain-owned record describing a Person's role in that domain (Lead in Sales, Participant and Coach in Delivery, Guardian relationship in Platform). See ADR-0003.
30
+ - **Canonical.** The authoritative Person record after deduplication and any merges. Always unique for a human within a tenant.
31
+
32
+ ## 4. Identifiers
33
+
34
+ ### 4.1 Person ID
35
+
36
+ Per ADR-0002. Strings of the form `per_<UUID v7 canonical>`, 40 characters, immutable after mint, stored as text. Consumers MUST NOT strip the `per_` prefix or use native `uuid` columns for Person references.
37
+
38
+ ### 4.2 Tenancy
39
+
40
+ Per ADR-0001. All identity tokens, event envelopes, and inter-service APIs SHALL carry `tenant_id`. Today the value is a fixed singleton resolved from auth context. The name `tenant_id` is reserved for the tenancy discriminator; the name `organization_id` is reserved for customer organizations.
41
+
42
+ ### 4.3 Canonical entity ID template
43
+
44
+ The pattern `<prefix>_<UUID v7 canonical>` is reserved for all canonical entity identifiers across the Platform.
45
+
46
+ | Prefix | Entity | Owner |
47
+ |--------|--------|-------|
48
+ | `per_` | Person | Platform identity service |
49
+ | `lead_` | Lead role record | Sales |
50
+ | `par_` | Participant role record | Delivery |
51
+ | `coa_` | Coach role record | Delivery |
52
+ | `gdn_` | Guardian relationship | Platform identity service |
53
+ | `pex_` | Person external mapping | Platform identity service (reserved per ADR-0010, lands with Identity Contract v1.1) |
54
+ | `ord_` | Order | Revenue (reserved) |
55
+ | `crd_` | Credit | Revenue (reserved) |
56
+ | `res_` | Credit Reservation | Revenue (reserved) |
57
+ | `inv_` | Invoice | Revenue (reserved) |
58
+ | `les_` | Lesson | Delivery (reserved) |
59
+
60
+ New prefixes MUST be registered in the Platform contracts registry before use. Prefixes SHALL NOT be reused across entity types and SHALL NOT change between versions.
61
+
62
+ ## 5. Canonical Person
63
+
64
+ Authoritative spec: `person-canonical-fields.md` v1.1.0.
65
+
66
+ The canonical Person record SHALL contain exactly ten fields when it crosses a team boundary or appears in an event payload: `person_id`, `status`, `alias_of`, `given_name`, `family_name`, `display_name`, `is_minor`, `is_test_data`, `created_at`, `updated_at`. Detailed type, mutability, default, and purpose for each field is in the sub-spec.
67
+
68
+ The sub-spec also enumerates what is NOT on the contract: contact methods (email, phone), DOB, Guardian relationships, external provider IDs, role-record state, and all relationship-shaped data. These exclusions are privacy-load-bearing and MUST NOT be relaxed without a major version bump.
69
+
70
+ Platform's identity service is system of record. Only Platform writes to the canonical fields. Other domains reference Person via `person_id` and read through the Person-by-id API or `person.*` events.
71
+
72
+ ## 6. Resolution semantics
73
+
74
+ Authoritative spec: `person-resolution-semantics.md` v1.0.0.
75
+
76
+ Summary of normative rules:
77
+
78
+ - **Minting.** A canonical Person SHALL be minted only when a signal does not auto-match an existing Person AND includes a phone number. Email-only signals SHALL NOT mint a Person.
79
+ - **Three-tier match.** Every incoming signal SHALL be classified into Tier 1 auto-match, Tier 2 manual review, or Tier 3 mint new. Triggers for each tier are specified in the sub-spec.
80
+ - **Merge.** When two Persons are confirmed to be the same human, the older `created_at` wins as canonical. Non-null fields on the canonical are preserved; null fields are populated from the non-canonical record where it has a value.
81
+ - **Alias resolution.** Merged Persons transition to `status='merged'` with `alias_of` populated. Chained merges SHALL one-hop-resolve through `alias_of` updates.
82
+ - **Non-reversibility.** Merge operations SHALL NOT be reversed as a first-class operation; the remedy for a wrong merge is mint-new plus manual reattachment.
83
+
84
+ Full normalization rules, match-tier triggers, role-record reconciliation, and edge cases are in the sub-spec.
85
+
86
+ ## 7. Events
87
+
88
+ Identity-owned event types ride on the Platform event envelope (separate contract, ADR-0005, pending).
89
+
90
+ ### 7.1 `person.created`
91
+ Emitted when a canonical Person is minted. Payload: the full ten-field Person shape.
92
+
93
+ ### 7.2 `person.updated`
94
+ Emitted when any of the ten canonical fields changes value. Payload: full current Person shape. Bumps `updated_at`. Changes to domain-owned role records, Platform-internal state, or external-link tables SHALL NOT emit this event.
95
+
96
+ ### 7.3 `person.merged`
97
+ Emitted when a Person is merged into another. Payload: `old_person_id`, `canonical_person_id`, `reason_code` (enum: `auto-phone-plus-email`, `auto-external-id`, `manual-operator-confirmed`, `ops-correction`), `promoted_fields`, `role_record_changes`.
98
+
99
+ ## 8. Versioning policy
100
+
101
+ ### 8.1 Semantic versioning
102
+ - **Patch** (1.0.0 → 1.0.1): editorial clarifications, typo fixes. No behavioral change.
103
+ - **Minor** (1.0.x → 1.1.0): additive changes. New optional fields, new event types, appended enum values. Consumers on older minors continue to work.
104
+ - **Major** (1.x.y → 2.0.0): breaking changes. Removal, type change, narrowing enum values, breaking event payload changes.
105
+
106
+ ### 8.2 Deprecation policy
107
+ On major-version publication, the previous major enters a two-week deprecation window per the Q2 2026 contract-currency metric. Consumers running against deprecated versions after the window contribute to drift.
108
+
109
+ ### 8.3 Additive discipline within a major
110
+ New fields MUST default to null or a backwards-compatible value. New enum values MUST be appended; consumers MUST treat unknown enum values as safe-to-ignore.
111
+
112
+ ### 8.4 Registry
113
+ All contract versions SHALL be registered in the Platform contracts registry (Notion).
114
+
115
+ ## 9. Consumer responsibilities
116
+
117
+ ### 9.1 Reference by ID
118
+ Consumers MUST store Person references as `person_id` values, not as denormalized copies of Person fields. Display-layer caching is permitted (see §9.2).
119
+
120
+ ### 9.2 Cache invalidation
121
+ Consumers MAY cache Person records for performance. Caches SHALL invalidate on receipt of `person.updated` or `person.merged` events for the cached `person_id`, or by comparing the cached `updated_at` against the value in a subsequent fetch.
122
+
123
+ ### 9.3 Guardian routing
124
+ When a consumer needs to send outbound communication to a Person where `is_minor=true`, the consumer MUST route through Platform's Guardian-aware comms-routing endpoint, which walks Guardian and returns the responsible adult. Consumers SHALL NOT store or use direct contact information for minor Persons, even transiently.
125
+
126
+ ### 9.4 Alias handling
127
+ Consumers SHOULD eagerly resolve `alias_of` on receipt of `person.merged`. Consumers MAY rely on lazy resolution at fetch time; the Platform API continues to return the canonical record for queries on merged IDs.
128
+
129
+ ### 9.5 Person merge reconciliation
130
+ Consumers SHALL subscribe to `person.merged` events and proactively re-key their stored references (in role records, historical snapshots, cached lookups) from `old_person_id` to `canonical_person_id`. The Platform identity service API continues to resolve queries on merged IDs through the `alias_of` field, but persistent reliance on lazy resolution across the four user-facing domains is a drift signal that surfaces in the contract-currency metric. (Added in v1.0.0 per `person-graph` validation finding.)
131
+
132
+ ### 9.6 Tenant scoping
133
+ Every read and write against identity data MUST carry `tenant_id` from auth context. Cross-tenant reads are prohibited.
134
+
135
+ ## 10. Producer responsibilities (Platform identity service)
136
+
137
+ ### 10.1 Uniqueness
138
+ Platform SHALL ensure `person_id` uniqueness within a tenant. Collisions are a critical integrity bug.
139
+
140
+ ### 10.2 Event fidelity
141
+ Every Person mutation SHALL emit the appropriate event (`person.created`, `person.updated`, or `person.merged`) with at-least-once delivery before the API response returns. Consumers MAY assume that a successful mutation API response implies the corresponding event has been published.
142
+
143
+ ### 10.3 Normalization
144
+ Platform SHALL normalize incoming signals per `person-resolution-semantics.md` before performing match or mint operations. Consumers of intake and import APIs MAY submit non-normalized data; Platform SHALL accept it.
145
+
146
+ ### 10.4 Invariants
147
+ Platform SHALL maintain `is_minor` as a daily-updated invariant. A Platform-owned scheduled job SHALL run at least once per day and flip `is_minor` to `false` for any Person crossing the 18-year-old threshold, emitting `person.updated` for each. The job evaluates DOB-based and birth_year-based crossings (the latter at January 1 of the year the Person turns 18); age_group-based Persons SHALL be re-bracketed by an operator or by a domain-emitted aged-up signal, since age_group has no time component the daily job can act on.
148
+
149
+ ### 10.5 Merge log
150
+ Platform SHALL retain a persistent merge log recording every merge operation: the pre-merge state of both Persons, the resulting canonical state, the reason code, the operator (if manual), and the timestamp. Retained indefinitely for audit.
151
+
152
+ ### 10.6 Guardian-aware comms-routing endpoint
153
+ Platform SHALL expose a `GET /identity/comms-routing/:person_id` endpoint that returns the Person who should receive outbound communications for the queried person_id (the Person themselves for adults, the Guardian for minors). Sales and Delivery both call this endpoint before any outbound send.
154
+
155
+ ## 11. Security and privacy
156
+
157
+ ### 11.1 PII footprint
158
+ The canonical Person record exposes display-only PII (name components, derived minor flag). Full contact data, DOB, and relationship data are NOT on the contract and do NOT distribute to other domains. The exclusions in `person-canonical-fields.md` are intentional and privacy-load-bearing.
159
+
160
+ Platform internally tracks two additional age-source fields beyond DOB: `birth_year` (integer) and `age_group` (enumerated bracket: `infant`, `toddler`, `preschool`, `school_age`, `teen`). These are operational inputs the mint API accepts when DOB is unavailable (e.g., a Student where the parent supplies only an age group); both fall under the same internal-only privacy class as DOB and SHALL NOT cross a domain boundary. Per §10.4, `is_minor` is computed from whichever source is most precise, in priority order DOB to birth_year to age_group. Adults supply none of the three.
161
+
162
+ ### 11.2 Access control
163
+ Access to the Platform identity service Person-by-id API SHALL be gated by tenant auth context. Cross-tenant reads are prohibited.
164
+
165
+ ### 11.3 Log hygiene
166
+ Consumers MAY log `person_id` freely (it is a pseudonymous identifier). Consumers SHOULD minimize retention of `given_name`, `family_name`, and `display_name` in long-lived logs where business need does not exist. Consumers SHALL NOT log fields excluded under §5 (which they should not have in the first place).
167
+
168
+ ## 12. Future work
169
+
170
+ - **ADR-0005: Event envelope contract.** Required before consumers can fully integrate against identity v1.0.0 in code. Identity v1.0.0 references envelope shape (`actor`, `subject`, `event_type`, `tenant_id`, payload, schema_version) but does not define it; that lives in the envelope contract once drafted.
171
+ - **ADR-0006: Credit reservation lock state machine.** Specifies the `customer.handoff` event trigger named in the amended ADR-0003.
172
+ - **Per-domain integration examples** as sub-specs as each domain extracts from Airtable into its own app.
173
+ - **Cross-tenant resolution strategy** when tenant two is committed.
174
+
175
+ ## 13. Change log
176
+
177
+ - **v0.1.0** (2026-04-23): Initial draft as `person-canonical-fields.md`.
178
+ - **v0.1.0** (2026-04-24): `person-resolution-semantics.md` added. Three validations passed (`coach-handling`, `person-graph`, `client-table`).
179
+ - **v0.1 amendment** (2026-04-24): Customer Lifecycles dissolved as a domain. Person ownership relocated from Customer Lifecycles to Platform's identity service. Lead role record relocated from Lifecycles to the new Sales domain. Service Delivery renamed to Delivery. The contract surface (nine fields, three-tier match, merge workflow) did not change; ownership boundaries shifted. Documented in the amended ADR-0003.
180
+ - **v1.0.0** (2026-04-24): Promoted from v0.1. Added §9.5 (person.merged reconciliation as a normative consumer expectation) per the `person-graph` validation suggestion. Top-level README created as the contract index, with sub-specs `person-canonical-fields.md` and `person-resolution-semantics.md` holding the authoritative detail.
181
+ - **v1.0.1** (2026-04-27): Editorial. Replaced "Operations" with "Delivery" throughout to match the renamed domain. The role-record ownership table (`par_` and `coa_`) and the canonical Person spec are unchanged. Consumers running against v1.0.0 are interface-compatible with v1.0.1.
182
+ - **v1.0.2** (2026-05-02): Editorial. Added ADR-0010 (provider externals at Platform; Proposed) to Related ADRs. Reserved the `pex_` prefix in §4.3 with a pointer to ADR-0010 and the forthcoming `person-externals.md` sub-spec. Added a "Sub-specs (draft)" line referencing `person-externals.md` (which lands as authoritative when Identity Contract v1.1 ships, gated on ADR-0010 acceptance). No change to canonical Person fields, resolution semantics, events, or any consumer-binding surface; consumers running against v1.0.0 or v1.0.1 are interface-compatible with v1.0.2.
183
+ - **v1.0.3** (2026-05-02): Editorial. §11.1 documents two additional internal-only age-source fields, `birth_year` and `age_group`, that Platform tracks alongside DOB; same privacy class, do not cross domain boundaries. §10.4 documents that the daily `is_minor` invariant job acts on DOB-based and birth_year-based crossings while age_group-based Persons SHALL be re-bracketed by an operator or domain signal. Operationally driven by the Sguild swim school case (parents often supply only an age bracket for new minors). No change to the canonical nine-field Person shape, resolution semantics, events, or any consumer-binding surface; consumers running against any prior 1.0.x are interface-compatible with v1.0.3.
184
+ - **v1.1.0** (2026-05-16): Additive minor. Adds `is_test_data` to the canonical Person shape and to `person.updated` changed_fields so downstream operator queues, warehouse models, and conversion analytics can filter QA and integration-smoke traffic without re-running Platform's test-data heuristic. Default is `false`; Platform auto-sets true for NANP fictional 1-555-0100-XXX phones and allows operator correction through the Person PATCH route. Consumers that do not need test-data filtering MAY ignore the field.
@@ -0,0 +1,120 @@
1
+ # Person canonical fields list
2
+
3
+ **Status:** v1.1.0
4
+ **Date:** 2026-05-16
5
+ **Owner:** Platform (identity service)
6
+ **Feeds:** Identity contract v1.1.0 (see `README.md`)
7
+ **Related:** ADR-0002 (Person ID shape), ADR-0003 (Person as canonical entity with role records per domain)
8
+
9
+ ## Scope
10
+
11
+ This document enumerates the fields the identity contract exposes cross-team. Fields on this list are distributed to Growth, Sales, Delivery, and Revenue, and they ride in the event envelope when a Person is the `actor` or `subject` of an event. Fields NOT on this list stay internal to Platform's identity service or live on domain-owned role records per ADR-0003.
12
+
13
+ The contract answers "who is this human, in the minimum shape every domain needs to reference, display, and lifecycle-manage them." It does not answer "how do I contact them," "what relationships do they have," or "what role do they play in my domain." Those questions route through Platform's identity service or domain-owned role records.
14
+
15
+ ## Fields
16
+
17
+ ### person_id
18
+
19
+ - **Type:** text
20
+ - **Shape:** per ADR-0002: `per_` + UUID v7 canonical dashed lowercase (40 chars total)
21
+ - **Required:** yes
22
+ - **Mutability:** immutable after mint
23
+ - **Purpose:** stable cross-domain identifier; primary key for every Person reference everywhere.
24
+
25
+ ### status
26
+
27
+ - **Type:** enum
28
+ - **Values:** `active`, `archived`, `merged`
29
+ - **Required:** yes
30
+ - **Default:** `active`
31
+ - **Mutability:** transitions via Platform identity service API; change emits `person.updated` event
32
+ - **Purpose:** downstream domains filter by status. `active` means fully operational. `archived` means preserve history, do not accept new relationships, stop comms. `merged` means this ID is an alias; consumers resolve via `alias_of`.
33
+ - **Note:** `deceased` deliberately not included in v1; deceased-as-archived covers today's needs. Additively expandable if legal or tax later requires a distinct state.
34
+
35
+ ### alias_of
36
+
37
+ - **Type:** text (person_id) or null
38
+ - **Required:** no
39
+ - **Default:** null
40
+ - **Mutability:** set once when `status` transitions to `merged`; never changes after
41
+ - **Purpose:** graceful resolution for consumers that hold a pre-merge ID. Lookups on a merged ID return the record with `alias_of` populated; consumer follows the link to the canonical Person. Without this field, stale references become silent integrity bugs.
42
+
43
+ ### given_name
44
+
45
+ - **Type:** text, max length 200
46
+ - **Required:** no (nullable; anonymous intakes can mint a Person before a name is known)
47
+ - **Mutability:** editable via Platform identity service API; change emits `person.updated`
48
+ - **Purpose:** first/personal name component, rendered in UIs across domains.
49
+
50
+ ### family_name
51
+
52
+ - **Type:** text, max length 200
53
+ - **Required:** no (nullable; same reason as given_name)
54
+ - **Mutability:** editable via Platform identity service API; change emits `person.updated`
55
+ - **Purpose:** family/last name component.
56
+
57
+ ### display_name
58
+
59
+ - **Type:** text, max length 200
60
+ - **Required:** no
61
+ - **Default:** when null, Platform identity service constructs `given_name + " " + family_name` (trimmed, with empty components omitted)
62
+ - **Mutability:** editable via Platform identity service API; change emits `person.updated`
63
+ - **Purpose:** the name the person prefers to be called (nicknames, preferred forms, cultural name ordering). Consumers render `display_name` if present, else fall back to the default construction. Eliminates every consumer building its own concatenation logic.
64
+
65
+ ### is_minor
66
+
67
+ - **Type:** boolean
68
+ - **Required:** yes
69
+ - **Default:** computed from internal age sources in priority order DOB → birth_year → age_group; `false` when all three are null
70
+ - **Mutability:** maintained as a Platform identity service invariant. The identity service runs a daily job that flips `is_minor` to `false` on the morning of a Person's 18th birthday and emits `person.updated`. Manual corrections (DOB updates, data-entry fixes) also emit events.
71
+ - **Purpose:** critical for Guardian routing per ADR-0003. Delivery uses it for eligibility rules. Sales and Delivery both gate outbound comms by this flag: any send to a Person where `is_minor=true` MUST route through Platform's Guardian-aware comms-routing endpoint, not direct.
72
+ - **Note:** DOB, birth_year, and age_group are all NOT on the contract. Exposing the derived flag gives consumers what they need for routing and eligibility without distributing birthdates or age brackets across four domains and every event payload.
73
+
74
+ ### is_test_data
75
+
76
+ - **Type:** boolean
77
+ - **Required:** yes
78
+ - **Default:** `false`
79
+ - **Mutability:** set automatically by Platform at mint when the normalized phone is in the NANP fictional 1-555-0100-XXX block; editable via Platform identity service API for operator-classified QA rows; change emits `person.updated`
80
+ - **Purpose:** test-data discriminator for automated tests, integration smokes, and operator QA flows. Warehouse models, operator queues, and conversion analytics filter on this flag so QA traffic does not pollute active customer cohorts. Distinct from `status='archived'`, which means a real human relationship is no longer active.
81
+
82
+ ### created_at
83
+
84
+ - **Type:** ISO 8601 datetime in UTC
85
+ - **Required:** yes
86
+ - **Mutability:** immutable
87
+ - **Purpose:** when the Person was first minted. Used for cohort analysis (Growth), tenure calculations (Delivery and Sales), and audit.
88
+
89
+ ### updated_at
90
+
91
+ - **Type:** ISO 8601 datetime in UTC
92
+ - **Required:** yes
93
+ - **Default:** equals `created_at` at mint
94
+ - **Mutability:** bumped ONLY when a field on this contract changes. Changes to domain-owned role records (Lead in Sales, Participant or Coach in Delivery), Platform-internal identity state, or external-link tables do NOT bump `updated_at`.
95
+ - **Purpose:** consumer cache invalidation. Consumers compare their cached `updated_at` against the one in `person.updated` events or API responses; if different, they refresh their snapshot.
96
+
97
+ ## NOT on the contract (reference list)
98
+
99
+ These fields stay internal to Platform's identity service or live on domain-owned role records. Access routes through Platform identity APIs or the owning domain's APIs, not through the contract surface.
100
+
101
+ - `date_of_birth`: Platform-internal; consumers use `is_minor` instead
102
+ - `birth_year`: Platform-internal; year-resolution alternative to DOB. Used to derive `is_minor` when DOB is unavailable
103
+ - `age_group`: Platform-internal enumerated bracket (`infant`, `toddler`, `preschool`, `school_age`, `teen`); least-precise alternative to DOB and birth_year. Adults have null. Used to derive `is_minor` when neither DOB nor birth_year is available
104
+ - `email`, `phone`, messaging handles: Platform-internal contact data; outbound comms route through Platform's Guardian-aware comms-routing endpoint
105
+ - test-data heuristics: Platform-internal; consumers see only the derived `is_test_data` flag
106
+ - Guardian relationships: Platform identity service Guardian table
107
+ - External provider IDs such as Square customer ID: Client Externals table in Delivery (or Revenue, depending on the linkage purpose), scoped by provider
108
+ - Preferences, tags, notes, comms history: owned by the consuming domain (Sales for lead-related notes, Delivery for customer-related notes); Platform does not store relationship-shaped data
109
+ - Lead pipeline state: Source, Stage, attempt history: Lead role record in Sales per amended ADR-0003
110
+ - Participant status, skill level, primary coach: Delivery Participant role record
111
+ - Coach bio, rates, skill levels taught: Delivery Coach role record
112
+ - Credit balances, orders, invoices, payment methods: Revenue
113
+
114
+ ## Versioning
115
+
116
+ Additive-only within v1. New nullable fields can be added in v1.x without breaking consumers. Removing a field, changing a field's type, or narrowing an enum requires v2, which triggers the two-week deprecation clock from the Q2 contract-currency metric.
117
+
118
+ ## Event envelope footprint
119
+
120
+ When a Person is the `actor` or `subject` of an event, the envelope carries only `person_id`. Consumers that need the full Person shape call the Platform identity service's `GET /identity/person/:id` endpoint, which returns the fields above. Changes to any field above emit a `person.updated` event whose payload is the full current shape.
@@ -0,0 +1,267 @@
1
+ # Person Externals
2
+
3
+ **Status:** draft (lands with Identity Contract v1)
4
+ **Domain:** platform
5
+ **Surface:** identity
6
+ **Date:** 2026-05-09
7
+ **Related ADRs:** ADR-0001 (tenant), ADR-0002 (entity ID prefixes; `pex_` registered here), ADR-0003 (Person canonical), ADR-0010 (architectural decision: provider externals at Platform as canonical mapping)
8
+ **Amended:** 2026-05-09. §3 updated to reflect the partial unique constraint (`WHERE retired_at IS NULL`) shipped in the 2026-05-02 schema migration; §5.4 added documenting the Person-merge external-reattachment flow under Shape A. Co-resolved with Revenue per `2026-05-09-platform-adr-0010-merge-case-shape-a`.
9
+
10
+ ## 1. Purpose and scope
11
+
12
+ This sub-spec defines the `person_externals` storage and API surface used by Platform's identity service to track provider-side identifiers for Sguild Persons. The decision rationale is in ADR-0010.
13
+
14
+ In scope: the table schema, the ID prefix, the two API endpoints (forward and reverse lookup), the consumer responsibilities for reading externals, and the producer responsibilities for writing externals.
15
+
16
+ Out of scope: provider-specific business data (Square payment methods, Quo SMS opt-in flags, etc.). Those live in the consuming domain's tables, keyed off the `external_id` this sub-spec defines.
17
+
18
+ ## 2. Terminology
19
+
20
+ - **Provider.** An external system Sguild integrates with that holds its own identifier for a Person. Examples: Square (today), Quo (likely soon).
21
+ - **External ID.** The provider's own identifier for the Person. Square's `customer_id`, Quo's `subscriber_id`, etc.
22
+ - **External record.** A `person_externals` row mapping (Sguild Person × Sguild Org × Provider × Provider Environment) to one External ID.
23
+ - **Provider Environment.** Operational environment label, when relevant. Square distinguishes `production` from `sandbox`; some providers do not have multiple environments and the field is null.
24
+
25
+ ## 3. Table schema
26
+
27
+ ```sql
28
+ CREATE TABLE person_externals (
29
+ person_external_id TEXT PRIMARY KEY,
30
+ person_id TEXT NOT NULL REFERENCES persons(person_id),
31
+ organization_id TEXT NOT NULL,
32
+ provider TEXT NOT NULL,
33
+ external_id TEXT NOT NULL,
34
+ provider_environment TEXT,
35
+ metadata JSONB,
36
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
37
+ last_seen_at TIMESTAMPTZ,
38
+ retired_at TIMESTAMPTZ
39
+ );
40
+
41
+ CREATE UNIQUE INDEX person_external_active_unique_tuple
42
+ ON person_externals (person_id, organization_id, provider, provider_environment)
43
+ WHERE retired_at IS NULL;
44
+
45
+ CREATE INDEX person_external_active_reverse_lookup
46
+ ON person_externals (organization_id, provider, external_id)
47
+ WHERE retired_at IS NULL;
48
+ ```
49
+
50
+ Field-by-field:
51
+
52
+ `person_external_id`. The canonical Sguild ID for this external mapping. Format: `pex_<UUID v7 canonical>` per ADR-0002. Generated by Platform on row insert; never supplied by callers. Stable across provider swaps; if a Person's Square `customer_id` changes (rare; Square reissues are an edge case), the old row is retired and a new row gets a new `pex_`.
53
+
54
+ `person_id`. Foreign key to the canonical Person. The Sguild-side identity this external mapping anchors to.
55
+
56
+ `organization_id`. Tenant scope per ADR-0001. Today a fixed singleton at the Sguild level, but the column is in place because today's Airtable Client Externals is already org-scoped and the Sguild operating model assumes per-org provider accounts (each Sguild org has its own Square merchant account, etc.).
57
+
58
+ `provider`. Provider name as a string. Closed-set enum at the application layer rather than at the database layer (the column is `TEXT` so adding a provider does not require a migration; the application-layer enum gets a new value alongside the new provider integration). Initial known values: `square`. Likely additions: `quo`.
59
+
60
+ `external_id`. The provider's identifier for this Person. Stored as opaque text; Sguild does not parse or validate the format because providers vary widely.
61
+
62
+ `provider_environment`. `production` or `sandbox` for providers that distinguish; null for providers that do not. The unique constraint includes this column so that a single Person × Org × Provider can have separate IDs across environments.
63
+
64
+ `metadata`. JSONB for provider-specific shape that does not earn a real column. Examples: Square's `merchant_id` (when distinct from `organization_id`'s mapping), the Square `location_id` if the integration is location-scoped, account-version stamps, anything you would want stored but never want to make a query-target column on `person_externals`. Provider-specific business data (payment method tokens, billing addresses, message thread state) does NOT go here; it goes in the consuming domain's tables.
65
+
66
+ `created_at`. Insert timestamp. Set by Platform, never by the caller.
67
+
68
+ `last_seen_at`. The last time Platform observed this external mapping in use (e.g., the last Square webhook that referenced it). Optional; consumers MAY update it through Platform's API to keep the freshness signal alive. Used by Platform for staleness reporting and eventual-cleanup suggestions; not load-bearing for any contract behavior.
69
+
70
+ `retired_at`. Soft-delete timestamp. Set when an external mapping is no longer active (provider migration, account closure). The reverse-lookup index excludes retired rows; the forward-lookup endpoint includes them by default but can filter. Audit trail preserved indefinitely.
71
+
72
+ The partial `UNIQUE` index on `(person_id, organization_id, provider, provider_environment) WHERE retired_at IS NULL` codifies the invariant from ADR-0010 in its accurate form: at most one ACTIVE row per (Person × Org × Provider × Environment) tuple, with any number of retired rows allowed for audit. The amended trigger-to-revisit framing on ADR-0010 (2026-05-09) clarifies that if a future provider integration produces a case where a single Person legitimately holds multiple ACTIVE `customer_id`s within a single Sguild org and environment for one provider, that is the future-revisit trigger that relaxes the partial constraint via a contract amendment. The Person-merge case is NOT that trigger; it is handled by the merge-handler retire-then-re-key flow described in §5.4 below, which produces multiple retired rows on a canonical Person without ever producing two active rows on the same tuple.
73
+
74
+ ## 4. ID prefix
75
+
76
+ `pex_<UUID v7 canonical>`. Per ADR-0002. Registered in the contracts registry as a reserved prefix when Identity Contract v1 ships.
77
+
78
+ ## 5. API endpoints
79
+
80
+ ### 5.1 Forward lookup: list externals for a Person
81
+
82
+ ```
83
+ GET /identity/v1/person/{person_id}/externals
84
+ ```
85
+
86
+ Query parameters (all optional):
87
+
88
+ - `provider`. Filter to a single provider value.
89
+ - `organization_id`. Filter to a single organization.
90
+ - `include_retired` (boolean, default `false`). When `true`, includes rows where `retired_at` is set.
91
+
92
+ Response (HTTP 200):
93
+
94
+ ```json
95
+ {
96
+ "person_id": "per_...",
97
+ "externals": [
98
+ {
99
+ "person_external_id": "pex_...",
100
+ "organization_id": "org_...",
101
+ "provider": "square",
102
+ "external_id": "...",
103
+ "provider_environment": "production",
104
+ "metadata": {},
105
+ "created_at": "...",
106
+ "last_seen_at": "...",
107
+ "retired_at": null
108
+ }
109
+ ]
110
+ }
111
+ ```
112
+
113
+ If the Person does not exist, returns HTTP 404. If the Person exists but has no externals matching the filter, returns HTTP 200 with an empty `externals` array.
114
+
115
+ ### 5.2 Reverse lookup: resolve an External ID to a Person
116
+
117
+ ```
118
+ GET /identity/v1/externals/lookup
119
+ ```
120
+
121
+ Query parameters (required):
122
+
123
+ - `provider`. The provider name.
124
+ - `organization_id`. The Sguild org scope.
125
+ - `external_id`. The provider's identifier.
126
+
127
+ Optional:
128
+
129
+ - `provider_environment`. When the provider distinguishes environments; defaults to no filter (which means the lookup may match on any environment, generally what callers want unless they need environment-specific resolution).
130
+
131
+ Response (HTTP 200) when a non-retired match exists:
132
+
133
+ ```json
134
+ {
135
+ "person_id": "per_...",
136
+ "person_external_id": "pex_...",
137
+ "organization_id": "org_...",
138
+ "provider": "square",
139
+ "external_id": "...",
140
+ "provider_environment": "production"
141
+ }
142
+ ```
143
+
144
+ Response (HTTP 404) when no non-retired match exists. Webhook handlers MUST handle 404 gracefully (typically by treating the inbound event as referring to a Person Sguild does not yet know about and either dropping it, queueing it for manual review, or triggering a mint flow if the provider payload carries enough identity signal).
145
+
146
+ This is the hot-path endpoint for inbound provider webhooks. Platform optimizes it as a single indexed lookup (the partial index on `(organization_id, provider, external_id) WHERE retired_at IS NULL`).
147
+
148
+ ### 5.3 Write paths
149
+
150
+ Externals are created by:
151
+
152
+ The one-shot Airtable migration script per ADR-0003 action item 6, which reads today's `Client Externals` table and writes one `person_externals` row per (Client × Org × Square account).
153
+
154
+ A consuming domain that observes a new external mapping during normal operation. For example, when Revenue completes a Square customer creation flow for a new buyer, Revenue calls a Platform write endpoint to register the new external mapping. Write endpoint:
155
+
156
+ ```
157
+ POST /identity/v1/person/{person_id}/externals
158
+ ```
159
+
160
+ Body:
161
+
162
+ ```json
163
+ {
164
+ "organization_id": "org_...",
165
+ "provider": "square",
166
+ "external_id": "...",
167
+ "provider_environment": "production",
168
+ "metadata": {}
169
+ }
170
+ ```
171
+
172
+ Response (HTTP 201) returns the created `person_externals` row. If the (`person_id`, `organization_id`, `provider`, `provider_environment`) tuple already has a non-retired row, returns HTTP 409 with the conflicting row in the body; the caller decides whether the existing mapping is correct (no-op) or whether to retire the existing mapping and create a new one (separate retire+create call sequence).
173
+
174
+ To retire an external mapping:
175
+
176
+ ```
177
+ POST /identity/v1/externals/{person_external_id}/retire
178
+ ```
179
+
180
+ Sets `retired_at` to now. Idempotent: retiring an already-retired row is a no-op and returns HTTP 200.
181
+
182
+ There is no DELETE. Externals are retired, not removed; the audit trail is load-bearing for cross-provider migration debugging.
183
+
184
+ ### 5.4 Person merge: external reattachment
185
+
186
+ Added 2026-05-09. When `mergePersons(personA, personB)` runs in `modules/person/service.ts` and one Person becomes the canonical (per the older-wins rule in `person-resolution-semantics.md` §3.3) while the other becomes the alias, externals on the alias Person are re-keyed to the canonical Person as part of the same transaction that promotes nullable fields and marks the alias as merged. External reattachment is a Platform-internal mechanic; consumer domains do not call any extra API to make merges work, and consumer reads against the canonical Person's externals automatically reflect the post-merge state.
187
+
188
+ The reattachment flow handles the unique-constraint case where both pre-merge Persons held an active external in the same `(organization_id, provider, provider_environment)` tuple. For each active external on the alias Person, the merge handler queries whether the canonical Person already has an active external in the same tuple. If yes, the handler retires the alias's external (sets `retired_at = NOW()`) before re-keying its `person_id` to the canonical Person; the now-retired row stays on the canonical Person's `person_id` for audit, recording that the canonical Person historically held both `external_id`s in that tuple. If no, the handler re-keys the alias's external directly without retiring; the partial unique index does not collide because no active row exists for the tuple on the canonical Person.
189
+
190
+ Where two externals on the same tuple have different `created_at` values, the older external survives as active and the newer one retires. Where the timestamps are identical (both registered in the same insert pass during a migration, for instance), the canonical Person's external wins by the same lexicographic-ID tiebreak that `person-resolution-semantics.md` §3.3 uses for Person merges themselves. The rule is `mergePersons`-internal and does not surface on the contract API; consumers reading the canonical Person's externals after the merge see exactly one active row per tuple, with retired rows visible via `?include_retired=true` on the forward-lookup endpoint per §5.1.
191
+
192
+ The transaction guarantees that either all of the alias's externals reattach successfully (with the right retire-vs-re-key choice per tuple) or none do; partial-merge states where some externals reattached and others did not are not observable. The audit-log entry produced by `markPersonMerged` carries the externals-touched list so the merge is reconstructible from the audit trail alone.
193
+
194
+ ## 6. Consumer responsibilities
195
+
196
+ ### 6.1 Identity boundary
197
+
198
+ Consumers MUST resolve external IDs to canonical `person_id` via Platform's reverse-lookup endpoint. Consumers SHALL NOT maintain their own (provider, external_id) → person_id mapping in their own tables, because the canonical mapping evolves (provider swaps, retirements) and consumer-side caches drift.
199
+
200
+ ### 6.2 Provider-specific data goes in the consuming domain
201
+
202
+ Consumers store provider-specific business data in their own tables, keyed off the `external_id` Platform returns. Revenue's payment-method tokens are Revenue-side schema; Quo's SMS opt-in state is the Quo-consuming domain's schema. Platform's `person_externals` carries identity-shaped fields only.
203
+
204
+ ### 6.3 Webhook hot path
205
+
206
+ The reverse-lookup endpoint is the hot path. Consumers calling it from webhook handlers SHOULD handle the latency budget at their layer; Platform optimizes the endpoint but cannot guarantee network round-trip times. Caching at the consumer layer is permitted for the short term (e.g., a 60-second in-process cache keyed by request); long-lived caches are discouraged because they delay provider-swap propagation.
207
+
208
+ ### 6.4 Org isolation
209
+
210
+ Consumers MUST scope reverse-lookup calls by `organization_id`. A Square `customer_id` is meaningful only within the Sguild org that owns the corresponding Square account; cross-org lookups by external_id are nonsensical and return 404 by design.
211
+
212
+ ### 6.5 Retired-row handling
213
+
214
+ Consumers SHOULD NOT use external IDs from retired rows for new business operations. The forward-lookup endpoint allows including retired rows for audit purposes; the reverse-lookup endpoint excludes them from active resolution. If a consumer needs to interpret a historical reference to a retired external (e.g., backfilling a report), use the forward lookup with `include_retired=true`.
215
+
216
+ ## 7. Producer responsibilities (Platform)
217
+
218
+ ### 7.1 Mint discipline
219
+
220
+ Platform SHALL NOT generate `external_id` values. External IDs are always supplied by the consuming domain (which got them from the provider) on the write path.
221
+
222
+ ### 7.2 Constraint enforcement
223
+
224
+ Platform SHALL enforce the unique constraint on `(person_id, organization_id, provider, provider_environment)` at the database layer. Application-layer dedup is not sufficient because race conditions on concurrent webhook processing exist.
225
+
226
+ ### 7.3 Reverse lookup performance
227
+
228
+ Platform SHALL maintain the reverse-lookup index and SHOULD monitor the endpoint's latency as part of identity-service observability. The 95th percentile SHOULD stay under 50 ms at the Platform service layer; consumer-perceived latency includes network round trip.
229
+
230
+ ### 7.4 Soft delete only
231
+
232
+ Platform SHALL NOT physically delete rows from `person_externals`. Retirement via `retired_at` is the only deactivation path. Hard deletion is reserved for compliance-driven data removal (e.g., a future GDPR request) and is out of scope for this contract.
233
+
234
+ ### 7.5 Backward compatibility
235
+
236
+ The application-layer provider enum is additive. Adding a provider (e.g., `quo`) is a documentation update plus the application-layer enum extension; consumers running against the older enum SHALL handle unknown provider values gracefully (per the event-envelope contract's §9.5 pattern, which applies here by analogy).
237
+
238
+ ## 8. Security and privacy
239
+
240
+ ### 8.1 PII footprint
241
+
242
+ External IDs themselves are not directly PII (they are provider-side opaque identifiers), but they map to canonical Persons that carry PII. The reverse-lookup endpoint MUST be authenticated and authorized; only services with a legitimate identity-resolution need are permitted to call it.
243
+
244
+ ### 8.2 Cross-org isolation
245
+
246
+ A reverse lookup with `organization_id=A` SHALL NOT return a row from `organization_id=B`, even if the same external_id happens to exist under a different org's provider account. Platform enforces this at the storage-access layer.
247
+
248
+ ### 8.3 Audit logging
249
+
250
+ Platform SHALL log every reverse-lookup call with the calling service, the requested (provider, organization_id, external_id), and the outcome (found / not-found). The audit log feeds the platform-wide identity-resolution observability story; data retention follows Platform's general identity-audit policy.
251
+
252
+ ## 9. Migration mapping from Airtable
253
+
254
+ Today's Airtable shape:
255
+
256
+ - Org-scoped `Client Externals` table holds Square `customer_id` per (Client × Organization).
257
+
258
+ Postgres-era target:
259
+
260
+ - One `person_externals` row per existing Airtable Client Externals row.
261
+ - The mint script reads each Client Externals row, joins to the canonical Person mint output (per ADR-0003 action item 6), and inserts a `person_externals` row with `provider='square'`, `external_id` from the Airtable External Person ID field, `organization_id` from the Airtable org link, `provider_environment='production'` (today's Airtable does not distinguish environments; the migration writes production for all rows).
262
+ - Airtable-side metadata on Client Externals (account-version stamps, etc.) goes into the JSONB `metadata` column on the migrated row.
263
+ - The Airtable Client Externals table is retained as legacy reference until Revenue migrates off Airtable; once Revenue is on Postgres, the Airtable table can be dropped.
264
+
265
+ ## 10. Change log
266
+
267
+ Initial draft, 2026-05-02. Lands as a sub-spec of the Identity Contract when v1 ships. ADR-0010 carries the architectural decision rationale.