An authoritative field-by-field audit of where the new patient canvas saves data versus where the legacy "Ask AI" path saved it, scored against live current code. Status colors are locked across the whole page.
Three follow-ups merged today (#510, #483, #482). Two by-design product decisions remain, neither blocking parity. What shipped, how the data flows, and the per-field detail are below.
Start here: the pull requests that delivered the mapping, then two diagrams of where patient data lives and how an onboarding save reaches it. The per-field detail is in Section 3.
Patient demographics live once, in PatientsV2. Every other service keeps only its own slice, keyed by ID. No service copies the demographics.
The facility you pick at onboarding is stored as Patients.DefaultFacilityId. Satellites hold relationship and status data only, reconciled by the PatientCreated event, never a demographic copy.
The canvas creates the patient synchronously and gets a patientId back; the PatientCreated event then fans out to the satellites. CareTeam, enrollment, and eligibility are created reactively, not inline.
This is why folding the create path into the CareTeam admission flow would double-write: the event already creates the CareTeam entry, and the admission flow would clone a second facility patient into the same unique key and throw.
Status is measured against where the old platform saved each field. By-design notes mark fields neither system ever stored.
The two mono columns are the comparison surface. file:line citations sit as secondary footnotes under the note.
| Field | Old platform destination | Today's DB destination | Status | Note |
|---|---|---|---|---|
| First name | Patients.BasicInfo_FirstName | Patients.FirstName (owned PatientBasicInfo) + mirrored GlobalPatient demographics | Match | Canvas firstName to PATCH /Patient/Demographics.PatientBasicInfo.cs:10 |
| Last name | Patients.BasicInfo_LastName | Patients.LastName (owned) + GlobalPatient | Match | PatientBasicInfo.cs:11 |
| DOB | Patients.BasicInfo_DateOfBirth | Patients.DateOfBirth (owned, DateOnly) + GlobalPatient | Match | FE normalizes to ISO; unparseable to null (no fabrication).PatientBasicInfo.cs:20 |
| Sex / gender | Patients.BasicInfo_Gender | Patients.Gender (enum, owned) + GlobalPatient | Match | FE M/F/X to 1/2/3; UI offers only F/M/X.PatientBasicInfo.cs:21 |
| SSN | Patients.BasicInfo_SSN | Patients.SSN (owned) + GlobalPatient | Match | Same role-based masking gate old and new.PatientBasicInfo.cs:25 |
| Member ID / MBI | Not in old demographics tool | GlobalPatients demographics .MBI via GlobalPatient.SetMbi() | Match | New field, no old equivalent; FE PUT /Patient/MemberId, MBI normalized to 11-char.CreatePatientCommandHandler.cs:151-156 |
| MedicaidId | Not in old | GlobalPatients demographics .MedicaidId via SetMedicaidId() | Match | New, no old equivalent.CreatePatientCommandHandler.cs:158-163 |
| MRN / Medical Record # | PatientPersonalInformation.MR (old free-text "MR" field) | No canvas input; echoed read-only as mr (seeded from Apollo) to avoid clobber; no edit route | Removed | Dead PUT /Patient/Mrn 404 removed (#506). No structured MRN column exists in Domain. |
| Room | Patients.Room (owned FacilityInfo) | Patients facility-context Room via SetFacilityContext | Match | FE PUT /Patient/{id}/room-wing; WingId/WingName not sent by canvas.CreatePatientCommandHandler.cs:103-112 |
| Language | Not captured by old path | Ignored - no column anywhere in Domain | Removed | No language input in canvas; no DB column. Parity with old (also absent). |
| Marital status | Not captured by old path | Ignored - no column anywhere in Domain | Removed | No input; no DB column. Parity with old. |
| Phone | Patients.BasicInfo_PhoneNumber | Patients owned PatientBasicInfo.PhoneNumber + GlobalPatient | Match | FE PUT /Patient/PhoneNumber, normalized to raw digits.PatientBasicInfo.cs:16-19 |
| Patients.BasicInfo_Email | Patients owned PatientBasicInfo.Email + GlobalPatient | Match | FE in PERSONAL_INFO_FIELDS to PATCH /Patient/Demographics. | |
| Facility / Place of Service | Facility scoping via GlobalPatient / tenant context (no demographics column) | Patients.DefaultFacilityId set at create via SetFacilityContext; canvas-edit placeOfServiceId scalar routes to local_only and is dropped on flush | Open | Create-time facility scoping works; the canvas Place-of-Service edit scalar is captured-but-unsent with a "coming soon" warning - no scalar PUT contract.CreatePatientCommandHandler.cs:78-112 ; canvas-draft-store.ts:247,263-267,295-300 |
| Doctor / primary physician | Patients.PrimaryPhysicianId + PatientProviders | Handled via contacts / care-team path (see Patient Circle) | Match | Old resolved doctor_name to provider; current threads provider via Contact / CareTeam. |
| Field | Old platform destination | Today's DB destination | Status | Note |
|---|---|---|---|---|
| Address rows (Home / Billing / Place-of-Service / Other) | Address table (FK PatientId): AddressLine1, City, State, PostalCode, Latitude, Longitude, Type, BuildingNumber | Addresses.* (Address.Create): AddressLine1, City, State, PostalCode, AddressLine2, BuildingNumber, CountryCode, FullAddress, Description, Type, Lat/Long, UseForDelivery/Billing/Mailing, IsDefault | Match | FE POST /PatientAddress/Add (one POST per row); type to server enum home 1 / billing 5 / place-of-service 3 / other 5. Brittle parseUsAddress regex deleted (#506/#509); discrete fields backfilled from Mapbox context. Row skipped (not 400) if geocode null or city/state unresolved; OCR auto-fill only on exact confidence.CreatePatientCommandHandler.cs:200-242 ; Address.cs:9-32 |
| Field | Old platform destination | Today's DB destination | Status | Note |
|---|---|---|---|---|
| Primary diagnosis (text) | PatientPersonalInformation.MainDiagnosis | PatientPersonalInformation.MainDiagnosis (comma-joined free-text) | Match | Row 0 to main; FE POST /Patient/update-personal-info. Section serializer preferred over dirty-only builder, so all rows persist.PatientPersonalInformation.cs:10 |
| Secondary diagnosis (text, incl. 3rd+) | PatientPersonalInformation.SecondaryDiagnosis (extras comma-joined) | PatientPersonalInformation.SecondaryDiagnosis (rows 1..n comma-joined) | Match | More-than-2-rows-dropped bug FIXED (#506).PatientPersonalInformation.cs:11 |
| ICD-10 code | None - no ICD column (string FDB-coded server-side) | No dedicated ICD column; codes live only as display text inside the free-text strings; Patient.cs:300-336 GetDiagnosisList emits CodedDto{Code==Description==text} | Open | Captured in UI (per-row, OCR-seeded) but explicitly display-only, not serialized. Decided-by-design: matches old (old also had no column). Open only if product later wants stored ICD-10.clinical-snapshot-section.tsx:304-309 |
| Field | Old platform destination | Today's DB destination | Status | Note |
|---|---|---|---|---|
| Allergen / description | Owned allergy child table, AllergyDescription | PatientAllergiesInfo.Description (owned collection) | Match | FE allergies:[{description, changeStateType:'Add'}] to update-personal-info; empty rows filtered.PatientAllergiesInfo.cs:16-17 |
| AllergyId (FDB code) | Owned allergy table AllergyId (legacy DEFECT: Guid.NewGuid() fabricated on FDB miss) | PatientAllergiesInfo.AllergyId set when FDB-resolvable, null when not (flag-not-fabricate) | Match | Improved over old: FE omits AllergyId, BE no longer fabricates.UpdatePatientPersonalInformationCommandHandler.cs:117-142 |
| Severity | None - no column | Ignored - no Severity property on entity/model | Open | Captured per-row + OCR-seeded in UI, never on DTO. By-design parity with old (neither stored it).PatientAllergiesInfo.cs:16-19 |
| Reaction | None - no column | Ignored - no Reaction property | Open | Same as severity - captured, never persisted. By-design parity with old. |
| NKA confirmed | n/a | allergies:[] sent (empty array) when nkaConfirmedRef true | Match | New affordance; serializer sends empty array.clinical-snapshot-section.tsx:494-495 |
All land on PatientMedications (owned MedicationInfo + RXInfo) via the NEW canvas route POST /api/Patient/medications to CreatePatientMedicationCommand (PatientController.cs:918-922), NOT legacy /PatientMedicaion/Add.
| Field | Old platform destination | Today's DB destination | Status | Note |
|---|---|---|---|---|
| Name | MedicationInfo.MedName | PatientMedications.MedName (owned MedicationInfo.MedName) | Match | CreatePatientMedicationCommandHandler.cs:98 |
| Strength | MedicationInfo.MedStrength | MedicationInfo.MedStrength | Match | FE splits strength out of name. |
| Dose form | RXInfo.DoseFormDesc / DoseFormId | RXInfo.DoseFormDesc | Match | FIXED #506 (FE send) + #259 (extractor packs dose_form). |
| Route | RXInfo.Route | RXInfo.Route | Match | FIXED #506/#259 (was dropped). |
| Directions | RXInfo.Directions | RXInfo.Directions | Match | |
| PRN | RXInfo.Isprn | RXInfo.Isprn | Match | |
| Qty / total units | RXInfo.Quantity | RXInfo.Quantity | Match | FIXED #506/#259 (qty parsing added). |
| Start / End date | RXInfo.StartDate / RXInfo.EndDate | RXInfo.StartDate / RXInfo.EndDate | Match | FIXED #506 (was dropped). |
| Refill date | RXInfo.NextRefillDate | RXInfo.NextRefillDate | Match | FIXED #479 (threaded RefillDate to NextRefillDate). |
| Schedule / frequency | PatientMedicationSchedule + PatientMedicationAdminHour child tables | Same child tables via PatientMedicationScheduleRequest{AdminHours} to IMedicationAdminHoursCalculator.GenerateSchedules | Match | FIXED #479; validator rejects unparseable times. Minor open nit: Repeated not set, defaults can produce a weekly-on-sync-day schedule instead of Daily (unfixed Greptile nit).CreatePatientMedicationCommandHandler.cs:193-200 ; :196-199 |
| Field | Old platform destination | Today's DB destination | Status | Note |
|---|---|---|---|---|
| Payer / member id (manual) | PatientInsuranceInfo owned collection (via POST /{patientId}/insurance / AddInsuranceInfo) | Canvas does NOT write it; dead FE writer (buildInsuranceDtos / saveInsurance) deleted (#506). Legacy BE endpoint still exists but unused by canvas. | Match | Orphaned split-brain writer removed - no save-read disagreement remains.ac640850 |
| Eligibility (Stedi 271) | n/a (old had no eligibility) | GlobalPatientInsurance (read path: hooks.ts / queries.ts); priority set upstream by RunEligibility UpsertProgramRow | Match | Canvas insurance is read-only from Stedi GlobalPatientInsurance; empty = eligibility not yet run, by design. Eligibility-drives-insurance shipped.7b7674e5 |
| GlobalPatientInsurance vs PatientInsuranceInfo | Old wrote PatientInsuranceInfo | Authoritative ranking on GlobalPatientInsurance (CoverageStatus / Source); PatientInsuranceInfo has no ordering and is no longer canvas-written | Match | Resolved-by-design (was the "split-brain"). patientInsurance value-object is unordered; ranking is on GlobalPatientInsurance. |
| Field | Old platform destination | Today's DB destination | Status | Note |
|---|---|---|---|---|
| Contact / POA name | Provider / contact resolution | Contact (name required) | Match | POA flag = role-label OR extracted is_power_of_attorney (#506).571f0d7e |
| POA / contact phone | Contact phone | Contact phone | Match | Persists, and activation gate relaxed to phone-OR-email (#483) - PatientStateMachine.cs now hasPoaContact = phone || email; email-only POA activates.679fd40e |
| POA / contact email | Contact email | Contact email | Match | BE relaxed to "at least one of phone/email" (#479); FE now sends an email-only POA standalone (PR #510 merged). Email-only POA persists.7ed2f87b |
| NPI | Provider record | Contact NPI | Match | Threaded into Contact (#506).571f0d7e |
| Specialty | Provider record | Contact Specialty | Match | Threaded into Contact (#506). |
| Care team member | PatientProviders / provider resolution | CareTeam_Member resolver pre-fills extracted clinicians | Match | Resolver added #506; role-derived relationship bot-fix.037d2763 |
| Field | Old platform destination | Today's DB destination | Status | Note |
|---|---|---|---|---|
| Face sheet | Not persisted in old "Ask AI" path | Patient Document (DocumentType=FaceSheet) via POST /Patient/add-document | Match | FIXED #506 (FE-only via blob objectUrl); idempotency re-keyed by patientId:artifactId (#509). |
| Field | Old platform destination | Today's DB destination | Status | Note |
|---|---|---|---|---|
| ADL | Existed in canvas pre-fix | Removed from canvas | Removed | Deleted #506.47906c57 |
| Vitals | Existed pre-fix | Removed | Removed | Deleted #506. |
| IADL | Existed pre-fix | Removed | Removed | Deleted #506. |
Each item tagged for whether it blocks a Match, and whether it is ship-ready or needs a product decision. Items 01, 02, 04 (PRs #510, #483, #482) are now merged and shown for context; two remain (03 and 05, both product decisions, neither blocks parity).
Backend accepts a phone-less, email-bearing POA (#479); the FE now sends an email-only contact standalone, so an email-only POA persists end-to-end. Done.
merge 7ed2f87bThe activation gate now accepts phone OR email: PatientStateMachine.cs uses hasPoaContact = phone || email (the "poa_phone" missing-key string is preserved so the task creator and FE still parse it). An email-only POA now persists AND activates the patient end-to-end. Done.
PR #483 ; merge 679fd40eCreate-time facility scoping works. The canvas Place-of-Service edit scalar (placeOfServiceId) routes to local_only and is dropped on flush, surfaced with a "coming soon" warning, because there is no scalar PUT contract yet.
canvas-draft-store.ts:247,263-267,295-300The canvas schedule request now sets Repeated = Repeated.Daily explicitly (it already defaulted to Daily via the enum zero-value, so no behavior change - this aligns the one site that relied on the implicit default and guards against future enum reordering). Done.
PR #482 ; merge 38abf516Captured in the UI but never serialized because no column exists in Domain (and none existed in the old path either). These are Open only if product later wants them stored. Not divergence from the old platform.
The canvas-to-PatientsV2 mapping is substantially conformant: every field the old "Ask AI" path persisted now lands in the same table and column, and the orphaned insurance and dead-route writers were deleted. With the poa_phone activation gate now relaxed (#483) and the med-schedule default made explicit (#482), the only things that do not round-trip were never columns in either system, or are deliberately read-only.
No item on the open list blocks a Match where the old platform actually stored data. The medications, insurance, and contacts domains are fully resolved; demographics, diagnoses, and allergies carry only by-design gaps.