Turnover money decisions: a dispatch ask is not a charge, and money is decided by code, not sampled
- Status: Accepted (Fede, 2026-07-30; implementation #5002, legacy deletion #5060)
- Date: 2026-07-30
- Decider: Fede — both decisions were made verbally on 2026-07-30. This document is the record, not the proposal.
- Supersedes: the temperature-0 determinism guarantee in ADR-0034 (
:126,:152,:252,:277). Nothing else in ADR-0034 changes. - Builds on: ADR-0044 (R2/R9 mined
chargeKnowledge, R5 the human gate before money moves, R17 charges trail WO actuals), ADR-0070. - Evidence: turnover production-readiness audit + measurement runs, 2026-07-30. Corpus docs are local only (PM dictation, unit-level PII); numbers summarised here.
Context
The turnover surface deducts money from real tenants' deposits. Two things about how it decides those amounts turned out to be wrong, and they are the same wrong thing at two levels.
A PM saying "call Cardenas for trash removal" was being read as an instruction to charge a tenant. On unit 612 that produced attribution: tenant, chargeable: true, and a damage line for an amount the PM never said. In production, mined pricing would have filled that amount in.
And the thing making that call is not reproducible. ADR-0034 promised the projection was "deterministic (temp 0, pinned model version)" and that the frozen artifact was defensible because it was never a live re-extraction "which would replay differently and is worthless in a dispute." Since #4688 flipped the model to claude-opus-5 — which rejects temperature — no temperature has been sent. #4948 made provenance honest; the policy was left open. Measured: 0 of 3 scenarios reproducible across repeat runs, with attribution/chargeable — the two fields that decide whether a tenant is billed — flipping between runs on identical dictation.
charge_correctness scored 53 across 52 replayed calls, against capture_completeness 97 and vendor_identification 100. The money dimension was the weak seam by a wide margin.
Decision 1 — A dispatch ask mints work, not a charge
A PM naming a vendor is an instruction to dispatch. It is never an instruction to charge.
- "Call <vendor> for <work>" produces a scope task and its work order, and no charge line at walk time.
- Cost arrives later, from the invoice / WO
actualCost— the ADR-0044 R17 actuals loop. Not from a guess made during the walk. - A tenant charge requires explicit PM attribution. No explicit statement of who pays →
unclear→ not proposed. - Typical amounts never create attribution. Mined per-property
chargeKnowledge(ADR-0044 R2/R9) prices deposit and estimate proposals the PM is being shown to confirm. It is not a fallback price for a dispatch ask, and its existence must never be the reason a line becomes chargeable. - No hardcoded per-property rules. Anything property-specific lives in mined
chargeKnowledge.
Two supporting requirements, because a charge decision that cannot be reconciled is not a decision:
- Persist which charge lines were staged — label, amount,
sourceItemId, category, attribution — not justchargesAppliedCount. Today amendments never reach the PMS draft, so a PM who waives a charge still bills the tenant, and nothing anywhere disagrees. Override writers diff against the persisted set and push amend/remove; where the PMS has no remove primitive, fail loud rather than diverge silently. - Approval freezes the money source. The conversational confirm is the freeze — the ADR-0044 R5 gate, finally wired to the path that actually approves. Today a PM who texts one more photo after approving silently replaces the source the work orders were minted from. Post-approval evidence produces an amendment and a re-confirm, never a silent replacement.
Decision 2 — Money is decided by code first, by a model only where code cannot
Fede, 2026-07-30, verbatim: "preferred i think is 1. deterministic 2. strong model to judge when ambiguous 3. panel if the first model cant figure it out."
The model's jurisdiction is the residue, not the input.
Tier 1 — deterministic code decides every confident case. No draw can move these: dispatch intent (Decision 1), quote-validated explicit attribution, PM-stated amounts and WO actuals, deterministic vendor match. Tier-1 rules run per-item, before any consolidation — see the note below on why that is not a detail.
Tier 2 — a strong model (claude-opus-5) judges only what Tier 1 declined, with Tier-1 facts supplied rather than re-inferred. Judging a narrowed question is a much easier task than open extraction.
Tier 3 — a unanimity panel on low-confidence verdicts. Sample 3; require unanimity; any money-field disagreement fails closed to unclear / PM review. Tier 3 is a disagreement detector, not a vote — it catches that the model is unsure, and never picks a winner among its guesses. It panels the judge's discrete fields on a fixed extracted item, never the extraction itself.
The evidence that shaped this
Temperature-0 is gone across the whole Claude 5 generation. claude-sonnet-5 returns the identical 400 ("temperature is deprecated for this model") as claude-opus-5, through the same client path the projection uses. No model swap within the generation restores ADR-0034's contract, so determinism had to be answered structurally rather than by picking a model. (claude-sonnet-4-6 still accepts it and was considered — see Alternatives.)
Majority voting was the original Tier-3 spec and it is refuted. Measured n=9 per scenario: the Cardenas defect appeared in 4/9 raw draws, and at k=3 2 of 3 triplets voted the wrong answer — voting amplified a mode that is only ~56% correct. Money-field flips went 11.7% → 10.5%; ties were 2/57, so "fail closed on ties" was never the safety net. Independent published evidence agrees: extraction errors are correlated across samples, and voting worsened 79% of model×dataset pairs in one benchmark. Self-consistency voting presumes independent errors that extraction does not have. Hence unanimity.
And panelling the raw projection is unimplementable anyway: strict-identity matching found 0 cross-draw keys in 3 of 4 scenarios. Independent draws do not produce comparable item sets, so there is nothing to vote on until the extraction is fixed.
The ladder measures well. The implementation (PR #5002) puts 94.5% of decisions in Tier 1 and moves charge_correctness from 78 → 94 on the replay corpus.
One finding that shaped Tier 1's shape. The interim guard shipped as #4965 downgrades an unpriced vendor-named task to unclear. On 1 of 9 draws it folded the Cardenas item into a consolidated housekeeping subgroup, downgraded the whole subgroup, and erased the PM's legitimate cleaning-deposit charge — money lost in the opposite direction. That is why Tier-1 rules run per-item, pre-consolidation: a dispatch ask suppresses its own line and nothing else. #4965 stays until Decision 1 makes it unreachable.
Consequences
- We stop claiming the projection is reproducible. ADR-0034's determinism clauses are superseded and annotated in place. Evals become distributional claims with a stated n, not single-draw comparisons — the corpus already scored one artifact twice and reported it as "reproduced," and opus-5's defect rate read 1/6, 3/6, and 4/9 across three runs. A rate we cannot pin down is a rate no single-draw eval can gate.
- The audit-replay promise is retired explicitly. The frozen artifact stays the record, but we can no longer regenerate it to defend it. That is a legal posture, written down rather than quietly abandoned.
- Tier 1's coverage carries the money safety — voting cannot repair a biased generator, so Tier 3 is a backstop, not a second opinion.
- We trade a flip rate for a PM-question rate. Every fail-closed
unclearis a human interruption, and a wrong Tier-1 rule is wrong every time — more auditable than 1-in-6, not less wrong. Each rule needs its own regression, and the audit's vendor-matching weaknesses (substring Pass-1 with no word boundary; two matchers disagreeing on generic tokens) become blocking, since deterministic vendor match is a Tier-1 input. CONDITION_TEMPERATURE = 0is deleted — a constant documenting a guarantee the code does not make is how this stayed invisible. ADR-0034'sseedclause is dropped: no seed is implemented and none is available on this API.
Alternatives considered
- Pin
claude-sonnet-4-6(the last temperature-accepting tier) to restore ADR-0034 as written. Not chosen — it is a generation downgrade whose sufficiency rests on an n=4 single-scenario control, and the ladder addresses the defect where it is decidable rather than making the guess repeatable. Recorded because it remains the lever if the ladder underdelivers. - Switch to
claude-sonnet-5. Rejected on measurement — same 400 on temperature, drifts more than opus-5, throws on 1/29, leaves 51% more itemsunclear, and dropspmNamedVendoron 2/6 draws, which is the field #4965's guard is gated on. - Keep #4965's downgrade guard as the answer. Rejected — its own commit body calls it defence in depth, it suppresses a bad draw after the fact rather than removing the case, and it has now been measured erasing a legitimate charge.
- Price dispatch asks from mined
chargeKnowledge. Rejected — invents a number the PM never stated and attributes it to a tenant. - Majority voting at Tier 3. Refuted by measurement; see above.
Appendix — implementation notes from the turnover audit
Not decisions. These are engineering fixes from the same audit that nobody would decide against; they are recorded here so they are not lost, and the full evidence with file:line is in TURNOVER-AUDIT.md §5 (local). Each wants its own PR.
- Work-order minting has no idempotency (P0, §5.1). The only guard is a non-atomic
tasks.lengthread, written after all N work orders already exist; the per-task idempotency key is passed and never read; four callers pass four different keys, one varying per attempt; and the confirm path signals the workflow before its own inline mint, so both readtasks: []. A Temporal retry mints a complete second set of work orders, each synced to the PMS and dispatched to a vendor. Fix: an atomicattribute_not_exists(scopeClaimedAt)claim before minting (copyclaimVendorDispatchSend/putPurchaseOrderJobBindingIfStatus), a stablefn(turnoverId, taskType)key enforced by the handler, and signal after the mint. WorkOrder.turnoverIdshould be authoritative (§5.2).childWorkOrderIdsis a cache maintained by six read-modify-write sites, and it is the cache — not the stamp — that gates every consumer, including the sync reconciler's own pre-check, which finds 0 unsynced and resets its counter. Cancel iterates the array, so orphaned work orders survive turnover cancellation: a rescinded notice cancels the turnover and the painter still shows up. Add theGSI1PK=TURNOVER#<id>index ADR-0044 R13 defers; interim, union with a stamp query and self-heal on read. Align the JSON test backend with Dynamo semantics first — its divergent filtering is why this class was invisible in tests.- Remediation is gated by the autonomy arm, and gate-skips reset the escalation counter (P0, §5.4). ADR-0068 D3 promises stranding is "structurally impossible" and introduces no property gate. The implementation gates all three passes and resets
attempton skip, so the D3(c) critical alert is unreachable — a non-autonomous property gets no remediation and no alert, ever. That is the default state of the fleet, Camellia included. Repair writes only to PropFlow's own DynamoDB; the arm should gate outbound (PMS writes, vendor dispatch, charge filing — ADR-0070's financial gating stands unchanged), not repair. Also fixsetCounter'sreturn 0-on-failure, without which no escalation can fire at all. - Turnover stage has ~12 writers and one transition table consulted by one of them (§5.7).
effectiveStageexists in three copies; theinspectedstage has a single writer, so the "legacy relabel" is load-bearing for the primary intake channel; and the stall-detector shapeinspected && !conditionReportIdis unreachable by construction. OnetransitionTurnoverStageprimitive, enforced by a drift test. - The confirm outcome is an unenforced union (§5.6).
handleConfirmTurnoverhas no exhaustiveness check — a fifth member compiles and lands in an unconditionaloutcome: 'scoped'success return, telling the PM work is being lined up and disarming the fabrication guard. Seven literals exist, four are typed, and 7 of 11 comparisons parse intoany. Ship the exhaustiveswitchwithconst _never: neverimmediately — hours of work, no migration, converts a silent guard-disarm into a compile error. Then onboard the ten handlers to ADR-0101's already-implemented{ok|retryable|fatal, safe_message}envelope, so raw DynamoDB/[Security]strings stop reaching model context. - The guard's failure signal expires at the turn boundary (§5.8). Turn N the dispatch errors and Clara's honest report survives; turn N+1 the PM asks "did that go through?", no tool fires, and the same true sentence is blocked into an approval loop. Stamp
{tool, at, reason}on the turnover row — read wherestagedChargesis already fetched, so no extra round trip — with a short freshness window and a curated message, never a raw error.