Transportation Provider Tier — Feature Spec¶
Status: DRAFT (2026-05-12)
Tracking issue: #713 — closes on merge of implementation chain.
ADR scope: ADR-0018 §K-adjacent — Phase 7 (Transportation Provider Tier), follows Phase 6 (Recovery) which shipped via #821-828, #832, #264.
Pattern reference: This spec mirrors the Recovery feature at every architectural layer. Where a recovery primitive already exists (model, matcher, orchestrator phase, prompt, FE card, workflow state), the transportation primitive is its near-twin with transport-specific axes substituted in. No new abstractions are introduced — only new instances of the recovery pattern.
Supersedes: docs/specs/deferred/coordinator-services-transport-spec.md (the deferred 2026-04-15 design). Concepts preserved: transport tiers T1-T4, journey-leg enum, repatriation workflow, vendor cost-model JSONB. Concepts retired: standalone coordinator_service_bookings table (folded into the recovery-pattern data model below).
Clinical review: Dr. Naidu MUST review the transport_planning stage guidance + the medical-safety wording for ambulance / mobility-impaired framings before any prompt YAML lands. Same gate as Recovery (Phase 6) had.
Land target: Flagsmith-gated rollout via new transport_tier_v1 flag (default off; dual env per feedback_flagsmith_dual_env.md).
Don't merge anything in PR-B (prompts) without clinical review. Voice rules, "Curaway coordinates, it does not practice medicine," and the medical-advice ban (
tests/test_no_medical_advice.py) all apply to transport surfaces. A patient post-knee-replacement asking about airport pickup is still a clinical-safety surface.
1. Goals + non-goals¶
1.1 Goals (MVP)¶
| # | Goal |
|---|---|
| G1 | Patient can ask "how do I get from the airport to the hospital?" mid-conversation and receive 2-3 ranked transport options (vendor, vehicle type, ETA, price) via a TransportOfferCard. |
| G2 | Coordinator can see suggested transport per journey leg on a case + orchestrate booking with the vendor (currently off-platform; on-platform when the marketplace flag flips in v2). |
| G3 | Trip booking lifecycle is tracked deterministically — offered → patient_selected → vendor_confirmed → in_transit → completed (mirrors recovery's recovery_offered → recovery_opted_in → recovery_matching → ...). |
| G4 | The transport matcher is a pure scoring function, no LLM, no orchestration — same architecture as recovery_matcher.py. Configurable weights via Flagsmith. |
| G5 | Trip types cover the journey-leg matrix: airport_pickup, airport_dropoff, intercity, hospital_to_recovery, hospital_to_airport, recovery_to_airport, inter_facility, emergency_dispatch. |
| G6 | Vehicle types cover MVP needs: sedan, suv, van, wheelchair_accessible, medical_van, ambulance_basic. Air ambulance + ICU transport deferred (see §1.2). |
| G7 | Multi-currency pricing per route (India = INR, US = USD, Turkey = TRY, etc.) routed through currency_service per CLAUDE.md ground rules. |
| G8 | All transport surfaces (matcher, orchestrator phase, prompt, FE card) participate in the v6 prompt architecture as a stage (transport_planning) — NOT v4/v5 work. |
1.2 Non-goals (explicitly deferred — open question §14 for SD)¶
- Ride-hailing API integration (Uber / Ola / Bolt / Careem). Out of scope MVP. Curaway coordinates with pre-contracted vendors only. If a tenant wants Uber pass-through, it ships in a separate spec post-launch.
- Air ambulance + ICU-level transport. The deferred-spec T4 tier (ICU + fixed-wing repatriation) ships as Phase 7b after MVP. The MVP
vehicle_typeenum reserves slots forambulance_icuandair_ambulancebut the matcher does not score them and the FE card does not render them. Open Q for SD: are ambulances in scope MVP or deferred to 7b? (see §14) - Insurance pre-auth integration. The deferred spec described insurance-coordinated transport reimbursement. MVP captures
cost_modelper vendor but does not run insurance pre-auth. Deferred to Phase 7c. - Driver-app integration (real-time GPS tracking for the patient). Driver details (name, plate, phone) appear 24h before pickup via coordinator workflow; no live tracking MVP.
- Cross-border transport (e.g., land transfer from one country to another). Deferred — vendor
coverage_areaenforces single-country MVP. - Repatriation workflow on-platform. Stays coordinator-managed off-platform for MVP (mirrors the deferred spec's "Phase A — manual"). On-platform repatriation orchestration ships Phase 7b.
2. User stories¶
2.1 Patient (caregiver flow + direct flow)¶
P1 — Pre-travel, mother asking on behalf of son post-knee-replacement booking:
"We've confirmed Apollo for [son's name] for May 20. How does he get from Hyderabad airport to the hospital? He'll be in a wheelchair after the flight."
→ Agent (stage =
transport_planning, knowledge addendum =procedure_clinical_facts/knee_replacementalready active) recognises the transport intent + mobility signal. Single-question response: "Got it — I'll pull a few wheelchair-accessible options between Rajiv Gandhi International and Apollo Jubilee Hills. Want a quote-only shortlist, or should I have the coordinator book one?"→ On affirmation,
transport_matcherruns server-side.TransportOfferCardreturns with 2-3 options: vendor name, vehicle type, ETA, INR price, mobility accommodations.
P2 — Direct flow, post-discharge:
"I'm discharged tomorrow. The hospital recommended a recovery facility in Gachibowli. How do I get there?"
→ Stage =
transport_planning(also matches: workflow showsrecovery_accepted=True, milestone signals discharge). Matcher runs withtrip_type=hospital_to_recovery. Card shows medical-van + sedan options.
P3 — Patient asks about return-home airport drop:
"When does my pickup back to the airport need to be on the day I fly home?"
→ Agent acknowledges, offers to schedule the leg. If accepted,
trip_type=recovery_to_airport(orhospital_to_airport),TransportOfferCardreturns options.
2.2 Coordinator¶
C1 — Coordinator dashboard transport panel:
After patient selects an option in P1, the booking enters state patient_selected. Coordinator gets a notification + dashboard card. Coordinator contacts vendor off-platform (MVP), confirms, flips state to vendor_confirmed. Patient is notified.
C2 — Coordinator overrides patient selection:
If the selected vendor cancels last-minute, coordinator re-runs the matcher (POST /api/v1/transport/cases/{case_id}/rank), picks an alternative, and overrides state back to offered → patient_selected → vendor_confirmed with the new vendor_id. The patient is notified of the swap.
C3 — Coordinator-initiated emergency dispatch:
For escalations (recovery milestone flags escalated=True with severity=high), coordinator can create a transport booking directly without patient selection. State path: offered → vendor_confirmed → in_transit → completed, skipping patient_selected. Flagged as booking_origin=coordinator_emergency for analytics.
3. Data model¶
Mirrors recovery_provider_profiles + hospital_recovery_partnerships + recovery_state patterns from Phase 6. Entity count: 4 new tables + 1 enum extension (table count parity with recovery: 4 tables).
3.1 Tables¶
| Table | Mirrors | Purpose |
|---|---|---|
transport_provider_profiles |
recovery_provider_profiles |
Extended provider profile for tenants with provider_type IN ('transport_ground', 'transport_medical'). One row per vendor. |
hospital_transport_partnerships |
hospital_recovery_partnerships |
M:N — surgical hospitals ↔ preferred transport vendors with partnership_type IN ('hospital_recommended', 'curaway_partnered', 'marketplace'). Same tie-breaker priority as recovery. |
transport_bookings |
recovery_milestones (loosely — bookings are time-anchored events) |
One row per booked trip leg. Holds pickup/dropoff, scheduled_at, actual_at, status, vendor details snapshot, cost. |
transport_outcomes |
recovery_outcomes |
Post-trip vendor ratings (patient + coordinator), on-time-rate, complaint flags. Powers the outcome_score axis in the matcher once a vendor has ≥5 completed trips. |
3.2 transport_provider_profiles (schema sketch)¶
CREATE TABLE transport_provider_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE,
-- Classification
vendor_class VARCHAR(30) NOT NULL,
-- 'ground_standard' | 'ground_accessible' | 'medical' | 'air' (reserved for 7b)
available_24_7 BOOLEAN NOT NULL DEFAULT false,
-- Service area
service_country VARCHAR(3) NOT NULL, -- ISO-3166-1 alpha-3
service_cities JSONB NOT NULL, -- ["Hyderabad", "Bangalore"]
service_airports JSONB, -- ["HYD", "BLR"] (IATA)
service_radius_km INTEGER, -- soft pre-filter, matcher narrows
-- Vehicle inventory
vehicle_types JSONB NOT NULL, -- ["sedan","suv","wheelchair_accessible","medical_van"]
medical_escort_available BOOLEAN NOT NULL DEFAULT false,
oxygen_equipped BOOLEAN NOT NULL DEFAULT false,
-- Pricing (CLAUDE.md: minor units + ISO 4217 — never floats)
pricing_model VARCHAR(20) NOT NULL,
-- 'flat_per_trip' | 'per_km' | 'hybrid'
base_fare_minor_units INTEGER, -- in currency's minor unit (paise/cents)
per_km_minor_units INTEGER,
medical_escort_surcharge_minor_units INTEGER,
pricing_currency VARCHAR(3) NOT NULL DEFAULT 'USD',
-- Conversion to display currency goes through currency_service per CLAUDE.md.
-- SLA
sla_response_minutes INTEGER, -- typical pickup-from-call time
rating_internal NUMERIC(3,2), -- 0.00 to 5.00
-- Audit
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX ix_tpp_tenant_provider ON transport_provider_profiles(tenant_id, provider_id);
CREATE INDEX ix_tpp_service_country ON transport_provider_profiles(service_country);
-- GIN index on service_cities + service_airports for matcher pre-filter
CREATE INDEX ix_tpp_service_cities ON transport_provider_profiles USING gin (service_cities);
RLS: identical to recovery_provider_profiles (tenant_isolation policy + curaway_app SELECT/INSERT/UPDATE grants).
3.3 hospital_transport_partnerships (schema sketch)¶
Direct mirror of hospital_recovery_partnerships. Keys: (hospital_provider_id, transport_provider_id) unique. Fields: partnership_type ('hospital_recommended' | 'curaway_partnered' | 'marketplace'), status ('active'|'suspended'|'expired'), contract_start / contract_end. Tie-breaker priority map in matcher uses the same hardcoded order as recovery (_PARTNERSHIP_PRIORITY in recovery_matcher.py:69).
3.4 transport_bookings (schema sketch)¶
CREATE TABLE transport_bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
transport_provider_id UUID REFERENCES providers(id), -- NULL until matched
trip_type VARCHAR(40) NOT NULL,
-- See §3.6 trip_type enum
vehicle_type VARCHAR(40) NOT NULL,
-- See §3.6 vehicle_type enum
status VARCHAR(30) NOT NULL,
-- 'offered' | 'patient_selected' | 'vendor_confirmed' | 'in_transit' | 'completed' | 'cancelled'
booking_origin VARCHAR(30) NOT NULL DEFAULT 'patient_initiated',
-- 'patient_initiated' | 'coordinator_emergency' | 'coordinator_proactive'
-- Locations (lat/lon + free-text)
pickup_location JSONB NOT NULL, -- {"lat":..., "lon":..., "label":"HYD Airport T1"}
dropoff_location JSONB NOT NULL,
-- Timing
scheduled_at TIMESTAMPTZ,
actual_pickup_at TIMESTAMPTZ,
actual_dropoff_at TIMESTAMPTZ,
-- Vendor snapshot at confirmation (de-normalized for audit)
vendor_snapshot JSONB, -- {name, contact, vehicle_plate, driver_name}
-- Cost
cost_quoted_minor_units INTEGER,
cost_actual_minor_units INTEGER,
cost_currency VARCHAR(3),
-- Patient context
patient_mobility_note TEXT, -- free-text note from triage / coordinator
medical_escort_required BOOLEAN NOT NULL DEFAULT false,
-- Audit
patient_notified_at TIMESTAMPTZ,
coordinator_id UUID REFERENCES tenant_members(id),
cancelled_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_tb_tenant_case ON transport_bookings(tenant_id, case_id);
CREATE INDEX ix_tb_status ON transport_bookings(status);
CREATE INDEX ix_tb_scheduled_at ON transport_bookings(scheduled_at);
3.5 transport_outcomes (schema sketch)¶
Mirrors recovery_outcomes. One row per completed booking. Fields: patient_rating (1-5), coordinator_rating, on_time_pickup (bool), on_time_dropoff (bool), complaint_flagged (bool), complaint_reason. Aggregated via repository method aggregate_scores_for_provider(...) for the matcher's outcome axis.
3.6 Enums¶
trip_type (PostgreSQL ENUM — Alembic creates via DO $$ pattern from n1o2p3q4r5s6 precedent):
airport_pickup -- airport → hotel / hospital / recovery
airport_dropoff -- hospital / recovery → airport (return home)
hospital_to_recovery -- post-op transfer
recovery_to_hospital -- escalation / follow-up
inter_facility -- generic facility-to-facility
intercity -- e.g., HYD → BLR for second opinion
emergency_dispatch -- coordinator-initiated, ambulance class
local_errand -- pharmacy run, supplementary
vehicle_type (PostgreSQL ENUM):
sedan
suv
van
wheelchair_accessible
medical_van -- equipped with oxygen, basic medical support
ambulance_basic -- BLS (basic life support) ambulance
ambulance_icu -- RESERVED — Phase 7b
air_ambulance -- RESERVED — Phase 7b
Booking status (string column, not enum — to allow rapid additions without migration; mirrors recovery_state JSON discipline): offered | patient_selected | vendor_confirmed | in_transit | completed | cancelled. Validated in Python via app/models/transport_booking_state.py (mirrors app/models/recovery_state.py).
3.7 Relationship to existing coordinator_vendors table¶
Migration 4d6c92af188f_seed_coordinator_vendors.py seeded a generic coordinator_vendors table with transport vendors (per the deferred spec's design). The MVP decision is to SUPERSEDE, not extend: transport vendors graduate to full provider tenants with provider_type IN ('transport_ground','transport_medical'). The coordinator_vendors rows for service_type='transport' are migrated into the new transport_provider_profiles table in PR-A's migration (and the corresponding providers rows are inserted as tenant=null, contract-only providers, with Clerk org provisioning deferred).
The deferred spec's "migration path to full provider" already anticipated this (lines 126-131 of deferred/coordinator-services-transport-spec.md). MVP executes that migration on day 1 of PR-A.
Other coordinator_vendors rows (translator, companion, ground_staff service types — if any seeded) stay in coordinator_vendors and are out of scope for this spec.
3.8 Workflow state — transport_bookings_state (JSON discipline mirror of recovery_state)¶
workflow_state.transport on the Case row holds a per-trip-leg state map:
{
"transport": {
"airport_pickup": {"status": "vendor_confirmed", "booking_id": "..."},
"hospital_to_recovery": {"status": "offered", "booking_id": "..."},
"airport_dropoff": null
}
}
Validation + transitions live in app/models/transport_booking_state.py (new file — mirrors app/models/recovery_state.py:1-203).
4. provider_type enum extension¶
Migration n1o2p3q4r5s6_create_provider_type_enum.py (#850) created the base enum with ('hospital','clinic','doctor_clinic','specialist_center'). Migration m7n8o9p0q1r2_add_recovery_data_model.py extended it with ('recovery_rehab','recovery_accommodation','recovery_both').
This spec adds (in PR-A's Alembic migration):
_NEW_TRANSPORT_PROVIDER_TYPES = [
"transport_ground", # standard + accessible ground vendors
"transport_medical", # ambulance + medical-van vendors
]
ALTER TYPE pattern identical to m7n8o9p0q1r2.upgrade() lines 47-50:
for value in _NEW_TRANSPORT_PROVIDER_TYPES:
op.execute(f"ALTER TYPE provider_type ADD VALUE IF NOT EXISTS '{value}';")
Reserved for Phase 7b (NOT added in MVP): transport_air (air ambulance), transport_icu (ICU-level ground ambulance).
5. transport_matcher service¶
File: app/services/transport_matcher.py (new, mirrors app/services/recovery_matcher.py:1-512).
Signature:
async def rank_transport_options(
*,
db: AsyncSession,
tenant_id: str,
case_id: str,
trip_type: TripType,
pickup_location: dict, # {"lat", "lon", "label"}
dropoff_location: dict,
patient_mobility_signal: str | None, # from triage / coordinator
medical_escort_required: bool,
budget_max_minor_units: int | None,
budget_currency: str = "USD",
surgical_provider_id: str | None, # for partnership tie-breaker
limit: int = 3,
) -> list[TransportMatch]:
...
Returns: ranked list of TransportMatch Pydantic models, each holding transport_provider_id, vehicle_type, score, score_breakdown, partnership_type, estimated_pickup_minutes, estimated_cost_minor_units, cost_currency, disqualified, disqualification_reason.
5.1 Scoring axes (mirrors recovery's 7-axis pattern)¶
| Axis | Weight (MVP) | Activated weight (≥5 outcomes) | Notes |
|---|---|---|---|
price_fit |
0.25 | 0.20 | Budget comparison (per _score_cost_fit recovery analog). Penalty escalates beyond 20% over budget. |
vehicle_suitability |
0.25 | 0.20 | Hard gate if mobility signal requires wheelchair_accessible or higher and vendor lacks it; soft score otherwise. |
mobility_accommodation |
0.15 | 0.12 | Oxygen-equipped, medical-escort-available signals against patient need. |
distance_to_pickup |
0.15 | 0.10 | Vendor depot ↔ pickup proximity (Haversine, mirrors recovery's _haversine_km at line 135). |
vendor_rating |
0.10 | 0.08 | Internal rating until ≥5 outcomes, then displaced by outcome_score. |
partnership_tier |
0.10 | 0.10 | Hospital-recommended > Curaway-partnered > marketplace. Same tie-breaker priority map as recovery_matcher._PARTNERSHIP_PRIORITY. |
outcome_score |
0.00 | 0.20 | Activates once vendor has ≥5 completed trips with patient ratings (mirrors MIN_OUTCOME_SAMPLE_SIZE = 5 in recovery_matcher). |
Total: sums to 1.00 in both weight maps.
Flagsmith flag: transport_matching_weights_v1_config (JSON value, parsed via is_feature_enabled + get_feature_value mirror of recovery's _load_weights at line 110).
5.2 Hard disqualifiers¶
(Equivalent to recovery's _score_procedure_capability 1.0-or-0.0 gate at line 239.)
- Vendor's
service_countrydoesn't match pickup/dropoff country. - Vendor's
service_citiesdoesn't include the pickup city. - Patient needs wheelchair-accessible / medical-van / ambulance and vendor's
vehicle_typeslacks it. - Vendor has
status='suspended'on the partnership row (if applicable).
5.3 Tie-breaker¶
Score DESC → partnership priority ASC → provider_id ASC (stable). Identical pattern to recovery_matcher._sort_key at line 505.
6. Workflow validator¶
File: app/models/transport_booking_state.py (new, mirrors app/models/recovery_state.py).
Booking states form a directed graph:
offered ─┬─→ patient_selected ─→ vendor_confirmed ─→ in_transit ─→ completed
│ │
│ └─→ cancelled (terminal)
│
└─→ cancelled (terminal)
└─→ vendor_confirmed (coordinator_emergency only — skips patient_selected)
cancelled is terminal from every state. patient_selected → offered is allowed (re-rank on vendor cancellation).
_ALLOWED_TRANSITIONS map identical shape to recovery_state._ALLOWED_TRANSITIONS at line 39. Raises InvalidTransportTransitionError (mirrors InvalidRecoveryTransitionError at line 67).
Side effects on transition (handled in TransportBookingService, not in the validator):
| Transition | Side effect |
|---|---|
* → patient_selected |
Emit transport.option_selected event. Notify coordinator (Telegram + dashboard). |
patient_selected → vendor_confirmed |
Snapshot vendor details into vendor_snapshot. Lock cost_quoted_minor_units. Patient push notification + SMS 24h before. |
vendor_confirmed → in_transit |
actual_pickup_at = now(). Emit transport.in_transit event. |
in_transit → completed |
actual_dropoff_at = now(). Trigger transport_outcome row creation (status='pending_rating'). |
* → cancelled |
cancelled_reason mandatory. Free up cost_quoted_minor_units reservation if any. |
7. Orchestrator phase / v6 stage¶
7.1 v6 stage definition¶
This feature lands as a v6 stage, NOT a v4/v5 phase context. Per docs/specs/conversation-v6-feature.md §2.2, stages are mutually exclusive and resolved deterministically by app/services/stage_resolver.py.
Proposed stage: transport_planning
Stage profile entry (to be added to config/prompts/stages.yaml in PR-B):
- id: transport_planning
goal: |
Patient is planning a trip leg (airport pickup, hospital transfer, return-home
drop, etc.) or asked about transport logistics. Surface 2-3 ranked options
via TransportOfferCard after the patient confirms they'd like to see them.
guidance: |
[300-500 tokens — see PR-B for full content. Voice rules + medical-safety bans apply.]
cards_to_use:
- TransportOfferCard
- TransportBookingStatusCard
advance_when:
- all transport_bookings for active legs in {vendor_confirmed, completed}
- OR patient explicitly declines transport coordination
do_not:
- Never invent prices. Always say "I'll pull a quote" before showing numbers.
- Never tell the patient a specific vehicle type is "medically required" —
that's a clinical judgment. Frame as "for the mobility signal you described,
these are the appropriate options."
- Never quote ETA without the matcher having run.
- Never list multiple questions in one turn (one-question discipline carries forward).
7.2 Stage resolver predicate¶
Per docs/specs/v6-stage-resolver-truth-table.md (the truth-table doc which this PR follows), the resolver evaluates predicates in declared order. Proposed insertion point — between recovery_offer and recovery_followup:
| Stage | Trigger predicate |
|---|---|
transport_planning |
Patient turn contains transport intent (detected by intent_capture layer extractor's transport_intent: true signal) AND consent_given == True AND any of: pre-op (treatment_started == False), post-op (treatment_completed == True), or recovery active. |
Conflict resolution: if recovery_offer and transport_planning both could match (e.g., patient asks "how do I get to the recovery facility?" right after the recovery offer), recovery_offer wins because the patient hasn't accepted recovery yet — the offer turn precedes the transport-planning turn. Once recovery_accepted == True, transport_planning wins over recovery_followup if the patient's turn is a transport question.
Clinical-rationale review note (mirrors v6-stage-resolver-truth-table's existing clinical-justification rows): Dr. Naidu must review this ordering. The rationale is that a patient asking "how do I get there?" inside a recovery-offer turn is still in the offer-acceptance loop, not in transport-planning yet — premature transport surfacing would feel like up-selling.
7.3 Orchestrator phase handler (v4/v5 fallback path only)¶
File: app/agents/orchestrator_phases/transport.py (new, mirrors app/agents/orchestrator_phases/recovery.py:1-249).
Functions:
- _handle_transport_offer(db, case, message) — Touch 1 Exploratory. Fires when a trip leg has no booking row yet and the patient asks. Sets booking to offered after matcher runs. Mirrors _handle_recovery_offer at recovery.py:24.
- _handle_transport_followup(db, case, message, booking) — fires when an active booking is in vendor_confirmed or in_transit. Used for "when does my pickup leave?" turns. Mirrors _handle_recovery_checkin at recovery.py:120 but without the milestone extractor (transport doesn't have milestones).
For v6 traffic, these handlers are not the primary path — stage_resolver resolves transport_planning and the prompt assembly happens through prompt_loader.compose_v6(). The orchestrator_phases handler is the v5 fallback for the dual-shadow rollout period (per v6 spec §3.x dual-shadow plan).
8. Prompts¶
File: config/prompts/phase_contexts/v2/transport_offer.yaml (v5 path) + stages.yaml entry (v6 path).
Mirrors config/prompts/phase_contexts/v2/recovery_offer.yaml:1-119. Structure (PR-B authors final content; Dr. Naidu reviews; this spec defines the skeleton only):
---
description: "Transport planning — patient asked about a trip leg"
phase: transport_offer
version: v2
context: |
PHASE: Transport Planning (ADR-0018 Phase 7, transport_tier_v1 flag)
Patient asked about a trip leg (airport pickup, hospital transfer, return drop,
etc.). Surface the offer to pull 2-3 options. Do not generate prices inline —
the matcher runs separately and the result returns as a TransportOfferCard.
RESPONSE SHAPE (4-5 lines max):
1. Acknowledgement — one sentence tying back to the patient's context
(procedure / mobility signal / leg they're asking about).
2. Insight — frame as "many patients in your situation use X type of vehicle"
NOT "you need an ambulance" (no medical advice).
3. Question — ONE per turn. "Want me to pull a few options?"
4. Close — short reassurance that it's optional.
VOICE CONSTRAINTS (non-negotiable — CI-enforced):
- NO medical advice. NEVER tell the patient "you need a medical van" or
"you're not fit to walk." Frame as "for the wheelchair signal you
mentioned, accessible vans are typically used." (Mirrors recovery_offer
lines 29-34 — same voice rule, same enforcement.)
- NO inventing prices. If asked "how much?", say "rates vary by vendor and
vehicle — once we run the match I'll show you a few with prices."
- NO outcome promises ("the driver will definitely be on time").
- NO diagnostic framing ("post-op patients can't sit up for long" — that's
a clinical claim).
- ONE question per turn.
RESPONSES TO PATIENT REPLY:
- Interest signal: trigger matcher run. Set extracted_data.transport_intent = "interested".
- Decline signal: acknowledge neutrally. Set extracted_data.transport_intent = "declined".
- Uncertain: ask follow-up about budget / vehicle preference. Set transport_intent = "uncertain".
WHAT NOT TO ASK:
- Don't ask demographics, document data, or anything already in patient_context.
- Don't ask the patient to choose a vendor yet — that's the next turn.
- Don't ask about insurance — out of scope MVP.
examples:
# PR-B authors 2-3 examples (caregiver flow + direct flow + escalation flow).
# Dr. Naidu reviews wording for medical-safety implications.
Cross-reference for Dr. Naidu's review: the same medical-safety wording rules from tests/test_no_medical_advice.py apply. Specifically, the patient post-knee-replacement asking about transport is in a clinical-state surface — phrasing must not say "you should rest" or "you can't walk." Frame as "post-surgical patients typically choose vehicles with wheelchair access for the first few days." See §15 open Naidu questions.
9. Frontend: TransportOfferCard¶
File: apps/patient-app/src/components/chat/cards/TransportOfferCard.tsx (new, mirrors apps/patient-app/src/components/chat/cards/RecoveryOfferCard.tsx).
9.1 Render contract¶
type TransportOfferCardProps = {
case_id: string;
trip_type: TripType;
pickup_location: { label: string; lat: number; lon: number };
dropoff_location: { label: string; lat: number; lon: number };
options: Array<{
booking_id_draft: string; // server stages a draft booking before render
vendor_name: string;
vehicle_type: VehicleType;
estimated_pickup_minutes: number;
estimated_cost_display: string; // formatted via currency_service, locale-aware
partnership_badge?: 'hospital_recommended' | 'curaway_partnered';
mobility_features: string[]; // ["wheelchair_accessible", "oxygen", "medical_escort"]
}>;
patient_action: (booking_id_draft: string, action: 'select' | 'decline_all') => void;
};
9.2 Visual design (defers to design-system showcase per reference_design_system_domain.md)¶
- 2-3 option cards stacked vertically (mobile-first, 375px min width per
mobile-responsive-feature.mdrules). - Each option: vendor name (heading), vehicle type icon + label, ETA, price chip, partnership badge if present, mobility chips.
- Primary CTA: "Choose this option" → calls
patient_action(draft_id, 'select'). - Secondary CTA: "I'll arrange my own transport" → calls
patient_action(draft_id, 'decline_all'). - Curaway brand tokens — teal
#008B8B, coral#FF7F50accents per CLAUDE.md ground rule #6. Use design system showcase for exact tokens. - press-feedback + focus-ring-brand on interactives (per
definition-of-done.mdFE checklist). dir="auto"for RTL support.
9.3 PostHog event tracking¶
Per definition-of-done.md analytics row, the card emits:
- view_transport_offer (with case_id, trip_type, options_count, partnership_mix)
- select_transport_option (with case_id, booking_id, vendor_id, vehicle_type)
- decline_transport_offer (with case_id, trip_type)
10. API surface¶
All routes under /api/v1/transport/.... All use Depends(require_case_access) per CLAUDE.md ground rule #3 (per-resource authorization).
| Method | Path | Purpose | Auth |
|---|---|---|---|
GET |
/api/v1/transport/cases/{case_id}/options |
List transport options for the case's currently-active leg(s). Calls transport_matcher.rank_transport_options(...). Returns 3 ranked options. |
require_case_access |
POST |
/api/v1/transport/cases/{case_id}/options |
Run a fresh matcher pass with overrides (different pickup, different budget). Returns ranked list without writing a booking. | require_case_access |
POST |
/api/v1/transport/bookings |
Create booking row in state offered. Body: {case_id, trip_type, vehicle_type, pickup, dropoff, transport_provider_id}. Returns booking_id. |
require_case_access |
PATCH |
/api/v1/transport/bookings/{booking_id} |
State transition (offered → patient_selected, etc.). Body: {status, [cancelled_reason]}. Validated via transport_booking_state. |
require_case_access |
GET |
/api/v1/transport/bookings/{booking_id} |
Read a booking row + vendor snapshot. | require_case_access (booking inherits case_id) |
GET |
/api/v1/transport/vendors |
List transport providers for tenant. Coordinator-only — uses require_coordinator_role not case access. |
require_coordinator_role |
POST |
/api/v1/transport/bookings/{booking_id}/outcome |
Coordinator (or patient via in-app rating) submits post-trip rating. Creates transport_outcomes row. |
require_case_access |
Coordinator routes: the coordinator_vendors endpoints today serve a generic vendor list. Per §3.7, transport vendors graduate to providers rows. Coordinator UI shifts to query /api/v1/transport/vendors for transport-specific listing. The legacy coordinator_vendors endpoint stays for non-transport service types (translator, companion, ground_staff) but filters transport rows out — handled as a one-line filter in PR-A's repository, no separate endpoint deprecation needed.
11. Multi-currency¶
Per CLAUDE.md ground rule "All money in USD cents + ISO 4217" — and multicurrency-feature.md discipline:
- Storage: all monetary fields in
transport_provider_profilesandtransport_bookingsuse minor units (integer) + ISO 4217 currency code. INR uses paise (1 INR = 100 paise). USD uses cents. - Conversion: display-time conversion routes through
app/services/currency_service.py. The matcher'sprice_fitaxis normalizes vendor pricing into the patient's preferred budget currency before comparing. - Locale-aware formatting:
TransportOfferCardformats prices viaformatMoney(amount_minor, currency, locale)helper (existing pattern from cost-estimate / multicurrency work). - No floats. Per CLAUDE.md "no floating-point currency math." All arithmetic in minor units.
Example: Hyderabad vendor sets base_fare_minor_units = 80000 (= 800 INR = ~9.50 USD at 84:1). Patient with budget_currency = USD and budget_max_minor_units = 2000 (= 20 USD) — matcher converts 80000 INR-paise to ~950 USD-cents, compares to 2000 USD-cents → comfortably under budget → price_fit = 1.0.
12. Phased rollout¶
12.1 PR-A — Data model + matcher (BE)¶
Scope:
- Alembic migration: extend provider_type enum, create 4 new tables + indexes + RLS policies. Migration pattern mirrors m7n8o9p0q1r2_add_recovery_data_model.py.
- Models: app/models/transport_provider_profile.py, transport_booking.py, hospital_transport_partnership.py, transport_outcome.py, transport_booking_state.py.
- Repositories: 4 new repository classes extending BaseRepository with _scoped_query(tenant_id).
- Service: app/services/transport_matcher.py — full scoring function, no LLM.
- API routes (subset): vendor listing + matcher endpoints. Booking write endpoints deferred to PR-B.
- Unit tests + integration tests for matcher (mirrors tests/test_recovery_matcher.py if it exists, or create alongside).
- Migrate transport rows from coordinator_vendors into new schema (one-shot data migration in PR-A's Alembic file).
Estimated size: 2.5 agent-days for 1 Sonnet BE chain (matches recovery's PR #825 ~2-day shape).
12.2 PR-B — Prompts + orchestrator wiring (BE + prompts)¶
Scope:
- New file config/prompts/phase_contexts/v2/transport_offer.yaml (v5 fallback path).
- New stages.yaml entry for transport_planning (v6 path) — coordinates with the open v6 stages.yaml work.
- app/agents/orchestrator_phases/transport.py — _handle_transport_offer + _handle_transport_followup.
- app/agents/extractors/transport_intent_extractor.py — detects transport intent in a patient turn (mirrors recovery_checkin_extractor shape but classifying instead of extracting structured data).
- Booking write endpoints (POST /transport/bookings, PATCH /transport/bookings/{id}).
- Workflow_state.transport JSON discipline + transport_booking_state validator wired into the booking PATCH endpoint.
- Mandatory: Dr. Naidu reviews transport_offer.yaml before merge.
- Baseline-3 + after-3 conversations on caregiver / direct / exploratory personas before any prompt change (per feedback_agent_chat_sacrosanct.md).
Estimated size: 2 agent-days for 1 Opus prompts chain + 1 Sonnet orchestrator-phase chain (prompts must be Opus per model-tier strategy).
12.3 PR-C — Frontend card (FE)¶
Scope:
- apps/patient-app/src/components/chat/cards/TransportOfferCard.tsx.
- New service apps/patient-app/src/services/transportApi.ts (per-chain split per definition-of-done.md speed-up rules — avoids merge conflicts with other chains).
- Wire card into chat rich-content rendering.
- PostHog event tracking for the 3 events listed in §9.3.
- Visual QA via /impeccable polish + /impeccable audit.
- Mobile-first at 375px.
Estimated size: 1.5 agent-days for 1 Sonnet FE chain.
12.4 Total¶
~6 agent-days end-to-end for 1 BE + 1 BE-prompts + 1 FE chain serialized (with Dr. Naidu review gate between PR-A/PR-B).
If parallelized (PR-A independent of PR-C scaffold; PR-B blocks PR-C card data), realistic wall-clock is 3-4 agent-days with 2 BE Sonnet chains + 1 FE Sonnet chain + 1 Opus prompts chain.
13. Estimated implementation size¶
| Layer | Files added | Files modified | Agent-days |
|---|---|---|---|
| BE — data model + matcher | 6 (4 models, 1 state validator, 1 matcher) + 1 Alembic migration | coordinator_vendors repository (filter) |
2.5 |
| BE — orchestrator + prompts + API | 4 (1 orchestrator phase, 1 extractor, 1 prompt YAML, 1 API router) | stages.yaml, stage_resolver.py (for v6 stage entry) |
2.0 |
| FE — card + service | 2 (1 card, 1 service) | chat rich-content renderer | 1.5 |
| Tests | Unit (matcher, state validator, extractor), integration (API endpoints), Playwright (card render) | — | included above |
| Docs | This spec | ADR-0018 amendment row (transport tier ack) | 0.25 |
Total: ~6 agent-days serialized, ~3-4 days parallelized.
Week plan reconciliation: the user's week plan budgets Track D as "3 parallel BE Sonnet chains + 1 FE Sonnet." This spec revises that to: - 1 BE Sonnet (data model + matcher — PR-A) - 1 BE Sonnet (orchestrator + API — PR-B's non-prompts portion) - 1 BE Opus (prompts — PR-B's prompts portion; Opus per ground rule + DoD model-tier table) - 1 FE Sonnet (card — PR-C)
Net: same chain count (4), but one is Opus not Sonnet (prompts can't be Sonnet — definition-of-done.md model-tier row "Architecture, design, compliance, prompt engineering: Opus").
14. Open questions for SD¶
- Are ambulances in scope MVP? Spec §1.2 reserves
ambulance_basicin the enum but defersambulance_icu+air_ambulanceto 7b. Shouldambulance_basicship MVP or also wait? Liability + insurance considerations. - Patient-pay-vendor-direct vs Curaway-as-payment-intermediary? MVP currently assumes the patient pays the vendor directly (off-platform), and Curaway just coordinates. Alternative: Curaway collects payment from patient, settles with vendor (commission opportunity, but treasury complexity). Recovery (Phase 6) currently uses patient-pay-direct — should transport mirror or diverge?
- Are
coordinator_vendorsrows forservice_type='transport'allowed to stay during MVP for a transition window? §3.7 says supersede on day 1 of PR-A. Alternative: dual-write for 2 weeks. Cost: more migration complexity. Benefit: zero-downtime cutover. - Should the matcher consider time-of-day pricing surge? Some vendors charge night premiums. MVP ignores (single
pricing_modelfield). Future: JSONBpricing_overrides. Trade-off: matcher complexity vs price accuracy. - Driver-app integration scope. Phase 7b reserves real-time GPS. SD's preference on whether to leave coordinator-mediated (current MVP) or push for a third-party driver app from day 1?
- Repatriation workflow on-platform or coordinator-managed? Deferred-spec line 213-220 described a manual repatriation flow. MVP keeps it off-platform. Confirm.
- Single-leg booking vs multi-leg trip bundle? MVP: one
transport_bookingsrow per leg. Alternative: atripaggregate that groups legs. Patient might say "book all my transport for the trip." Defer to v2? - Insurance integration timing. Deferred-spec §1.2 noted "Phase 7c." Confirm MVP excludes pre-auth entirely (only
cost_quotedlives on the booking; insurance is patient-managed off-platform).
15. Open questions for Dr. Naidu¶
- Medical-safety wording for the
transport_planningprompt (transport_offer.yaml§8). What is the right framing for "patient with mobility issue post-surgery"? Proposed: "For patients still using a wheelchair after [procedure], accessible vehicles are typically used." Is "still using a wheelchair" appropriate, or does it imply a clinical state we shouldn't acknowledge? Alternative phrasings welcome. - Ambulance recommendation language. If the matcher returns
medical_vanas top option because the patient mentioned post-op oxygen-dependence, how should the agent surface this? "Many patients with oxygen needs choose medical vans" feels OK; "you should take a medical van" is medical advice. Confirm the line. - Escalation transport wording. When a recovery milestone flags
escalated=True(pain ≥7), coordinator may dispatch transport to the hospital. Should the agent proactively suggest transport in that conversation, or only respond if asked? (Recovery's existing escalation prompt does NOT proactively suggest transport — should it?) - Mobility signal sensitivity. The intent extractor will look for words like "wheelchair," "can't walk," "weak," "tired," "post-op," "in pain" to set the mobility signal. Is "in pain" too clinical a trigger for routing to medical-van options? Would prefer narrower triggers?
- Driver/vendor name disclosure timing. MVP: driver name + plate appear 24h before pickup. Is there a clinical-safety reason to disclose earlier (e.g., patient anxiety)? Or later (e.g., privacy)?
- Stage-resolver clinical rationale for
recovery_offer>transport_planningordering (§7.2): if a patient asks "how do I get there?" inside a recovery-offer turn, the resolver keeps them in recovery_offer (not transport_planning) until they accept. Is this the right clinical sequencing, or should transport be surfaced concurrently? - Voice rules carry-forward. Recovery's prompt bans "I hear you / I understand / completely natural to feel" (line 36 of
recovery_offer.yaml). Same bans for transport, or are there transport-specific phrases we should also ban (e.g., "no rush," "we'll handle everything")?
16. References¶
- ADR-0018 §K (Phase 6, Recovery — the pattern this spec mirrors).
- ADR-0018 Phase 7 — to be amended with a "Transportation Provider Tier" sub-section after this spec lands. Amendment is part of PR-A.
- Recovery files mirrored (with line numbers used as templates):
app/models/recovery_provider_profile.py:1-105→transport_provider_profile.pyapp/models/recovery_state.py:1-203→transport_booking_state.pyapp/services/recovery_matcher.py:1-512→transport_matcher.pyapp/agents/orchestrator_phases/recovery.py:1-249→orchestrator_phases/transport.pyconfig/prompts/phase_contexts/v2/recovery_offer.yaml:1-119→transport_offer.yamlalembic/versions/m7n8o9p0q1r2_add_recovery_data_model.py:1-525→ PR-A's migrationapps/patient-app/src/components/chat/cards/RecoveryOfferCard.tsx→TransportOfferCard.tsx- v6 spec:
docs/specs/conversation-v6-feature.md(transport_planning is added as a stage per §2.2). - v6 stage resolver truth table:
docs/specs/v6-stage-resolver-truth-table.md(this spec adds a new row). - Deferred predecessor:
docs/specs/deferred/coordinator-services-transport-spec.md— superseded; concepts preserved per §1. - Issue: #713.
- Voice rules:
config/voice_rules.yaml,tests/test_no_medical_advice.py,tests/test_voice_compliance.py. - Multi-currency:
docs/specs/multicurrency-feature.md,app/services/currency_service.py. - Per-resource auth gate:
app/middleware/case_access.py,tests/test_route_access_scanner.py.