0091 — Reconcile renewals against external PMS actions (PMs are co-actors, not spectators)
- Status: Proposed
- Date: 2026-07-15
- Deciders: Fede
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:
- Two "Escalated — Clara needs your answer" items where the PM had (a) canceled the renewal
offers inside AppFolio's renewal module weeks earlier, then (b) renewed the tenancy
economically — editing the recurring rent charge schedule to run one more year and hand-posting
the current month's rent — without generating a lease document. AppFolio's own renewal report
shows "Did Not Renew" for these occupancies while their charge schedules run 12 more months.
PropFlow's sagas for them still sit in
OFFER_PREPARED, one holding apmsRefto an offer that no longer exists. - Three "Lease ended — nothing in motion" holdovers that are in fact deliberate month-to-month
arrangements: the PM set open-ended lease terms, in one case already applied a rent increase, and
scheduled the next increase (
NextRentIncrease) 7–21 months out. The PM considers these settled; the board calls them stalled.
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:
RenewalSaga.closedReason(src/lib/data/types.ts~5065) admits'executed' | 'ntv_received' | 'transferred' | 'holdover' | 'cancelled'— there is no way to record "a human resolved this in the PMS."- Sagas advance only on events PropFlow itself generates (offer prepared, letter sent, tenant reply, countersign detection). An offer canceled in the PMS, a charge-schedule renewal, or a PM-managed MTM arrangement produces no event, so the saga and the board row live forever.
- The board's needs-you buckets (
src/app/(workspace)/(operations)/admin/dev/renewals-cohort/map-renewal.ts) derive urgency purely from PropFlow-side state (escalated,OFFER_PREPARED, MTM-with-no-saga), never from what the PMS currently says about the tenancy. - Existing crons watch for our failures only:
renewal-stuck-saga-sweep(alert-only, stuck sagas),renewal-prepare-retry(PREPARE_FAILED redrive),lease-execution-notices(signings we expect). Nothing watches for their actions.
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
- Add
'resolved_externally'toRenewalSaga.closedReason, carrying the triggering event + snapshot as evidence. - Board: externally-resolved rows move to Settled with a plain-English outcome ("Handled in
AppFolio" — never mechanism-speak), instead of haunting "Decisions for you".
mtm_managedrows leaveholdover_stalledand render as month-to-month with the PMS's scheduled-increase date. - Safety (the 2026-06-24 rule): the reconciler never closes a saga with recent or open tenant
conversation activity. Those cases surface as a proposed dismissal in the decisions tab
("This looks handled in AppFolio — confirm?") for one-click PM/founder confirm.
Everything the reconciler does is evidenced, logged, and reversible (reopen = new saga, existing
find-or-mintpath).
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:
- 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.
- Phase 1 — board display:
mtm_managedre-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. - Phase 2 — saga close-out:
resolved_externallywrites 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. - 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
- The decisions tab becomes trustworthy: an item present means no actor anywhere has resolved it. Stale accretion (5/9 today) trends to zero.
- We commit to a per-PMS truth read on every adapter — a real cost for each new PMS integration, but one that reuses reports each integration already needs for sync.
- Reconciliation cadence bounds staleness (≤1h target) — PM actions inside that window can still briefly show stale state; the freshness caption makes that honest.
- New failure mode to own: a wrong external-resolution inference could close a live negotiation. Mitigated by the observe-only phase, evidence-carrying events, conversation-recency guard, and the HITL confirm tier — and closing is always reversible.
- The charge-only renewal pattern becomes visible product surface (hygiene chore) rather than invisible drift — and later, something Clara can offer to fix (generate the lease from the charge terms) once trust is earned.
Alternatives considered
- PMS webhooks / event push. AppFolio's reports API exposes no renewal/charge webhooks; polling diff is the only portable mechanism. Revisit per-PMS if push becomes available.
- Manual "dismiss" button only. Keeps the PM as the sync engine — the exact failure being fixed. A dismiss affordance still ships (HITL confirm tier) but as the exception path.
- Full bidirectional field-level sync platform. Overkill: tenancy facts already flow inward via entity sync; the gap is interpreting those facts against process state, not moving more data. Field-level two-way sync also reopens the write-safety questions ADR-0070/no-auto-writes deliberately closed.
- Widen entity sync to overwrite saga state directly. Rejected: sync writers are dumb-copy by design; encoding renewal semantics there couples ingestion to domain logic and bypasses the evidence/HITL guards this ADR requires.
Addendum — 2026-07-17: graceful workflow wrap-up replaces the live-workflow + recency guards ("less clicks less reviews")
- Status: Accepted (amends §Decision 3 and the Phase-2 rollout note; supersedes the "confirm lane is permanent by design" framing for the live-workflow and conversation-recency cases specifically)
- Deciders: Fede (founder-ratified 2026-07-16/17)
- Does NOT change: the outcome mapping (
renewed_externally_lease→ EXECUTED,renewed_externally_charges→ HOLDOVER/month-to-month) — that stays the recorded, real disposition. No "handled in AppFolio"-style outcome label is introduced anywhere; provenance stays in activity-log / attribution metadata only.
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:
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.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 staysproposed(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.The conversation-recency guard is DELETED.
CONVERSATION_GUARD_DAYSand its check are gone. The workflow's own decision state — not a heuristic on message timestamps — is the authoritative conflict source.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
- The confirm lane is now the exception, reached only on a genuine contradiction or a Temporal outage — not the default for every in-flight renewal. This is a deliberate narrowing of the "HITL tier is permanent by design" language in §Decision 3 / Phase 2: the conflict HITL tier is permanent; the live-workflow and recency HITL tiers are removed.
- New surface to own: a wrap-up must never fire an outbound send. Guaranteed structurally — the
wrap-up exit cancels pending timers and skips
recordWorkflowOutcome.
Addendum — 2026-07-19: mtm_managed is a SETTLING resolution, not a display-only fact (X-16)
- Status: Accepted (amends the §Decision 2 event table's
mtm_managedrow, §Decision 3's "mtm_managedrows leaveholdover_stalledand render as month-to-month" line, and the external-apply engine's "display-only facts, persisted 'observed'" treatment ofmtm_managed) - Deciders: Fede (founder-ratified 2026-07-19)
- Does NOT change: the classifier (
classifyExternalEvents) — the detection rule formtm_managed(open-ended lease + a future PMS-side scheduled increase) is unchanged. Only what the acting engine + board DO with an emittedmtm_managedevent changes.
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
- Acting engine (
external-apply.ts):mtm_managedjoins the resolution set. It closes the affected saga to HOLDOVER (resolved_externally) — the same month-to-month disposition asrenewed_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 eventproposed). No communications are sent. - Board (
map-renewal.ts/data.ts): anobservedorappliedmtm_managedevent settles the row tosettled_externalcarryingexternalResolutionType: 'mtm_managed', so the Settled dot + KPI read yellow "Month-to-month" (isSettledMonthToMonth). It never re-buckets towaitingor leaves an offer prompt standing. - Already-MTM guard (no artifact needed): the mapper never emits a ready/held-review offer
prompt for a lease that is already MTM (
monthToMonthtrue, or an empty lease end) — the offer-prompt statuses (INELIGIBLE/PREPARED/PREPARING) route to the honest holdover state.
Consequences
mtm_managedis no longer a permanent "observed" row; it settles like any other applied external resolution. The event table'smtm_managedrow and §Decision 3's re-bucket line are superseded by the settle behavior above.- Same structural no-send guarantee as the other resolution wrap-ups (the wrap-up exit cancels
pending timers and skips
recordWorkflowOutcome; the reconciler remains the single saga-closer).
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
Acting engine (
external-apply.ts): when the workflow's wrap-up (externalResolutionUpdate) returnsconflict, the reconciler no longer stops there. It issues a SECONDexternalResolutionUpdate withoverrideConflict: trueand a fresheventId(never a replay of the first) — the workflow accepts the wrap-up through the exact sameexitExternallyResolved()exit as a clean no-conflict wrap-up (no comms, norecordWorkflowOutcome, no second saga-closer), then the reconciler closes the saga (transitionSagaToResolvedExternally) and marks the eventapplied, 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 intoproposed— 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 throughexitWithOutcome('executed'), not the wrap-up path: it firesrecordWorkflowOutcome, which sends an accounting notice and becomes a second saga-closer racing the reconciler's own transition. TheoverrideConflictUpdate above is the corrected design — it reachesexitExternallyResolved(), the same no-send, single-closer exit every other wrap-up already uses.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.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
proposedunder the old conflict-parks-forever behavior is notapplied/dismissed, so the very next scheduled tick re-runs it through the same (now auto-applying) logic.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. Aproposedrow 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
- No PM is ever asked to confirm a renewal AppFolio has already resolved, including the dropped-decline conflict case that previously always waited for one.
- The confirm-lane UI and endpoint remain live code, but exercise only the fail-safe / ambiguous paths going forward — not the primary flow.