Turnover Before/After: replaying every real scenario through today’s code

2026-07-30 · Camellia Apartments (customer) + The Willows (sandbox) · 107 scenarios censused from production, 52 replayed in a JSON sandbox · prod DynamoDB read-only, zero writes, zero sends, subscription LLM only
TL;DR. The test set is history, not invented scenarios. Every capture, walk call and turnover that ever ran on the two properties was enumerated from production, then the ones with enough surviving context were re-driven through today’s real handlers. Zero regressions across all 52 replays. On the cleanly-comparable subset, today’s pipeline extracts 5.4× more findings from the same dictation and the share it refuses to classify drops from 56% to 19%. Three things came out of it that matter more than the score: the projection’s reproducibility guarantee is void, the duplicate-condition-report race is still open, and the confirm-leg stress harness has been reporting 16/16 green while every work order silently failed to mint.
107
scenarios censused
52
replayed · 0 errors
0
regressions
86
overall quality score
16 → 86
findings extracted (n=20)
49%
of history replayable

Method

Where a comparison is honestly impossible, it says so. 32 of the 52 replays are marked partial-input for one of two reasons, both harness limits rather than results: photos are not replayable (the corpus keeps photo metadata only, so a historical projection that read ceiling stains and tub grime out of images cannot be reproduced from text), and shared reports (several historical reports were built by more than one call, so replaying one call feeds a strict subset). Those scenarios report their measured deltas but never a pass or fail. unclear-classification counts are likewise reported as a measured axis and never folded into a verdict — today’s prompt is deliberately more conservative about blaming a tenant without a move-in baseline, and this comparison cannot tell a policy change from a defect.

Results

verdictnmeaning
improved20a projection where history had none, or more findings from identical input
same5identical input, identical item count, every historical line still captured
partial-input27photos or shared reports — counts not comparable, reported not graded
regressed0
replay-error0

The cleanly-comparable subset (n=20)

metricBEFORE (prod)AFTER (today)
projection items extracted1686
of which unclear9 (56%)16 (19%)
charge lines assembled (all 52)038

The charge row is not like-for-like and shouldn’t be read as one: the charge layer did not exist until 2026-06-08/09, and production never persisted a single charge row for any of these 46 turnovers. Every dictated dollar figure — $150 clean, $250 clean, $300 carpet, the $200 cleaning edit — died in the conversation. That is a capability added, not a defect fixed.

Quality scorecard (five dimensions, LLM judge, verbatim transcript evidence)

dimensionscoreread
capture_completeness97spoken findings survive into the notes
finding_grouping90rooms are over-specified — the model narrows “the master” to “Master bathroom”
charge_correctness53the weak seam — real findings become amount: null (needs_amount) instead of the amount the PM said
vendor_identification100every spoken vendor name is captured
vendor_roster_match67capturing the name is not resolving it against the 812-vendor roster
overall86108 evidence-backed defect items emitted, 54 grouping + 34 charges

Finding 1 — the projection’s reproducibility guarantee is void

condition-projection.ts pins CONDITION_PROMPT_VERSION with an explicit instruction to bump it whenever the model changes. A PR on 2026-07-27 changed the default agent model to claude-opus-5 and did not bump it. Independently, claude-opus-5 rejects temperature with a 400, so the projection call now omits it — the “temperature 0, reproducible” guarantee that ADR-0034 and every projection eval rest on no longer holds.

Measured, not inferred.

all 52 replay provenances : model=claude-opus-5 promptVersion=v13 temperature=undefined the 6 historical v13 rows : model=claude-sonnet-4-6 promptVersion=v13 temperature=0

Same version string, two different models, two different sampling regimes — provenance can no longer tell them apart, so no stored report can be attributed to the code that produced it. Three repeat runs of three scenarios: item counts held steady (8/8/8, 9/9/9, 7/7/7) but item labels drifted run to run (PaintPaint / walls, Wall (anchor wall)Wall). Structurally stable, textually not.

