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.
- The full conversation history: all 791 real Camellia conversations pulled from production, read-only (620 phone calls, 91 emails, 80 texts) — 9,271 individual messages, 3,471 of them from prospects. 135 of those conversations actually touch a tour (propose/confirm/cancel/decline/reschedule) — 119 of those could be replayed turn-by-turn against reconstructed state; the other 468 non-tour conversations went through as a coverage pass (nothing to replay, but nothing skipped either — every single one is accounted for).
- 43 previously known bugs — every tour/booking bug found since incidents in July and August, turned into a test scenario with the state it started in, what was said, and what should have happened.
- 124 adversarial scenarios across 23 categories — deliberately tricky replies (ambiguous "sure?", stale replies to superseded proposals, two tours in flight, a property manager's own message landing on the thread, "STOP" mid-flow, prompt-injection attempts aimed at the AI classifier, and more) — built from reading the actual decision code, not guessed.
- Both modes, same test set, twice. Willows (PropFlow's own sandboxed test property, not a real customer) was flipped between "legacy" and "v2" mode and every scenario replayed through the real code path both ways, so the comparison is apples-to-apples on the same inputs.
- Three safety proofs before the real run: (1) nothing was written to the real production database — everything landed in the isolated dev/test database; (2) zero real messages went out — a live proof run showed the only "sends" were 3 internal calendar-sync notices to PropFlow's own operations inbox, never a real prospect; (3) zero paid AI usage — every judging/classifying call ran on the subscription plan, never a metered API key.
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
| Mode | Correct | Incorrect | Needs-human | Not exercisable | No expectation (coverage-only) |
|---|---|---|---|---|---|
| Legacy (today's code) | 76 | 77 | 136 | 20 | 986 |
| v2 (new single decider) | 80 | 73 | 136 | 20 | 986 |
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)
| Outcome | Count |
|---|---|
| Both got it right | 65 |
| Both got it wrong | 61 |
| Both landed on needs-human | 127 |
| Both not exercisable | 17 |
| v2 right, legacy wrong | 9 |
| Legacy right, v2 wrong | 7 |
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
| Channel | Turns compared | Disagreed | Rate |
|---|---|---|---|
| Text (SMS) | 168 | 38 | 23% |
| 102 | 2 | 2% | |
| Phone (voice) | 484 | 0 | 0% |
Source: results/diff.json (via results/diff-summary.json); SCORING.md "Per channel"
By source
| Source | Mode | Correct | Incorrect | Needs-human | Not exercisable |
|---|---|---|---|---|---|
| Real conversations (119 tour-relevant) | Legacy | 35 | 46 | 38 | 0 |
| Real conversations (119 tour-relevant) | v2 | 36 | 45 | 38 | 0 |
| Known bugs (43) | Legacy | 18 | 9 | 9 | 19 |
| Known bugs (43) | v2 | 18 | 9 | 9 | 19 |
| Adversarial (124) | Legacy | 23 | 22 | 89 | 1 |
| Adversarial (124) | v2 | 26 | 19 | 89 | 1 |
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:
| Mode | Adjusted correct rate |
|---|---|
| Legacy | 70.4% (76/108) |
| v2 | 74.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).
| Check | First run (Aug 16, caveated) | Hardened re-run (Aug 17) |
|---|---|---|
| Mode confirmed by | The test rig's own local setting | The server's own trace of what it actually ran |
| Mode mismatches found | Not measured | 0 of 1,289 turns, both versions |
| Recorded activity steps | 0 (dead signal, misread as "nothing happened") | Legacy 1,575 · v2 1,599 |
| Reply text compared? | No | Yes |
| Scenarios where the two versions disagreed on the first pass | 40 | 34 |
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 check | Count | What it means |
|---|---|---|
| Confirmed a database read-timing artifact | 0 | Ruled out — not an explanation for anything observed |
| Confirmed a real, repeatable legacy-vs-v2 difference | 0 | Ruled out — nothing reached this bar |
| Settled as noise (both versions agree with themselves and each other) | 32 | The 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 versions | 1 | Still noise, not a mode difference — the version that's inconsistent is inconsistent the same way on both sides |
| Genuinely different between versions | 1 | The 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:
- Every one of the 43 previously-known, real, customer-facing bugs — the actual incidents this system has caused since July — was replayed against both versions and produced the identical outcome on every single one. Not one case where legacy handled it correctly and v2 broke it. See the table below for the fixed/still-broken/needs-human breakdown per bug.
- All 484 real voice-call turns from Camellia's history showed zero disagreement between the two versions — the entire phone channel is a dead match.
- The one channel with real disagreement (text messages, 23% of turns) was rerun three times each to separate a real difference from the AI classifier being inconsistent with itself — nothing survived that check as a repeatable, direction-consistent v2-only failure. The two places it looked closest to a real difference (the RG-decline email fixture, and the "decline a confirmed tour" adversarial set) were checked directly against the current code and both fail identically in both versions — same shared bug, not a v2-specific one.
- A real live trial in production (below) ran actual conversations through v2 end to end and found zero cases where it produced a wrong or missing outcome that legacy wouldn't also have produced.
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.
- 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
- 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
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.
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)
- Declining a tour is unreliable in both versions. Depending on the exact wording, a decline sometimes does nothing at all (state stays unchanged, prospect's "no" gets silently dropped) and sometimes gets recorded as "cancelled" instead of a proper "declined" — there's no dedicated decline state that actually fires reliably in either version.
- Declining an already-confirmed tour is a silent no-op in both versions. We expected (from reading the code) that this might silently overwrite the confirmed tour to a wrong state, since the decline code path is missing a safety check that the confirm path has. What we actually saw in five separate test scenarios: the tour just stays confirmed and the prospect's decline is dropped on the floor — no notification, no state change, in both versions identically. Different failure mode than predicted, but still a real gap worth fixing.
- The email-decline case (from the regression test fixture) reproduces the same way in both versions — "that time doesn't work for me" on email leaves the tour in its prior "proposed" state instead of moving to declined, in legacy and v2 alike.
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.
| # | Flow | What happened | Result |
|---|---|---|---|
| 1 | Text: propose a tour, then confirm | Clara offered two days/times by text; prospect picked one; a real tour was booked with a real calendar hold | PASS |
| 2 | Text: cancel a confirmed tour | "Something came up, need to cancel" → Clara cancelled it and confirmed by text, real-time | PASS |
| 3 | Text: decline, multi-turn | Prospect said "no thanks, found another place" mid-conversation before a specific time was picked; Clara responded gracefully and updated the prospect's status correctly | PASS (didn't get to test declining an already-booked tour specifically — real conversations kept resolving another way) |
| 4 | Text: reschedule, multiple back-and-forth turns | Prospect asked to move an already-confirmed tour twice; Clara handled both without creating a duplicate booking or duplicate calendar hold | PASS |
| 5 | Email: propose → confirm → cancel, with a real calendar hold | First 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 request | PASS |
| 6 | Voice: book a tour on a real phone call, then cancel it by text | A 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 channels | PASS |
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
- This is a one-sided replay. It shows the two versions matched each other on this test set — it does not prove either version is "correct" against some independent standard beyond what actually happened in the real conversation or what the scenario was built to expect.
- The test path skips four production safety checks that sit above the decider: Twilio signature validation, per-number rate limiting, duplicate-message dedup, and — most importantly — the code that intercepts "STOP"/"START"/"HELP" before it ever reaches the decision logic in real production. Any adversarial scenario involving opt-out keywords or duplicate delivery was testing the decider in isolation, not the full real-world path; scored and flagged accordingly, not blended into the headline numbers.
- The AI classifier that reads a prospect's reply is not perfectly consistent run to run. Rerunning every single-run disagreement three times each per version (the hardened re-run's 34, and the original run's 40) confirms this: the large majority settle as classifier noise where both versions land on the same answer once you rerun them, not a real mode difference.
- The classifier's own stated reasoning for each call still wasn't successfully captured in the hardened re-run either — an open observability gap, useful for future debugging, but it doesn't affect any correct/incorrect/regression finding in this report (those are all based on the actual tour state and reply text, both of which were captured and compared).
- The Willows sandbox calendar doesn't match Camellia's real historical availability for the replayed dates — this is why 90 of the real-conversation "incorrect" results are a replay-environment artifact rather than a decider finding (see "The numbers" above), and it's also the root cause of the one genuinely-different scenario in the hardened re-run (see "The hardened re-run" above).
- The live production trial's decline flow (row 3) didn't manage to test "decline an already-booked tour" specifically — real test conversations kept resolving into either an explicit cancel or a decline before a time was picked. Not a failure, just an honest scope gap in that one trial run.
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.
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.
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.
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
- Decline registration — make declines register reliably (not silently no-op, not miscoded as "cancelled")
- Decline-of-a-confirmed-tour — add the missing safety check so a decline on a confirmed tour doesn't get silently dropped instead of registering
- "Arrived with no tour on file" handling — a prospect saying they're here for a tour, when the system has no live tour proposal to point to, currently either invents a time that was never offered or goes completely silent, depending on the run; needs a real fallback (acknowledge + route to a human) instead of either extreme
- Spam-filter monitoring gap — a genuinely real, first-time prospect who gets wrongly caught by the spam/triage filter today has no automatic alert; only senders with prior conversation history are watched. Found during the live trial's email flow, not the tour decider itself, but worth its own ticket
Technical appendix
Repo paths, PR numbers, branch names, commit SHAs
- Mode flag:
Property.tourDeciderMode, controlled insrc/lib/domain/leasing/tour/tour-decider-flag.ts; global kill switch envTOUR_DECIDER_MODE=legacy. - Legacy =
handleTourProposalConfirmationinhandle-tour-confirmation.ts; v2 =applyTourIntentvia thetour_pipeline_confirmationtrigger. - v2 merged to main in PR #5801 (commit
9fa94067a), currently inert in production (Willows has notourDeciderModeset). - 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 pathPOST /api/admin/pipeline-test→dispatchInbound()→routeInboundMessage→ tour decider, identical to the real SMS/email/voice chain below the transport layer. - Corpus pull: propflow-prod,
entityType-createdAt-indexGSI,propertyId=1773625953462(Camellia), read-only. Replay target:propflow-devtable only. - Judge panel:
scripts/tour-decider-e2e/judge-panel-subscription.ts, subscription/bearer OAuth lane, modelclaude-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 reruns8af7e909fc, both on branchfede/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 (labelhold-for-review), not yet merged. - Live production trial: real conversations run against propflow-prod (not a replay/dev table), Willows (
appfolio-45) withtourDeciderMode=v2set on the real property record for the trial window (2026-08-17T17:19Z–18:04Z), confirmed via direct DynamoDB reads before/during/after, left atv2when 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) andsummary.json,results-v2pass/summary.json(hardened re-run),BUG-CATALOG.md,ADVERSARIAL.md,SCORING.md,STATUS.md— state dir~/agents/002/replay-camellia-willows/.