0070 — Guarantee move-out charge filing (reconcile + fail loud)

Context

ADR-0068 fixed the same class of bug for work orders: post-approval work that depended on a live Temporal workflow catching a signal would silently strand when no workflow was parked. ADR-0068 closed it with D1 (unconditional inline mint) + D3 (a standing cohort-walker reconciler that re-runs mint and re-syncs WOs to AppFolio).

Move-out tenant charges were left on the old, fragile path.

Current architecture (verified from code, 2026-06-24)

  1. Capture. During the walk, a stated price is extracted as a tenant deposit charge: condition-projection.ts → scope task { attribution: 'tenant', pmStatedAmountCents }.
  2. Approve. PM approves the recap → confirmTurnoverScope stamps estimateApprovedAt, then fires signalChargeConfirmed(turnover). src/lib/domain/turnover/confirm-turnover-scope.ts:308-309
  3. File (async, Temporal worker). If a turnoverWorkflow is running and parked at its charges-wait condition(), the chargeConfirmed signal unblocks it and it runs appendTurnoverChargesActivitywriter.appendMoveOutChargesPOST /api/move-out-charges on the L4 browser agent.

Why it fails — root cause

Why not the D1 (inline) shape

WO mint is a fast DDB write, so ADR-0068 ran it inline on the SMS reply path. WO AppFolio sync is a slow browser-agent write, so ADR-0068 deliberately kept it off the reply path and drove it from the reconciler. Filing charges is the same slow browser-agent write as WO sync — awaiting it inline would risk timing out the approval reply. So charges ride the reconciler path, mirroring the WO-sync half of ADR-0068, not the inline-mint half.

Decision

Mirror ADR-0068's reconciler pattern for charges. appendTurnoverChargesActivity is already fully idempotent (chargesAppliedStatus gate) and already fires the loud terminal-failure alert (logCritical + emitAlert on permanent PMS errors). We do not rewrite charge logic — we guarantee it runs.

Idempotency & no-thrash

Gating (safety)

reconcileTurnoverCharges is gated per-property on isPropertyTurnoverAutonomous (autonomousTurnoverEnabled), fail-closed — identical to the WO mint/sync reconcilers. Real Camellia (not opted in) is skipped, so the reconciler never files Camellia deposit charges until the property is explicitly opted in. Willows (test number, autonomous) is filed. The move-out draft itself is only created on armed properties (draftMoveOutAtInspectionComplete is isTurnoverArmed-gated), so a non-armed property has no draft to file against regardless.

Consequences

Verification

Unit: findChargeStrandedCandidates + reconcileTurnoverCharges (filed / no_charges / gate-skip / pending / escalation / throw) and the cohort-walker charge pass (files + counter reset, already-filed exclusion, error isolation). Includes a named regression for the exact TEST-101 stranding.

Prod (to follow, mirroring ADR-0068's proof loop): drive a real approved-but-uncharged turnover on Willows, confirm the reconciler files the charge to AppFolio; inject a failure and confirm the loud alert + heal. Camellia must be observed skipped on every sweep.