Scope: The Agent D companion's chip-driven trip-plan wizard (plan-step-builder.ts and friends). Server-side only — the wizard is fully server-composed and mobile renders cards generically, so the redesign ships on a worker deploy with no app rebuild.
Two screenshots (2026-06-12) showed the wizard badly broken for India:
Bug 1. "Plan a trip" → "Within India" → "Where in India?" offers "India" — a nonsense self-loop. Root cause: destination is asked before trip length, so the nearby-getaway branch (gated on num_days <= 4) can't fire, ranked India cities are never loaded for the where step, and the builder falls into a hardcoded {value:'india', label:'India'} fallback (plan-step-builder.ts:863).
Bug 2. "Plan a weekend" → Lepakshi (3 days) → "When for Lepakshi?" offers "Hill stations (Apr-Jun)" → "How should this India trip feel?" offers Golden Triangle (Delhi→Jaipur→Agra→Mumbai). Root causes: picking a nearby getaway doesn't set single_city, so the route step still fires; getRouteOptionsForPlan returns country-level routes unfiltered by duration (prod data: all 14 India routes are 8-14 days); season chips are country-generic regardless of picked destination.
Direction: rethink the chain from first principles — how a person actually creates a trip — and design every path, question, and edge case for both India and international, before any implementation.
Verified prod data grounding this design (audited 2026-06-12 against prod verso-db):
destination_vibes: India has 14 country-level routes (8-14 days ideal), zero state-scoped routes (zero anywhere). ~40 countries have routes, all 7-14 day range.destinations: India has ~477 citylike rows (250 city, 56 heritage, 53 hill-station, 32 pilgrimage, 15 beach, 13 national-park, ...). BUT the metadata is two-tier: every non-city type is ~fully seeded (best_months, vibes, days_needed_min/max), while only 36 of 250 city rows carry any of those — the other 214 are bare name+coords rows (Barsana, Somanathapura, Kushalnagar...). 17 rows are explicit day-trips (days_needed_max = 1: Lepakshi, Sultanpur Bird Sanctuary, ...).destinations.transport_from_metros: 186 India rows carry per-metro {km, mode, hours} (e.g. Mussoorie→Delhi: 221km, drive, 3.7h; Coorg→Bangalore: 206km, drive, 3.4h). This is the door-to-door feasibility + chip-annotation source — strictly better than haversine km.countries: 102 rows; visa_type populated on 91 (clean enum visa-free / visa-on-arrival / evisa / embassy); popularity_score populated; best_months on only 1 of 102 → country seasons stay in PLAN_SEED.regions: 36 India states already exist and are linked via destinations.region_id (Uttarakhand 31 dests, Karnataka 29, Himachal 26, Tamil Nadu 26, Kerala 15, ...). The region table in Part 6 keys onto these rows — no new slugs.range:YYYY-MM-DD:YYYY-MM-DD chip values and renders option chips generically.Distance-only ranking audit — the second-order bug this design must also fix. Simulating today's suggestNearbyPlaces (pure distance sort, 1km floor, catch-all-type exclusion only) from the two anchor metros:
Delhi top-4: Sultanpur Bird Sanctuary (37km — a day-trip bird sanctuary), then Barsana, Vrindavan, Govardhan (bare satellite-town rows with zero metadata). Mussoorie is rank ~23; Ranthambore further still. A Delhi weekend card should obviously read Mussoorie / Rishikesh / Jim Corbett / Agra.
Bangalore top-4: Lepakshi (92km, days_needed 1-1 — a day trip, not a weekend stay), Somanathapura (bare), Horsley Hills, Shravanabelagola (bare). Coorg / Mysuru / Ooty — the actual weekend canon — rank below all of them.
"Distance + season-fit + saves" (the naive Mode A spec) does not fix this: Sultanpur is in-season and 37km away; it would still win. The fix is the getaway-grade hard gates + desirability ranking specified in Part 3, Q-WHERE Mode A.
A trip always starts anchored on one of three things, almost never more than one:
| Anchor | Example | What they need from us |
|---|---|---|
| TIME | "I need a break this weekend", "Diwali week is free" | Destination options that FIT the time window |
| PLACE | "Goa with friends", "Japan in April", "saved 12 Bali reels" | Shaping: when exactly, how long, who, budget |
| NEITHER (open) | "Want to go somewhere", "Help me decide" | Cheapest narrowing first: time → mood → ranked suggestions |
A VIBE-first start ("honeymoon", "beach trip") is a sub-case of NEITHER with mood pre-filled.
Each answer constrains the next question's OPTIONS — never ask a question whose options ignore prior answers:
trip length -> bounds reachable distance (weekend <= ~6-8h door-to-door; a week opens
all-India / short-haul intl; 10+ days opens circuits & long-haul)
origin -> bounds "nearby" + flight costs
mood -> HARD-filters destination types/vibes (beach mood must never
surface desert circuits or hill regions); thin pools degrade
honestly, never pad with off-mood chips
season/month -> ranks destinations (monsoon, winter); never blocks — EXCEPT
hard closures (national parks shut in monsoon)
destination -> determines when-options (its best window), route eligibility,
city list, budget ballparks
length+dest -> determines whether a multi-city route makes sense at all
The single biggest structural fix: ask trip length BEFORE destination whenever destination is unknown. Length is a one-tap, zero-thought answer that collapses the destination space enormously.
computeStepStates answered/needed discipline; the redesign changes step ORDER and OPTION SOURCES, not the accounting model.num_days — its seeded days_needed_min/max range must overlap the trip length, and it must be metadata-complete (best_months + vibes + days_needed all seeded) so we can annotate and season-rank it. Day-trip rows (days_needed_max = 1) and bare name+coords rows never become chips; both stay reachable by typing.geo_mood — whether from the undecided branch or parsed from typed text ("beach trip in October") — filters every WHERE surface: domestic modes, region chips, intl country tiers, the undecided blend, and route/circuit chips. If the filtered pool is thin, widen reachability and say so honestly ("Beaches need a short flight from Delhi - these fit"), never pad with off-mood chips.is_featured, activity_count) decide order. The nearest row is usually a satellite town, not a getaway.| Entry | Pre-seeded | First question |
|---|---|---|
| "Plan a trip" pill | nothing | Q-SCOPE |
| "Plan a weekend" pill | geo_scope=domestic, num_days=3 | Q-WHERE (nearby mode) — origin promoted first if unknown |
| Typed intent ("5 days in Kerala in Oct with parents") | everything parseable (country/region/city/days/month/origin/companions) | first unanswered step |
| Country collection CTA ("Plan a Japan trip") | destination_country | Q-LENGTH |
| Destination/place context (city page) | country + city + single_city | Q-LENGTH |
| Reel-itinerary convert | fully formed | (bypasses wizard) |
Recomputed after every answer; skip-if-known throughout.
SCOPE -> LENGTH -> MOOD* -> ORIGIN** -> WHERE -> WHEN -> DATES
-> ROUTE*** -> CITIES*** -> WHO -> BUDGET -> PACE**** -> CONFIRM
* MOOD only on the undecided branch.** ORIGIN promoted here only when needed to anchor nearby suggestions (short domestic, no resolvable coords); otherwise it stays a late skip-if-known step as today.*** ROUTE and CITIES are mutually exclusive slot-mates; both skipped when a single destination is already picked.**** PACE only when effective days ≥ 5 (existing gate).Change from today: LENGTH (num_days) moves from position 6 to position 2 — before WHERE for every branch. Everything else keeps its relative order. This one move is what makes every WHERE card length-aware.
Accelerator — "Use my usual" (recommended, small): once WHERE + WHEN are answered, the trip is essentially decided; WHO/BUDGET/PACE are refinements. Each tail card carries a secondary chip "Finish with my usual" that fills WHO from the user's last trip (else couple), BUDGET = the country's mid tier, PACE = balanced, and jumps straight to CONFIRM — collapsing the tail from 3-4 taps to 1. CONFIRM already renders every field as an editable row, so nothing is hidden or locked in. Net effect: the realistic happy path becomes 3-4 taps total without removing a single question for users who want control.
Every question with exact copy, option sources, value formats, merge effect, and skip predicate. Value formats matter because the merge layer and the model/free-text relay both parse them.
domestic "Within India" (default) / international "Somewhere abroad" / undecided "Help me decide"geo_scope2-3 "Weekend" / 4-5 "Long weekend" (default for domestic) / 6-8 "About a week" (default otherwise) / 10-14 "10 days - 2 weeks" / escape: pick dates lives on Q-WHENnum_days = midpoint (existing parser).num_days set, or a full date range known.num_days step; same card type, same merge case — only sequence position and labels change.geo_mood. Skip: not undecided, or mood set.The length-aware core. Three modes by num_days:
Best getaways within reach of origin. suggestNearbyPlaces is rewritten from "nearest rows" to "gates first (hard), desirability second (soft)" — the audit in Context shows distance-sorting alone serves Sultanpur/Barsana to Delhi and Lepakshi to Bangalore.
Hard gates — a candidate must pass ALL of:
best_months, vibes, days_needed_min/max all seeded. Removes the 214 bare city rows (Barsana, Somanathapura, Kushalnagar...) — they can't be annotated, season-ranked, or length-checked. Malformed JSON in any of these fields counts as missing (skip the row, never throw).days_needed_max ≥ 2 AND days_needed_min ≤ num_days. Removes day-trips (Lepakshi 1-1, Sultanpur 1-1, Alwar & Sariska 1-2) from a weekend card, and 5-day-minimum places from a 2-day card.transport_from_metros (186 rows), use its {mode, hours}: weekend ≤ ~7h door-to-door by drive/train, or ≤ ~2.5h flight; long weekend ≤ ~10h surface or any short flight. Haversine band fallback (≤350km weekend / ≤700km long-weekend) only when tfm or the origin metro is missing — the old 800km cap is a 14-hour Indian drive, not a weekend.geo_mood is set (see Mood threading below).national-park / wildlife types with a known target month outside best_months, exclude — these are hard closures (Ranthambore is shut Jul-Sep), not preferences. All other types: season ranks, never blocks.Rank within survivors (desirability, NOT distance-ascending): user saves (×5) → season-fit (+3) → mood/vibes match (+2) → popularity (is_featured, activity_count) → mild proximity tiebreak. Then a type-diversity pass: max 1 chip per type among the 4 (never three pilgrimage towns in a row), backfilling from the next-ranked other types. Dedupe by name containment, not just exact match ("Mathura & Vrindavan" vs bare "Vrindavan").
transport_from_metros[origin], flavour word from vibes[0]; "~Xh" haversine estimate only as fallback.place:<slug> → merge seeds destination_country='India', the city, and single_city=true (new — this is the screenshot-2 fix; ROUTE + CITIES both skip).Blended "anchor + region" picks, ranked by saves + season + variety-spread:
[days_needed_min, days_needed_max] overlaps num_days (Goa 3-7, Coorg 3-5, Andamans, ...), ranked saves + season + popularity. Same hard gates as Mode A minus reachability (a week opens all-India; flights are fine). Value place:<slug>, sets single_city=true.state:<slug> where the slug is the existing regions row slug (36 India states already linked via destinations.region_id — don't invent new slugs) → merge sets destination_country='India' + destination_region (draft field already exists, already threaded into the city picker and route query). CITIES step then shows region-scoped cities.regions rows: season window, mood tags (beach → Goa / Kerala / Andamans; mountains → Himachal / Uttarakhand / Ladakh / Sikkim; Rajasthan never under beach), anchor cities, min-days; a region needs ≥ 2-3 metadata-complete cities; saves-boosted.state: goes region → cities; an explicit "Classic circuits" chip (value circuits) routes to Q-ROUTE with the 8-14-day country routes, which finally match the trip length.geo_mood is set and no route's tags match it — a beach-mood user never sees Golden Triangle.Mood threading (all domestic modes): when geo_mood is set it is a hard gate, matched via MOOD_TO_TYPES ∪ a new MOOD_TO_VIBES check against the seeded vibes array — vibes catch what type misses (Rishikesh is type city but vibes adventure/wellness). If the gated pool yields < 2 chips, widen in this order: (1) relax reachability one band (weekend hours → long-weekend hours / short flight, chip annotation says so), (2) fall back to the in-season mood-matching slice of INDIA_TOP_DESTINATIONS. Never backfill with off-mood chips; the card copy owns the stretch ("Beaches need a short flight from Delhi - these fit a weekend").
Failure degradation (all modes): if the DB/suggester yields nothing → in-season (and mood-matching, if mood set) slice of INDIA_TOP_DESTINATIONS (PLAN_SEED, static) as text chips. The literal "India" chip is deleted; there is no code path that can emit it.
Length-aware tiers (new), ranked by user saves + season + a new static haul/visa table:
| Trip length | Pool | Examples |
|---|---|---|
| ≤ 5 days | Short-haul (≤ ~5h flight or visa-easy) | Sri Lanka, Dubai/UAE, Singapore, Thailand, Nepal, Malaysia, Vietnam, Maldives, Bali |
| 6-9 days | Short + medium-haul | + Japan, Georgia, Turkey, Egypt, Azerbaijan, Oman |
| 10+ days | Everything; saves-first | + Europe, Japan multi-city, Australia, ... |
countries.visa_type column (91/102 populated; clean enum visa-free / VoA / e-visa / embassy; authored override only where stale or missing), flight-hours band + mood tags from the small authored INTL_HAUL table. visa.service exists for live data but the column is enough for chips (and deterministic).geo_mood filters the tier pool via INTL_HAUL.moods (beach → Maldives / Sri Lanka / Thailand / Bali / Mauritius; mountains → Nepal / Georgia / Switzerland; food-cafes → Vietnam / Japan / Italy). A city-break country never shows under beach.countries.popularity_score is the no-saves tiebreak.rankForUndecided (saves + season + mood + proximity) gains the length input: ≤4 days → domestic-nearby-weighted + at most 1 short-haul intl; 5-8 → domestic anchors + short/medium intl mix; 10+ → intl-leaning + India circuits.place: vs country).A range: pick sets dates + month + days → Q-DATES auto-skips (already true). Mobile needs nothing new. Weekend boundaries computed in IST (the worker runs UTC; mirror trip-live-promotion's IST handling).
Chips from the picked destination's best_months (or the region's authored window), phrased as windows: "Oct-Feb · best season" (default) / "Apr-Jun" / next concrete month / "Pick specific dates". Falls back to country seasons → month chips. The "When for Lepakshi? → Hill stations (Apr-Jun)" class disappears: country-generic season labels only ever show when the scope IS the country.
Existing country season windows from PLAN_SEED — appropriate at country level. Unchanged.
Question copy: "When for {place}?" stays; for weekend mode: "When works?". Skip: month or dates known.
Unchanged (skips when a range was picked).
New gates (the Golden-Triangle fix), in order:
single_city (set by getaway/anchor picks, city-states, destination contexts).destination_cities non-empty (cities already chosen — typed intent, prior step).num_days < 5.ideal_duration_days ≤ num_days + 3; rank by |ideal - num_days|, classic first among fits; if zero routes fit → skip to Q-CITIES.destination_region set; a region with no region routes (today: all of them) skips ROUTE and goes to CITIES scoped to the region — never falls back to country circuits (today's fallback is the bug).ideal_duration_days).Existing behaviour, now also region-scoped via destination_region (plumbing already exists: getCityOptionsForPlan({scopeRegion})). Day-scaled pre-check unchanged. Skipped when single_city or cities set.
Unchanged (budget chips already country+days aware; pace keeps its ≥5-day gate).
| # | Case | Behaviour |
|---|---|---|
| 1 | Short domestic, no GPS, no profile city | ORIGIN promoted before WHERE (existing mechanism, now reliably triggered since length is known) |
| 2 | Typed origin not in INDIAN_CITIES | WHERE-A degrades to in-season INDIA_TOP_DESTINATIONS slice — never "India", never empty |
| 3 | Today is Sat/Sun and "this weekend" can't fit num_days | Drop "This weekend", offer next two weekends |
| 4 | IST vs UTC near midnight | Weekend math in IST explicitly |
| 5 | Weekend + user types a far place ("Leh for the weekend") | Accept silently; the wizard never blocks a typed answer |
| 6 | Off-season pick (Goa in July) | Ranked down in options, never blocked once picked |
| 7 | Date range conflicts with earlier LENGTH answer | Range wins (existing applyRangeValue already overwrites num_days) |
| 8 | Typed multi-city weekend ("Jaipur and Udaipur, 3 days") | Cities accepted, ROUTE skipped (cities non-empty), no length policing |
| 9 | New user, zero saves | Season + popularity ranking only (all rankers already degrade) |
| 10 | D1/suggester failure on any option load | Static PLAN_SEED fallbacks per step; degenerate chips ("India") deleted |
| 11 | Region picked but region has no seeded cities | CITIES falls back to country list (existing fallback chain) |
| 12 | Region picked, no region routes (true for ALL regions today) | ROUTE skipped → region-scoped CITIES; never country-circuit fallback |
| 13 | No routes fit the duration filter | ROUTE skipped → CITIES |
| 14 | ideal_duration_days NULL on a route | Treated as fits-any, ranked after explicit fits |
| 15 | City-states (Singapore, Monaco) | Existing collapse unchanged (single_city) |
| 16 | Mid-plan side question (digression) | Existing handling unchanged — answer, don't mutate draft |
| 17 | Re-tap on a stale card (screenshot 1's duplicate) | Server compares body.step to the conversation's current plan_step; stale-step picks re-merge idempotently and re-emit the current card without inserting a duplicate user+assistant message pair |
| 18 | Progress counter (1 OF 10 → 2 OF 9 shrink) | Root-cause: entry-point BuildStepOptions inconsistency between runStartPlan and /plan-step-pick builds; fix = both paths construct opts identically + a chained regression test asserting the counter is monotonic across every walkthrough path P1-P8 |
| 19 | In-flight drafts at deploy time | Sequence is recomputed from the draft each turn; old drafts continue under the new order; counter may shift once — acceptable |
| 20 | Free-text/model relay path | New chip values (state:, weekend range: chips, circuits) all flow through existing parse/merge shapes; cards stay within the options ≤ 8 Zod contract |
| 21 | Undecided + weekend | Suggestions lean domestic-nearby; intl limited to visa-easy short-haul |
| 22 | "Plan a weekend" pill but user changes length later via dates | Range-derived days recompute; no stale weekend assumptions persist |
| 23 | Day-trip rows (days_needed_max=1, 17 rows: Lepakshi, Sultanpur, Alwar & Sariska...) | Never chips on any WHERE card (length-fit gate); typed picks still accepted |
| 24 | Bare rows (214 city rows, no months/vibes/days: Barsana, Somanathapura...) | Never chips anywhere (metadata gate); reachable by typing; backfill is a data task, not a launch blocker |
| 25 | Mood set + zero in-range matches (beach from Delhi, weekend) | Widen reachability one band → mood-matching TOP_DESTINATIONS slice; honest copy; never off-mood padding |
| 26 | National park suggested/picked in closure months (Ranthambore Jul-Sep) | Excluded from suggestions when target month known (gate 5); a typed pick is accepted with a one-line caution on CONFIRM |
| 27 | Heavy saves on a gated row (user saved 6 Lepakshi reels) | Gates run BEFORE save-boosting — saves rank survivors, never resurrect gated rows; the saved place still surfaces via saves rails + typed input |
| 28 | Origin metro not keyed in transport_from_metros (user in Chandigarh) | Haversine band fallback (350/700km); chip annotation degrades to "~250km" |
| 29 | Flight-only destination (Andamans) on a short trip | Hours gate excludes at ≤3d; eligible again at 4-5d only if days_needed_min fits (it doesn't — 4+ days min) |
| 30 | Overlapping rows ("Mathura & Vrindavan" pilgrimage + bare "Vrindavan") | Chip-build dedupes by case-insensitive name containment, on top of the existing exact-name dedupe |
| 31 | Top-4 collapse into one type (3 pilgrimage towns near Delhi) | Type-diversity pass: max 1 chip per type, backfill from next-ranked other types |
| 32 | Malformed vibes/best_months/days_needed JSON on a row | Treated as metadata-incomplete (gate 1): row skipped, never thrown |
| 33 | Mood arrives from typed text ("beach trip in October"), not the MOOD step | Parser sets geo_mood outside the undecided branch too; the same hard filter applies on every WHERE surface |
| 34 | "Use my usual" accelerator with no trip history | WHO defaults to couple, budget mid-tier, pace balanced; CONFIRM rows stay editable so a wrong guess costs one tap |
INTL_HAUL static table (~40 countries): flight-hours-band from India + mood tags. Visa class now comes from the existing countries.visa_type column (91/102 populated) — author overrides only where it's stale/missing. Authored once, lives next to PLAN_SEED.regions slugs (36 state rows, already linked via destinations.region_id — don't invent parallel slugs): season window, mood tags, anchor cities, min-days. Powers WHERE-B/C region chips and the region mood gate.MOOD_TO_VIBES mapping (~6 moods × the seeded vibe vocabulary): extends the existing MOOD_TO_TYPES so mood matching sees vibes too (Rishikesh: type city, vibes adventure/wellness — type alone misses it).destinations audits/backfills (none block the code ship; the gates make missing data safe):
best_months/vibes/days_needed_min/max on the ~50 most chip-worthy of the 214 bare city rows (Dehradun, Coonoor, Sakleshpur...); the rest stay gated out harmlessly.transport_from_metros coverage is 186/477: ensure every Mode-A candidate within haversine range of the 8 keyed metros has tfm, else good getaways silently fall to the conservative km fallback.best_months on all national-park/wildlife rows to correctly encode monsoon closures (Ranthambore, Corbett zones, Kaziranga) — gate 5 trusts this field.INDIA_TOP_DESTINATIONS slugs resolve to metadata-complete seeded rows — they are the failure fallback, and a fallback that fails the gates defeats the design.destination_vibes routes for Rajasthan/Kerala/Himachal circuits — unlocks region ROUTE cards; design already accommodates their absence.All server-side, in workers/api/src/services/agent-d/ (+ destination.service.ts); ships on worker deploy, no app rebuild:
plan-step-builder.ts — move num_days in the step table; rewrite whereOptionsFor (3 domestic modes, intl tiers, delete the "India" chip); whenOptionsFor weekend + best-months modes; ROUTE needed gates; breadcrumb region row.plan-step-merge.ts — place: sets single_city; new state: + circuits values.start-plan-core.ts — lazy loaders for the new WHERE modes + WHEN best-months; unify BuildStepOptions construction across entry points (counter fix).destination-suggester.ts — rewrite suggestNearbyPlaces (hard gates: metadata-complete / length-fit / hours-reachability via transport_from_metros / mood / closure; desirability rank; type-diversity + containment dedupe); new suggestIndiaWeek/Grand blends; length + mood inputs to rankForUndecided; MOOD_TO_VIBES beside MOOD_TO_TYPES.seasons.ts — weekend range: chip builder (IST); destination best-months chips.destination.service.ts — getRouteOptionsForPlan duration filter + no-country-fallback-when-region flag.agent-d.routes.ts — stale-step idempotent re-emit (edge 17).INTL_HAUL, India region table (in plan-seed-data.ts).plan-flow-chained.test.ts to walk P1-P10 asserting question order, option invariants ("India" never an option; route never shown <5d or with cities picked), and counter monotonicity; unit tests for the new suggesters/season chips/merge cases — including fixture-driven gate tests (a day-trip row, a bare row, a monsoon-closed park, a beach-mood pool) asserting each is excluded.single_city, cities picked, or num_days < 5; route chips always satisfy the duration filter; progress counter monotonic on every path.best_months/vibes/days_needed; no days_needed_max=1 row on a ≥2-day card; weekend chips inside the hours band; geo_mood='beach' never yields a chip, region, or route without beach affinity (the "Rajasthan under beach" test); the Delhi-weekend fixture surfaces Mussoorie-class getaways and none of Sultanpur / Barsana / Vrindavan.