0091 — Reconcile renewals against external PMS actions (PMs are co-actors, not spectators)

Context

The incident class (verified against prod + AppFolio, 2026-07-15)

A one-by-one audit of the renewals board's "Decisions for you" tab (Camellia, 9 items) against the live AppFolio reports API found 5 of 9 items stale or misclassified because the on-site PM had already resolved them directly in AppFolio:

The four remaining items (two collections policy holds, one genuinely escalated conversation, one countersign) were confirmed genuine.

The design flaw

Every layer of the renewal pipeline assumes PropFlow is the only actor:

The consequence is compounding: every renewal the PM handles manually becomes a permanent "decision" item, the tab loses signal, and the PM learns to ignore it — which erodes exactly the trust surface (Sean audits these counts) the cohort board was built to earn.

This is the same lesson as the 2026-06-24 dropped-decline incident from the opposite direction: that one taught us not to mutate live sagas blindly; this one teaches us not to ignore the PMS mutating the world around them. Industry framing is standard: the PMS is the system of record for tenancy facts; a layered product must run periodic reconciliation against it to detect out-of-band changes, with explicit authority rules for which system wins per fact.

Decision

Treat the PMS as the system of record for tenancy facts (lease terms, offer status, charge schedules, scheduled increases, move-outs). PropFlow owns process state (conversations, outreach, approvals); it must reconcile its process state against PMS facts on a schedule, and represent "a human did this in the PMS" as a first-class outcome.

Concretely, four pieces:

1. PMS-agnostic truth contract on PMSClient