Finding 2 — the duplicate condition-report race is still open

Production holds five groups of duplicate move-out reports: 16 rows for 5 real captures, so 11 spurious reports. One customer turnover has four reports stamped the same minute; one sandbox turnover has five, then three more.

There were two candidate mechanisms. The cross-call capturedBy fork was closed in June. The concurrent/retried start_inspection race was not. Every duplicate group shares one identical capturedBy — so it is the race, and the race is still reachable today:

start_inspection takes only { turnoverId } ← no idempotencyKey (append_notes has one) captureConditionReport() mints an id + saves ← no uniqueness constraint on turnoverId patchTurnover({ conditionReportId }) ← last-writer-wins two invocations that both observe an empty pointer both create a report

The voice provider makes this easy to hit: it cancels a tool client-side with “Tool execution was abandoned due to user input” while the server call completes, and the agent then retries. That exact sequence is visible in one harvested call at t=25s and t=34s.

Latent hazard on top: getConditionReportByTurnover returns items[0] under a comment asserting the partition holds at most one row. Production disproves the comment, so that reader returns an arbitrary report — and the dangling-pointer recovery path added in June depends on it.

Finding 3 — the confirm-leg stress harness is green and blind

mintLocalWorkOrderId uses the DynamoDB document client and table name directly — an atomic counter that does not honour DATA_BACKEND=json. In any JSON sandbox it throws ResourceNotFoundException, scopeTurnover rethrows it as a non-retryable failure, confirmTurnoverScope catches it, and the turnover ends with zero tasks and zero work orders.

Verified twice. Replaying the two real historical PM approvals plus three more, all five returned:

{ ok: true, outcome: "scoped", taskCount: 2, message: "On it — lining up the work now. You'll see the work orders in AppFolio shortly." }

… advanced the stage to work_scheduled, stamped estimateApprovedAt, and persisted 0 tasks and 0 work orders. Then, as a control, the existing confirm-stress.ts harness run unmodified on main: 16/16 PASS with the same failure firing inside every scenario. It passes because its checkScoped asserts stage + estimateApprovedAt + one tasks_scoped event + a note — and never that a task or a work order exists. The confirm-leg suite has never exercised work-order minting.

Two separable facts, and it matters which is which:

The scope decision layer is correct: it computed 2 tasks for the customer unit and 2 for the sandbox unit, matching history exactly. It is the mint step that cannot run in a sandbox.

Finding 4 — a money-corrupting mis-attribution that production actually shipped is fixed

The clearest single before/after in the corpus. A sandbox walk on 2026-06-10. Production’s notes ended with Charge tenant two hundred fifty dollars. and Covered by property, not charged to tenant. sitting as orphaned lines under the “Full cleaning needed” block — the $250 belonged to pest control and the property-covered attribution belonged to the window. The sentence-level dedupe of that era had eaten both lines from their real findings and left them attached to the wrong one, which is how a cleaning line ends up carrying someone else’s $250.

Replaying the same three append_notes payloads through today’s block-level dedupe — which landed three hours before that very call — produces the correct notes: both amounts bound to their own findings, nothing orphaned. Same input, corrupted then, right now.

Finding 5 — the customer property barely exercised the pipeline at all

This is the honest answer to “do we have the whole context”: mostly we do not, because there is very little real customer history to have.

So the pipeline’s real history is: one complete customer loop, one complete sandbox loop, roughly forty internal test walks, and a large amount of sync noise. The autonomy-confidence question is answered here for the walk-capture and charge-assembly lanes. It is not answered for the approval lane at customer scale, because that record does not exist yet.

Finding 6 — the one possible regression in the whole exercise

A separate like-for-like A/B: the 6-entry corpus from the 2026-07-22 harness run, re-driven through today’s code and re-scored with the same grader.

dimension2026-07-22today (run 1)today (run 2)
capture_completeness90100100
finding_grouping677876
charge_correctness1005050
vendor_identification100100100
vendor_roster_match505050
overall777374

