PMS adapter cleanup

Result of the "PMS code lives in adapters" chain · all 14 PRs merged as of 2026-10-16 · build check live

As of 2026-10-16, 4:26 AM Mountain. Merge states read from GitHub at that time. Numbers from the PR bodies and the allowlist file on main.

Contents

  1. The rule
  2. The chain, PR by PR
  3. The build check that keeps it clean
  4. Production facts behind the chain
  5. Debt left for later PRs
  6. Lessons

In one breath: Clara's leasing code now reads our own neutral records (application status, unit id, move-in started, rent-roll and tenant rows) instead of AppFolio's raw report strings, and every step was proven to change nothing on the full live book. A new build check now fails any PR that adds the word "appfolio" outside the PMS layer. 2,812 old mentions in 590 files are recorded as debt that can only shrink.

BEFORE AFTER Leasing gates Clara's status answers Staff desk text Move-in planner AppFolio shapes "Decision Pending" af.unitId "AppFolio" in text raw report rows Each core file sniffs vendor strings itself; adding Yardi means touching all of them. Core flows leasing gates status answers staff desk move-in planner read: pending / approved / unit ref PMS layer domain/pms adapters AppFolio Build check: new "appfolio" in core = red PR
Left: core code read AppFolio's own words. Right: core code reads our neutral values; only the PMS layer knows AppFolio exists, and the build check guards the line between them.

Before

  • Status read by guessing at substrings ("cancel", "in review")
  • 20 core files imported an address helper from the AppFolio scraper
  • Staff texts hard-coded "AppFolio"
  • Nothing stopped a new literal

After

  • One explicit status table in the adapter; unknown stays unknown
  • Address helper in a neutral module
  • Staff texts name the company's own PMS
  • Build check: 2,812 mentions can only go down
1. The rule

Fede's rule (2026-10-15): core flows, meaning the engines, cadences, gates, lists and screens, never name a PMS (the property management system, AppFolio today, Yardi tomorrow). Every PMS read or write goes through an adapter, a translator module that turns AppFolio's shapes into ours, living in src/lib/domain/pms. Statuses, ids and events are translated once, where the sync pulls them in. Old mentions are removed in small PRs that change no behavior.

2. The chain, PR by PR

Byte-identical replay means we ran the old and the new code over every live customer's real rows and compared the outputs bit for bit. A mutation control is a deliberately planted change that must make that comparison fail, proving the test can see a difference.

#PRWhat changedProofState
1#20201Application completeness (screening done, fee paid, whole group submitted) and the neutral status land on our records at the sync694 real rows, identical; control differsmerged c2a62548a
2#20340Status table covers every word core code used to sniff for2,893 statuses identical; control differsmerged 2c73fb67c
3#20349Staff call-note "unreviewed application" fact reads the neutral status372 prospects, 118 = 118 identical; control differsmerged ba22acc5e
4#20355Application stage reads the neutral status, no substring guessing2,521 rows, 0 differ; two controlsmerged b4304884d
4b#20386Stage follows Fede's rule: Decision Pending + Converting/Converted = approved (3 prospects move)Replay: exactly the 5 expected rows changemerged 6d1df2b28
4c#20394No new-lease review item once the move-in has started in the PMS (no duplicate lease)2,521 rows: only Camellia #119 changesmerged e556554f2
5#20356One accessor for the PMS unit id in the leasing joinsEvery live unit row identical; control differsmerged b1d287671
6#20363Address key moved out of the AppFolio scraper into a neutral module1,256 addresses, 0 differ; control 60 differmerged 56fe29d40
7#20368Staff-desk lines name the company's own PMS, not "AppFolio"870 renders identical; Yardi control 6 of 6 differmerged fc7aed462
8#20374Tool result key queued_for_appfolio becomes queued_for_pmsFixture eval baseline = branch; Willows run after mergemerged 92c6ec483
9#20372Move-in planner reads a neutral rent-roll row148 properties, 0 differ; control differsmerged 86584b72c
9b#20395Move-in planner reads a neutral tenant-directory row; no AppFolio imports left in it148 properties, 0 differ; control 2 differmerged db6280336
10#20373"Open in AppFolio" reminder link moved into the PMS layer1,408 cases identical; control 764 of 764 differmerged bc2d2e70a
11#20387Build check forbidding "appfolio" outside the adapter layerPlanted literal goes red; real tree 0 violationsmerged 7e3d6a4e8

Context, same day: #20194 (merged e1e2f2414) made only an approved application hold a home; 9 homes reopened. #20203 (merged ae0ba8f36) made Decision Pending + Converting count as approved for that hold, at the adapter only; its replay of the live report changed exactly 5 of 2,554 rows.

3. The build check that keeps it clean

scripts/check-pms-literals.ts reads every app source file and flags any "appfolio" text or AppFolio import outside the PMS layer. Comments and tests don't count. It runs in npm run review:self and in the CI Lint job. Today's mentions are listed in scripts/check-pms-literals.allowlist.json, and that list is shrink-only: a file's count may go down or disappear, never up, and a file not on the list starts at zero.

LaneFilesMentionsShare of mentions
Leasing92198
Renewals41127
Comms mirror50201
Robot532
Onboarding50240
Other (tools, maintenance, admin)3522,014
Total5902,812
4. Production facts behind the chain

Read-only dump, 2026-10-16: 2,521 application rows across the three customer databases (JP&Co 38, Western Slope 757, Situs 1,726). After the status table, no row has an unknown status.

AppFolio decisionRowsOur value
Approved1,771approved
Canceled399canceled
Denied307denied
Decision Pending39pending
Decision Pending + Converting5approved (adapter, #20203)

The 4c replay: of 2,521 rows, only Camellia #119 changes outcome. Once 4b merges, she will not get a new-lease review item, because staff already started her move-in in AppFolio.

5. Debt left for later PRs

Open issues from the build check review: #20392 (one advisory hides three different skip causes), #20393 (exclude the public website and legal subprocessor list). Filed by the chain's builders along the way, not cleanup debt: #20361, #20375, #20376.

6. Lessons

Related: testing playbook.

PropFlow Docs