Every state a test tenant on the Willows can be in, the AppFolio tool that moves it along each edge, and the read that tells you which state you are in and why. For whoever is driving the bench.
2026-09-05 · property 45 (the Willows) only · drafted from the live tool catalog and the browser-agent routes · cancel-notice edge shipped and live-proven the same day (occupancy 986: Past → Current)
The rule Gera asked for: a clean way to go to and from any state, so when a test tenant is "down" we can see why and drive it to the state we need — without opening AppFolio by hand.
Fede's own AppFolio profile (tenant 1832, occupancy 986, unit TEST-102) had been given notice on 2026-06-11 and a move-out date of 2026-06-25, then left there: status Past, move-out accounting never finalised, lease expired 2026-07-14, phone empty. The bench could not borrow it and nothing on our side said why. We drive that exact tenant-status form in one direction (file a notice) and had no tool for the other (cancel it). The same shape repeats for every state below.
AppFolio's own words are in small caps. Each edge names the tool that drives it today, or the gap.
appfolio.move_in_tenant (offline-signed lease, wizard end to end)run-new-lease-send (e-sign new lease; mock-proven, not live-proven)appfolio.create_unit, appfolio.post_vacancyappfolio.edit_tenant_status (backdate the move-in to today)lease-document-statusappfolio.file_notice_to_vacateset-lease-mtm route onlysend_renewal, change_term_renewal, cancel-renewal, list_renewals_ready_to_countersigntenant_phone_set, tenant_email_set, run-tenant-name-set route onlyappfolio.cancel_notice_to_vacate liveallowOverlappingOccupancy posts AppFolio's own skip switch, which the Willows units need because they carry harness occupancies.move-out-tenants + move-out-charges routes onlyappfolio.move_in_tenant)read-move-out-chargesappfolio.get_tenant_details already answers the AppFolio half in one call. The fields that decide the state, in the order to read them:
| field | what it decides |
|---|---|
occupancy.status / tenant.status | Current · Past · Future. The headline. |
tenant.noticeDate, occupancy.moveOut | Both set = on notice, or Past once the date passes. Both empty = clean Current. |
tenant.pendingMoveOut, occupancy.isMoveOut | Past-not-finalised (true/false) is reversible; Past-finalised (·/true) is not. |
occupancy.leaseExpirationDate | In the past on a Current tenant = month-to-month territory; the renewal edges apply. |
tenant.phone | Empty = nothing for the bench to bind. The borrow edge rewrites it; a released unit gets a synthetic number. |
occupancy.currentBalance | Delinquent persona or not. The bench shows it per unit. |
The PropFlow half is three more reads, and each has failed on us at least once this week:
TenantOccupancy row, so the bench cannot list it however the allowlist reads.IdentityClaim on the tenant's Person. The bench reads heldBy off it; a stale claim from a previous holder shows as orphanHeldBy.CLAIM#<org>#PHONE#<e164>. The sync then answers reused_deprecated_warn and keeps the old number; the cure is to free that row and re-sync. This is what blocked Fede's number on 2026-09-04.Numbered 0 and 0b are the two the live runs surfaced after this page was first written; they outrank the rest.
| # | gap | what closes it | size |
|---|---|---|---|
| 1 | No reverse of a notice / an unfinalised move-out | cancel-notice-to-vacate L4 route (browser-agent #317, #318) + appfolio.cancel_notice_to_vacate catalog tool (propflowai #7110), property-45 hard scope, dry-run first. shipped 2026-09-05 First live run: dry-run exact, live refused by AppFolio's overlap check (TEST-102 also holds fixture 1009), re-run with the overlap switch → Current, readback confirmed. | done |
| 2 | Nothing says why a unit is not borrowable | A bench "diagnose" read per occupancy: the six AppFolio fields above + synced row + claim holder + sentinel check, rendered as one sentence ("Past since 06-25, not finalised → cancel the notice"). | half a day |
| 3 | Five routes have no catalog tool (month-to-month, finalise move-out ×2, tenant name, unit fields) | Catalog entries following the adding-a-new-tool runbook; each gets a fixture and the property-45 guard. | half a day |
| 4 | Property scope is per route, not per runtime | One shared scope guard in the browser-agent that every mutating route re-asserts before the write; today each route carries its own. | half a day |
| 0 | The sync treats "former" as terminal. A tenant who moves out is archived; a tenant whose notice is then cancelled in AppFolio (occupancy back to Current) stays former in PropFlow forever — the rent-roll applier reconciles onto the old row but never revives it. Seen live on occupancy 985 (2026-09-06): AppFolio Current, PropFlow former on every 5-minute tick. This is customer-facing, not bench-only: a real resident who withdraws notice becomes invisible to Clara. | Revive in the applier when a rent-roll row resolves by occupancy ref to a former row and describes a live tenancy (the reverse of the archive path, same writer). shipped #7174 Proven on production 2026-09-06 16:47Z: occupancy 985 flipped former → active on the first tick after deploy, same row, no duplicate. | done |
| 0b | Two Current occupancies on one unit are invisible to the sync. The rent roll is one row per unit, so after the overlap bypass on TEST-102 the sync reports 1009 and never sees 986. AppFolio also refuses any tenant-status save on either sibling while both are Current. | Keep test units to one Current occupancy: end the sibling first (the notice route has the same overlap switch — shipped; 1009 was ended with it and 986 synced within a minute). The overlap bypass is for getting out of a wedge, not a state to stay in. | shipped + rule |
| 5 | Occupancy 986 is not in the bench's borrowable list | Added in #7110. shipped Becomes borrowable once the 5-minute rent-roll sync carries the reactivated row. | done |
One card per borrowable occupancy: the state pill (Current · On notice · Past · Past-finalised), the one-sentence "why", and only the edges that are legal from that state as buttons. Every button is an L4 route that refuses any property but 45. That is the "masters of AppFolio" surface: no AppFolio tab, no guessing which form to edit.