0046 — Renewal lifecycle completion: month-to-month holdover, execution notifications, and PMS-agnostic off-platform-lease detection
- Status: Proposed
- Date: 2026-06-03
- Deciders: Fede (decisions taken on the 2026-06-03 JP/Camellia accounting call with Sean; JP attendees: owner rep, leasing staff, and accounting)
- Related: ADR-0016 (renewal state-source registry + reset), ADR-0023 (PMS authority registry), ADR-0025 (Temporal engine + two-factor disarmed send gate), ADR-0026 (IDs-only at activity entry), ADR-0036 (email-offer-with-letter-PDF attach path), ADR-0037 (
signalWithStartto legacy sagas), ADR-0039 (PMS is source of truth), ADR-0045 (shared Temporal namespace) - Trello: Auto-handle expired unsigned renewals · Email office a notification + executed-lease PDF · Month-to-month / $100 warning in reminder copy · Renewal double-charge (0htmZyh0)
Context
PropFlow runs the renewal lifecycle as an autonomous, Temporal-orchestrated pipeline (ADR-0025): a daily auto-start scan opens a renewalWorkflow per lease coming due, the workflow prepares + sends an offer through the PMS, runs a reminder ladder (D+2 / D+9), and resolves to executed / declined / escalated. The AppFolio rent-roll poller flips a saga to executed when it observes a countersigned renewal.
On the 2026-06-03 JP/Camellia call, the accounting side surfaced gaps that only appear once a real property runs renewals end-to-end:
- Double-charges on late signers. Two Camellia tenants were billed twice. Root cause (already verified on card
0htmZyh0): the PM manually changes the lease end date to keep a rent charge rolling, and AppFolio's auto-countersign posts a second recurring charge when the tenant signs after the 1st. The agreed process fix is "stop changing dates by hand." But that exposes the inverse failure — - The no-charge gap. If a tenant does not sign by lease expiry and nobody touches the date, the unit shows occupied on the rent roll with no rent charged. Today PropFlow explicitly does nothing here —
renewal-auto-start.ts:196-204skips leases withdaysLeft <= 0("a separate operational signal"), and no holdover/auto-convert handler exists (grep forholdover/autoConvert/lapsedLeasereturns nothing). The decision: when a lease expires unsigned, the system should auto-convert the tenant to month-to-month with the property's premium, update the recurring rent, void the stale offer link, and send a fresh offer for the next month — removing the manual step that causes (1) and closing the rent-roll gap. - Zero execution visibility. When a renewal completes online, the office gets no notification — "she didn't know this person renewed, we got nothing." They want an email (optionally with the executed-lease PDF) to JP accounting on every execution.
- No deadline consequence in the reminder. The renewal letter states the month-to-month option + premium, but the follow-up reminder never warns "sign by
<date>or you roll to month-to-month at +$premium." Adding it creates urgency and reduces late-sign double-charges. - Off-platform leases are invisible. Some tenants will never use the portal; the office scans + uploads signed paper leases into the PMS. PropFlow must be able to tell a manually-uploaded lease from a PMS-generated e-signed one — so it doesn't fire holdover conversion or chase a renewal that already happened on paper, and so accounting still gets visibility.
Design principles (binding for this ADR)
- Keep it simple. No new top-level entities; one new scheduled-scan branch; a handful of new interface methods; copy edits. Prefer extending a field over inventing a type.
- Reuse what exists. The Temporal scheduled-scan pattern (
renewal-auto-start), the event→workflow-signal handler shape (decline-renewal-on-ntv), therecordWorkflowOutcometerminal chokepoint,sendEmail+ the ADR-0036 PDF-attach path,PropertyKnowledge.renewalPolicy.mtmPremium, and thePropertyLeasingSettingsrecipient pattern. - Scale to 10k units. Time-driven sweeps run off a lease-end index over a bounded cohort, never a full-table scan; per-unit PMS calls happen only for the units actually transitioning. Execution notifications are event-driven off the workflow terminal, not a poll.
- PMS-agnostic. Every new PMS action goes through the existing
PMSWriterseam (src/lib/domain/pms/writer.ts: "AppFolio = one implementation. Yardi/OneSite/RealPage/Entrata follow"). Domain code never learns an AppFolio noun. Off-platform detection is a generic lease-provenance read, not an AppFolio flag.
Decision
Seven coordinated changes. Each names the existing machinery it reuses.
D1 — Month-to-month holdover auto-conversion (new scheduled-scan branch)
Add a holdover branch to the existing daily renewal scan rather than a new service. Reuse the processRenewalAutoStart shape (src/lib/domain/leasing/renewal-auto-start.ts) and its Temporal Schedule (renewal-auto-start-daily, cron 0 9 * * *). The branch selects the bounded cohort of leases where leaseEnd < today, the renewal is unsigned, and the lease is not already month-to-month, via a lease-end-date index (see Scale below). For each:
- Compute the holdover rent = current rent +
PropertyKnowledge.renewalPolicy.mtmPremium(the existing per-property field — the Camellia "$100" is config, not a constant:types.ts:299, read byresolveRenewalOfferContext→renewal-rent.ts:118-136). - Call the PMS-agnostic
PMSWriter.convertToMonthToMonth(new method, D-PMS) to set the lease to MTM and update the recurring rent. This is the single mutation that replaces the PM's manual "unfollow → click month-to-month → change rent." - Email the office a plain-language heads-up (D3 recipient resolution; not gated by the tenant-send gate — it's operational, mirroring
escalateToPM). - Transition the saga to a terminal
HOLDOVERoutcome via the workflow (D-state), and chain D2.
Gated by the existing two-factor autonomous gate (ADR-0025): global RENEWAL_AUTONOMOUS_SENDING==='armed' AND per-property Property.autonomousRenewalEnabled. Ships disarmed; a conversion that mutates billing must never fire on an un-armed property.
D2 — Void the stale offer (re-offer: superseded)
Superseded 2026-09-05 (Fede): no re-offer; month-to-month is the end state. "i dont want to offer anything, just roll mtm." The re-offer half of this decision is retired and the code that implemented it is deleted. What remains of D2 is the void. The chain after a conversion is now exactly: convert to month-to-month → cancel the stale unanswered offer → mark the saga
HOLDOVER→ terminate any in-flight renewal workflow → email the office. Nothing else.Why: the re-offer only ever started a workflow and promised the office an offer that was never sent. On 2026-09-05, the first live conversions at Camellia (units 420/504/522) started three
…-reofferworkflows; all three recordedchannelsAttempted: []and were closed 30 minutes later by the external reconciler asresolved_externally, with zero messages to anyone. The step bought nothing and put an unwanted offer path in front of tenants the office had decided to leave month-to-month.
After a holdover conversion (or whenever an offer lapses unsigned), cancel the outstanding offer:
- Void = the already-defined-but-stubbed
PMSWriter.cancelRenewal(writer.ts:138; AppFolio impl throws "not yet wired" today — we wire it; the runner can already cancel viatryCancelActiveRenewal/resetRenewal.ts). No new interface. Re-offer = a newRemoved 2026-09-05 (see the note above). TherenewalWorkflowfor the next-month start date via the existingtriggerRenewalSync+startRenewalWorkflowpath. A new cycle = a new saga, recorded with the existingprecededBychain.precededBylineage field itself stays — other renewal supersession paths still use it.
D3 — Execution notification + (optional) executed-lease PDF to accounting
Hang a notification side-effect off the single terminal chokepoint every executed renewal already funnels through: recordWorkflowOutcome (src/lib/temporal/activities/renewal.ts:1698), gated on outcome === 'executed'. Do not hook the tenant-portal "signed" email parser — it is telemetry-only and fires pre-countersign (handle-appfolio-renewal-signed.ts:1-22). Reuse sendEmail (src/lib/integrations/email/client.ts:203, SendGrid, attachment-capable) with operational: true.
The PDF is best-effort and optional ("I don't know if we need the PDF, just notification would be fine" — Darrin). Ship notification-only first (no new runner route, no Browserbase cost on the happy path); attach the executed-lease PDF once PMSWriter.fetchExecutedLeasePdf (D-PMS) lands, reusing the ADR-0036 fetch→Buffer→attach pattern (renewal.ts:935-942). The existing fetchRenewalLetterPdf returns the offer letter, a different document — do not reuse it for the executed copy.
D4 — Where the accounting recipients live: a per-property CC list, not the Person spine
Decision: add a recipient list to PropertyLeasingSettings (types.ts:4821), beside the existing renewalContactEmail/renewalContactPhone:
/** Addresses CC'd on renewal-EXECUTION + holdover notices (e.g. the property's
* accounting team). These contacts have NO login and NO role-scoped
* permissions — they are a notification CC only. Distinct from
* renewalContactEmail (the needs-review / escalation recipient). The actual
* addresses are runtime config (DynamoDB), never code/fixtures. */
renewalExecutionCcEmails?: { name?: string; email: string }[];
Why not model JP accounting as a Person + PersonRole "accounting" team member (the option raised when we captured the card): the identity spine (ADR-0018/0020) is for humans PropFlow operates on or authenticates — tenants, prospects, staff who log in, vendor contacts who get dispatched. JP accounting is a passive CC target: no login, no inbound identity to resolve, no permissions. Modeling it on the spine would force a brand-new PersonRoleType value (accounting) and three rows (Person + IdentityClaim + PersonRole) per contact for what is functionally an email CC — and there is no existing "notification-contact Person" pattern to follow. That is over-engineering and pollutes the staff category. A {name?, email}[] list is the lightest correct home, sits next to the field it's a sibling of, and is trivially PMS-agnostic and multi-tenant. If a real login-bearing accounting persona ever appears, promote it to the spine then — not now.
This also fixes a latent limitation: every notification path today resolves to exactly one recipient string (leasingCalendar.email, renewalContactEmail, or propertyEmail) — there is no CC/list anywhere. A list is required regardless of where it lives.
D5 — Month-to-month / premium warning in the reminder copy
Edit the two SMS reminder builders (src/lib/domain/leasing/renewal-reminder-sms.ts:29 and :58) to append, on the no-response branch, a deadline + consequence line. Shipped wording (reviewed + approved by Fede 2026-06-03) — soft on the first reminder, firmer on the second:
- First (+48h): "Heads up: if you don't sign by
<leaseEndDisplay>, your lease rolls to month-to-month at<mtmRent>/mo." - Second (+5d): "Reminder: without a signed renewal by
<leaseEndDisplay>, your lease moves to month-to-month at<mtmRent>/mo."
Pull the premium from policy.mtmPremium and the date from lease.endDate — both already plumbed; do not hardcode "$100." Inherits quiet-hours + the autonomous gate automatically (it only changes copy on tenant-facing SMS).
Product note (ratified): the rest of the system bakes the premium into the displayed MTM rent rather than showing "+ $100 premium" (format-terms-pitch.ts:9-13). The call asked for the "$100 premium" framing; Fede chose to show the consequence as the absolute month-to-month rate (e.g. "rolls to month-to-month at $1,950/mo") — the baked convention and it communicates the increase. The wording above is the approved copy, not free-handed by the implementer (CLAUDE.md customer-copy rule).
D6 — The premium and the "playbook" stay where they are (reuse, don't invent)
The $100 premium already lives at PropertyKnowledge.renewalPolicy.mtmPremium — reuse it, add nothing. The renewal "playbook" stays intentionally split by concern: rent/term/premium on PropertyKnowledge.renewalPolicy (types.ts:288-306); timing + recipients on PropertyLeasingSettings (types.ts:4821-4860); reminder copy in code. We do not introduce a RenewalPlaybook entity — that is scope creep for this ADR. We do delete the already-@deprecated 90/60/30 cadence fields (types.ts:4838-4844) while we're here. New policy knobs (if any) extend renewalPolicy, not a new type.
D7 — Off-platform (manually-uploaded) lease detection as generic lease provenance
Expose a PMS-agnostic read on the existing read-side seam (PMSClient, src/lib/pms/client.ts):
interface LeaseProvenance {
/** Did the current lease term originate from a PMS-generated renewal offer,
* or was it uploaded off-platform (scanned paper lease), or unknown? */
renewalSource: 'pms_generated' | 'manually_uploaded' | 'unknown';
/** True if a backing renewal offer / e-sign envelope exists for this term. */
backingOfferExists: boolean;
signedAt?: string;
}
// PMSClient.getLeaseProvenance({ pmsRef, externalPropertyId }): Promise<LeaseProvenance>
// (property id is required — the renewal-status report is per-property)
The AppFolio adapter implements it in two tiers (cheap-first, scales):
- Tier 1 — Reports API, no runner (default, runs in the existing renewals sync). Cross-check:
rent_rollshows an active/renewed term (LeaseTo) butrenewal_summaryhas no current-cycleSent/Renewedrow for that occupancy →manually_uploaded(high-confidence inference: the term did not come through a PMS renewal offer). Scope strictly to the current cycle (maxRenewalSentDate), exactly aspms-adapter.ts:329-366already does. Reachable today with what PropFlow has. - Tier 2 — internal JSON:API corroboration via the runner. ⚠️ The original positive-confirmation design here was disproven by live recon (2026-06-04, occupancy 983) — see the "Off-platform detection decision (D7)" section below for the verified object model. In short:
/api/occupancies/<id>?include=renewal_offer,lease_documentsreturns HTTP 500 (invalid include keys on that resource);/api/lease_documentscannot be occupancy-scoped (every occupancy-filter variant returns the entire account collection) and everylease_documentsrow carries arenewal_offer(a lease_documents record is a renewal e-sign envelope by construction), so "a lease_document with no offer" is not a real shape; manual uploads live in a separatedocumentscollection that has no occupancy-filterable read. No clean positive Tier-2 confirmation is reachable. What the runner can add is corroboration fromGET /api/occupancies/<id>(a clean per-occupancy read exposingstatus,move_out,total_rent): a term whosemove_outadvanced whilestatusstayedCurrentwith no current-cycle renewal_offer reinforces the Tier-1 inference. The merged adapter therefore returnsunknown(never a falsemanually_uploaded) when Tier-1 finds no offer row — which is the correct, safe behavior given Tier 2 cannot positively confirm.
Provenance gates D1/D2: a lease with renewalSource: 'manually_uploaded' is skipped by the holdover scan and renewal outreach (it was already renewed on paper). Per the call, the office handles the paper ones' notification manually, so auto-D3 stays scoped to pms_generated executions; provenance at least makes the off-platform ones visible in PropFlow.
D-PMS — PMS-agnostic interface additions (the whole PMS surface this ADR needs)
Reusing PMSWriter/PMSClient as the only seam (no AppFolio noun in domain code):
| Method | Side | Status | AppFolio impl |
|---|---|---|---|
prepareRenewal |
writer | exists | |
cancelRenewal |
writer | exists, stubbed | wire it (runner can already cancel) — D2 void |
convertToMonthToMonth(pmsRef, newRent, effectiveDate, idempotencyKey) |
writer | new | new runner route /api/set-month-to-month (internal JSON:API lease/charges) — D1 |
fetchExecutedLeasePdf(pmsRef) |
writer | new | new runner route (the lease_documents executed doc, distinct from the offer letter) — D3 optional PDF |
getLeaseProvenance({pmsRef, externalPropertyId}) |
client | new | Tier 1 Reports API inference (shipped); Tier 2 = /api/occupancies/<id> corroboration only — no positive confirm reachable (verified 2026-06-04) — D7 |
The premium math, recipient resolution, gating, idempotency keys, and copy all live in PropFlow domain code; the adapter only translates an opaque pmsRef into PMS calls. A Yardi/RealPage adapter satisfies the same five signatures.
Entity classification (per ADR-0027)
This ADR introduces no new persisted top-level entity. Changes:
| Change | Class | Notes |
|---|---|---|
PropertyLeasingSettings.renewalExecutionCcEmails? (new field) |
canonical | Field on an already-@canonical interface (types.ts:4816); spine trace: embedded in Property. Operator-authored, no producer rebuilds it → canonical. PropFlow-only (not a PmsManagedEntity per ADR-0023; AppFolio has no "CC" concept). |
HOLDOVER saga terminal outcome (new enum value) |
n/a (enum) | Extends RenewalSagaState terminal set (types.ts:3815) + the display RENEWAL_STATUS projection. |
LeaseProvenance (new interface) |
derived | * read-through view computed from PMS state on demand; not persisted (or cached with ≤ sync-interval drift). Rebuilt by: PMSClient.getLeaseProvenance. |
RenewalTerm-style param/result interfaces for the new PMSWriter methods |
n/a (DTO) | Transport shapes at the adapter boundary, not entities. |
Architecture
Current renewal pipeline (for context)
flowchart LR
subgraph Schedules["Temporal Schedules"]
AS["renewal-auto-start-daily\n(0 9 * * *)"]
end
AS -->|lease due in 90d| WF["renewalWorkflow\n(one per cycle)"]
WF -->|prepareRenewal| PMSW["PMSWriter (AppFolio)"]
WF -->|outreach SMS/email/voice| TEN["Tenant"]
WF -->|D+2 / D+9 reminders| TEN
POLL["AppFolio rent-roll poller\n(appfolio-sync Lambda)"] -->|countersigned| WF
WF -->|recordWorkflowOutcome| TERM(["executed / declined / escalated"])
New: holdover auto-conversion (D1 + the D2 void; the D2 re-offer was superseded 2026-09-05)
flowchart TD
AS["renewal-auto-start-daily (reused schedule)"] --> SCAN{"Holdover branch:\nlease-end index query"}
SCAN -->|"leaseEnd < today\nunsigned, not MTM"| PROV["PMSClient.getLeaseProvenance"]
PROV -->|manually_uploaded| SKIP["skip (renewed off-platform)"]
PROV -->|pms_generated / unknown| GATE{"autonomous gate armed\n+ property enabled?"}
GATE -->|no| HOLD["leave for PM (disarmed)"]
GATE -->|yes| CONV["PMSWriter.convertToMonthToMonth\n(rent + mtmPremium)"]
CONV --> VOID["PMSWriter.cancelRenewal\n(void stale offer)"]
VOID -.->|"re-offer removed 2026-09-05\n(no next-month cycle)"| END(( ))
CONV --> NOTIFY["email office heads-up\n(operational, ungated)"]
CONV --> SAGA["saga → HOLDOVER terminal"]
New: execution notification (D3)
sequenceDiagram
participant POLL as AppFolio poller
participant WF as renewalWorkflow
participant ACT as recordWorkflowOutcome (activity)
participant PMS as PMSWriter.fetchExecutedLeasePdf (optional)
participant MAIL as sendEmail (SendGrid)
POLL->>WF: pmsChanged{countersigned}
WF->>ACT: outcome = executed
ACT->>ACT: idempotency guard (hasRenewalReceipt)
ACT-->>PMS: fetch executed PDF (best-effort)
PMS-->>ACT: pdf | null
ACT->>MAIL: notify renewalExecutionCcEmails (+PDF if present)
Off-platform detection decision (D7)
flowchart TD
A["lease shows active/renewed term (rent_roll.LeaseTo)"] --> B{"current-cycle renewal_offer\nin renewal_summary?"}
B -->|"Sent/Renewed row exists"| G["pms_generated"]
B -->|"no row → Tier-1 inference"| C["corroborate: GET /api/occupancies/:id\n(status, move_out)"]
C -->|"move_out advanced + status Current,\nstill no current-cycle offer"| U["manually_uploaded (inference)"]
C -->|"no corroborating term change"| K["unknown\n(never a false manually_uploaded)"]
%% NOTE: the old positive-confirmation node — occupancies?include=renewal_offer,lease_documents
%% — was removed: that include returns HTTP 500 (verified 2026-06-04). No positive Tier-2 exists.
AppFolio deep dive — renewals (reference for implementers and for Fede)
AppFolio is one
PMSWriter/PMSClientimplementation. Everything below is adapter-internal; nothing here leaks into domain code.
The three id-spaces (do not mix — mixing 404s/422s)
renewal_documents.id(==pms_external_id)renewal_offer.id— the cancel / send / letter gatelease_documents.id— the offer letter and the executed-lease PDF
How a lease gets manually uploaded in AppFolio — step-by-step (so you know what we're detecting)
Best-known UI procedure (AppFolio does not publish exact button labels; corroborated by AppFolio help + third-party guides — treat labels as approximate, not a verbatim spec).
- Leasing → find the unit/tenant, open the lease/occupancy record.
- Open the Documents tab on that lease/occupancy.
- Add Document (or drag-and-drop). A scanned wet-signed lease is a PDF/image.
- Mark Shared (tenant sees it in the portal under Shared Documents) or Private (staff-only).
- Save. The file lands as an attachment on the occupancy/lease — with no
renewal_offer, no e-signlease_documentenvelope, and nosend_all_to_tportalevent. That absence is exactly what D7 keys on.
(AppFolio also supports drag-and-dropping an existing lease during lease setup, which then syncs charges/deposits — still an upload, still no renewal-offer workflow trail.)
How we detect it (signals, ranked)
| # | Signal | Reliability | Reachable from |
|---|---|---|---|
| 1 | rent_roll term present but no current-cycle Sent/Renewed row in renewal_summary |
High (inference) | Reports API — PropFlow has this today (pms-adapter.ts:304-366) |
| 2 | move_out advanced with no current-cycle renewal_offer + status=Current |
Corroborating | GET /api/occupancies/<id> (clean per-occupancy read — verified 2026-06-04) |
| 3 | renewal_offer / no e-sign lease_document behind the current term/api/occupancies/<id>?include=renewal_offer,lease_documents → HTTP 500 (invalid include keys); /api/lease_documents can't be occupancy-scoped (filter ignored, returns the whole account) and every row carries an offer; manual uploads are a separate documents collection. No positive Tier-2 confirmation exists. |
— | — (verified 2026-06-04) |
| 4 | tenant_tickler "Renewal Signed" event |
Unreliable — do not use (adapter notes it isn't emitted on countersign, pms-adapter.ts:294-296) |
Reports API |
| — | rent_roll.LeaseTo alone |
Not a discriminator (set regardless of origin) | — |
Reachability: PropFlow ships D7 as Tier-1 inference (Reports API) + GET /api/occupancies/<id> corroboration (status + move_out). Recon done 2026-06-04 (O2 closed): the originally-proposed positive-confirmation include keys are invalid — /api/occupancies/<id>?include=renewal_offer,lease_documents returns HTTP 500 (not silently ignored), /api/lease_documents cannot be occupancy-scoped (filter ignored → whole-account collection) and every row already carries a renewal_offer, and manual uploads live in a separate documents collection with no occupancy-filterable read. No positive Tier-2 confirmation is reachable; the merged adapter returns unknown (never a false manually_uploaded) when Tier-1 finds no offer, which is correct. See src/__tests__/fixtures/appfolio/{lease_document_provenance,occupancy_object}.json + d7-provenance-shape.test.ts for the captured shapes, and memory reference_d7_provenance_endpoints_verified.
The three new runner routes AppFolio needs (D-PMS impls)
/api/set-month-to-month— set the recurring rent to the holdover rate effective the conversion date. BacksconvertToMonthToMonth. Mechanism VERIFIED live 2026-06-04 (occ 986): AppFolio's UI "Schedule Increase" is a contiguous split of the Rent Income recurring charge, replayed byrunScheduleRentIncreaseL4as two JSON:API writes —PATCH /api/recurring_charges/<id>to end-cap the current charge (end_on = effectiveDate − 1) andPOST /api/recurring_chargesto create the new charge (amount = newRent,start_on = effectiveDate, no end). The active Rent Income charge is discovered viaGET /api/recurring_charges?filter[occupancy][id]=<occ>…include=gl_account(gl_account name"Rent Income"). The earlier "no programmatic path" reading was wrong — the raw/api/recurring_chargesPUT 405s, but the React app's PATCH+POST split works. Idempotent on(occupancy, effectiveDate, newRent); live e2e scheduled $1500→$1650 eff 07/15, verified the split, re-ran (already-applied), reversed clean. Route + L4 + 18 tests inappfolio-browser-agentPR #191. Ships disarmed behind the two-factor gate. See memoryproject_overnight_renewal_holdover_build./api/set-lease-mtm— flip the lease-level "Month to Month" flag to Yes after the rent conversion (the rent change above leaves "Month to Month: No"). Backs the best-effort flag call inconvertToMonthToMonth. Mechanism captured live 2026-06-08 (occ 986): the flag is a Rails occupancy-lease form field, NOT JSON:API (PATCH /api/leases/<id> {is_mtm}→ 403); replayed byrunSetLeaseMtmL4asPOST /occupancies/<occ>?block_name=occupancy_lease(_method=put,occupancies_lease_information_form[month_to_month]=1, echoing the current lease fields so dates/signed_on are preserved). Idempotent (alreadyApplied). Route + L4 + 7 tests inappfolio-browser-agentPR #196.- a
lease_documentsexecuted-PDF fetch (distinct from the existing/api/fetch-renewal-letter, which is the pre-signature offer letter). BacksfetchExecutedLeasePdf. Still pending.
cancelRenewal needs only wiring (the runner already cancels offers; the PropFlow AppfolioPMSWriter.cancelRenewal is a throwing stub — pms-writer.ts:50-55).
Scale (10k units)
- Holdover sweep is cohort-bounded, not a scan. Query a lease-end-date index (GSI on
leaseEnd, or reuse the index the auto-start scan already relies on) forleaseEnd ∈ [today-N, today)with non-terminal renewal status — the daily holdover cohort across 10k units is tens, not thousands. NogetLeaseProvenance/ PMS call happens except for units actually in that cohort. - Execution notifications are event-driven off
recordWorkflowOutcome— zero added polling; cost scales with executions (bounded), not unit count. - Provenance Tier 2 (runner) is lazy — only on Tier-1-ambiguous units, never a fleet sweep. Tier 1 piggybacks the existing renewals sync (no extra Reports calls).
- Idempotency on every new activity (
alreadyDispatched+hasRenewalReceipt,renewal.ts:722-730,1422-1431) so Temporal replays/retries never double-convert, double-void, or double-email — essential at fleet scale. - PII stays out of Temporal history (ADR-0026): new activity inputs/returns are IDs + enums + the numeric rent only.
Consequences
Easier: the no-charge gap and the manual lease-date edits (root cause of double-charges) disappear; accounting gets execution + holdover visibility; tenants get a real deadline consequence; off-platform leases stop triggering phantom outreach. The whole thing rides existing schedules, gates, idempotency, and the PMS seam — so a second PMS inherits it by implementing five method signatures.
Harder / new commitments: we now own billing-mutating automation (convertToMonthToMonth changes rent in the PMS) — it must ship disarmed, be idempotent, and be reconcilable; the heads-up + execution emails are the audit trail. Two new AppFolio runner routes plus one wiring of cancelRenewal are net-new browser-agent surface to build and canary. getLeaseProvenance Tier 1 is an inference and can mislabel a prior-cycle PMS renewal as "uploaded" if cycle-scoping regresses — covered by tests (next phase). The HOLDOVER terminal adds a state the renewals UI must render.
Follow-up implied: build + canary the runner routes; expand the renewal test harness to cover holdover, execution-notify, provenance, and the reminder copy (the immediately-next task); render HOLDOVER in the renewals list/detail. (The open question "should holdover months be re-offered indefinitely or capped?" was answered 2026-09-05: never — month-to-month is the end state.)
Alternatives considered
- Model JP accounting as a
Person+accountingPersonRole. Rejected (D4): heavyweight, needs a new role type + three rows per contact, pollutesstaff, and there's no notification-contact Person pattern. A CC list is the simple correct home; promote to spine only if a login-bearing accounting persona appears. - AppFolio-specific holdover/notify/detection code. Rejected on principle: violates the PMS-agnostic constraint. The
PMSWriter/PMSClientseam already exists for exactly this; the cost of routing through it is ~five method signatures. - A new
RenewalPlaybookentity to unify policy + timing + copy. Rejected as scope creep (D6). The split-by-concern storage is intentional and already noted for eventual consolidation (renewal-policy.ts:58); this ADR only deletes the dead 90/60/30 fields. - Trigger the execution notification off the tenant-portal "signed" email. Rejected: that email is pre-countersign and telemetry-only (
handle-appfolio-renewal-signed.ts:1-22) — it would notify before the renewal is actually executed. TherecordWorkflowOutcometerminal is the correct, single chokepoint. - Make the executed-lease PDF mandatory on the notification. Rejected: couples a simple email to an expensive, not-yet-built Browserbase fetch. The customer said notification-alone is fine; PDF is best-effort once the runner route exists.
- A dedicated holdover scanner service / Lambda. Rejected: a branch on the existing daily scan reuses the schedule, gate, and idempotency — fewer moving parts, same scale profile.
Test plan
(Encoded by the next work item — expand the renewal test harness.) Minimum coverage:
- Holdover (D1): lease expired + unsigned → converts to MTM at rent+premium; respects the disarmed gate (no-op when un-armed); idempotent on replay; skips
manually_uploadedand already-MTM leases; premium read fromrenewalPolicy.mtmPremium(not hardcoded). - Void (D2): stale offer cancelled exactly once. (Superseded 2026-09-05: the original "a new next-month
renewalWorkflow/saga opens withprecededBy" acceptance criterion is retired — a conversion must now open NO successor cycle.) - Execution notify (D3): fires once per execution off the terminal; CC list resolved from
renewalExecutionCcEmails; PDF attached when fetch succeeds, notification still sent when it fails; never fires for declined/escalated. - Recipients (D4): multi-recipient CC; empty/missing list falls back to
renewalContactEmail/ env; no spine rows created. - Reminder copy (D5): warning line present on the no-response branch with the real date + rent; absent on other branches; quiet-hours + gate still honored.
- Provenance (D7): Tier-1 inference (term w/o current-cycle offer → uploaded), current-cycle scoping (prior-cycle PMS renewal →
pms_generated, not mislabeled), Tier-2 positive path,unknownfallthrough. - PMS-agnostic: the holdover/notify/provenance domain logic drives a fake
PMSWriter/PMSClientwith zero AppFolio imports — proving portability.
Rollout & open questions
- Gating: ships disarmed (ADR-0025). Arm per-property only after the holdover canary is green on the test property.
- Sequencing: D5 (copy) and D3 notification-only ship first (no runner work). D1/D2/D3-PDF wait on the two new runner routes +
cancelRenewalwiring. D7 Tier 1 ships with D1; Tier 2 follows. - Open: (O1) cap on consecutive holdover months before forcing PM review? (O2 — closed 2026-06-04, see Reachability above.) (O3 — closed 2026-06-04: it must replace, and the verified mechanism does exactly that. AppFolio's "Schedule Increase" end-caps the prior Rent Income charge (
end_on = effectiveDate − 1) and creates a contiguous new charge starting on the effective date — the two charges never overlap, so there is no double-charge by construction.convertToMonthToMonthreplays that split; it does not merely add a second charge. Verified live on occ 986.)