Capture and grouping improved reproducibly — that is the filler filter landing. The charge_correctness drop is not a clean regression and should not be reported as one: today’s code produces charge packages on 4 of the 6 calls where it produced 2 before, so the denominator changed and the newly-scored calls score low.

There is exactly one like-for-like case, and it reproduced in both re-runs. Verbatim dictation:

“We need to call Cardenas for trash removal.”

Today that becomes Damage — "Trash removal from unit …" — amount: null (needs_amount) — attribution: tenant ("trash left behind by the tenant at move-out"). A vendor dispatch instruction was converted into a tenant-attributed charge with no amount. The judge failed it, and the judge looks right: “call Cardenas” is an instruction to place work, not a statement that the tenant owes for it.

Labelled honestly: possible regression, n=1 call, reproduced twice, worth a targeted regression test — not a headline. It is the only candidate the entire 52-scenario corpus produced.

Eras the replay confirms are gone

erawhat it didevidence today
2026-05-26→05-27
15 calls
start_inspection returned HTTP 400 16×: the voice webhook tools pointed at the bare unified route instead of the per-tool routenot reproducible — the handler was never reached, so those calls have no domain BEFORE at all. All 15 replay cleanly today
pre-2026-05-29
prompt v1
findings defaulted to unclear without a move-in baselinethe v1→v2 “inspector-authoritative” flip shows up directly: unclear share 56% → 19%
pre-2026-06-09work-order cost estimates came from a hardcoded matrix with ±10% random jittertoday’s replays produce needs_amount rather than an invented number — less satisfying, honest
2026-06-11, 4-hour windowthe projection token cap was lowered: a heavy walk could truncate to an empty scope and leak synthetic-item sentinels into the PM-facing recapcap restored; no sentinel appears in any of the 52 replays
pre-2026-07-22filler (“Got it”, “(media)”, the “I’m doing a turnover” preamble) was captured as findingsfiller filter: one unit goes 13 captured lines → 5 real findings

Coverage — what fraction of history was replayable, and why the rest was not

52 of 107 scenarios (49%). The 55 that were not break down cleanly, and nothing was skipped for convenience:

Within the 52 that ran, only 20 give a clean verdict. All 9 customer-property replays are partial-input — every one of its captures either carried photos or shared a report with another call — so the customer property contributes zero clean verdicts. That is the single biggest hole in this exercise, and it is fixable: harvesting the photo bytes into the corpus (they sit in the photo bucket and are referenced by key on every report) would convert 27 partial-input scenarios into full comparisons.

25 of the 52 needed a synthesized outgoing-tenant occupancy and lease because production’s rows were deleted — handleStartInspection fails closed without a lease whose person matches the outgoing tenant. The shell carries no findings and no money, only the ids the lease-anchor guard needs. The utterances in those replays are entirely real; only the container is rebuilt.

Lanes not replayed — marked pending, not faked

What to do next, in order

  1. Bump the projection prompt version and record the model + sampling regime in provenance so a stored report can be attributed to the code that made it. Decide explicitly whether losing temperature 0 is acceptable for a money-bearing classifier.
  2. Give start_inspection an idempotency key and a conditional write on turnoverId; fix getConditionReportByTurnover to stop returning an arbitrary row under a false comment.
  3. Make mintLocalWorkOrderId respect the data-layer backend, then add a work-order-exists assertion to checkScoped. Until both land, no sandbox test can prove an approval creates work.
  4. Attack charge_correctness (53) and vendor_roster_match (67) — 34 and 3 evidence-backed items respectively are already written up with verbatim quotes.
  5. Harvest photo bytes into the corpus to convert 27 partial comparisons into real ones, which is what would let the customer property contribute a verdict at all.
Scope note. Every number here comes from production reads and sandbox replays. No prod row was written, no message sent, no vendor dialled. Tenant names and phone numbers are omitted; unit labels are generalized on the customer property. Companion: Turnover PM-Flow RCA & Robo-Test Findings (2026-07-29).
PropFlow Docs