Tour Decider v2 vs Legacy — Camellia Replay

Every real Camellia tour conversation, replayed end to end through both versions of the code that decides what to do when a prospect replies about a tour, run on the Willows test property, then checked once more with a real live trial in production · Aug 16–17, 2026

Source: STATUS.md items 8, 8b, 8c, POST-REVIEW · results-v2pass/summary.json · LIVE-TRIAL.md · SCORING.md headline + Finding #2

What we ran

Fede's ask (verbatim, evening of Aug 16): run every past Camellia conversation end to end through the Willows test property, include all previously known bugs, add adversarial testing, and report whether v2 made things better or worse.

Source: STATUS.md items 4–7, 7a; SCORING.md "What we ran"

The numbers

The scoring buckets: correct (matched what actually happened / what should happen), incorrect (didn't), needs-human (the right answer is genuinely debatable — both versions get graded the same way here, not resolved by a coin flip), and not exercisable (the scenario can't run through this test path at all, e.g. an empty text message that real Twilio would also reject).

Per mode — turn-level counts

ModeCorrectIncorrectNeeds-humanNot exercisableNo expectation (coverage-only)
Legacy (today's code)767713620986
v2 (new single decider)807313620986

Source: results/scoring/summary.json → overallByMode; SCORING.md line ~32

Head-to-head, one verdict per scenario (754 scenarios, excludes the 468 coverage-only ones)

OutcomeCount
Both got it right65
Both got it wrong61
Both landed on needs-human127
Both not exercisable17
v2 right, legacy wrong9
Legacy right, v2 wrong7

Net: v2 ahead by 2 scenarios on the cases where the two disagreed — well inside noise once you account for classifier randomness (see below).

Source: results/scoring/summary.json → headToHead

By channel — how often the two versions actually disagreed

ChannelTurns comparedDisagreedRate
Text (SMS)1683823%
Email10222%
Phone (voice)48400%

Source: results/diff.json (via results/diff-summary.json); SCORING.md "Per channel"

By source

SourceModeCorrectIncorrectNeeds-humanNot exercisable
Real conversations (119 tour-relevant)Legacy3546380
Real conversations (119 tour-relevant)v23645380
Known bugs (43)Legacy189919
Known bugs (43)v2189919
Adversarial (124)Legacy2322891
Adversarial (124)v22619891

Source: results/scoring/summary.json → bySourceByMode

The important caveat: most of the "incorrect" count on real conversations isn't a decider bug — it's a test-environment limitation

90 of the "incorrect" results on real Camellia conversations come from the same root cause: Willows (the sandbox we're required to test in, since we can't touch real Camellia data) doesn't have the same calendar availability that the real property had on those historical dates in 2026. So in both legacy and v2, the conversation gets stuck one step earlier — it can't even find an open tour slot to propose — before it ever reaches the confirm/cancel/decline decision we're actually trying to test. This happens almost identically in both versions (the two versions only disagree on 0.2% of these), so it tells us nothing about legacy vs v2 — it's a sandbox-vs-reality gap, not a decider defect.

Once those 90 are set aside, the more meaningful, adjusted correct-rate is:

ModeAdjusted correct rate
Legacy70.4% (76/108)
v274.1% (80/108)

Labeled "adjusted" deliberately — this is the honest, smaller, more meaningful sample, not the headline number.

Source: SCORING.md "Finding #1"; results/scoring/scores.json (envLimitation flag)

The hardened re-run: proving the mode check for real

After the Aug 16 review, we rebuilt the parts of the test rig that check "did this turn actually run under legacy or v2" and re-ran the entire 754-scenario comparison, twice each version again (1,289 turns per version, same set as before).

CheckFirst run (Aug 16, caveated)Hardened re-run (Aug 17)
Mode confirmed byThe test rig's own local settingThe server's own trace of what it actually ran
Mode mismatches foundNot measured0 of 1,289 turns, both versions
Recorded activity steps0 (dead signal, misread as "nothing happened")Legacy 1,575 · v2 1,599
Reply text compared?NoYes
Scenarios where the two versions disagreed on the first pass4034

The scenario count moved from 40 to 34 because this re-run used freshly regenerated fixtures under the corrected code, not because anything was hidden — see results-v2pass/summary.json for the exact breakdown (36 disagreements were about the tour's end state, 5 were reply-wording-only, 1 was a latency-warning flag only).

Every one of those 34 disagreements was then rerun three times per version (6 runs per scenario, 204 runs total) to separate "the AI classifier being a little random" from "the two versions actually behave differently":

Outcome of the 3x-rerun checkCountWhat it means
Confirmed a database read-timing artifact0Ruled out — not an explanation for anything observed
Confirmed a real, repeatable legacy-vs-v2 difference0Ruled out — nothing reached this bar
Settled as noise (both versions agree with themselves and each other)32The original disagreement didn't reproduce — it was the classifier being inconsistent with itself on ambiguous wording, not a mode difference
Inconsistent within a version but agrees between versions1Still noise, not a mode difference — the version that's inconsistent is inconsistent the same way on both sides
Genuinely different between versions1The one case examined by a judge directly — see below

The one genuinely different case, read by a judge: a real Camellia email conversation where a prospect emailed "I would like to schedule a tour" with no time given, then three weeks later wrote "I am here for my tour!" The real prospect had a genuinely scheduled 3:15pm tour that day, arranged through a channel this replay doesn't have a record of — but the test fixture that reconstructs this scenario has no starting tour time saved for it, so the code sees "prospect says they're here" with no tour on file to point to. Legacy's majority answer confirms a tour anyway, using a placeholder 2:00pm time that was never actually offered — which is itself a real defect (making up a time nobody agreed to). v2's majority answer says nothing at all — also a real defect (a prospect standing at the door gets silence). The judge's ruling: this is a shared, pre-existing gap in how either version handles "prospect claims to be present, no live tour proposal on file," triggered by a corpus test-data gap (the real tour time was never captured into the test fixture) — not a v2 regression. Both bad behaviors (silence, and inventing a time) showed up on both sides across the 6 reruns; the split just happened to land unevenly this one time.

Source: results-v2pass/summary.json (diff, stabilityClassification) · results-v2pass/final-judge-conv_2d5fa55ec29cc164.md

Blocked / why / correct — Fede's original question

Resolved as "zero is the real answer, not a measurement gap." Fede's original ask was specifically "report on how many it blocked, why it blocked, and if it was correct." The first run's guard-block and tool-call counts came back zero because of a field-naming bug — that got fixed, and the hardened re-run counted 1,575 recorded activity steps for legacy and 1,599 for v2 across all 1,289 turns per version, so the measuring equipment is now confirmed to be capturing real activity, not going quiet. But the specific "safety guard blocked an action" and "named tool was called" counters still read zero, and this time we traced why: this code doesn't have anything literally named a guard-block event, and the tour decider writes to the database directly rather than through a named-tool interface, in either version — so there's genuinely nothing for those two specific counters to count here. That's a fact about how this part of the code is built, not a leftover measurement hole. Everything else in this report (the state outcomes — correct/incorrect/needs-human, plus the reply text and step counts) is confirmed trustworthy.

Source: STATUS.md item 8d; results-v2pass/summary.json → measuredAcrossAllTurns (with notes) · PROGRESS-D-harness.md "ev.type → ev.event" correction

How do we know there are no regressions?

Four independent checks, all pointing the same way:

Source: STATUS.md items 8b, 8c, POST-REVIEW · results-v2pass/summary.json namedScenarioVerdicts (RG-decline, ADV-122) · SCORING.md Finding #2

Previous bugs — did v2 fix, break, or leave any of them?

Zero regressions. Every one of the 43 previously-known bugs produced the identical outcome in legacy and v2 — not one case where legacy handled it fine and v2 broke it.

Fixed in both — 14
  • PM tour text notifications, silently dead since an earlier bug, now send
  • A tour minted with the wrong year (2024 instead of 2026) is corrected
  • The word "reschedule" no longer auto-cancels a tour and abandons it
  • Rescheduling with a counter-time by text now actually moves the tour
  • Accepting a time Clara herself suggested now fires the booking
  • Hesitate-then-different-time no longer mints a duplicate tour
  • A dropped reschedule on email is now caught by the hallucination guard
  • Prospect arriving early for a same-day tour now notifies the PM
  • Clara no longer states a stale tour status right after cancellation
  • Tour reminders no longer fire on a stale date after a correction
  • No more same-day "tour is tomorrow" reminder seconds after booking
  • Cross-property bookings now send the prospect a confirmation text
  • Clara no longer claims "confirmed" before the calendar write lands
  • Confirmation email no longer logs "sent" when the send was actually suppressed
Still broken, both versions — 6
  • Pre-agent pipeline can still grab a move-in date instead of the requested tour date
  • A missing signing secret still fails tour booking closed for the affected traffic
  • Tours can still book outside office hours (e.g. 4:30 AM) when a property has none configured
  • "2 p.m." can still mis-parse as 2:00 AM in some phrasings
  • Clara can still say "confirmed" before the write actually succeeds
  • The deeper architectural issue — three separate deciders able to touch a tour — this whole replay is scoped to only two of the three
Needs human judgment, both versions agree — 7

Both versions land on the same "genuinely ambiguous" call for things like: a weekday-resolution off-by-one, silently substituting the nearest available slot instead of what was asked for, minting a duplicate tour when someone hesitates then picks a different time, a Spanish-language decline reason, a pricing-guard false positive, and two similar edge cases. Neither version resolves these any better or worse than the other.

Out of scope for this test — 16

Bugs this harness can't exercise: voice on-behalf linking bugs, activity-log/notification-only issues, vendor-booking bugs (not prospect tours), and known-live Sentry issues that need direct production verification rather than a replay. None of these are decider-mode-specific.

Source: SCORING.md "Finding #2"; results/scoring/summary.json → bugStatus; BUG-CATALOG.md

Adversarial testing — what broke, in both modes equally

124 tricky scenarios across 23 categories (ambiguous replies, stale/out-of-order messages, two tours in flight, PM messages on the same thread, "STOP" mid-flow, prompt-injection attempts, and more). 47 of the 124 were built expecting "reasonable people could disagree" as the correct answer. Of the rest, the two versions disagreed on 39 — nearly all of that traced to the AI classifier being inconsistent with itself on ambiguous wording, not a real behavioral difference (see stability check below).

Real shared defects the adversarial set found (present in both legacy and v2 — not caused by v2, not fixed by v2)

Source: SCORING.md "Finding #3" and "Finding #4"; ADVERSARIAL.md category summary + "no-test-coverage-decline-guard"

Live production trial — The Willows, Aug 17

A replay proves the two versions agree on paper. To close the gap between "agrees on a test rig" and "works for real," we flipped the Willows test property (PropFlow's own sandbox, not a real customer — Camellia was never touched) to v2 in actual production and ran 6 real end-to-end conversations: real text messages, a real phone call through the AI voice agent, a real email, real calendar holds created on PropFlow's own calendar. A fake test identity ("Dana LatencyTest," a phone number PropFlow already owns) placed every call and text.

#FlowWhat happenedResult
1Text: propose a tour, then confirmClara offered two days/times by text; prospect picked one; a real tour was booked with a real calendar holdPASS
2Text: cancel a confirmed tour"Something came up, need to cancel" → Clara cancelled it and confirmed by text, real-timePASS
3Text: decline, multi-turnProspect said "no thanks, found another place" mid-conversation before a specific time was picked; Clara responded gracefully and updated the prospect's status correctlyPASS (didn't get to test declining an already-booked tour specifically — real conversations kept resolving another way)
4Text: reschedule, multiple back-and-forth turnsProspect asked to move an already-confirmed tour twice; Clara handled both without creating a duplicate booking or duplicate calendar holdPASS
5Email: propose → confirm → cancel, with a real calendar holdFirst attempt (a sender address that literally said "test" in it) got no reply — see anomaly below. Second attempt, from a normal-looking sender with a realistic message, went through cleanly end to end: proposed times, confirmed, created a real Outlook calendar hold, then cancelled on requestPASS
6Voice: book a tour on a real phone call, then cancel it by textA real ~40-second phone call through the AI voice agent booked a real tour with a real calendar hold; a text message afterward from the same number correctly cancelled that same tour — the system recognized it was the same person across two different channelsPASS

All 6 PASS. Every confirm, reschedule, and cancel created or moved a real calendar hold — never a duplicate. A scan of PropFlow's error-tracking system for the trial window found nothing new or v2-related; the only things that fired were an expected "test-property notifications are muted" gate (working as designed) and one unrelated noise event.

The one thing that looked odd — resolved, not a bug. The first email attempt used a sender address with the word "trial" in it and a generic template body. It never got a reply. Investigation found: the email did arrive, PropFlow's own webhook correctly received it — and then PropFlow's own AI spam filter correctly recognized "an address that says 'test' in it, with a generic templated message" as not-a-real-prospect and silently skipped it, exactly as that filter is supposed to work. The tour code (legacy or v2) never even got a chance to run — the filter caught it a step earlier. Rerunning with a normal-looking sender and a realistic message went through cleanly (row 5 above). Separately, this surfaced a real but unrelated gap worth its own follow-up: a genuinely real first-time prospect who gets wrongly caught by that spam filter today has no automatic alert — only a person manually checking a log would catch it. Not a v2 issue; filed as its own follow-up below.

Source: LIVE-TRIAL.md · PROGRESS-TRIAL.md · EMAIL-ANOMALY.md

Honest limitations

Source: STATUS.md items 7b, 8c, 8d, POST-REVIEW; PROGRESS-D-harness.md "Entry-path fidelity"; SCORING.md "Finding #5"; LIVE-TRIAL.md "Anomalies"

Decision for Fede

Every number above says the same thing, now checked twice: v2 is behaviorally a wash against legacy — zero confirmed regressions, a noise-level edge on paper, identical on voice, identical on every known bug, and 6/6 real flows passing in a real production trial. The actual value of shipping v2 is architectural: it replaces three separate pieces of code that can each act on a tour (the thing that caused the Aug 15 incident where a legitimate reply got wrongly blocked) with one.

Where this leaves the rollout ladder: the replay is done, and the live trial is done. What's left is Fede's call on the next step for Camellia — the real customer this whole exercise is about.

Recommended A. Flip Camellia to v2 as a watched canary, then delete the legacy handler

Both the replay and the live trial are behind us now — this is the step both were building toward. "Watched canary" means: flip it, watch it closely for a defined window (conversations, Sentry, PM feedback), keep the ability to flip back instantly, then delete the old code once the window is clean.

Also reasonable B. Run a second live trial, this time using the real Camellia phone numbers/mailbox in a shadow or off-hours window, before flipping the real property

More cautious than A. Costs a delay; the Willows trial already exercised the same code path across all 4 channels (text, phone, email, voice-then-text) with zero defects — this option is about comfort, not new evidence.

Not recommended C. Revert v2 off main entirely

No regressions were found anywhere across the replay, the hardened re-run, or the live trial. Reverting throws away the fix and keeps the three-decider architecture that caused the Aug 15 incident in the first place, for no measured benefit.

Worth fixing regardless of which option Fede picks — all shared bugs, present in both versions equally, none caused by v2

Technical appendix

Repo paths, PR numbers, branch names, commit SHAs
  • Mode flag: Property.tourDeciderMode, controlled in src/lib/domain/leasing/tour/tour-decider-flag.ts; global kill switch env TOUR_DECIDER_MODE=legacy.
  • Legacy = handleTourProposalConfirmation in handle-tour-confirmation.ts; v2 = applyTourIntent via the tour_pipeline_confirmation trigger.
  • v2 merged to main in PR #5801 (commit 9fa94067a), currently inert in production (Willows has no tourDeciderMode set).
  • Replay branch: fede/tour-decider-camellia-replay, PR opened, not merged — held for Fede's review.
  • Harness: scripts/tour-decider-e2e/ (driver.ts, diff.ts, suppression-proof.ts, judge-panel-subscription.ts); entry path POST /api/admin/pipeline-testdispatchInbound()routeInboundMessage → tour decider, identical to the real SMS/email/voice chain below the transport layer.
  • Corpus pull: propflow-prod, entityType-createdAt-index GSI, propertyId=1773625953462 (Camellia), read-only. Replay target: propflow-dev table only.
  • Judge panel: scripts/tour-decider-e2e/judge-panel-subscription.ts, subscription/bearer OAuth lane, model claude-opus-5, 66 calls (22 scenarios × 3 lenses) for the original scoring pass; the hardened re-run's single divergent scenario got its own direct judge read, also subscription lane (results-v2pass/final-judge-conv_2d5fa55ec29cc164.md).
  • Hardened re-run code heads: legacy leg e66acb004a, v2 leg + all stability reruns 8af7e909fc, both on branch fede/tour-decider-camellia-replay. Harness code PR #5816 went through 3 rounds of automated review on that branch (round 1: 11 findings fixed; round 2: 2 of 3 fixed correctly, 1 fixed with an inert check; round 3: that check corrected) — still HELD (label hold-for-review), not yet merged.
  • Live production trial: real conversations run against propflow-prod (not a replay/dev table), Willows (appfolio-45) with tourDeciderMode=v2 set on the real property record for the trial window (2026-08-17T17:19Z–18:04Z), confirmed via direct DynamoDB reads before/during/after, left at v2 when done. Test identity: fleet-owned Twilio number +17205942061 ("Dana LatencyTest"), a pre-existing Willows test prospect. Full turn-by-turn evidence (Twilio SIDs, tour IDs, DB snapshots): LIVE-TRIAL.md, PROGRESS-TRIAL.md, EMAIL-ANOMALY.md.
  • Full artifact set: results/legacy/*.json, results/v2/*.json, results/diff.json, results/stability/summary.json, results/scoring/scores.json (2,590 records) and summary.json, results-v2pass/summary.json (hardened re-run), BUG-CATALOG.md, ADVERSARIAL.md, SCORING.md, STATUS.md — state dir ~/agents/002/replay-camellia-willows/.
PropFlow Docs