0070 — Guarantee move-out charge filing (reconcile + fail loud)
- Status: Accepted
- Date: 2026-06-24
- Deciders: Fede
- Prompting incident: 2026-06-24 TEST-101 call. The PM walked the unit, said "charge $150 for cleaning" (a tenant-attributed deposit charge), and approved the recap by SMS. The work orders minted and synced to AppFolio correctly (ADR-0068 D1/D3 worked), but the $150 tenant charge was never filed. The only trace was a single
logInfoline: "no running workflow … charge staging skipped."
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)
- Capture. During the walk, a stated price is extracted as a tenant deposit charge:
condition-projection.ts→ scope task{ attribution: 'tenant', pmStatedAmountCents }. - Approve. PM approves the recap →
confirmTurnoverScopestampsestimateApprovedAt, then firessignalChargeConfirmed(turnover).src/lib/domain/turnover/confirm-turnover-scope.ts:308-309 - File (async, Temporal worker). If a
turnoverWorkflowis running and parked at its charges-waitcondition(), thechargeConfirmedsignal unblocks it and it runsappendTurnoverChargesActivity→writer.appendMoveOutCharges→POST /api/move-out-chargeson the L4 browser agent.
Why it fails — root cause
- CRC-1 — Charge filing depends 100% on a signal reaching a live, parked workflow. When no workflow is parked (turnover predates the flag, never started, raced a not-yet-parked workflow, or hit a completed one),
signalChargeConfirmedcatchesWorkflowNotFoundErrorand returns silently. The charge is permanently lost, logged only atlogInfo.src/lib/domain/turnover/signal-charge-confirmed.ts:49-55 - No inline fallback and no reconciler. ADR-0068 D1 added an unconditional inline
scopeTurnovercall inconfirmTurnoverScope; there is no equivalent for charges. The D3 cohort-walker reconciler (remediateTurnoverStrandsActivity) scans for stranded mints and unsynced WOs but has zero charge awareness — no scan for approved-but-uncharged turnovers.
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.
- C1 — Make the strand observable.
signalChargeConfirmed's silentlogInfoswallow becomes alogWarnthat names the reconciler as the safety net. The TEST-101 loss was invisible precisely because it was an info line. - C2 — Reconciler files stranded charges (the guarantee). Add
findChargeStrandedCandidates(approved +chargesAppliedStatusunset + not cancelled + older thanSTALE_MS) andreconcileTurnoverChargesto the reconciler module, and a third pass in the cohort walker that runsappendTurnoverChargesActivityfor each candidate. ~3 minutes after approval at worst. - C3 — Escalate. After
MAX_ATTEMPTSsweeps with charges still unfiled, firelogCritical+emitAlert('turnover-stall')(per-turnover fingerprint, Sentry-deduped). IndependentchargeRemediationAttemptscounter so charge escalation never interferes with mint/sync.
Idempotency & no-thrash
- The activity's
chargesAppliedStatusgate makes re-runs safe; the finder excludes any turnover already stamped. - A new terminal sentinel
chargesAppliedStatus = 'no_charges'is stamped when the activity reportsno_tenant_charges, so a turnover with nothing to charge drops out of future sweeps instead of being re-evaluated every 3 minutes.
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
- A tenant deposit charge approved with no parked workflow is filed by the standing reconciler within ~3 minutes, or surfaces a critical alert if it can't be — never silently lost.
- One new turnover field (
chargeRemediationAttempts) and one newchargesAppliedStatusvalue ('no_charges'). - The reconciler now performs financial writes (tenant deposit charges) on autonomous properties. This is gated to opted-in properties only and bounded by the activity's own idempotency +
noop_boundaryprotection on completed drafts. - No schedule change: the charge pass rides the existing
turnover-cohort-walkerschedule (every 3 min; gated solely by the schedule's paused/unpaused state — theTURNOVER_COHORT_WALKER_ENABLEDenv gate was retired 2026-07-07).
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.