0046 — Renewal lifecycle completion: month-to-month holdover, execution notifications, and PMS-agnostic off-platform-lease detection

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:

  1. 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 —
  2. 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-204 skips leases with daysLeft <= 0 ("a separate operational signal"), and no holdover/auto-convert handler exists (grep for holdover/autoConvert/lapsedLease returns 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.
  3. 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.
  4. 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.
  5. 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)

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:

  1. 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 by resolveRenewalOfferContextrenewal-rent.ts:118-136).
  2. 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."
  3. Email the office a plain-language heads-up (D3 recipient resolution; not gated by the tenant-send gate — it's operational, mirroring escalateToPM).
  4. Transition the saga to a terminal HOLDOVER outcome 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 …-reoffer workflows; all three recorded channelsAttempted: [] and were closed 30 minutes later by the external reconciler as resolved_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:

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:

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.mtmPremiumreuse 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):

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 reuse for D2 re-offer (re-offer superseded 2026-09-05)
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/PMSClient implementation. Everything below is adapter-internal; nothing here leaks into domain code.

The three id-spaces (do not mix — mixing 404s/422s)

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).

  1. Leasing → find the unit/tenant, open the lease/occupancy record.
  2. Open the Documents tab on that lease/occupancy.
  3. Add Document (or drag-and-drop). A scanned wet-signed lease is a PDF/image.
  4. Mark Shared (tenant sees it in the portal under Shared Documents) or Private (staff-only).
  5. Save. The file lands as an attachment on the occupancy/lease — with no renewal_offer, no e-sign lease_document envelope, and no send_all_to_tportal event. 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 No renewal_offer / no e-sign lease_document behind the current termNOT reachable. /api/occupancies/<id>?include=renewal_offer,lease_documentsHTTP 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)

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)

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

Test plan

(Encoded by the next work item — expand the renewal test harness.) Minimum coverage:

Rollout & open questions