Skip to content

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_type enum reserves slots for ambulance_icu and air_ambulance but 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_model per 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_area enforces 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_replacement already 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_matcher runs server-side. TransportOfferCard returns 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 shows recovery_accepted=True, milestone signals discharge). Matcher runs with trip_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 (or hospital_to_airport), TransportOfferCard returns 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_country doesn't match pickup/dropoff country.
  • Vendor's service_cities doesn't include the pickup city.
  • Patient needs wheelchair-accessible / medical-van / ambulance and vendor's vehicle_types lacks 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.md rules).
  • 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 #FF7F50 accents per CLAUDE.md ground rule #6. Use design system showcase for exact tokens.
  • press-feedback + focus-ring-brand on interactives (per definition-of-done.md FE 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_profiles and transport_bookings use 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's price_fit axis normalizes vendor pricing into the patient's preferred budget currency before comparing.
  • Locale-aware formatting: TransportOfferCard formats prices via formatMoney(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

  1. Are ambulances in scope MVP? Spec §1.2 reserves ambulance_basic in the enum but defers ambulance_icu + air_ambulance to 7b. Should ambulance_basic ship MVP or also wait? Liability + insurance considerations.
  2. 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?
  3. Are coordinator_vendors rows for service_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.
  4. Should the matcher consider time-of-day pricing surge? Some vendors charge night premiums. MVP ignores (single pricing_model field). Future: JSONB pricing_overrides. Trade-off: matcher complexity vs price accuracy.
  5. 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?
  6. Repatriation workflow on-platform or coordinator-managed? Deferred-spec line 213-220 described a manual repatriation flow. MVP keeps it off-platform. Confirm.
  7. Single-leg booking vs multi-leg trip bundle? MVP: one transport_bookings row per leg. Alternative: a trip aggregate that groups legs. Patient might say "book all my transport for the trip." Defer to v2?
  8. Insurance integration timing. Deferred-spec §1.2 noted "Phase 7c." Confirm MVP excludes pre-auth entirely (only cost_quoted lives on the booking; insurance is patient-managed off-platform).

15. Open questions for Dr. Naidu

  1. Medical-safety wording for the transport_planning prompt (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.
  2. Ambulance recommendation language. If the matcher returns medical_van as 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.
  3. 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?)
  4. 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?
  5. 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)?
  6. Stage-resolver clinical rationale for recovery_offer > transport_planning ordering (§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?
  7. 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-105transport_provider_profile.py
  • app/models/recovery_state.py:1-203transport_booking_state.py
  • app/services/recovery_matcher.py:1-512transport_matcher.py
  • app/agents/orchestrator_phases/recovery.py:1-249orchestrator_phases/transport.py
  • config/prompts/phase_contexts/v2/recovery_offer.yaml:1-119transport_offer.yaml
  • alembic/versions/m7n8o9p0q1r2_add_recovery_data_model.py:1-525 → PR-A's migration
  • apps/patient-app/src/components/chat/cards/RecoveryOfferCard.tsxTransportOfferCard.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.