0100 — PM-decided month-to-month conversion (the no-renewal path)
- Status: Proposed
- Date: 2026-07-17
- Deciders: Fede (renewals fix-stream rulings, 2026-07-16/17)
- Related: ADR-0046 (autonomous holdover conversion — the lapse-time converter this ADR rides), ADR-0091 (external-actor reconciliation + the 2026-07-17 addendum's workflow wrap-up Update, whose pattern the decision signal reuses), ADR-0025 (Temporal engine), ADR-0055 (workflow outcome semantics)
Context
A renewal cycle has always had three PM-visible endings: the tenant renews, the tenant leaves, or the tenant transfers. There is a fourth real-world ending with no product path today: the PM decides not to offer a renewal and instead lets the tenancy continue month-to-month.
Where this bites, concretely (Camellia, 2026-07):
- The board's Requires action → Review lane holds renewals that the versioned eligibility engine flagged for a human — e.g. a tenant carrying more than two months of unpaid rent. The PM's realistic choices are offer the renewal anyway or don't renew — let them go month-to-month. The product offers only the first (approve). The second can only be done by silently doing nothing, which strands the row in Requires action forever and leaves the renewal workflow running its outreach ladder toward an offer the PM never wanted to send.
- The flagged/approve lane has the same gap: "Approve" is approve-or-
nothing. The 2026-07-15/16 founder sessions ratified a two-option decision
("Offer renewal" / "Month-to-month") delivered through a review drawer —
the drawer component exists (
RenewalDrawer.tsx) but has been orphaned since #3878 replaced the classic table, and it only ever knew "approve." - MTM conversion machinery exists but is only autonomous: ADR-0046's holdover branch of the daily scan (armed 2026-06-24, fixed by #3960 to respect reconciler-recorded external rolls) converts a lease that lapsed with no resolution. Nothing lets a PM choose that ending ahead of the lapse, and the scan cannot distinguish "nobody decided" from "the PM decided MTM on purpose."
Founder rulings that bind this design (2026-07-15/16/17):
- Month-to-month is a settled outcome with its own yellow designation on the board — never a top-level bucket, never an action item once decided.
- Outcomes name the real disposition (Renewed / Month-to-month / Did Not Renew / Transferred) — never the mechanism ("handled in AppFolio", "holdover scan", etc.). Provenance goes in the activity log.
- No tenant comms on the MTM decision. Recording the decision sends nothing. (The ADR-0046 reminder-ladder copy already tells tenants the MTM-with-premium consequence during outreach; that is the only place the tenant hears about MTM, and it fires only while an offer is live.)
- Minimize human touches: one decision, one click, row settles. No confirmation lanes, no bookkeeping steps.
- Colorado legal guardrails (§38-12-701 60-day notice before a rent increase, §38-12-702 one increase per 12 months, HB24-1098 for-cause non-renewal) are documented, not enforced, in v1 — explicit founder deferral 2026-07-16.
Decision
One new PM decision — "Move to month-to-month" — captured in the revived review drawer, recorded as a first-class renewal outcome, executed in AppFolio by the existing ADR-0046 holdover converter at lease end. Five coordinated pieces, each naming what it reuses:
1. Decision capture: the review drawer, two options
The Requires-action rows whose "What's needed" is a decision — flagged
(playbook says a PM approves) and held_review (an eligibility-engine hold, e.g. balance)
— render their label as an in-place button that opens the revived
RenewalDrawer (side panel; #3878 removed its caller, not the component).
The drawer shows: tenant · unit, lease end, current → proposed rent with
delta/%, the MTM figure with the property's premium (computeMtmFallback +
renewalPolicy.mtmPremium), the specific hold reason in plain English, and
Clara's recommendation. Two primary actions:
- Offer renewal — the existing approve path
(
POST /api/leasing/renewals/approve, channels per policy after #3957). - Move to month-to-month — new,
action: 'convert_mtm'on the same decision endpoint (implementation may rename the route to something disposition-neutral, e.g./decision, keeping/approveas an alias — the review noted "approve" is a strained name for a non-renewal disposition; the route validates the renewal is in a decision-shaped state and is idempotent on repeat posts).
Chore rows (add_contact) and take-over rows (escalated, prepare_failed)
are out of scope here — they have no one-click decision (see ADR-0046 /
task-20 escalation work).
2. Recording: a first-class outcome, settled immediately
convert_mtm writes a PmRenewalDecision (who/when/reason) to the saga and
resolves the cycle at decision time, not at lapse:
- Saga: transitions to the HOLDOVER-pending terminal state used by ADR-0046's
converter, with
decisionSource: 'pm'(vs the converter's existing implicit "nobody decided"). No new state names — the disposition is month-to-month either way;decisionSourceis attribution. - Board: the row leaves Requires action immediately and settles yellow "Month-to-month" (ruling 1). Detail page timeline shows "You decided: month-to-month" as the resolution step, with the premium and the conversion date (lease end + 1) as facts, not promises.
- Activity log: decision attribution (PM name, timestamp, held-reason at decision time). This is where provenance lives (ruling 2).
3. Workflow: wrap up, don't abandon
If the renewal workflow is RUNNING (offer prepared, outreach queued), the
decision must stop it the same way an external renewal does — gracefully.
Reuse the ADR-0091 addendum machinery verbatim in shape: a synchronous
Temporal Update (pmDecision, sibling of externalResolution, gated by
workflow.patched('pm_mtm_decision_v1')) that:
- cancels pending outreach/reminder/voice timers (all nine wait predicates already wake on the wrap-up condition from #3985);
- voids a live offer link if one was sent (ADR-0046 item 2's void step — a stale offer must not remain signable after the PM chose MTM);
- exits with workflow outcome
month_to_month(ADR-0055 vocabulary), sending no comms (ruling 3) — the wrap-up path deliberately skipsrecordWorkflowOutcome's notification side effects, exactly as the external-resolution wrap-up does; - conflict check: if the workflow has already received a tenant decision
(signed! or formal NTV), the Update returns
conflictand the drawer surfaces it in plain English ("The tenant already signed this renewal — month-to-month isn't available anymore") instead of silently overriding. Same June-24 no-drop guarantee, same serialized-Update mechanics as #3985.
No running workflow (pre-saga or already terminal): the decision records directly; nothing to wrap up.
4. Execution in AppFolio: the ADR-0046 converter, now with two triggers
Nothing converts in AppFolio at decision time — the lease is still live until
its end date, and AppFolio stays source of truth (ADR-0039). At lease end the
existing daily-scan holdover branch performs the conversion (end-cap
PATCH, recurring rent = proposed rent + mtmPremium, next-month charge),
with two changes:
- The candidate gate accepts
decisionSource: 'pm'sagas as authorized conversions (today it only handles never-resolved lapses). Same converter, same #3960 pre-flight (skip if the reconciler already recorded an external roll — the PM may have been beaten to it by the office). - Arm interaction (pinned): the two-factor arm
(
RENEWAL_HOLDOVER_CONVERSION+Property.autonomousHoldoverEnabled) exists to gate autonomous AppFolio writes — conversions nobody asked for. AdecisionSource: 'pm'saga carries explicit human authorization, so the converter executes it regardless of the per-propertyautonomousHoldoverEnabledflag; a PM-decided row must never settle yellow and then silently fail to convert. The globalRENEWAL_HOLDOVER_CONVERSIONkill-switch still gates ALL converter writes, PM-decided included — it is the emergency stop, and an emergency stop that lets some writes through is not one. - The conversion activity stamps the receipt with the decision reference, so the activity log reads "Converted to month-to-month per your July 17 decision" — outcome first, mechanism in the log.
The autonomous no-decision lapse path is unchanged. Both paths end in the
same HOLDOVER terminal state; decisionSource is the only difference.
5. What v1 explicitly does not do
- No tenant comms at decision or conversion time (ruling 3). A future "notify the tenant of the MTM terms" feature is its own reviewed ADR — it is new outbound copy and Colorado's §38-12-701 notice clock almost certainly wants to ride it (see below).
- No legal-guardrail enforcement (ruling 5). v1 records and converts. Phase 2 (deferred, needs its own founder sign-off): surface the 60-day §38-12-701 clock on the premium's effective date (charge base rent until notice + 60 days when the decision lands < 60 days before lapse), the §38-12-702 one-increase-per-12-months check against charge history, and HB24-1098 cause-coding if "don't renew, terminate" is ever added — that last one is a different decision than MTM and stays out of this ADR.
- No bulk MTM. Per-renewal decisions only; the drawer is the only entry.
- No new board bucket (ruling 1).
Consequences
Positive. The Review lane finally closes its loop — every decision row lands somewhere a decision can actually be made, and the second-most-common real ending (no renewal, tenant stays) becomes one click instead of "do nothing and let the row rot." The autonomous and PM-decided MTM paths share one converter, one terminal state, one outcome vocabulary — no drift between "the scan converted them" and "I converted them." Wrap-up semantics are uniform across every way a cycle ends early (external actor, PM decision): one Update pattern, one conflict rule, no dropped tenant decisions.
Negative / accepted. The premium may legally need to wait out the 60-day notice window that v1 does not track — accepted by explicit founder deferral; the phase-2 note above is the mitigation path. A PM can choose MTM while a tenant is mid-conversation with Clara about renewing; the conflict check only blocks on received decisions (signed / NTV), not on chatter — consistent with the 2026-07-17 deletion of the conversation-recency guard.
Testing gate. Per the standing founder rule, the end-to-end chain ships
with Willows regression-harness coverage in the same PR: decision → saga
settles MTM (yellow, board leaves Requires action) → workflow wrap-up cancels
outreach + voids the offer → converter authorizes on decisionSource: 'pm'
→ conflict path (tenant already signed) surfaces and changes nothing.
Rollout
- Land drawer + decision recording + wrap-up Update behind the normal PR flow (no flag — the action is PM-initiated and sends nothing).
- Converter gate change rides the next daily-scan deploy; first PM-decided conversion at a real lapse gets a manual post-conversion verification against AppFolio (rent, charge, end date) before the path is called proven — same canary discipline ADR-0046 prescribed.
Addendum — Outreach exhaustion: quiet wrap-up (2026-07-17)
Ruling (founder-approved, minimal change). When the full outreach ladder — initial offer + reminders + the 30-day and 15-day expiration nudges — runs and the tenant never responds, the renewal workflow no longer treats that as a PM escalation. Outreach running out is a non-event for the PM: the tenancy simply rolls month-to-month. So the terminal exhaustion branch now exits quietly instead of raising a "Review" row.
What was removed (terminal exhaustion branch only). The exhaustion branch no
longer calls escalateToPM (no PM email, no PM SMS) and no longer records an
escalated outcome. It now exits exactly the way the external-resolution and
PM-decided wrap-ups do: it cancels pending outreach/timers, sets phase='done',
and records no terminal outcome. Because outcome stays null, the board
projection falls to the phase rung — phase='done', outcome=null → PREPARED
("Offer sent", Upcoming) — so the row keeps its last outreach state and never
projects to needs_you. escalateToPM itself is untouched and still serves its
other callers (prepare_failed and the awaiting-countersign timeout).
What already covers the rest. Nothing else changes. At lease lapse the
ADR-0046 holdover pipeline detects the holdover, end-caps the lease in AppFolio,
closes the saga to HOLDOVER (the board settles yellow "Month-to-month"), sends
the D1 office heads-up (notifyHoldoverToOffice). It makes no next-month
re-offer — that ADR-0046 D2 step was superseded 2026-09-05 (Fede: "i dont want
to offer anything, just roll mtm"); month-to-month is the end state. The
converter's proof-of-warning gate is satisfied by the
15-day nudge receipt (mtmWarning-15d) — which is exactly why the nudges are
left in place and untouched by this change.
Replay gating. The quiet exit is gated behind
workflow.patched('exhausted_quiet_wrapup_v1'). In-flight pre-patch histories
return false and keep the old escalation behavior, so a workflow that recorded
no patch marker replays deterministically.
Testing gate. Ships in the same PR with the exhaustion-exit workflow-body test (quiet exit, pre-patch escalation preserved, and tenant-sign / NTV / intent-to-vacate / external-resolution all winning over the quiet exit) and a Willows-harness permutation matrix (X-15) walking the full non-responder → holdover chain across the exhaustion exit, the converter gates, and the board projection.