Status: Accepted · Date: 2026-05 · Deciders: Aarokya Engineering ·
Supersedes: ADR-004 (wire shape only)
Context
ADR-004 shipped aprovider-tagged discriminated union for the list-messages
response. Each provider carried its own message types (NarayanaConsultationMessage*,
NarayanaConsultationMessageBody, …), surfacing the upstream vocabulary 1:1 on the wire.
The principle was additive provider onboarding — a new provider would land as a new
union variant without changing existing ones.
In practice, the cost showed up before the second provider:
- Mobile + dashboard clients had to learn each provider’s wire vocabulary. A union with variant-specific message shapes meant downstream parsers per-provider. The “additive” property was paid for by every client.
- Provider-specific leaks (e.g. Narayana’s
iso8601_istIST-shifted naïve timestamp, the two-id model ofid+chat_message_idon attachments, free-formsystem_event.kindstrings) all flowed straight to the client. - Adding a provider still requires a wire shape change — the discriminator value, the schema, the per-variant types. The union framing only seemed additive.
Options Considered
Option A — Flatten at API boundary, keep domain provider-keying (chosen)
Option A — Flatten at API boundary, keep domain provider-keying (chosen)
api_models becomes neutral: one ConsultationMessage, one ConsultationMessageBody,
one ConsultationMessages { provider, messages } envelope (a plain struct — provider
is informational metadata, not a serde discriminator). The domain layer keeps
DomainConsultationMessages::Narayana(Vec<NarayanaDomainMessage>); the adapter maps
its own domain message into the neutral API shape in utils/consultation/narayana.rs
via the existing *Ext::to_response convention.Pros: clients see one shape; the “adapter owns its domain types” invariant from
ADR-004 survives where it actually pays off (DomainThread, DomainAttachmentStream, the
way the create-thread call carries upstream-specific data); adding a provider becomes
one mapping arm in utils/consultation.rs instead of a public schema change.
Cons: two shapes per concept on the inside (neutral api + provider-keyed domain).
Mild duplication, paid by the adapter author, not by clients.
Verdict: Accepted.Option B — Push neutrality into the domain layer too
Option B — Push neutrality into the domain layer too
Delete
NarayanaDomainMessage; the adapter constructs neutral domain messages directly.Pros: one shape end-to-end.
Cons: burns the “adapter owns its domain types” invariant — a future provider with
a richer chat model would either drop fields or re-introduce per-provider types,
effectively undoing this refactor.
Verdict: Rejected.Option C — Envelope rename only
Option C — Envelope rename only
Keep
NarayanaConsultationMessage* on the wire; only collapse the union → struct so
the discriminator becomes a regular field.Pros: smallest change.
Cons: doesn’t meet the goal — clients still parse provider-shaped messages.
Verdict: Rejected.Decision
The list-messages response carries one neutral message shape we own. Theprovider field
stays as informational metadata on the envelope (not a serde tag).
datais a struct, not a serde-tagged union.provideris a plainBenefitProviderKindfield.Narayana*message types are removed fromapi_models. Neutral types (ConsultationMessage,ConsultationMessageBody,ConsultationMessageText,ConsultationMessageAttachment,ConsultationMessageSystemEvent) replace them.- New field
sender_roleon each message:PATIENT | DOCTOR | SYSTEM | UNKNOWN. The Narayana adapter classifies from upstream sender metadata; the current heuristic is a prefix-match againstPATIENT_DISPLAY_NAME_PREFIX("Patient:", which we ourselves mint on outbound). System message types collapse toSYSTEM. - New enum
SystemEventKind(PARTICIPANT_ADDED | PARTICIPANT_REMOVED | TOPIC_UPDATED | OTHER) replaces the free-formsystem_event.kindstring.OTHERis the forward-compat fallback if an adapter ever sees an upstream kind it doesn’t know — keeps the response parseable instead of dropping the page. - Timestamp rename
created_on→created_at; serde becomes the project-standardiso8601(UTCZ). The earlieriso8601_istwas wrong (treated naïve UTC as IST and shifted by +5:30 on the wire) and is now unused. - Attachment field rename
chat_message_id→id(the same identifier clients use to pull the binary from/attachment).
Consequences
Gained
- Clients see one shape regardless of provider. New providers no longer break parsers.
- Adding a provider is a single
*Ext::to_responsemapping inutils/consultation/<provider>.rs. No public schema change. - Provider-shaped leaks (IST timestamps, two-id attachments, free-form system kinds) closed.
- Adapter-owns-domain-types invariant from ADR-004 stays in force for
DomainThread,DomainAttachmentStream, and any provider-rich create-thread inputs.
Trade-offs
- Hard wire break: mobile + dashboard parsers must reflect the new envelope; no back-compat shim.
SystemEventKind::Otheris the first non-exhaustive wire enum in the codebase. Future enums should follow the same posture deliberately (catch-all variant +warn!on unknown).sender_rolefor Text/Attachment messages today depends on a prefix-match heuristic; if upstream returns messages we didn’t mint (multi-tenant Narayana share?), classification flips toDOCTOR. Acceptable today, documented in AGENT-NOTES.
Related
- ADR-004: Provider-Keyed Consultation Messages (superseded — wire shape)
- Consultation module: modules/consultation, api/consultation-guide
- Backend agent notes:
backend/ai-docs/domains/consultation/AGENT-NOTES.md