The capability interface lives on PMSClient (src/lib/domain/pms/client.ts) behind a new PMSCapability marker ('renewalTruth', src/lib/domain/pms/types.ts), resolved by the reconciler via getPMSClient — domain code never imports src/lib/integrations/* directly. Each integration's adapter implements one read:

fetchRenewalTruth(propertyExternalId: string): Promise<PmsRenewalTruthSnapshot[]>

One snapshot per active occupancy, normalized across PMSes:

interface PmsRenewalTruthSnapshot {
  occupancyRef: string;          // PMS-native occupancy/lease id
  leaseFrom: string | null;      // ISO date
  leaseTo: string | null;        // null = open-ended / MTM
  offers: Array<{ ref: string; status: 'pending' | 'accepted' | 'countersigned' | 'closed' }>;
  rentScheduleEnd: string | null;      // active recurring rent charge end date (null when the PMS can't expose it)
  scheduledIncreaseAt: string | null;  // PMS-side "next rent increase"
  rentPostedThrough: string | null;    // latest rent charge actually posted — the observable
                                       // proxy where no schedule read exists (AppFolio v1 reports)
  moveOutAt: string | null;
  asOf: string;                  // when we read it
}

The AppFolio implementation composes existing reports (tenant_directory, rent_roll, renewal_summary, charge schedule) — all already reachable through AppfolioClient. Yardi (SIPP) and RealPage implement the same contract later; no reconciler logic may reference an AppFolio-specific field.

2. A reconciler cron that diffs truth against open process state

New cron renewal-external-reconcile, following the renewal-stuck-saga-sweep / spine-reconcile pattern (withAutomationRun, requireCronOrAdminAuth). Per property with an open renewal season: fetch truth snapshots, diff against open sagas + board-visible renewals, and classify into typed external events:

Event Detection rule (all from the snapshot diff)
offer_cancelled_externally Saga holds pmsRef to an offer whose PMS status is closed (and we didn't close it)
renewed_externally_lease PMS leaseTo advanced past the cycle's expiration and we hold no executed-renewal evidence
renewed_externally_charges leaseTo did NOT advance and has lapsed, but the economics extended ≥6 months past it: a rentScheduleEnd ≥6mo out, OR a future-dated scheduledIncreaseAt ≥6mo out plus rent actually posted past leaseTo (the AppFolio-observable proxy)
mtm_managed leaseTo null AND scheduledIncreaseAt set in the future — a deliberate PM-managed month-to-month, not a stall. (Live data carries increase dates years stale; a past date is not forward-looking management — Phase 0 harness finding, 2026-07-15.)
moveout_scheduled moveOutAt set while a renewal saga is open

A snapshot with leaseTo null and no scheduledIncreaseAt deliberately produces no event: absent any PMS-side signal of management, the row keeps its existing holdover_stalled bucket — a genuine stall stays visible rather than being optimistically dismissed.

This cron is the sanctioned drift-detector pattern (same family as spine-reconcile and the alert-only renewal-stuck-saga-sweep), not a timer-compensation cron: out-of-band PMS mutations emit no Temporal signal and never will, so polling diff is the only mechanism that can observe them. It compensates for a missing external event source, not for a workflow we failed to signal.

Events are idempotent (keyed on saga/occupancy + event type + evidence hash) and land in the activity log with their evidence snapshot.

3. External resolution as a first-class outcome

4. PMS data-hygiene surface

renewed_externally_charges is simultaneously a resolution and a compliance gap (tenancy extended with no lease on file; PMS renewal module contradicts its own charge schedule). These flag into the board's Data cleanup tab as their own chore kind ("Charges run to <date> but no lease is on file — generate the lease in AppFolio") so the paper-trail gap gets fixed rather than silently absorbed.

Rollout phases

Each phase names its promotion trigger up front so no mode can rot into a permanent flag:

  1. Phase 0 — observe-only (first PR set): contract + AppFolio impl + cron in log/alert-only mode. Acceptance: reconciler output reproduces the 2026-07-15 manual audit verdicts on Camellia exactly (the audit doubles as the golden fixture, anonymized). Promote when: two consecutive weeks with zero false-positive events on Camellia. Observe-only mode is then deleted, not left as a switch.
  2. Phase 1 — board display: mtm_managed re-bucketing + "Handled in AppFolio" settled outcome + hygiene chores. No saga writes yet. Promote when: founders confirm the board's reconciled counts match a manual AppFolio spot-check for one full cron cadence.
  3. Phase 2 — saga close-out: resolved_externally writes behind the conversation-recency guard + HITL confirm tier. The HITL tier is permanent by design (it is the safety boundary, not a rollout gate); only the auto-close subset widens as evidence accumulates.
  4. Phase 3 — second PMS: Yardi or RealPage adapter implements fetchRenewalTruth, proving the contract holds.

Entity classification (per ADR-0027)

Entity Class Naming Spine trace (canonical) OR derived-from / rebuilt-by / drift-tolerance (derived)
PmsRenewalTruthSnapshot derived *Snapshot suffix from: PMS reports at capturedAt · Rebuilt by: renewal-external-reconcile cron each tick · Drift tolerance: ≤ cron cadence (target 1h)
RenewalExternalEvent canonical bare name Spine trace: via saga personId → person; carries occupancyRef + evidence snapshot

Consequences

Alternatives considered


Addendum — 2026-07-17: graceful workflow wrap-up replaces the live-workflow + recency guards ("less clicks less reviews")

What changed

The original Phase 2 diverted a resolution event into the human confirm lane whenever either of two guards tripped: (1) a RUNNING Temporal workflow on an affected saga, or (2) tenant conversation activity within CONVERSATION_GUARD_DAYS (14). Both were "a human confirms before anything closes." In practice this parked most real, correct resolutions behind a click — the exact friction the reconciler was built to remove — because a live workflow is the normal state of an in-flight renewal.

The founder ruling replaces both guards with graceful workflow wrap-up:

  1. RUNNING workflow → wrap up, don't divert. When a resolution event's saga has a RUNNING workflow, the reconciler signals the workflow (a synchronous Temporal Update, externalResolution, the repo-idiomatic equivalent of a signal — chosen so the reconciler gets the verdict in one RPC to set the event-row status; see below) to wrap up: the workflow cancels its pending outreach / reminder / voice / expiration-nudge timers and scheduled sends, and exits cleanly. The reconciler then applies the event and closes the saga exactly as it would for a completed/absent workflow. workflow.patched('external_resolution_wrapup_v1') keeps in-flight histories deterministic.

  2. The conflict check is the load-bearing safety piece (the 2026-06-24 rule, preserved). Inside the wrap-up the workflow reads its own decision state for a genuine contradiction: a tenant decision already received/in-flight — declined / vacate intent / formal NTV — that contradicts the external renewal. Because the Update is serialized after every prior signal in the workflow task loop, a decline that landed moments earlier can never be missed (a plain query could race it). On a contradiction the workflow returns conflict; the event stays proposed (the existing confirm lane) with a plain-English reason ("The tenant told Clara they're declining, but AppFolio shows a renewal — which is right?"). No contradiction → the event auto-applies with no human involvement.

  3. The conversation-recency guard is DELETED. CONVERSATION_GUARD_DAYS and its check are gone. The workflow's own decision state — not a heuristic on message timestamps — is the authoritative conflict source.

  4. Probe / update failure still fails SAFE. If Temporal is unreachable (probe throws, or the Update throws because the workflow completed between probe and update), the reconciler cannot know the workflow's decision state, so it keeps the event proposed — never bulldozing a possibly-live negotiation.

Why the workflow, not the reconciler, owns the wrap-up + conflict check

The reconciler closes the saga (source-of-truth outcome), but it must not strand a tenant decision the workflow is holding. Putting the conflict check inside the workflow, reached via a serialized Update, is the only place that observes every prior signal atomically — the strongest guarantee against the 2026-06-24 dropped-decline class. The workflow's wrap-up exit deliberately does not call recordWorkflowOutcome (which would transition the saga itself and, on executed, send an accounting notice): the reconciler remains the single saga-closer, and no communications are sent by the wrap-up.

Consequences


Addendum — 2026-07-19: mtm_managed is a SETTLING resolution, not a display-only fact (X-16)

The deferral this closes

Phase 1 shipped mtm_managed as a display-only signal: the acting engine persisted it observed and never closed a saga, and the board mapper only softened a row that was ALREADY a holdover_stalled stall (re-bucketed it to waiting). That left the real prod shape unhandled — Camellia units 217/305/314 carried mtm_managed/observed rows since 2026-07-16 while the board still showed "Ready to send / held for PM review" with an Approve & send button proposing offers below current rent, because those rows were never stalled holdovers (a recent saga suppresses the lapse flag, so they surfaced a pre-send status instead).

The ruling (Fede 2026-07-19)

A lease that is already month-to-month is a TERMINAL/settled state on the renewals board. Clara never prompts an offer for it and never offers "Move to month-to-month" for it. If a PM wants to re-lease an MTM tenant they act in AppFolio and this reconciler picks it up.

What changed

  1. Acting engine (external-apply.ts): mtm_managed joins the resolution set. It closes the affected saga to HOLDOVER (resolved_externally) — the same month-to-month disposition as renewed_externally_charges — and, on a RUNNING workflow, routes through the identical graceful wrap-up (cancel pending outreach/timers, no comms) with the same conflict check (a contradicting tenant decline/NTV keeps the event proposed). No communications are sent.
  2. Board (map-renewal.ts / data.ts): an observed or applied mtm_managed event settles the row to settled_external carrying externalResolutionType: 'mtm_managed', so the Settled dot + KPI read yellow "Month-to-month" (isSettledMonthToMonth). It never re-buckets to waiting or leaves an offer prompt standing.
  3. Already-MTM guard (no artifact needed): the mapper never emits a ready/held-review offer prompt for a lease that is already MTM (monthToMonth true, or an empty lease end) — the offer-prompt statuses (INELIGIBLE/PREPARED/PREPARING) route to the honest holdover state.

Consequences

Addendum — 2026-09-03: a workflow-reported conflict no longer parks the event for a human (supersedes the 2026-07-15 confirm design for this case)

The ruling (Fede 2026-09-03)

"if it was updated in appfolio we should just follow the signal, no one is going to manually review." The PMS remains the system of record even when Clara's own workflow holds an in-flight tenant decision that contradicts the external evidence (the 2026-06-24 dropped-decline scenario, §Addendum 2026-07-17). AppFolio wins outright — there is no PM confirm step for this case anymore, and no feature flag: this closes only renewals AppFolio already shows handled, and nothing is ever sent to a tenant on the auto-close.

What changed

  1. Acting engine (external-apply.ts): when the workflow's wrap-up (externalResolution Update) returns conflict, the reconciler no longer stops there. It issues a SECOND externalResolution Update with overrideConflict: true and a fresh eventId (never a replay of the first) — the workflow accepts the wrap-up through the exact same exitExternallyResolved() exit as a clean no-conflict wrap-up (no comms, no recordWorkflowOutcome, no second saga-closer), then the reconciler closes the saga (transitionSagaToResolvedExternally) and marks the event applied, exactly like the no-conflict path. The workflow's plain-English conflict text (from the FIRST call's verdict) is carried into the saga's audit entry (conflictNote) instead of into a PM's queue, so the override stays explainable even though nobody reviewed it. Only a genuine failure to act (a Temporal outage, the workflow completing mid-flight, or a pre-patch workflow history that can't accept the override) still fails SAFE into proposed — that's an inability to safely close, not a reviewable disagreement.

    An earlier version of this change tried to force the override via the PM-action signal the confirm route sends (signalPmAction, force_resolve). Bot review on the PR caught that this exits the workflow through exitWithOutcome('executed'), not the wrap-up path: it fires recordWorkflowOutcome, which sends an accounting notice and becomes a second saga-closer racing the reconciler's own transition. The overrideConflict Update above is the corrected design — it reaches exitExternallyResolved(), the same no-send, single-closer exit every other wrap-up already uses.

  2. Audit entry (renewal-saga/writer.ts): an auto-applied close (actor: 'system') now writes "Closed automatically — AppFolio shows <type>", plus the conflict text when one was overridden. A human-confirmed close (actor: 'pm') keeps its original copy — a PM genuinely clicked Confirm, that's a different fact to record.

  3. Backlog: no separate reprocessing job. The apply engine has no notion of "new" vs. "already proposed" — every reconciler tick re-derives events from the PMS snapshot and re-upserts by evidence hash. A row a prior run left proposed under the old conflict-parks-forever behavior is not applied/dismissed, so the very next scheduled tick re-runs it through the same (now auto-applying) logic.

  4. Board / confirm UI (map-renewal.ts, BoardV2.tsx, /api/leasing/renewals/external-resolution): left in place as a dead-fallback safety net rather than removed. A proposed row reaching the board today is now rare — a genuine Temporal-outage fail-safe, or a lone ambiguous cancelled offer with no co-occurring resolution (never auto-closes alone; unchanged from Phase 2). Removing the Confirm/Not-handled UI touches the board mapper, BoardV2.tsx, the API route, and several test suites for a lane that still needs somewhere to land genuine act-failures — deferred rather than done in the same change.

Consequences