Validate phone numbers at the intake boundary
What happened
An operator could not complete a sales-ordering close. The final step returned "Square customer is missing payer email or phone after customer sync." That message originates in Revenue's close endpoint and Sales surfaced it verbatim. The cause was not a missing phone. The lead had a phone, but it was corrupt: a doubled US country code that reduced to a national number with an invalid area code (it rendered in the operator UI as "(191) 891-4989", and the area code "191" is not a valid NANP area code). Square rejected it at customer sync, so the synced customer had no usable phone and no email, and the close failed.
The important part for Growth and Platform: one corrupt value from a single intake reached two separate stores.
- Platform
Person.phone_normalized, which the close reads through the operator identity contact. This is the value Revenue forwards to Square. - Sales
LeadIntakeSnapshot.phone, the mirror that drives the workbench display.
Because they are independent copies, correcting one does not fix the other. The operator fixed the Platform record (which unblocks the close), and the Sales display still showed the bad number until the snapshot was corrected separately.
Root cause
Nothing on the write path rejects a phone whose national form is not a real NANP number. Platform's normalizePhone in modules/person/normalize.ts correctly prepends a 1 to a clean 10-digit number, but it passes an already-malformed input straight through to storage. The intake capture that seeds both stores does the same. So a doubled country code, or any number with an invalid area code or exchange, is stored as-is and only fails much later, at Square, with an opaque message.
Compounding this: phone is immutable downstream by the intake-amendment contract. Sales applies Growth's intake.amended events but is explicitly forbidden from mutating phone or email. So once a bad phone lands, there is no supported in-app path to correct it on the Sales side, which is why a one-off data edit was needed.
What Sales has already done
These shipped in the Sales repo and are pending deploy. They reduce blast radius but do not fix the source.
A pre-flight payer-contact check now runs in the sales-ordering close before any soft-hold is created. It validates the email, normalizes the phone to E.164, and rejects any number whose national form is not a valid NANP number. It drops an invalid value so Revenue only ever receives well-formed contact, and when neither a valid phone nor email is present it returns a clear operator message ("correct the phone number or add an email before booking") instead of the cryptic Square error after the fact. A bad phone no longer blocks a close that a valid email could fund, and it no longer leaves an orphaned soft-hold.
A guarded, dry-run-by-default script corrects a single LeadIntakeSnapshot.phone when a value was already corrupted, validating the replacement as a real US number before writing.
Asks
Growth: validate and normalize phone at the intake boundary so
intake.capturednever emits a phone whose national form is not a valid NANP number. Reject or flag at capture rather than storing a value that fails three systems downstream.Platform: harden
mintOrMatchPersonandnormalizePhoneto reject or flag an invalid national number rather than storing it, and run a one-time sweep ofPerson.phone_normalizedfor invalid NANP values. One caveat on scope: the only data Sales could inspect was local seed data, where the only invalid numbers were the intentional 555-0190 fiction block, so the production set should be checked directly rather than assumed clean.Shared decision: because phone is contract-immutable downstream, a legitimately corrected phone has no supported propagation path to consumers today. Please decide the supported correction path (a targeted re-capture, a dedicated correction event, or an intake-amendment contract change that allows phone with provenance) so a fix flows to both Platform identity and the Sales snapshot without per-row manual edits.
References
Revenue's sales-ordering-surface close is where the error surfaced. The normalization gap is in Platform modules/person/normalize.ts. The Sales mitigations live in the close-orchestration pre-check and the fix-lead-snapshot-phone script.