The verdict, in plain English (v10)
This page is the product side of Fede's proposed Yardi plan, built for about seven systems. His page covers the Yardi robot. Of it, the calendar decision (5 Oct) is made and the robot's core boundary is built (PRs #470–#476 (six PRs; #475 was never used)); the rest is his to confirm. This page covers everything above the robot: a neutral core, what each path needs, one id shape, one write door, one sync engine, and Tenio, a test PMS of our own.
The diagnosis held up. Clara mostly answers from our own copy of the data, but the middle is AppFolio-shaped. Ids are stored as numbers in an AppFolio slot. The scheduled sync only brings residents in through AppFolio. Credentials accept only AppFolio. And work-order notes and photos discard the robot's "couldn't confirm" signal. Nothing reads from Yardi yet; that waits on the login.
Yale's day one is a leasing launch with known gaps, said plainly. There's no tour calendar yet: Clara takes the requested time and ConAm's leasing team confirms it. Prices come from August's scrape until the robot reads Yardi. Target go-live: Thursday 2026-11-12 if ConAm signs by Fri 11-06 and the stage-0 code has merged by then (Propflow works Veterans Day; ConAm's steps skip it; later signatures map to later dates in tab D), and, by this plan's default, never before ConAm's contract is signed and the questionnaire answered. That default is ours, not a ruling: Fede parked the questionnaire on 5 Oct and the brief proposes sending it on signature; whether Yale may launch before signature is Fede's one open call. If those slip, go-live slips. Before then: the apply link, the emergency line (the phone is already live), where team notes, held replies and texts go, and a dry run on a co-managed test pair on Mon 11-09.
Built right the first time, proven off-customer: Tenio, a test PMS of our own (tenio.dev: own site, API and browser screens, fake data) is the second system every new design is proven on. It lets work start now instead of waiting for Yardi's login. Yardi then plugs into proven designs. AppFolio moves over last, and every old path is deleted by a named PR.
Founder calls: Gera answered all four on 11-04 (tab D). Fede has four calls of his own, each due Fri 11-06 with a default the plan follows if he is silent: is he Yale's week-one approver (default if not: Gera), yes to his own per-account gate (default yes), who sends ConAm the written-access question (default: we do), and may Yale go live before ConAm signs (default: no). Gera has one: what Yale's already-live phone number does before lead-routing day (default: not given out). Everything else is an owned, dated gate.
1. Today: neutral edges, an AppFolio middle
The house has universal sockets in every room, but the wiring behind the walls only takes one brand of plug.
applyEntitySync) already exist, though that door refuses any building tagged with an AppFolio property id. The coupling sits in the product's middle.- ~140 shipped files import AppFolio code from outside the adapter (two recounts: 129 and 144).
- Guest-card writes and work-order create read back; work-order notes and photos don't.
- Fede's build check stops new mentions; 2,715 old ones remain.
- The check skips
lambda/**and exemptssrc/lib/domain/pms/**.
Proves: census at f528ed1bf34, recounted in two review rounds (tab V); agents/clara/lib/email/report-apply.ts:158.
2. The target: four layers, one direction
An operating system with drivers. Apps never talk to the printer. They print, and the driver for that printer speaks its language.
- This finishes the "ports and adapters" doctrine already in
PMS_INTEGRATION.md. - Layer 4 follows Fede's proposal. Its boundary is built (#470–#476); his step 7 split is not.
- Adapters choose API or robot per capability, with a declared fallback.
- Customers see their own PMS's name, or "property software".
Proves: Fede's Yardi plan §3 and §6; PMS_INTEGRATION.md §1–4.
3. Paths say what they need; adapters say what they can do
A recipe lists its ingredients and each kitchen lists its pantry. Compare the two and you know which dishes that kitchen can cook tonight.
workOrderWrite splits into verbs. What an adapter can do lives in code; whether it works for this customer's connection is stored per connection, so one customer's failure never darkens another's.- No mass rename: renaming would wrongly fence generic ones like
collectionsStatus. One small PR. - One stored binding per (building, module) → PMS connection; it must belong to the company operating the building, or reads fail closed.
- Answers are per building, so a read-only login counts.
- Clara is never handed a tool her building can't back.
Proves: pms/types.ts:20; unit-vacancy-sync.ts:120; ProspectRecordOwner already splits leasing ownership.
4. One write door, at the moment a write leaves us
A post room. You drop the letter in the tray (queued), the clerk sends it (accepted), and the signed receipt comes back (applied). You tell the sender only what has actually happened.
- A write moves from API to robot only after a clean refusal, never after a timeout.
- Each manifest cell says how it confirms, and by when.
- First callers: AppFolio's work-order note and photo, today's receipt gap.
- Any write can be held for a person: the hold rail learns a generic write envelope.
Proves: writer-registry.ts (three factories, :299, :373); guest-card-outbox.ts:314-318; types.ts:6224,6997,23898; work-order-writer.ts:266,289.
5. One sync engine, plus a live lane for the calendar
One mail room on a schedule, plus a front-desk phone for the one question that can't wait: "is 3 pm free?"
- The scheduled sync only creates residents through AppFolio (
handler.ts:5617). - Rent-roll exports use the existing door (
applyEntitySync), once its AppFolio refusal becomes a capability check. - The alarm for a building with no adapter fires, but names the wrong reason.
- AppFolio's budget keys keep their names, so its shared limit never splits.
Proves: reads audit; report-apply.ts:158; calendar/provider/ (Google and Outlook only today); Fede §3 and §7.
Deeper → A4 reads and sync
6. One id shape, tied to the data, not the login
A passport's number belongs to the person, not to the clerk who stamped it. Change clerks and the passport still works.
- A person's PMS ids stay identity claims (many allowed); ref lists live on occupancies and inquiries, not on the person.
- AppFolio moves one record type at a time, each paired with its "stop writing
af" PR. - Lookup key
source:instance:kind:id: two databases, or a lead and a resident with the same id, never collide. - Sandbox stays its own PMS type (Fede, 09-03), as one line in a single registry.
Proves: pms-pointers.ts:83-90; sync-accounts.ts:58; types.ts:3-15 (sandbox design).
Deeper → A2 data model
7. The robot: one credential owner, signed per-call grants
The building's key cabinet sits in one office. A tenant gets a signed slip for one door, for one hour, and the slip is logged.
- Fede §6 already puts Yardi's login shape in propflowai's credential rows.
- A rogue caller can't act without a grant, and the robot can't mint one: it only holds the public key. (A fully compromised robot still holds its warm sessions; grants don't cover that.)
- Each driver lists the form posts that are only reads (Yardi reads are posts); the kill switch blocks everything else.
- The robot's login choke point (
withLoginBudget) refuses to log in without a budgeted one-time grant from propflowai, so every caller needs one: scheduled re-mints, the refresh routes, a mid-call 401 and scripts. A fence test finds them all.
Proves: robot lib/runtimeAuth.ts (RUNTIME_SHARED_TOKEN), accountContext.ts:131,466, readonlyLock.ts; Fede §6.
Deeper → A3 writes and robot · A7 drafts
8. Yale 25: what day one is, and what it isn't
Opening a leasing office before the phone system is installed: you take names and preferred times on paper, and call people back to confirm.
- Under Supervised the tour note is itself held, so a tour request passes two people; the confirm timer starts when the note is released.
- Also open: four conflicting facts, the questionnaire, and whether the contract is signed.
- Read 11-04: owned by JP&Co, managed by ConAm since 9-24, no AppFolio fields. Held-reply emails and the text channel still follow the owner (R 0.3a); Fede approves as a Propflow admin.
- Ids with letters would be dropped today; Yardi never uses that path.
Proves: tools-leasing.ts:10408-10420; launch brief; handle-create-work-order.ts:1265.
Deeper → R stage 0 · D decision 1
9. Tenio: our own test PMS (tenio.dev)
A flight simulator. Real cockpit, real controls, fake sky, and a button that makes an engine fail on purpose.
- Park West (the fake building) joins Propflow in prod as a TEST property under the test client company Resio Residential (Gera's go, 11-04). No phone tests.
- Staff reply in its messenger just as Clara is about to: the collision Fede sees on AppFolio.
- Login: email + password, with a switch for the second step (none, text, email, app code).
- Proposed to replace Fede's Willows proof (his D3), his call. ADR-0072's vendor mocks stay.
Proves: Gera's answers, 2026-11-03; ADR-0072's case for keeping mocks outside the app; architecture critic on testing with no sandbox.
Deeper → R stage 1
10. Roadmap: Yale, then new shapes proven on Tenio, then Yardi, then AppFolio
Open the shop with what works, build and test the new wing in a showroom, fit it to the real building when the keys arrive, and rewire the old wing last.
- Stage 0 needs decision 1, ConAm's questionnaire and config fixes, not Yardi.
- Stage 2's first login is gated by a written check that ConAm's contract and Yardi license allow automated access (tab D; this plan proposes Fede adds it to his ConAm questions, which today cover login URL, MFA, roles and concurrent logins).
- Repo rename and package move wait until a second real system works.
- Stage 3 (AppFolio moves) waits on Tenio, not Yardi: it starts once Tenio passes conformance.
Proves: both critics' critical paths, corrected for ConAm's no-email, no-scheduler facts.
Deeper → R roadmap, PR by PR
11. How we'll know it's done
The test of a universal charger is plugging in a phone it has never seen.
- Conformance suite: one test per capability, run for every adapter that claims it.
- "Live" follows the proof ladder in chapter 10; a failing daily check drops it.
- The Paths page shows "Works with Yardi" from the same data.
- A customer switching PMS keeps its history.
Proves: scripts/check-pms-literals.ts allowlist (581 files / 2,715); fact-check recounts (tab V).
Deeper → A1 census · D decisions
S1. What Propflow takes in and sends out
A shipping container. Every port loads it differently, but every ship, crane and truck only has to handle one box shape.
pmsDetail, which core never reads.- "Unknown" is a real value, decided once in core. Today each feed guesses differently.
- Each adapter declares its priority scale; a lossy mapping shows on the receipt.
- Balance carries past-due and net separately. Yardi's net is stored as past due today.
- A lead is one person with interests in several buildings, as AppFolio models it.
- A new vendor field is added as optional; no other adapter changes.
Proves: grounded in today's neutral rows (pms/types.ts) and writers; hazards with file:line in A9 §1.6.
Deeper → A9 schemas and mapping tables
S2. Adding a path, and which systems can run it
A new dish on a restaurant chain's menu. Each kitchen checks its pantry: some can cook it tonight, some need one ingredient, some can't yet. The dish never belongs to the kitchen that asked for it.
clara-paths.ts; Yardi's are on paper until its login is live.- No path may name a PMS; the build check enforces it.
- Yardi's 16 "not yet" (the 42-row table at
f528ed1, rerun with the eligibility script): all 8 renewal rows, 4 maintenance (incl. the emergency work order's PMS copy), 2 collections, 2 leasing. - Emergency calls never depend on the PMS; only the emergency work order's PMS copy does.
- Paths page: "Works with / Partly / Not yet", plus what's missing.
Proves: clara-paths.ts (73 rows); elig.py (hand-derived needs table, ids checked against the file); derivations marked "inferred" in A10.
A9 · Canonical schemas, inbound and outbound, with mapping tables
Draft grounded in today's neutral rows and writers at f528ed1bf34; vendor columns marked unverified where docs are paywalled. Founder questions it raised are in tab D.
1.0 What exists today, and what is AppFolio-shaped
The repo already has most of the neutral seams. They are uneven, and several neutral types still carry AppFolio's shape.
| Seam | Where | Shape today | Verdict |
|---|---|---|---|
Read DTOs PMSBalanceRow, PMSRentRollRow, PMSUnitVacancyRow, PMSTenantContactRow, PMSLeaseState, PMSRenewal, PMSRentalApplication, PmsInquiry, PMSGuestCard, PMSWorkOrderDraft, PMSVendorDraft, PMSOwnerEntityRow, PMSPurchaseOrder, PMSTenantDirectoryRow, PmsRenewalTruthSnapshot, PMSCollectionsAccountStatusRow |
src/lib/domain/pms/types.ts:97-1205 |
Neutral names. Ids are PMSExternalId = string (types.ts:88). Several fields are AppFolio columns renamed (list below) |
Base for the canonical rows; rename and tighten, don't rewrite |
Read port PMSClient |
src/lib/domain/pms/client.ts:280-460 |
capabilities: ReadonlySet<PMSCapability> + optional list* methods |
Keep. It becomes what the sync engine's EntityFetcher calls |
Status normaliser PMSStatusNormalized (+ status_raw) |
src/lib/domain/pms/status.ts |
Closed set with unknown. Vendor tables live in the adapter (integrations/appfolio/status-enum.ts) |
The right pattern. Generalise it to every enum (§1.5) |
WO writer PMSWorkOrderWriter |
src/lib/domain/pms/work-order-writer.ts:46-74 |
5 verbs + verifyTarget?. Results are void (no receipt) |
Becomes workOrder.*. The door adds receipts |
Guest-card writer PMSGuestCardWriter |
src/lib/domain/pms/guest-card-writer.ts:553-645 |
createGuestCard + 11 optional verbs. Receipts carry verified (read-back) |
Becomes guestCard.* / appointment.* / application.send |
Lease/unit writer PMSWriter |
src/lib/domain/pms/writer.ts:741-847 |
Renewal verbs plus optional move-out, unit, listing and GL verbs | Becomes renewal.*, moveOut.*, unit.*, listing.publish |
Renewal adapter RenewalPmsAdapter |
src/lib/domain/pms/renewal-adapter/types.ts:150-170 |
Best-formed contract in the repo: typed failures (:14-26), Result not throw (:42), accepted = id pending (:117-127), capabilities() facts (:138-148), RenewalRef (:45-59). "No callers yet" (:4) |
Model for every port. Adopt its failure kinds as PmsFailureKind |
Prospect record adapter NormalizedProspectRecord, VendorRecordRef |
src/lib/domain/pms/record-adapter/types.ts |
{vendor, pmsAccount, entityType, x_id} + custom_data escape hatch. Shape only, no callers |
Folds into PmsLeadRow. Its pmsAccount becomes instance |
Mirror row PmsRecord |
src/lib/data/types.ts:24460-24576 |
pmsType, pmsAccount, x_id, custom_data |
Persisted mirror. custom_data is the precedent for pmsDetail |
| Raw passthrough precedent | IncomeStatementRow.raw (client.ts:260-277); the rule in types.ts:1012-1017 ("add it deliberately … via a pmsSpecific/raw passthrough … so this shared type never accrues per-PMS fields") |
Already written down once | Becomes §1.5 rule 1 |
AppFolio-shaped spots inside the "neutral" types (the canonical schema removes each one):
| Spot | file:line | Why it is AppFolio-shaped |
|---|---|---|
PMSWorkOrderDraft.status: WorkOrderStatus, priority: WorkOrderPriority |
pms/types.ts:928-929, :431-432 |
The adapter must emit PropFlow's lifecycle (pending_dispatch / dispatched / completed / auto_resolved / cancelled, domain/maintenance/work-order-statuses.ts:5-10). So each adapter guesses for unknown values, and the guesses disagree (§1.6). No raw value is kept |
externalServiceRequestId beside externalWorkOrderId |
pms/types.ts:924 |
AppFolio's two id spaces (SR# 1258 vs WorkOrderId 1259) |
unitTurnCategory, vendorTrade, issueType, workDoneOn |
pms/types.ts:942-958 |
AppFolio work_order columns, renamed |
PMSBalanceRow.pastDueAmount + fixed 0-30/31-60/61-90/90+ buckets |
pms/types.ts:234-261 |
AppFolio rent_roll.PastDue + aged_receivables. Yardi reports a net balance instead (§1.6) |
PMSUnitVacancyRow.postedToInternet / postedToWebsite, program |
pms/types.ts:369-373, 408 |
AppFolio unit_vacancy columns |
PmsInquiry.inquiryId: number | null; createdAt verbatim "09/18/2026"; marketRent display string |
pms/types.ts:670, 716, 731 |
AppFolio numeric id; AppFolio date and money formats crossing the boundary |
PMSGuestCard.externalGuestCardUuid (required), assignedUserId, tenantId, status = AppFolio's activity-feed column |
pms/types.ts:750, 826, 846, 769; status.ts header |
AppFolio CRM URL id, user id, and conversion signal. status is "the most recent event", not a state |
PMSRentalApplication.email: string, phone: string (required) |
pms/types.ts:555-556 |
Knock and others may lack one. Today an absent value is forced to '' |
PMSRentalApplication.createdVia = AppFolio CreatedBy |
pms/types.ts:579-584 |
|
PMSVendorDraft.personName "Allman, John" |
pms/types.ts:1001 |
AppFolio name order |
CreateWorkOrderRequest.propertyId = PropFlow id; priority?: string untyped |
work-order-writer.ts:79, 93 |
The Yardi writer then sends our property id as Yardi's PropertyCode (integrations/yardi/work-order-writer.ts:60) |
SetTerminalStatusRequest.completion: 'job_done' | 'ready_to_bill' |
work-order-writer.ts:158 |
AppFolio's billing step |
CreateWorkOrderResult.pmsNumber / pmsWorkOrderId |
work-order-writer.ts:40-44 |
AppFolio's drifting id spaces |
CreateGuestCardReceipt.guestCardId?: number, accountKey; UpdateGuestCardSourceRequest.guestCardId: number; CreateShowingReceipt.showingId?: number; CancelShowingRequest.showingId: number |
guest-card-writer.ts:100, 112, 219, 413, 466 |
Numeric AppFolio ids |
APPFOLIO_GUEST_CARD_SOURCE_LABELS in the domain writer file |
guest-card-writer.ts:86 |
Vendor picklist in core |
CreateShowingRequest.date / startTime / endTime local strings |
guest-card-writer.ts:376-378 |
AppFolio's showing form. Knock takes one scheduledAt instant |
PrepareRenewalParams.pmsRef "for AppFolio this is occupancyId"; UnitListingTarget 'internet' | 'website' |
writer.ts:69, 561 |
|
Stored ids Property.af.propertyId: number, Unit.af {propertyId, unitId, occupancyId, unit_uid}, WorkOrder.af, WorkOrder.appfolioEnrichment, Tour.af {pms:'appfolio', showingId:number}, ProspectInquiry.af {…guestCardId, guestCardUuid, partyId, guestCardAccountId…}, TenantOccupancy.appfolioTenantId, VendorCompany.af.vendorId, TenantBalanceSnapshot.source: 'appfolio-api-v1' |
data/types.ts:3719, 4715, 7099, 7134, 14063, 20850, 20215, 5744, 5398 |
The plan's chapter 6 / R 3.2 ladder replaces these with pmsRefs |
Property.pmsSource?: string (untyped) and the "has af.propertyId so it is AppFolio" fallback |
data/types.ts:3553; pms/registry.ts:345-347 |
1.1 Shared building blocks
// ── Which system, which data space ─────────────────────────────────────────
// Plan A7: replaces the 7 parallel unions. 'appfolio-sandbox' stays its own entry
// (Fede 09-03, pms/types.ts:4-18).
export type PmsSource =
| 'appfolio' | 'appfolio-sandbox' | 'yardi' | 'onesite' | 'realpage' | 'entrata' | 'tenio';
export interface PmsRef { // plan A7
source: PmsSource;
kind: PmsRecordKind; // 'property' | 'unit' | 'occupancy' | 'resident' | 'lead' | 'workOrder' | …
instance: string; // a data-space id WE mint per connected PMS database; the vendor's names
// (AppFolio database, Yardi server+database, Tenio company) are aliases. Never a login.
id: string; // ALWAYS text. Never Number()'d (Yardi "ZZTEST-LM-01" → NaN today, plan ch. 6)
displayNumber?: string; // what people see ("18752-1", "M-00412"); never our ticker (Gera 10-30)
secondary?: Record<string, string>; // other ids the PMS needs: { serviceRequest, workOrderId, uuid, partyId, unitUid, guestCode }
endedAt?: string; // ref kept as history after a PMS switch
}
// Inside one fetch the source + instance are known, so rows carry a PmsKey and the
// sync engine stamps source/instance to make the PmsRef. One place, one rule.
export type PmsKey = Pick<PmsRef, 'id' | 'displayNumber' | 'secondary'>;
// ── Raw passthrough (plan naming tab) ──────────────────────────────────────
export interface PmsDetail {
source: PmsSource;
schemaVersion: number; // the ADAPTER's own payload version, not the canonical one
payload: Record<string, unknown>; // verbatim vendor fields; opaque to core; PII → mirror retention rules
}
// ── An enum the PMS gave us, normalised, with its raw word kept ────────────
// Generalises PMSStatusNormalized + status_raw (status.ts). 'unknown' is a value, never a default to something else.
export interface Coded<T extends string> {
value: T | 'unknown';
raw: string | null; // verbatim vendor word; null = the PMS sent nothing
}
// ── Values ─────────────────────────────────────────────────────────────────
export type IsoDate = string; // 'YYYY-MM-DD', a calendar day in the PROPERTY's zone
export type IsoInstant = string; // RFC 3339 with offset; adapter converts local wall clocks using PMSListParams.timeZone
export type Cents = number; // integer minor units (precedent: PMSPurchaseOrder.totalCents, types.ts:1162)
export interface PersonName { first: string | null; last: string | null; display: string } // display is natural order, "First Last"
export interface ContactPoint { value: string; kind: 'mobile' | 'home' | 'work' | 'other' | 'unknown'; primary: boolean }
// phones are E.164 or dropped (and the drop counted, as toGuestCard's malformedPhoneSink does today);
// emails are lowercase. An empty array = the PMS has none. Never a fabricated value.
// ── Every inbound row ──────────────────────────────────────────────────────
export interface PmsRowBase {
key: PmsKey;
asOf: IsoInstant; // when WE read it (today's asOf)
pmsDetail?: PmsDetail;
}
// ── Three-state optional fields (the rule already used in types.ts:124-129, 240-254) ─
// field ABSENT = this adapter does not report it (or did not read it this tick)
// field = null = the PMS was asked and said nothing / none
// field = value = the PMS said this
// Consumers render unknown as unknown ("—"), never as a zero, "no", or a guess.
1.2 Inbound: canonical rows per entity
Each entity lists the canonical type, what it replaces, the capability that backs it, and the AppFolio-shaped part it removes. Required fields are only the ones every real PMS can supply. Everything else is optional under the three-state rule.
1.2.1 Property — capability: none today (read at onboarding); NEW properties read
export interface PmsPropertyRow extends PmsRowBase {
name: string; // as the PMS names it
marketingName?: string | null; // Yardi MarketingName
address: { line1: string | null; line2?: string | null; city: string | null; state: string | null; postalCode: string | null; country?: string | null };
timeZone?: string | null; // IANA if the PMS states one (Yardi TimeZone); else onboarding sets Property.timezone
active?: boolean | null;
unitCount?: number | null;
ownerKeys?: PmsKey[]; // ownerEntities (PMSOwnerEntityRow, types.ts:975) points the other way today
}
- Replaces: nothing. Today's property rows come from AppFolio onboarding (
property_directory) and fromYardiProperty(yardi/types.ts:86-103), with no neutral row between them. - AppFolio-shaped today:
Property.af.propertyId: number(data/types.ts:3719).
1.2.2 Unit and its availability — capabilities: rentRoll (shape), unitVacancy (marketing)
export interface PmsUnitRow extends PmsRowBase {
propertyKey: PmsKey;
unitNumber: string; // what a prospect says ("217"). SEPARATE from key.id even when equal (Yardi UnitCode is both)
floorplan?: { name: string | null; bedrooms: number | null; bathrooms: number | null } | null; // AppFolio BdBa "0/1.00" parsed here, not in core
sqft?: number | null;
marketRentCents?: Cents | null;
status: Coded<'occupied' | 'vacant' | 'notice' | 'future'>; // PMSRentRollRow.occupancyStatus (types.ts:317) + unknown
spaces?: PmsSpaceRow[]; // NEW: bed/space level (Entrata Unit Space, RealPage beds, Yardi RoomCode (U)). Absent = not a by-the-bed PMS
}
export interface PmsSpaceRow { key: PmsKey; label: string; status: PmsUnitRow['status']; marketRentCents?: Cents | null }
export interface PmsUnitAvailabilityRow extends PmsRowBase { // = PMSUnitVacancyRow (types.ts:359) neutralised
unitKey: PmsKey; propertyKey: PmsKey; unitNumber: string;
status: Coded<'vacant-unrented' | 'notice-unrented' | 'vacant-rented' | 'notice-rented'>; // 'other' becomes 'unknown'; never offerable
advertised: Array<{ channel: 'internet' | 'website' | 'ils' | string; posted: boolean | null }> | null; // replaces postedToInternet/postedToWebsite
rentReady?: boolean | null;
daysVacant?: number | null;
availableOn?: IsoDate | null;
nextMoveIn?: IsoDate | null;
advertisedRentCents?: Cents | null; // never 0 for "unstated" (types.ts:384-386)
marketingDescription?: string | null;
program?: Coded<'pbv' | 'hcv' | 'lihtc' | 'home' | 'deed_restricted'> | null; // programSource moves to pmsDetail
}
- Replaces: the unit half of
PMSRentRollRow(types.ts:303-319) andPMSUnitVacancyRow(types.ts:359-411). - The rule that stays: absence from the availability set means "not offerable", never "occupied" (
types.ts:348-353).
1.2.3 Resident and occupancy (the person and the household on a unit) — capabilities: tenantContacts, rentRoll, leaseStates
Two rows, because every PMS separates the household holding the unit from the people in it:
- AppFolio splits
OccupancyIdfromTenantId(af-pms-adapter mapAppfolioTenantDirectoryRow,pms-adapter.ts:1865-1895). - Yardi's
Resident.Code(t0010101) is the household, and the extra people sit inOtherOccupants(yardi/types.ts:127-157).
export interface PmsOccupancyRow extends PmsRowBase { // key = AppFolio OccupancyId, Yardi Resident.Code
propertyKey: PmsKey;
unitKey: PmsKey | null; // null only if the PMS has a household with no unit (rare; keep it)
unitNumber: string | null;
status: Coded<'current' | 'notice' | 'future' | 'past' | 'month_to_month'>; // + unknown (Yardi maps unknown→'current' today: wrong)
moveInOn?: IsoDate | null;
noticeOn?: IsoDate | null;
moveOutOn?: IsoDate | null;
moveOutReason?: string | null;
residentKeys: PmsKey[]; // who lives here (may be empty if the PMS lists only the household)
}
export interface PmsResidentRow extends PmsRowBase { // key = AppFolio TenantId, Yardi per-person code (U)
occupancyKey: PmsKey;
name: PersonName;
phones: ContactPoint[]; // Yardi Phone[] typed; AppFolio "Type: number" list parsed in the adapter
emails: ContactPoint[];
role: Coded<'primary' | 'co_tenant' | 'guarantor' | 'occupant'>; // TenantOccupancyRole (data/types.ts:20141) + occupant
financiallyResponsible?: boolean | null;
language?: string | null;
}
- Replaces:
PMSTenantContactRow(types.ts:418-429),PMSTenantDirectoryRow(types.ts:1181-1205), and the occupancy half ofPMSRentRollRow. - AppFolio-shaped today:
- AppFolio does not declare
tenantContacts(pms-adapter.ts:310-341). It delivers residents throughwriters/tenant-directory-daily.tsand the occupancies writer instead. - The sync only creates residents through AppFolio (plan ch. 5,
handler.ts:5617). TenantOccupancy.appfolioTenantId(data/types.ts:20215).
- AppFolio does not declare
1.2.4 Lease (the current term on an occupancy) — capabilities: leaseStates, leaseProvenance, leases
export interface PmsLeaseRow extends PmsRowBase { // key = the PMS lease id where one exists, else the occupancy id
occupancyKey: PmsKey;
termStart: IsoDate | null;
termEnd: IsoDate | null; // null + monthToMonth=true = open-ended
monthToMonth: boolean | null;
rentCents: Cents | null;
recurringCharges?: Array<{ code: Coded<BalanceChargeCategory>; amountCents: Cents }> | null;
lastSignedAt?: IsoDate | null;
provenance?: Coded<'pms_generated' | 'manually_uploaded'> | null; // LeaseRenewalSource (types.ts:146)
securityDepositCents?: Cents | null;
}
- Replaces:
PMSLeaseState(types.ts:434-454) andLeaseProvenance(types.ts:148-155). - AppFolio-shaped today:
moveOutReasondocumented as "AppFolio tickler" (:446).
1.2.5 Balance and ledger — capabilities: balances, collectionsStatus, NEW ledger
This is the deepest disagreement in kind (§1.4.5). The canonical row carries what kind of number the PMS gave, so no adapter has to turn a net balance into a past-due amount.
export interface PmsBalanceRow extends PmsRowBase { // key = occupancy id
occupancyKey: PmsKey;
// At least one of the two amounts; each nullable on its own (null = the PMS did not say; NEVER 0 by default; Mira incident, types.ts:221-234)
pastDueCents?: Cents | null; // AppFolio rent_roll.PastDue
netBalanceCents?: Cents | null; // signed: negative = credit. Yardi GetResidentBalances.Balance; Buildium/Entrata ledger sum
includesPendingCharges?: boolean | null; // also a manifest fact; the row may override
aging?: { bucketsDays: number[]; amountsCents: Cents[] } | null; // AppFolio 0-30/31-60/61-90/90+ is ONE layout, not the schema
agingUnread?: true; // types.ts:263-268, unchanged
oldestOpenChargeOn?: IsoDate | null; // Yardi OldestOpenChargeDate
openCharges?: Array<{ key: PmsKey | null; postedOn: IsoDate | null; category: Coded<BalanceChargeCategory>; label: string | null; openCents: Cents }>;
lastPayment?: { on: IsoDate | null; amountCents: Cents | null }; // three states, atomic pair (types.ts:236-255)
}
export interface PmsCollectionsStatusRow extends PmsRowBase { // = PMSCollectionsAccountStatusRow (types.ts:97-138)
occupancyKey: PmsKey;
inCollections: boolean | null; // null allowed now: a PMS that cannot say ≠ "not in collections"
certifiedFundsOnly: boolean | null; // Yardi PaymentAccepted 'CashEquivalent' looks like this (U)
paymentsBlocked?: boolean | null; // Yardi 'DoNotAccept' / 'OnHold' (U)
onPaymentPlan: boolean | null;
}
export interface PmsLedgerEntryRow extends PmsRowBase { // NEW; MITS Resident Transactions shape
occupancyKey: PmsKey;
kind: Coded<'charge' | 'payment' | 'credit' | 'concession' | 'adjustment' | 'reversal'>;
chargeCode: Coded<BalanceChargeCategory>; // raw = the PMS's GL/charge code name
amountCents: Cents; // signed as the PMS signs it, normalised: charge +, payment −
postedOn: IsoDate | null;
dueOn?: IsoDate | null;
openCents?: Cents | null;
description?: string | null;
}
- Replaces:
PMSBalanceRow(types.ts:218-277) andPMSCollectionsAccountStatusRow(types.ts:97-138). - Kept:
BalanceChargeCategory(data/types.ts:5345-5354). - One change to existing behaviour:
collectionsStatusbooleans become nullable. Today an occupancy with no row reads "both false" (types.ts:95-96). For a PMS that has no such feed, that silently means "not in collections". Whether dunning may proceed onnullis a founder call (§2.4).
1.2.6 Work order — capabilities: workOrders (read), workOrder.* (write)
// The PMS's own lifecycle, NOT PropFlow's. Core maps it to WorkOrderStatus in ONE table (§1.5 rule 3).
export type PmsWorkOrderState =
| 'new' | 'assigned' | 'scheduled' | 'in_progress' | 'on_hold' | 'work_done' | 'closed' | 'cancelled';
export interface PmsWorkOrderRow extends PmsRowBase {
// key.id = the id writes address (AppFolio: SR record id = WorkOrder.pmsId, data/types.ts:6978);
// key.displayNumber = what people see (AppFolio pmsNumber "18752-1", :6989);
// key.secondary.workOrderId = AppFolio WorkOrderId (the read-side join; pmsid-contract drift test)
propertyKey: PmsKey;
unitKey?: PmsKey | null; // null = property-level job
unitNumber?: string | null;
occupancyKey?: PmsKey | null; // replaces tenantHint (types.ts:279-295)
requester?: { name: PersonName | null; phone?: string | null; email?: string | null } | null;
state: Coded<PmsWorkOrderState>;
priority: Coded<'low' | 'medium' | 'high' | 'emergency'>;
category: Coded<WorkOrderCategory>; // 'plumbing'|'hvac'|'electrical'|'appliance'|'general' (work-order-category.ts:9)
issueType?: string | null; // PMS's own sub-category verbatim (AppFolio WorkOrderIssue, Yardi SubCategory)
title: string;
description: string;
permissionToEnter?: boolean | null;
accessNotes?: string | null;
vendorKey?: PmsKey | null;
vendorName?: string | null; // a NAME. Never an id (Yardi VendorCode is put here today: yardi/pms-adapter.ts:394)
scheduledStart?: IsoInstant | null;
scheduledEnd?: IsoInstant | null;
dueOn?: IsoDate | null;
workDoneAt?: IsoInstant | null; // when the WORK was done (AppFolio WorkDoneOn)
closedAt?: IsoInstant | null; // when the TICKET closed
cancelledAt?: IsoInstant | null;
estimatedCostCents?: Cents | null;
actualCostCents?: Cents | null;
intakeChannel?: Coded<'call_center' | 'online_intake' | 'portal' | 'pm_assistant' | 'staff'> | null; // PMSWorkOrderIntakeChannel
createdAt: IsoInstant | null; // NOT now() when unparseable (pms-adapter.ts toWorkOrderDraft fabricates it today)
updatedAt: IsoInstant | null; // NOT a copy of createdAt (pms-adapter.ts toWorkOrderDraft does that today)
notes?: Array<{ key: PmsKey | null; at: IsoInstant | null; author: Coded<'tenant' | 'staff' | 'vendor' | 'system' | 'clara'>; text: string }>;
attachmentCount?: number | null;
}
- Replaces:
PMSWorkOrderDraft(types.ts:910-965). The AppFolio columnsunitTurnCategory,vendorTradeandintakeChannelRawmove intopmsDetailorCoded.raw. - AppFolio-shaped today: status and priority are emitted in PropFlow's lifecycle (
:928-929);WorkOrder.af(data/types.ts:7099-7121);appfolioEnrichment(:7134).
1.2.7 Vendor — capabilities: vendors, vendor.create
export interface PmsVendorRow extends PmsRowBase { // account-scoped; key.id = AppFolio VendorId / Yardi VendorId "V0001"
companyName: string | null;
contact: PersonName; // "Allman, John" parsed in the adapter
phones: ContactPoint[];
emails: ContactPoint[];
trades: Array<Coded<WorkOrderCategory>>; // raw = PMS trade word
vendorType?: string | null; // verbatim category (AppFolio VendorType, Yardi UsualCategoryId)
usableForWork: boolean | null; // NEW: AppFolio DoNotUseForWorkOrder (inverted) / Yardi IsInactive (inverted)
address?: PmsPropertyRow['address'] | null;
}
- Replaces:
PMSVendorDraft(types.ts:992-1018). Its own comment asks for the inactive signal to be added "deliberately", and that isusableForWork.
1.2.8 Lead (the PMS's guest card) and its activity — capabilities: guestCards, NEW guestCardActivity
The capability keeps the vendor word guestCards. The row is named for what every PMS API calls the object, a lead (plan naming tab).
The canonical lead is person-level with per-property interests, because AppFolio's card is a person with N inquiries (types.ts:631-650), and MITS models a Prospect holding Customers with Preferences and Events. A PMS whose lead is per-property (Entrata (U), Knock) yields one row per lead, with one interest. The spine (Person + IdentityClaim) decides who the person is (record-adapter/types.ts:73-80), never the adapter.
export interface PmsLeadRow extends PmsRowBase {
// key.id = AppFolio GuestCardId; secondary.uuid = GuestCardUuid (deep link), secondary.partyId = PartyID (messages)
name: PersonName;
phones: ContactPoint[]; // may be empty
emails: ContactPoint[]; // may be empty (AppFolio ingest drops a card with neither: pms-adapter.ts toGuestCard)
status: Coded<PMSStatusNormalized>; // the card's state; for AppFolio derived from an activity feed (status.ts header)
source: Coded<ProspectSource> | null; // raw = PMS picklist label ("Rent.", "No Source Recorded"); matchChannel lives in core
receivedAt: IsoInstant | null;
assignedTo?: { pmsUserKey: PmsKey | null; name: string | null } | null;
preferences?: {
moveInOn?: IsoDate | null; bedrooms?: number | null; maxRentCents?: Cents | null; maxRentRaw?: string | null;
pets?: string | null; monthlyIncomeCents?: Cents | null; creditRange?: string | null;
};
interests: PmsLeadInterest[]; // ≥1; one per property asked about
convertedTo?: { occupancyKey: PmsKey | null; residentKey: PmsKey | null } | null; // AppFolio TenantId
notesText?: string | null; // STAFF notes; never replayed to a prospect (Fede 09-18, types.ts:801-816)
lastActivity?: { kind: Coded<PmsCardActivityKind>; on: IsoDate | null } | null;
}
export interface PmsLeadInterest { // = PmsInquiry (types.ts:667-736), neutralised
key: PmsKey | null; // AppFolio data-inquiry-id (was number) → text
propertyKey: PmsKey | null; // null = could not resolve; never "this property" (types.ts:673-676)
propertyName: string | null;
unitKey?: PmsKey | null; unitNumber?: string | null;
status: Coded<PMSStatusNormalized>;
active: boolean;
source: string | null;
receivedBy?: 'system' | 'staff' | null;
firstMessage: string | null; // the renter's own words (never a staff note)
messages?: Array<{ key: PmsKey; at: IsoInstant | IsoDate | null; text: string; source: string | null }>;
createdOn: IsoDate | null; // was a verbatim "09/18/2026"; raw kept in pmsDetail
lastInterestOn: IsoDate | null;
inactivatedOn?: IsoDate | null;
desiredMoveIn?: IsoDate | null;
quotedRentCents?: Cents | null; // was "$1,550.00" display string
bedBathRaw?: string | null;
}
export interface PmsLeadActivityRow extends PmsRowBase { // NEW; MITS Event / Entrata lead event / Yardi guest activity
leadKey: PmsKey;
kind: Coded<PmsCardActivityKind>; // types.ts:862-885 + unknown
at: IsoInstant | IsoDate | null; // precision varies: AppFolio gives a DAY only (types.ts:892-894)
atPrecision: 'instant' | 'day' | 'unknown';
channel?: 'sms' | 'email' | 'call' | 'in_person' | 'web' | null;
direction?: 'inbound' | 'outbound' | null;
author: Coded<'prospect' | 'staff' | 'system' | 'clara'>;
text?: string | null; // staff-authored text is fenced from Clara/prospects (same rule as notesText)
}
- Replaces:
PMSGuestCard(types.ts:746-856),PmsInquiry/PmsInquiryMessage(:652-736),PmsCardLastActivity(:888-895), andNormalizedProspectRecord(record-adapter/types.ts).
1.2.9 Appointment (showing / tour) — capabilities: NEW appointments (read), appointment.* (write)
export interface PmsAppointmentRow extends PmsRowBase { // key = AppFolio showingId (was number) / CRM IQ appointment id (U)
propertyKey: PmsKey;
unitKey?: PmsKey | null; // AppFolio REQUIRES one (buildingTour best-guess, guest-card-writer.ts:395-399); others may not
leadKey?: PmsKey | null; // null = a calendar block not tied to a lead (busy time)
start: IsoInstant;
end: IsoInstant | null; // Knock gives only scheduledAt (realpage/client.ts:59-65)
timeZone: string; // the property's; AppFolio's local "10:00 AM" strings are converted with it
kind: Coded<'in_person' | 'virtual' | 'self_guided'>;
status: Coded<'scheduled' | 'confirmed' | 'completed' | 'canceled' | 'no_show'>; // CardShowing.status (guest-card-writer.ts:514) + more
host?: { pmsUserKey: PmsKey | null; name: string | null } | null;
createdAt?: IsoInstant | null;
}
- Replaces:
CardShowing(guest-card-writer.ts:504-516) andTour.af(data/types.ts:14063-14076). - Plan link: calendar busy reads follow chapter 5. A PMS calendar is a
CalendarProviderId, today'outlook' | 'google'only (domain/calendar/provider/types.ts:174).
1.2.10 Application — capabilities: rentalApplications (read), application.send (write)
export interface PmsApplicationRow extends PmsRowBase { // key = AppFolio RentalApplicationId; secondary.groupId
propertyKey: PmsKey | null; // Situs rows carry no property id (types.ts:544-552): propertyName fallback stays
propertyName?: string | null;
unitKey?: PmsKey | null; unitNumber?: string | null;
leadKey?: PmsKey | null; // AppFolio InquiryID
groupKey?: PmsKey | null; // co-applicants (AppFolio RentalApplicationGroupId)
applicant: { name: PersonName; phones: ContactPoint[]; emails: ContactPoint[] }; // was required email/phone strings
status: Coded<'pending' | 'approved' | 'denied' | 'canceled' | 'converted'>; // PMSStatusNormalized subset
desiredMoveIn?: IsoDate | null;
monthlyIncomeCents?: Cents | null;
receivedAt: IsoInstant | null;
decidedAt?: IsoInstant | null;
leadSource?: Coded<ProspectSource> | null;
applicantReportedSource?: string | null;
completeness?: { screeningDone: boolean | null; feePaid: boolean | null; allApplicantsSubmitted: boolean | null; screenedOn: IsoDate | null };
moveInStarted?: boolean | null; // Fede 10-15 rule: never prepare a second lease
pets?: string | null;
}
- Replaces:
PMSRentalApplication(types.ts:531-610) andPMSApplicationCompleteness(:613-628). - Removed:
createdVia(AppFolioCreatedBy) moves topmsDetail, andintakeChannelcan be added later if a consumer needs it.
1.2.11 Renewal offer — capabilities: renewals, renewalTruth, renewal.*
Adopt the renewal adapter's types as canonical (renewal-adapter/types.ts). They are already neutral. Two changes:
export interface PmsRenewalOfferRow extends PmsRowBase { // key = AppFolio LeaseDocumentUuid; RenewalRef → PmsRef
occupancyKey: PmsKey;
lifecycle: Coded<OfferLifecycle>; // 'none'|'prepared'|'out_for_signature'|'tenant_signed'|'executed'|'cancelled'
terms: Array<{ months: number; rentCents: Cents }>; // months 0 = MTM unless manifest fact mtmAsFlag
sentAt: IsoInstant | null; respondBy: IsoDate | null; signedAt: IsoDate | null;
newTermStart: IsoDate | null; newTermEnd: IsoDate | null;
previousRentCents: Cents | null; chosenRentCents: Cents | null;
}
- Replaces:
PMSRenewal(types.ts:494-525),PmsRenewalOfferStatusandPmsRenewalTruthSnapshot(:1034-1078, which become an occupancy + offers read), andRenewalEvent/OpenOffer(renewal-adapter/types.ts:62-92). - Change 1:
RenewalRef.pmsAccountbecomesPmsRef.instance. - Change 2:
PmsRenewalOfferStatusmaps unknown statuses to'pending'(types.ts:1030-1033). That is a deliberate fail-safe, and it stays as core's reading ofunknown, not the adapter's.
1.2.12 Owner, purchase order, income statement
These keep their current neutral shapes, with ids moved to PmsKey: PMSOwnerEntityRow (types.ts:975), PMSPurchaseOrder (:1118, already in cents and already approvalState: 'unknown'-aware), and IncomeStatementRow (its raw becomes pmsDetail). They are not on Clara's main paths, so this draft does not reshape them.
1.3 Outbound: canonical requests and receipts
1.3.1 The envelope (the plan's write door, A7)
export interface PmsWriteRequest<C extends PMSWriteCapability, P> {
capability: C;
propertyId: string; // OUR building id: the door resolves (building, module) → adapter + connection
module: ModuleFlag; // routing key (plan ch. 3); also the mode the door takes the writer factories' decision on
target?: PmsRef; // the PMS record written to (absent on creates)
idempotencyKey: string; // caller's (outbox intent id / saga id / note id), as today
actor: 'clara' | 'staff' | 'system' | 'resident' | 'vendor';
subject: ModuleSubject; // emergency / declared test etc. (module-mode.ts), so the door applies the ONE rule
payload: P;
}
// WriteReceipt as in A7, plus two proposed fields:
export interface WriteReceipt<R = unknown> {
status: 'applied' | 'accepted' | 'unconfirmed' | 'refused' | 'not_written';
capability: PMSWriteCapability; ref?: PmsRef; marker?: string;
evidence?: { readBackAt: string; observed: Record<string, unknown> }; // required for 'applied'
confirmBy?: string;
notWrittenReason?: 'preview' | 'off' | 'mode_unresolved' | 'held' | 'not_supported' | 'unproven' | 'test_property_guard' | 'test_call';
failure?: { kind: PmsFailureKind; retryable: boolean; detail: string }; // PmsFailureKind = RENEWAL_PMS_FAILURE_KINDS, promoted
result?: R; // capability-specific (below)
coerced?: Array<{ field: string; asked: unknown; sent: unknown }>; // PROPOSED: lossy mappings (priority medium→"Normal")
dropped?: string[]; // PROPOSED: optional fields this PMS cannot carry
}
- Field contract per capability: each request field is either required (the adapter must carry it, or the door refuses with
not_supportedbefore any call) or optional (the adapter may drop it, and the receipt lists it indropped). The manifest cell states which optional fields it honours (AdapterCapability.fields?: readonly string[], NEW). This is our version of Apideck's sparse fields and Propexo'signore_unknown_fields, made loud. - Writes address
target: PmsRef, never a PropFlow id that the adapter re-resolves. TodayCreateWorkOrderRequest.propertyIdis our id (work-order-writer.ts:79), and that is how the Yardi writer ended up sending our id asPropertyCode(yardi/work-order-writer.ts:60). - Creates name their parent by ref:
payload.propertyRef,payload.unitRef, and so on.
1.3.2 Per capability (the A7 list)
| Capability | Request payload (required bold) | result on the receipt |
Today's method | Notes |
|---|---|---|---|---|
workOrder.create |
propertyRef, title, description, unitRef?, occupancyRef?/requester?, priority (low|medium|high|emergency), category (WorkOrderCategory), permissionToEnter?, accessNotes?, vendorRef?, scheduledStart?/End?, localWorkOrderId (marker source) |
{ ref: PmsRef /* id + displayNumber + secondary.workOrderId */, issuesNumberInline: boolean } → applied with read-back, or accepted + confirmBy when the number comes later |
createWorkOrder (work-order-writer.ts:48) |
maintenance.wo_created_text may speak a number only when ref.displayNumber is held (plan R 0.4; standing rule) |
workOrder.note |
target, text, author (tenant|clara|pm|vendor|system) |
{ noteRef?: PmsRef } |
addNote (:57) |
Manifest fact noteSemantics: 'append' | 'replace_field'. Yardi TechnicianNotes is replace-only (yardi/work-order-writer.ts:94-118), so an adapter must read-then-append, or the door refuses |
workOrder.attach |
target, file (s3Key, contentType), caption? | { attachmentRef?: PmsRef } |
attachDocument (:55) |
Yardi's is a silent no-op that returns success (yardi/work-order-writer.ts:182-187), which would read as applied. The door needs read-back or unconfirmed. RealPage has no file upload through the API (research §1b) |
workOrder.update NEW (A7 lacks it; edit_work_order, agents/clara/lib/agent/tools.ts:390) |
target, field ∈ {title, description, priority, category, permissionToEnter, accessNotes, schedule}, value | {} |
updateField (:59) |
Today vendor is also a field. It moves to assignVendor |
workOrder.assignVendor |
target, vendorRef (or inHouseTech: PmsRef) |
{} |
updateField('vendor'); AppFolio tech assign (tech-sms-dispatch.ts:879,1144) |
|
workOrder.complete |
target, completion (job_done|ready_to_bill), resolution?, workDoneAt? |
{ stateAfter: Coded<PmsWorkOrderState> } |
setTerminalStatus('completed') (:61) |
completion is optional. A single-state PMS lists it in dropped |
workOrder.cancel |
target, reason? | same | setTerminalStatus('cancelled') |
Naturally idempotent (plan) |
guestCard.create |
propertyRef, name.first, name.last, phones[], emails[], source (ProspectSource), unitRef?, preferences?, firstNote? | { ref: PmsRef /* id, secondary.uuid */, url?: string, noteLanded?: boolean } |
createGuestCard (guest-card-writer.ts:554) |
Contact rule differs per PMS. AppFolio needs only names (:24-25). Knock needs email (realpage/client.ts:44). Manifest field list says which, and a missing required contact is refused with not_supported |
guestCard.note |
target, text, kind (note|communication), communicationType?, occurredAt?, unitRef? (adds an interest) |
{ activityRef?: PmsRef, interestAdded?: boolean } |
addNote, recordCommunication (:570, :644) |
One verb, two kinds. recordCommunication never falls back to a note (:638-644), so kind is a required-honoured field |
guestCard.update |
target, change: {field:'source', value} | {field:'contact', kind:'phone'|'email', value, fillOnly:true} | {field:'assignee', to} | {field:'status', to:'inactive'|'lost', reason?} |
{ before, after } |
updateSource, setContact, assign, markInactive (:578-607) |
The status change is the example in §2.1 |
appointment.create |
propertyRef, start, end|durationMin, timeZone, leadRef?, unitRef?, kind, hostPersonId? (we keep no person→PMS-user map, Fede 10-28), note? | { ref: PmsRef } with read-back |
createShowing (:616) |
Plan 1.6: AppFolio's schedule_showing outbox kinds map here. Yardi phase 1 row 6 |
appointment.update |
target, start, end | {} |
rescheduleShowing (:630) |
Never creates (:626-630) |
appointment.cancel |
target, outcome (canceled|no_show) |
{} |
cancelShowing |
|
application.send |
leadRef, propertyRef, channel(s) (sms|email), unitRef? |
{ sentVia: ('sms'|'email')[], linkSeen?: string } |
sendApplication (:600) |
The PMS mints the link, never us (:594-600) |
renewal.prepare |
occupancyRef, terms[{months, rentCents}], newTermStart, offerContext, respondBy? | { ref, lifecycle } or accepted |
prepareOffer (renewal-adapter/types.ts:158) |
|
renewal.changeTerm |
target (offer ref), term | { newRef } or accepted (the old ref is dead) |
changeAcceptedTerms (:161) |
Plan R 0.5: refuse on a non-supporting adapter. Today change_renewal_term enqueues an AppFolio job for any PMS (agents/clara/lib/agent/tools/renewal/handlers.ts:2039-2041) |
vendor.create |
name (company or person), phones[], emails[], trades[], address? | { ref } |
createVendorL4 via handle-create-vendor.ts:78 (bypasses the ports) |
Plan R 3.5 moves it behind the door |
message.send (v9: a message capability on the message door, not a PMS write; listed here for its shape) |
recipientRef (resident/lead), channel (pms_messenger), text |
{ messageRef? } |
AppFolio inbox text door (agents/clara/lib/messaging/adapters/appfolio-inbox-text.ts, src/lib/pms/pms-text-door-drain.ts) |
No neutral port exists. PMSMessageSender is named in comments (guest-card-writer.ts:7,12), but no interface is defined anywhere. Today the only send path is AppFolio's inbox text door (cited left); there is no neutral one. The channel is the company's one label (Gera 11-03, replacing the 11-02 "last channel" idea); v9 builds one neutral pms_inbox carrier over an adapter messenger port |
1.3.3 Write verbs the existing writers already need (NEW, same resource.verb style)
PMSWriter (writer.ts:741-847) and today's paths use these verbs, and A7's list omits them. Each is a capability cell in every manifest (default unsupported).
| Capability | From | Used by path |
|---|---|---|
renewal.cancel |
cancelRenewal / cancelOffer |
renewals.fill_pms |
renewal.monthToMonth |
convertToMonthToMonth (writer.ts:753) |
renewals.holdover |
lease.draft, lease.sendForSignature |
new-lease executor (print-order/new-lease-appfolio-executor.ts) |
leasing.new_lease_paperwork |
moveOut.record, moveOut.charges |
recordMoveOut, appendMoveOutCharges (writer.ts:767, 839) |
maintenance.turnover_writes |
unit.setAvailability, unit.setMarketRent |
writer.ts:779, 786 |
turnover; pricing |
listing.publish |
setUnitListingPublished (writer.ts:814) |
leasing.publish_listing |
portal.inviteResend, portal.passwordReset |
PMSClient.sendPortal* (client.ts:387, 393) |
resident.answer_general |
They are also NEW reads: appointments, guestCardActivity, ledger, properties.
1.4 Mapping tables
Legend:
- ⚡ = the systems disagree in kind (type, scale, semantics, id space), not just in name. The "absorbed by" column says how the canonical schema takes it in.
- AppFolio: report column (Reports API) unless marked robot.
- Yardi: SIPP =
yardi/types.ts(doc-derived, never live). robot = CRM IQ / Voyager screens, phase 1, all (U) until Fede's recon. - Others: from the research file. Field-level detail is mostly (U) (partner portals).
1.4.1 Work order
| Canonical | AppFolio | Yardi Voyager | Entrata / RealPage / Rent Manager | Tenio (designed to differ) | ⚡ / absorbed by |
|---|---|---|---|---|---|
key.id |
Service-request record id (WorkOrder.pmsId, field ops address it) |
SIPP ServiceRequestId (number) |
RM ServiceManagerIssues id; Entrata work order id (U) |
Text id wo_7Hq2… |
⚡ number vs text. Absorbed: id: string always |
key.displayNumber |
pmsNumber "18752-1" ≠ record 18761 on drifting DBs (data/types.ts:6979-6989) |
= ServiceRequestId (U) | (U) | Separate human number M-00412, issued minutes later |
⚡ id ≠ number. Absorbed: displayNumber + accepted receipt |
key.secondary |
workOrderId = AppFolio WorkOrderId (1259 vs SR 1258, types.ts:912-923) |
— | — | — | ⚡ two id spaces. Absorbed: secondary, never a second top-level id |
propertyKey |
PropertyId (int) |
PropertyCode "ZZTEST-LM-01" |
Entrata property id; RM property id | prop_… |
⚡ int vs code. Yardi writer sends our id today (yardi/work-order-writer.ts:60) |
unitKey, unitNumber |
UnitId + UnitName/Unit |
UnitCode (is both id and number); RoomCode (U) |
Entrata unit / space (U) | unit + bed space |
⚡ unit vs room/space. Absorbed: unitNumber separate; spaces on the unit row |
state |
Status + CanceledOn; new→pending; completed/complete/ready to bill/completed no need to bill/work done→completed; cancel; anything else → dispatched (writers/guards.ts:271-340) |
CurrentStatus: Submitted; In Progress/On Hold/Waiting on Part/Vendor Scheduled/Tenant No-Show → dispatched; Complete; Cancelled; unknown → pending_dispatch (yardi/pms-adapter.ts:37-53) |
RM, Entrata status lists (U); Buildium splits resident request / work order / task (research §2a) | 8 states incl. awaiting_parts + one new state the chaos panel turns on unannounced |
⚡ unknown defaults disagree (dispatched vs pending). Absorbed: Coded<PmsWorkOrderState> with unknown, mapped to PropFlow lifecycle in ONE core table; unknown never moves our status (§1.5 rule 3) |
priority |
low/medium/high/emergency(+critical, urgent); unknown → medium (pms-adapter.ts:2769-2775). A second AppFolio table maps emergency → high (writers/guards.ts:353-358) |
Normal/Urgent/Emergency; Normal → low, unknown → low (yardi/pms-adapter.ts:55-64); write: low & medium → Normal (yardi/work-order-writer.ts:23-33) |
(U) | 5 levels P1…P5 + unset |
⚡ 4 vs 3 vs 5 levels, round trip lossy (medium→Normal→low). Absorbed: Coded<priority> + manifest priorityScale; writes report coerced. Emergency rides ModuleSubject.emergency, never the PMS scale |
category, issueType |
Built from WorkOrderIssue/Title/Category/JobDescription (buildAppfolioWorkOrderDescription); free text, backfilled to 5 trades |
Category + SubCategory from the customer's own GetCustomValues list |
(U) | trade codes PLB, HVC, ELC |
⚡ free text vs per-customer picklist. Absorbed: Coded<WorkOrderCategory> + verbatim issueType; write sends the PMS's picklist value from a per-connection map (pmsDetail-sourced) |
title / description |
composed | ServiceRequestBriefDescription / …FullDescription |
(U) | summary / details |
— |
permissionToEnter |
robot form radio; enrichment | HasPermissionToEnter "Yes"/"No" string |
(U) | tri-state yes/no/call_first |
⚡ bool vs string vs tri-state. Absorbed: boolean | null; call_first → null + accessNotes |
accessNotes |
special instructions (create) | AccessNotes |
(U) | entry_notes |
— |
vendorKey / vendorName |
vendor_id + Vendor (name) |
VendorCode only, put into vendorName today (yardi/pms-adapter.ts:392-394) |
(U) | vendor id only; name needs a lookup | ⚡ id vs name. Absorbed: both fields, name never filled from an id |
scheduledStart/End |
ScheduledStart |
DueDate used as "nearest" (yardi/work-order-writer.ts:205-207); Employees.ScheduleStart/Finish |
(U) | window start/end instants | ⚡ due date vs appointment window. Absorbed: dueOn and scheduled* are separate fields |
workDoneAt / closedAt |
WorkDoneOn / CompletedOn (local wall clock, tz-parsed) |
UpdateDate when Complete (approximation, yardi/pms-adapter.ts:396); StatusHistory "MM/DD/YYYY HH:MM AM/PM" |
(U) | separate work_done_at, closed_at (UTC) |
⚡ two closures vs one. Absorbed: both optional |
| completion step | job_done vs ready_to_bill (work-order-writer.ts:149-158) |
single Complete (yardi/work-order-writer.ts:128-130) |
(U) | two-step like AppFolio | Absorbed: optional request field, dropped when single-step |
| costs | EstimateAmount, ActualCost |
TotalCharge, TotalPay |
(U) | cents | ⚡ dollars strings vs numbers. Absorbed: Cents |
createdAt / updatedAt |
CreatedAt, now() if unparseable; updatedAt = CreatedAt (pms-adapter.ts toWorkOrderDraft) |
ServiceRequestDate / UpdateDate |
(U) | real updated_at |
Absorbed: nullable; never fabricated |
intakeChannel |
CreatedBy: Tenant→portal, Property Manager→staff |
Origin, CreatedBy (U) |
(U) | channel enum incl. ai_agent |
Coded + unknown |
notes |
robot note (AppFolio "Remarks", agents/clara/lib/agent/tools.ts:272) |
TechnicianNotes (replace), TenantMaintenanceNotes, ProblemDescriptionNotes |
RM HistoryNotes: the created note's id is not returned (research §1a) |
append-only notes with ids, confirmed late | ⚡ append vs replace; id vs no id. Absorbed: manifest noteSemantics; receipt accepted with no noteRef |
1.4.2 Lead (guest card)
| Canonical | AppFolio | Yardi (CRM IQ robot / ILS-Guest Card API) | Entrata / Knock (RealPage) / ResMan (MITS) | Tenio | ⚡ / absorbed by |
|---|---|---|---|---|---|
| granularity | card = person, N inquiries per property ("Inquiries Received", types.ts:631-650) |
guest card per property, roommates as additional guests (U) | Entrata lead per property (U); Knock prospect per community; MITS Prospect → Customers | one lead per (person, property), plus a separate "household" link | ⚡ person vs per-property. Absorbed: PmsLeadRow.interests[]; per-property PMSs yield 1 interest; spine merges people |
key.id (+secondary) |
GuestCardId int, GuestCardUuid (page), PartyID (messages), InquiryID per inquiry (data/types.ts:20850-20900) |
prospect/guest code (U); SIPP ImportYardiGuest_Login / GetYardiGuestActivity (research) |
Entrata lead id; Knock prospectId; MITS Identification |
lead_… text |
⚡ 3 AppFolio ids for one record. Absorbed: secondary {uuid, partyId} |
name |
Name "Last, First" parsed (toGuestCard) |
first / last (U) | Knock firstName/lastName required |
single full_name + optional parts |
⚡ split vs full. Absorbed: PersonName with display always set |
phones / emails |
one PhoneNumber, one EmailAddress; card dropped if neither |
(U) | Knock email required on create (realpage/client.ts:44) |
arrays, typed | ⚡ required-contact rules differ. Absorbed: arrays; create-contract per manifest |
status |
Status is the latest activity, not a state ("Text Sent", "Call") → status-enum.ts table |
CRM IQ stage (U) | Entrata lead status (U); MITS has no single status | explicit stage field with values AppFolio never uses (nurturing) |
⚡ activity feed vs state. Absorbed: Coded<PMSStatusNormalized>; activity goes to lastActivity / PmsLeadActivityRow |
source |
Source picklist label ("Rent.", "No Source Recorded"); APPFOLIO_GUEST_CARD_SOURCE_LABELS (guest-card-writer.ts:86) |
source (U) | Knock sourceId (an id into a per-community list, realpage/client.ts:46,82-86) |
source id + label | ⚡ label vs id. Absorbed: Coded<ProspectSource>; write maps our source → PMS label/id per connection |
assignedTo |
AssignedUser + AssignedUserID |
leasing agent (U) | Knock agentId |
owner_user_id |
Absorbed: {pmsUserKey, name} |
preferences |
MoveInPreference, MaxRent "$1,800" string, PetPreference, CreditScore "700 to 749", MonthlyIncome |
(U) | Knock preferredMoveInDate, desiredBedrooms, desiredRent (number), desiredFloorPlan |
numbers + ranges | ⚡ display strings vs numbers. Absorbed: numeric field + …Raw |
receivedAt |
Received local wall clock, needs timeZone (types.ts:181-187) |
(U) | (U) | UTC instant | Absorbed: IsoInstant, adapter converts |
convertedTo |
TenantId (status may still read Pending, types.ts:839-846) |
(U) | (U) | explicit converted_resident_id |
— |
| activity | LastActivityType/Date (day only) + timeline import (integrations/appfolio/guest-card-timeline-importer.ts) |
GetYardiGuestActivity (API, U); CRM IQ activity log (robot row 2, U) |
Entrata getLeadEvents; MITS Events > Event (EventType, FirstContact) |
events with instants and ids | ⚡ day vs instant. Absorbed: atPrecision |
1.4.3 Resident and occupancy
| Canonical | AppFolio | Yardi | Entrata / Buildium / RM | Tenio | ⚡ / absorbed by |
|---|---|---|---|---|---|
PmsOccupancyRow.key |
OccupancyId (int) |
Resident.Code "t0010101" (household) |
Entrata lease/customer ids (U); Buildium lease id | hh_… |
⚡ int vs code. id: string |
PmsResidentRow.key |
TenantId / SelectedTenantId |
per-person code inside OtherOccupants (U) |
Entrata customer id (U) | res_… |
⚡ Yardi may not expose a per-person id in the feed we know. Absorbed: residentKeys may be empty; spine matches on contacts |
name |
Tenant "Last, First"; FirstName/LastName |
FirstName/LastName |
(U) | full name only | PersonName |
phones |
PhoneNumbers "Type: number" list (parsed in adapter) |
Phone[] {PhoneDescription, PhoneNumber, PhoneType} |
(U) | typed array | ContactPoint[] |
emails |
Emails comma list |
Email single |
(U) | array | ContactPoint[] |
status |
rent_roll Status substring rules (unknown → occupied; null → vacant: pms-adapter.ts:1897-1907); tenant_directory Status 'future' |
Status Current/Notice/Future/Past; unknown → vacant (rent roll) and → current (contacts) (yardi/pms-adapter.ts:71-103) |
(U) | adds eviction and month_to_month |
⚡ three different unknown defaults. Absorbed: Coded + unknown |
role |
PrimaryTenant Yes; TenantType "Financially Responsible" |
(U) | (U) | guarantor, occupant, minor |
Coded<role> |
| dates | MoveIn, MoveOut, NoticeDate, LeaseFrom/LeaseTo |
MoveIn, MoveOut, Notice, LeaseFrom, LeaseTo, Possession |
(U) | ISO | IsoDate |
| rent | Rent "1,550.00" |
Rent string; UnitRent number |
(U) | cents | Cents |
1.4.4 Unit and availability
| Canonical | AppFolio | Yardi | Entrata / RealPage | Tenio | ⚡ / absorbed by |
|---|---|---|---|---|---|
key.id / unitNumber |
UnitId / Unit; showings need a third id, unit_uid (data/types.ts:4715-4740) |
UnitCode serves both (yardi/pms-adapter.ts:171,174) |
(U) | id ≠ number, always | ⚡ Absorbed: unitNumber separate; secondary.unitUid |
| floorplan | BdBa "0/1.00" |
UnitType, UnitTypeDescription |
MITS Floorplan; Entrata floorplan (U) | explicit bedrooms, bathrooms |
parsed in adapter |
| sqft / market rent | SquareFt, MarketRent |
UnitSqFt, UnitRent/UnitTypeRent |
(U) | numbers | — |
| status | rent_roll Status; unit_vacancy UnitStatus (unknown → other, never offerable: pms-adapter.ts:1909-1925) |
UnitEconomicStatus (U) |
(U) | adds down / model |
Coded |
| availability | AvailableOn, NextMoveIn, RentReady, PostedToInternet, PostedToWebsite, AdvertisedRent, DaysVacant, Description, Tags/AffordableProgram |
Voyager "Unit Availability" report export (robot row 8, U) | RealPage "Pricing and Availability ODE" (research) | per-channel posting list | ⚡ two fixed flags vs a channel list. Absorbed: advertised[] |
| spaces | — | RoomCode on service requests (U) |
Entrata Unit Space; RealPage beds (student) | one building leases by the bed | ⚡ unit vs space. Absorbed: spaces? |
| rent-roll coverage | rent_roll lists every unit, vacant included | GetResidents lists residents: a vacant unit never appears, and a Past resident yields a "vacant" row for a unit someone else may occupy (yardi/pms-adapter.ts:158-183) |
(U) | units endpoint separate from residents | ⚡ unit-centric vs resident-centric. Absorbed: PmsUnitRow from a units read; occupancy separate. Yardi's listRentRoll breaks PMSRentRollRow's contract (types.ts:297-302) |
1.4.5 Balance
| Canonical | AppFolio | Yardi | Buildium / Entrata / RM | Tenio | ⚡ / absorbed by |
|---|---|---|---|---|---|
pastDueCents |
rent_roll.PastDue ("0.00" stated for paid accounts; null = broken read, pms-adapter.ts:1519-1525) |
not provided | (U) | not provided (ledger only) | ⚡ |
netBalanceCents |
not mapped (U whether a column exists) | GetResidentBalances.Balance, net, signed; today written into pastDueAmount and credits floored to 0 (yardi/pms-adapter.ts:260-272) |
sum of lease transactions (Buildium /leases/{id}/transactions) |
computed by us from the ledger | ⚡ past due vs net balance (a resident who owes this month's rent but nothing late reads as "past due" on Yardi). Absorbed: two separate nullable fields + includesPendingCharges; collections reads pastDue and refuses when only net exists unless the manifest says net excludes current-period charges (founder/ops call) |
aging |
aged_receivables 0To30/30To60/60To90/90Plus (hourly) |
OldestOpenChargeDate only |
(U) | aging computed from ledger | ⚡ buckets vs date. Absorbed: bucketsDays[] layout + oldestOpenChargeOn |
openCharges |
aging line items → BalanceOpenCharge |
ItfResidentTransactions20 (U) |
transactions | full ledger | openCharges / PmsLedgerEntryRow |
lastPayment |
receipts + delinquency reads, three-state | (U) | payment transactions | payments in ledger | — |
| collections posture | delinquency report: InCollections, CertifiedFundsOnly, payment plan |
PaymentAccepted: Any / DoNotAccept / CashEquivalent / OnHold (yardi/types.ts:183-196), which maps onto certifiedFundsOnly/paymentsBlocked (I) |
(U) | explicit flags | ⚡ flags vs one enum. Absorbed: nullable booleans |
1.4.6 Appointment (showing)
| Canonical | AppFolio | Yardi (CRM IQ, robot rows 5-6) | Knock (RealPage) / Entrata | Tenio | ⚡ / absorbed by |
|---|---|---|---|---|---|
key.id |
Showing id (int), on Tour.af.showingId |
appointment id (U) | requestAppointment returns an untyped record (realpage/client.ts:111) |
appt_… |
id: string |
| time | date + startTime "10:00 AM" + endTime, property-local strings (guest-card-writer.ts:376-378) |
(U) | scheduledAt one instant, no end (realpage/client.ts:59-65) |
UTC instants + duration | ⚡ local strings vs instant; end optional. Absorbed: start, end | null, timeZone |
| unit | required: unitId → unit_uid, which nothing populates yet (data/types.ts:4730-4740) |
(U) | floorplan (U) | optional | ⚡ required vs optional. Absorbed: manifest says unitRequired; buildingTour best guess stays a core decision |
| lead | card uuid/id | guest (U) | prospectId |
optional (busy blocks) | leadKey? |
| host | hostPersonId → PMS picks a user by name match (guest-card-writer.ts:400-406) |
leasing agent (U) | agentId |
user id | ⚡ Absorbed: we pass our person; adapter matches; no PMS-user map (Fede 10-28) |
| status | scheduled / canceled / no_show / other (guest-card-writer.ts:514) |
(U) | (U) | adds confirmed, completed |
Coded |
| kind | virtual flag, building-tour note in description | (U) | appointmentType |
in_person/virtual/self_guided |
Coded<kind> |
| calendar busy read | none live (Fede D3 Willows list-showings route is "phase 0", dark) | rows 5 (read before every booking + 15 min) | (U) | calendar endpoint | NEW appointments capability |
1.4.7 Vendor (short)
| Canonical | AppFolio vendor_directory |
Yardi GetVendors |
Tenio | ⚡ |
|---|---|---|---|---|
| key | VendorId (int; per database, collisions across accounts, data/types.ts:5744-5760) |
VendorId "V0001" |
text | instance in the ref fixes the cross-account collision |
| name | company / "Allman, John" | GovernmentName, VendorFirstName/LastName |
single | PersonName |
| trades | VendorType + trades |
UsualCategoryId (id) |
list | Coded<WorkOrderCategory>[] |
| usable | DoNotUseForWorkOrder |
IsInactive, VendorStatus |
active |
usableForWork |
1.4.8 What Tenio should deliberately do differently
Tenio exists to break assumptions AppFolio lets us keep (plan ch. 9). Each item below is a chaos-panel switch or a fixed design choice, and each has a conformance test.
- Text ids with letters and prefixes everywhere (
wo_7Hq2,lead_…), plus a human number different from the id that arrives after the create (accepted→ number later). This testsPmsRef.id: string,displayNumber, and Clara's "the number follows" line. - Priority on a 5-level scale (
P1…P5) and 8 WO states, plus one undocumented state the panel can switch on. This testsCoded+unknownand the rule thatunknownnever moves our lifecycle. - Lead per (person, property), the opposite of AppFolio's person card, with roommates as separate leads linked by a household id. This tests
interests[]and spine-side merging. - Ledger only, no balance field. Past due must be computed from
dueOn, and credits are negative. This tests the balance split. - One by-the-bed building (spaces). This tests
spacesand showing a "unit" that is a bed. - UTC instants with offsets in the API, local wall-clock strings on the staff screens. This tests that the robot path and the API path normalise to the same
IsoInstant. - Notes are append-only with their own ids, confirmed late. One switch makes notes replace a single field, like Yardi
TechnicianNotes. This testsnoteSemanticsand read-then-append. - Required email on lead create (like Knock) and an optional unit on showings. This tests per-capability required-field contracts.
- Money in cents, one tenant priced in a second currency. This tests that
Centsdoesn't assume USD in a display path (I: low priority). - A field rename between two "releases" (screen layout change switch). This tests that
pmsDetailabsorbs the rename and that the recipe check flips the connection cell tofailing.
1.5 Rules
Rule 1: core vs pmsDetail. A field goes in the canonical row only if both of these hold:
- (a) a core consumer reads it to decide or say something, and
- (b) its meaning can be stated without naming a vendor.
Everything else the adapter can see goes in pmsDetail.payload, verbatim. That includes vendor ids we don't use, display strings, picklist labels, audit columns, UserDefinedField1-7, and AppFolio's UnitTurnCategory. pmsDetail is opaque to core: no core code may read a key out of it, which the build check can fence (the same fence as check-pms-literals). The adapter may render it (for example, a staff "details from your property software" panel). It is PII and follows the mirror's retention. This is the rule types.ts:1012-1017 already states, made general, with the same precedents as PmsRecord.custom_data and IncomeStatementRow.raw.
Rule 2: no vendor-named field in a canonical type. No appfolio*, no af, no yardiCode. Vendor ids live in PmsRef.secondary under neutral keys (serviceRequest, uuid, partyId, unitUid). The plan's R 3.3 renames the existing ones.
Rule 3: enums are mapped in the adapter, by an explicit table, and unknown stays unknown.
- Every vendor enum becomes
Coded<T>through a table inintegrations/<pms>/map/<enum>.ts, never by substring sniffing. AppFolio's rent-roll status does sniff today (pms-adapter.ts:1897-1907) and should move to a table. - An unmatched value becomes
{ value: 'unknown', raw }and is recorded in the unmapped-value ledger, which already exists for AppFolio WO statuses (writers/guards.ts:336-338,isKnownAfStatus). - No adapter may pick a "safe-looking" default. Today's defaults to
dispatched,pending_dispatch,medium,low,occupied,vacantandcurrent(§1.6) each becomeunknown. - What
unknownmeans for a decision is core's call, in one place per enum. Examples: a WO stateunknownkeeps our current status and flags it; a renewal offerunknownreads as pending (types.ts:1030-1033); a vacancyunknownis not offerable (types.ts:332-335). One table per enum means one place to read the fail-safe. - Mapping into a vendor enum on writes uses the same table backwards. A lossy write is reported in
coerced.
Rule 4: values are normalised at the adapter boundary. The adapter normalises:
- dates to
IsoDate/IsoInstant, using the property's zone (PMSListParams.timeZone); - money to
Cents; - phones to E.164;
- emails to lower case;
- names to
PersonName.
An unparseable value becomes null, with the raw value in pmsDetail. Never now(), never 0, never '' for "unknown". The Mira $0 incident is types.ts:221-234, and toWorkOrderDraft's now() fallback breaks this rule today.
Rule 5: absence has three states (§1.1). Every optional field documents what absent / null / value mean, as lastPaymentDate does (types.ts:118-129).
Rule 6: schema versioning.
- Canonical rows are TypeScript types in one repo. Additive changes (a new optional field, a new enum member) need no version bump, because consumers already handle
unknownand absent. A breaking change (renaming or retyping a field, or making one required) creates a new type (PmsWorkOrderRowV2) with a dual-read window, and the conformance suite pins which version each adapter emits. - Persisted shapes carry an explicit
schemaVersion:pmsDetail(per adapter), the mirror rows, receipts, and the robot's route contract. The robot is a separate deploy, so its request/response contract is versioned per route (/v1/yardi/guest-cards), and propflowai refuses a response whose version it doesn't know (refused,failure.kind: 'upstream_error'). - Capability vocabulary changes are additive only. A capability is never renamed in place (plan: "Today's 21 names stay"). Retiring one (the vestigial
tenants,types.ts:29) is a deletion PR that also touchesrealpage/pms-adapter.ts:53.
Rule 7: adding a new PMS field without breaking others.
- Land it in that adapter's
pmsDetailfirst. This touches no core type and no other adapter. - When a core consumer needs it, promote it: add an optional canonical field with its three states documented, named in our vocabulary. Never make a new field required on an existing row, because that would force every adapter (
types.ts:695-699, 830-836already follow this "optional so every mapper keeps compiling" posture). - Adapters that have the field fill it. The others stay absent, and consumers degrade.
- If it is a fact about the whole capability (for example "balance includes pending charges", "notes replace", "unit required on showings", or "MTM is a flag"), it goes in the manifest's capability facts (
AdapterCapability, likeRenewalAdapterCapabilities,renewal-adapter/types.ts:138-148), not on every row. - If it creates a new ability (a new verb), it is a new capability (§2.1), not a field.
- Tenio adds the field too, ideally with a twist, so the conformance test has a second implementation.
Rule 8: writes carry refs, not our ids, and never send a field the PMS will misread. For example, PropertyCode = our propertyId. The door validates that every PmsRef in a request has the connection's source and instance.
1.6 Hazards found in today's mappings
These are the evidence behind the rules. Each one is a concrete bug or trap a builder would otherwise copy into the next adapter.
| # | Hazard | file:line | Rule |
|---|---|---|---|
| H1 | Unknown WO status defaults differ by PMS: AppFolio → dispatched, Yardi → pending_dispatch |
writers/guards.ts:336-339; yardi/pms-adapter.ts:49-51 |
3 |
| H2 | Two AppFolio priority tables disagree on emergency (→ emergency vs → high). Yardi maps Normal → low and writes medium → Normal, so a round trip turns medium into low |
pms-adapter.ts:2769-2775; writers/guards.ts:353-358; yardi/pms-adapter.ts:55-64; yardi/work-order-writer.ts:23-33 |
3 |
| H3 | Yardi net Balance is written as pastDueAmount, and credits are floored to 0 |
yardi/pms-adapter.ts:260-272 |
§1.4.5 |
| H4 | Yardi listRentRoll is resident-centric: vacant units are missing, and a Past resident emits a "vacant" row |
yardi/pms-adapter.ts:71-85, 158-183 vs contract types.ts:297-302 |
§1.4.4 |
| H5 | The Yardi WO create sends our property id as PropertyCode |
yardi/work-order-writer.ts:60; work-order-writer.ts:79 |
8 |
| H6 | Yardi addNote overwrites TechnicianNotes |
yardi/work-order-writer.ts:94-118 |
manifest noteSemantics |
| H7 | Yardi attachDocument is a silent no-op that resolves successfully |
yardi/work-order-writer.ts:182-187 |
door read-back |
| H8 | Yardi vendorName is filled with VendorCode; unitNumber is filled with UnitCode |
yardi/pms-adapter.ts:384-394 |
2/§1.4.1 |
| H9 | AppFolio toWorkOrderDraft: createdAt falls back to now(), and updatedAt = CreatedAt |
integrations/appfolio/pms-adapter.ts:2673, 2679 |
4 |
| H10 | Unknown resident status: Yardi contacts → current, Yardi rent roll → vacant, AppFolio rent roll → occupied (substring sniff) |
yardi/pms-adapter.ts:71-103; pms-adapter.ts:1897-1907 |
3 |
| H11 | PMSClient.listGuestCards says "PropFlow is the CRM and never writes cards back", but the guest-card writer has 12 verbs |
client.ts:336-341; guest-card-writer.ts:553-645 |
stale doc |
| H12 | PMSMessageSender is referenced but defined nowhere; the messenger send is AppFolio-only |
guest-card-writer.ts:7,12; agents/clara/lib/messaging/dispatcher.ts:474 |
message.send port |
| H13 | The Yardi adapter's capability set includes workOrderWrite, but prod returns null outside mock |
yardi/pms-adapter.ts:119-127; pms/registry.ts:395-398 |
the connection capability layer (A7) is what makes this honest |
A10 · Path lifecycle and eligibility per PMS
Required/optional needs per path are derived from the code and marked inferred where so. Counts are reproducible with the eligibility script.
2.1 Lifecycle for adding a new path
The same lifecycle applies whether a path comes from our roadmap or from one customer on one PMS. A request from a Yardi customer produces a Yardi-agnostic path that happens to light up on Yardi first.
| Step | What happens | Artifact | Who decides |
|---|---|---|---|
| 1. Write the row | Add the ClaraPath row (clara-paths.ts:111-139): id, group, kind, audience, channel, governedBy, wiring per mode, evidence |
1 row + its door | Technical (builder + non-driver reviewer) |
2. Declare pmsNeeds |
pmsNeeds: { required: PmsNeed[]; optional?: PmsNeed[] } with maxAgeMs where staleness matters (A7). Required = without it the path would say or do something untrue (for example promise a WO number, dun an account the PM placed in collections, or send an offer that doesn't exist). Optional = makes it better and degrades cleanly |
Field on the row | Technical. Founder when required-vs-optional changes what a customer or resident experiences or risks (examples in §2.4) |
| 3. Vocabulary check | If a needed capability doesn't exist, add it: a read noun (ledger) or a resource.verb write (ledger.postCharge), with its canonical row/request schema under Part 1's rules. Every adapter defaults to unsupported: the manifest type is Partial<Record<PMSCapability, AdapterCapability>> and a missing cell means unsupported, so the new name compiles everywhere without touching any adapter. An exhaustive snapshot test lists every (adapter × capability) cell so a reviewer sees the new column |
PMSCapability union + schema + manifest snapshot |
Technical, one small PR. An ADR only if it changes how systems relate (agentflow ADR-0004), for example a capability that only the robot could ever serve |
| 4. Conformance test | One file per capability in the conformance suite (plan naming tab; research §4e), run against every adapter whose manifest claims it, at tiers mock → replay → live read-only on a TEST property, never a customer building | pms-conformance/<capability>.test.ts |
Technical |
| 5. Tenio implements first | Tenio gets the API endpoint and the staff screen, with chaos switches. The Tenio adapter's manifest cell goes live when conformance passes against Tenio. This is the first real caller, and writes are free there (plan ch. 9) |
Tenio PR + adapter cell | Technical |
| 6. Eligibility is computed | pmsSupport(path, building) = adapter manifest (code) ∩ connection status (stored) ∩ routing (building, module), plus freshness (maxAgeMs vs adapter freshnessMs). Values: supported | partial | unsupported | not_needed. Nothing is hand-set |
Derived; CLI twin prints it | Machine |
| 7. Each adapter fills it in over time | Adapter cell unsupported → mockOnly / docDerived → live (adapter-level proof). Then the per-connection cell unproven → live follows the proof ladder: mock → Tenio → with writes refused by the door while the capability is "unproven" → a ConAm test record, or one real write a person approves on the held-write rail → mark the capability live for that connection (founder go; the robot lock is an emergency kill switch only) → live for that connection. A failing daily recipe/conformance check sets that connection's cell to failing, and the path drops to partial or not-yet for that customer only |
Manifest PR per adapter; connection rows | Technical, except that the first real write on a customer PMS needs a person's approval (never an agent's) |
| 8. Turn it on at a real property | The path stays Off at a real building until switched on | Mode/path setting | Founder call (Fede or Gera; constitution IV.6). Tests only on TEST properties; no phone tests (standing rules 11-01/11-03) |
| 9. Show it | Paths page and onboarding read the same computed value (§2.3) | — | — |
The rule against hard-wiring. A path is never written against the PMS whose customer asked for it.
- No PMS literal may appear in
clara-paths.ts, in a path's door, or in itspmsNeeds. Extendscripts/check-pms-literals.tsto fence those files (it already gates literals, and plan R 1.15 widens it). - The path's behaviour reads capabilities through the door and the registry, never
pmsSource === 'yardi'. - The asking customer's adapter is just the first adapter, after Tenio, to fill the cell.
- If the vendor feature has no neutral meaning at all, it does not become a path. It stays a staff tool behind the adapter (the research's "passthrough only for onboarding/admin" rule).
Worked example A: existing capability, partial elsewhere. ConAm (Yardi) asks: "when a prospect says they leased elsewhere, mark the lead lost with the reason in CRM IQ."
- New path
leasing.mark_lead_lost,pmsNeeds.required = [guestCard.update(status)]. guestCard.updatealready exists. Itsstatuschange gains the valuelost+reason(an additive enum value, Rule 6).- Tenio implements
lostwith a reason code first. - AppFolio's manifest says
guestCard.update.status ∈ {inactive}(it only hasmarkInactive,guest-card-writer.ts:572-578), so on AppFolio the path is partial: the lead lands Inactive plus a note with the reason, and the receipt listscoerced: status lost→inactive. - Yardi goes
livewhen its CRM IQ recipe passes. - No code says "Yardi".
Worked example B: a capability nobody has. A customer asks Clara to post a lockout fee to the resident's ledger.
- Add
ledger.postCharge(NEW write; request{occupancyRef, chargeCode, amountCents, postedOn, description}; result{entryRef}). All adapters areunsupported. - Tenio builds it at stage 3 (money).
- The path shows "Not yet with AppFolio / Yardi" until each adapter proves it.
- Whether Clara may post charges at all is a product and money question: founder call.
2.2 Eligibility matrix today
How it is computed (script kept at scratchpad/elig/elig.py, so the counts are reproducible):
- ✔ supported = all required and all optional capabilities present.
- ◐ partial = all required present, some optional missing.
- ✖ unsupported = a required capability is missing.
- — not needed.
Manifests assumed:
AppFolio (prod). Declared reads (
pms-adapter.ts:310-341), plus residents through the tenant directory, plus the writers it implements:- guest-card writer, all 12 verbs (
integrations/appfolio/guest-card-writer.ts:621-1469); PMSWriterrenewal, move-out, unit and listing verbs (integrations/appfolio/pms-writer.ts:157-918);- the WO writer;
createVendorL4;- the inbox text door.
No
appointmentsbusy read (Fede D3 route is dark) and noledger(open charges only). Per-connection exceptions are not modelled; for example, Situs writes are blocked by our readonly write policy (memory note 10-28, not re-verified here). That is exactly what the connection layer is for.- guest-card writer, all 12 verbs (
Yardi phase 1 = Fede's rows 1-10 on the robot:
guestCards,guestCardActivity,guestCard.create,guestCard.note,appointments,appointment.create/update/cancel,rentalApplications,unitVacancy,rentRoll+leaseStates+balances(nightly),tenantContacts(nightly). All of it is planned and none of it is live: recon is alltodo, and today's prod Yardi read client and writer arenull(pms/registry.ts:395-398). So the honest live count for Yardi today is 0. The column shows phase-1 design eligibility.Tenio = plan build order. T1 leasing (leads, showings, calendar, applications incl.
application.send, units). T2 maintenance (WO read/write, vendors, plus residents and lease states (I)). T3 money (balances, ledger, collections flags). T4 renewals. T5 breadth (lease, move-out, unit writes, listing, portal, messenger, POs). The cell shows the first stage at which the path reaches that status.Generic API PMS = an assumed Buildium/Rent Manager/Entrata-class REST PMS (I/U): leads R/W, applications R, units, residents, leases, ledger+balances, WO R/W without attach, vendors R/W. No appointments,
application.send, renewal e-sign, collections flags, lease drafting, move-out, listing publish, portal or messenger.
pmsNeeds below are my derivation from code (I). Where the plan already states one (Fig. 3: inbound lead, collections), it matches.
Leasing (leasing.prospects)
| Path | Required | Optional | AppFolio | Yardi ph.1 | Tenio | Generic API | Missing on Yardi ph.1 |
|---|---|---|---|---|---|---|---|
leasing.answer_texts_emails |
— | unitVacancy, rentRoll, guestCards, rentalApplications | ✔ | ✔ | ✔ T1 | ✔ | — |
leasing.answer_calls |
— | same | ✔ | ✔ | ✔ T1 | ✔ | — |
leasing.first_email_reply (syndicated guest card → first email; clara-guest-card-first-email-send.ts:1-15) |
guestCards | guestCardActivity (first words) | ✔ | ✔ (3-min poll vs 1-min) | ✔ T1 | ✔ | — |
leasing.first_reply_text |
guestCards | — | ✔ | ✔ | ✔ T1 | ✔ | — |
leasing.followup_no_reply |
— | guestCards (stop when staff closed it), rentalApplications | ✔ | ✔ | ✔ T1 | ✔ | — |
leasing.followup_went_quiet |
— | same | ✔ | ✔ | ✔ T1 | ✔ | — |
leasing.tour_confirmation |
— | appointments, appointment.create, appointment.cancel | ◐ | ✔ | ✔ T1 | ◐ | — |
leasing.tour_reminders |
— | appointments (see staff moves) | ◐ | ✔ | ✔ T1 | ◐ | — |
leasing.tour_unconfirmed_nudge |
— | appointments | ◐ | ✔ | ✔ T1 | ◐ | — |
leasing.application_link |
— | application.send (PMS-made link, application-link-mode.ts:51-56), rentalApplications |
✔ | ◐ (link or team send) | ✔ T1 | ◐ | application.send |
leasing.post_tour_chase |
— | rentalApplications, guestCards | ✔ | ✔ | ✔ T1 | ✔ | — |
leasing.new_lease_paperwork |
rentalApplications, lease.draft, lease.sendForSignature | — | ✔ | ✖ | ✔ T5 | ✖ | lease.draft, lease.sendForSignature |
leasing.guest_card_write |
guestCard.create | guestCard.note, guestCard.update | ✔ | ◐ | ✔ T1 | ✔ | guestCard.update |
leasing.publish_listing |
listing.publish | unitVacancy | ✔ | ✖ | ✔ T5 | ✖ | listing.publish |
On AppFolio the three tour paths are ◐ only because no PMS calendar busy read exists. Google/Outlook carry them today.
Renewals (leasing.renewals)
| Path | Required | Optional | AppFolio | Yardi ph.1 | Tenio | Generic API | Missing on Yardi ph.1 |
|---|---|---|---|---|---|---|---|
renewals.offer_text |
leaseStates, tenantContacts, renewals, renewal.prepare | renewalFeatures, leaseProvenance, renewalTruth | ✔ | ✖ | ✔ T4 | ✖ | renewals, renewal.prepare (+ opt.) |
renewals.offer_email |
same | same | ✔ | ✖ | ✔ T4 | ✖ | same |
renewals.offer_call |
same | same | ✔ | ✖ | ✔ T4 | ✖ | same |
renewals.mtm_warning |
leaseStates, renewals (I: don't warn a resident who renewed off-platform) | leaseProvenance | ✔ | ✖ | ✔ T4 | ✖ | renewals |
renewals.answer_replies |
renewals (an offer exists) | renewal.changeTerm | ✔ | ✖ | ✔ T4 | ✖ | renewals |
renewals.answer_calls |
renewals | renewal.changeTerm | ✔ | ✖ | ✔ T4 | ✖ | renewals |
renewals.fill_pms |
renewal.prepare | renewal.changeTerm, renewal.cancel | ✔ | ✖ | ✔ T4 | ✖ | renewal.prepare |
renewals.holdover |
leaseStates, leaseProvenance, renewal.monthToMonth | — | ✔ | ✖ | ✔ T4 | ✖ | leaseProvenance, renewal.monthToMonth |
Yardi renewals are Fede's row 12, phase 2 (SIPP ItfLeaseRenewal, read only). Plan R 0.5 guards change_renewal_term meanwhile.
Maintenance (maintenance) + emergency WO
| Path | Required | Optional | AppFolio | Yardi ph.1 | Tenio | Generic API | Missing on Yardi ph.1 |
|---|---|---|---|---|---|---|---|
maintenance.answer_texts |
— | tenantContacts, workOrders, message.send (PMS messenger door) | ✔ | ◐ | ✔ T5 (◐ from T2) | ◐ | workOrders, message.send |
maintenance.answer_calls |
— | tenantContacts, workOrders | ✔ | ◐ | ✔ T2 | ✔ | workOrders |
maintenance.file_work_order |
workOrder.create | workOrder.note, workOrder.attach, workOrder.update, tenantContacts | ✔ † | ✖ | ✔ T2 | ◐ (no attach) | workOrder.* (row 11 = phase 2) |
maintenance.wo_created_text |
workOrder.create (with a number) | — | ✔ | ✖ | ✔ T2 | ✔ | workOrder.create |
maintenance.rating_ask |
— | workOrders (PMS-side close) | ✔ | ◐ | ✔ T2 | ✔ | workOrders |
maintenance.email_updates |
— | workOrders | ✔ | ◐ | ✔ T2 | ✔ | workOrders |
maintenance.handyman_texts |
— | workOrder.assignVendor (tech-sms-dispatch.ts:879,1144) |
✔ | ◐ | ✔ T2 | ✔ | workOrder.assignVendor |
maintenance.vendor_dispatch |
— | vendors, workOrder.assignVendor, purchaseOrders | ✔ | ◐ | ✔ T5 (◐ from T2) | ◐ | all three |
maintenance.vendor_quotes |
— | vendors | ✔ | ◐ | ✔ T2 | ✔ | vendors |
maintenance.answer_vendors |
— | workOrder.complete, .note, .attach (completion proofs) | ✔ † | ◐ | ✔ T2 | ◐ | workOrder.* |
maintenance.vendor_calls_in |
— | vendors (I) | ✔ | ◐ | ✔ T2 | ✔ | vendors |
maintenance.turnover_writes |
moveOut.record, moveOut.charges, unit.setAvailability | unit.setMarketRent | ✔ | ✖ | ✔ T5 | ✖ | all |
emergency.work_order |
workOrder.create (for the PMS copy only) | — | ✔ | ✖ ‡ | ✔ T2 | ✔ | workOrder.create |
- † AppFolio WO note and photo writes discard the robot's "couldn't confirm" (plan ch. 1, citing
integrations/appfolio/work-order-writer.ts:266,289). Eligible, but receipts areunconfirmeduntil R 1.2 lands. - ‡ Emergency is never gated by a PMS capability. On a ✖ building the work order is kept in Propflow, the team gets it as a note, and no PMS number is promised. The on-call chain, relay call and team alert are PMS-independent paths and always go (ADR-0149;
module-mode.tsheader). Today, though,pmsManaged: Boolean(prop.pmsSource)promises a number whenever any PMS is set (handle-create-work-order.ts:1266). That is plan R 0.4.
Collections, resident services, owner reports, company
| Path | Required | Optional | AppFolio | Yardi ph.1 | Tenio | Generic API | Missing on Yardi ph.1 |
|---|---|---|---|---|---|---|---|
collections.reminder |
balances (≤1 day), collectionsStatus | tenantContacts | ✔ | ✖ | ✔ T3 | ✖ | collectionsStatus (Fig. 3: "Not yet with Yardi") |
collections.answer_money |
balances | collectionsStatus, ledger | ◐ (no full ledger) | ◐ | ✔ T3 | ◐ | collectionsStatus, ledger |
collections.owner_escalation |
balances, collectionsStatus (follows the reminder) | — | ✔ | ✖ | ✔ T3 | ✖ | collectionsStatus |
resident.answer_general |
— | balances, leaseStates, workOrders, tenantContacts, portal.inviteResend, message.send | ✔ | ◐ | ✔ T5 (◐ from T1) | ◐ | workOrders, portal, message.send |
resident.lease_billing_calls |
— | balances, leaseStates, tenantContacts, portal.inviteResend | ✔ | ◐ | ✔ T5 (◐ from T1) | ◐ | portal.inviteResend |
owner.weekly_report |
— | balances (AR), rentRoll (rent snapshot), renewals (cohort), guestCards (funnel) (reporting-digest/run-digest.ts:83-105) |
✔ | ◐ | ✔ T4 | ◐ | renewals |
company.staff_replies |
— | message.send (reply on the PMS messenger when that is where they wrote) | ✔ | ◐ | ✔ T5 | ◐ | message.send |
Headline counts (42 PMS-touching paths)
| ✔ supported | ◐ partly | ✖ not yet | Eligible (✔+◐) | |
|---|---|---|---|---|
| AppFolio (prod) | 38 | 4 | 0 | 42 / 42 |
| Yardi phase 1 (planned; 0 live today) | 10 | 16 | 16 | 26 / 42 |
| Tenio after stage 1 (leasing) | 12 | 13 | 17 | 25 / 42 |
| Tenio full build (T5) | 42 | 0 | 0 | 42 / 42 |
| Generic API PMS (I) | 16 | 13 | 13 | 29 / 42 |
Yardi's 16 ✖:
- all 8 renewal rows in the 42-row needs table (pinned to
f528ed1;clara-paths.tshas gained ids since, and R 1.7'spmsNeedsPR recounts from main); - 4 maintenance (
file_work_order,wo_created_text,turnover_writes,emergency.work_order); - 2 collections (
reminder,owner_escalation); - 2 leasing (
new_lease_paperwork,publish_listing).
Set aside by Gera's 11-04 answer (robot only, no Yardi API; tab D answer 3). For the record: Fede's phase 2 (SIPP ItfServiceRequests, ItfLeaseRenewal) would have moved the 4 maintenance ✖ to ✔ and the renewal reads to "required renewals present". Prepare and change-term stay ✖ until a renewal write exists.
PMS-independent paths (31): no pmsNeeds, same on every PMS
- Leasing:
after_tour_checkin(its notes land viaguest_card_write),self_guided,staff_asked_text,rollout. - Renewals:
rollout. - Maintenance:
appointment_reminders,resident_relay,vendor_chase,rollout. - Collections:
rollout. - Resident:
key_pickup. It is labelled "property software" by mistake (plan A5 2a) and should be relabelled. - Team notices:
renewal_notes,leasing_notes,resident_notes,decision_requests,routine_forwards,tour_notices,text_leasing_agent,general_notes,pm_action_reminders,renewal_review_ask,caller_note,test_notice,test_reply. - Emergency:
oncall_chain,relay_call,team_alert. These must stay PMS-independent. - Company:
unknown_sender,mass_sends,login_codes,transfer_to_person.
Team notices triggered by a PMS-gated path (renewal notes, "lease executed") inherit that path's eligibility, because no trigger means no note. They need no needs of their own.
2.3 How the Paths page and onboarding show it
Paths page (customer page tab, clara-paths.ts):
- New column per building: "Works with <their PMS>", with the brand from
pmsBrandForProperty(src/lib/domain/pms/display.ts), else "property software". Words from the plan's naming tab:- Works with Yardi (✔).
- Partly (◐). The (i) says what degrades in one plain sentence per missing optional, for example "Tours are confirmed from your Google calendar; Yardi's calendar is not read yet."
- Not yet (✖). The (i) names the missing ability in plain words ("Clara can't file work orders in Yardi yet; she keeps them in Propflow and emails the team").
- No property software needed (—).
- Where routing splits a building by module (leasing on one PMS, back office on another, plan ch. 3), the column shows the adapter that serves that path's module.
- The (i) (metadata behind it, UI taste rule) lists each need with its connection status (
live/being proven/failing since <date>), transport (API/robot), and freshness ("updated nightly"). - Enforcement:
- A ✖ path cannot be switched above Off at that building. The mode writer refuses, and the row is grayed with the reason. This is the same mechanism as
readsOwnModegray-out (clara-paths.ts:130-135). It is consistent with "Clara is never handed a tool her building can't back" (plan ch. 3): the per-turn tool strip (agents/clara/lib/agent/capabilities/tool-registry.ts:20-23) uses the same computation. - A ◐ path can be on.
- Locked emergency and caller-note paths are never grayed by PMS. They show "Works (property software optional)".
- A ✖ path cannot be switched above Off at that building. The mode writer refuses, and the row is grayed with the reason. This is the same mechanism as
- Turning a path on at a real property stays a founder call. The column informs that decision and never flips anything.
- CLI twin (standing rule):
pms paths --building <id>prints the same matrix from the same function. That function is also what the conformance dashboard and the daily check use.
Onboarding (customer side):
- Only go-live blockers the customer can fix (standing rule: customer sees only go-live blockers). A required capability that is
failingorunprovenbecause of their connection becomes a blocker in their words. Examples: "Your Yardi user needs the CRM IQ agent role"; "We couldn't sign in to AppFolio: reconnect". - A capability that is ✖ because we haven't built it for their PMS is never customer homework. It shows on the staff go-live card ("Leasing: 14 paths work with Yardi; 2 not yet: new-lease paperwork, listing publish") and in the founders' turn-on decision.
- Everything else is pre-filled, with no confirm buttons.
- Staff go-live card (
derivePmsRow, plan R 0.2): per module, counts of ✔/◐/✖ for that building and the list of ✖ with reasons. "Leasing-only, no PMS needed" is shown when every enabled path is ✔ or — without a connection (Yale day one).
Storage: pmsSupport is derived, never stored as truth. It comes from the manifest (code), plus connection capability rows (stored per pmsConnection), plus routing. A cached snapshot for the page is fine. A stored "eligible" flag would split the truth.
2.4 Calls this draft could not make (v9: items 1–4 answered by Gera 11-04, tab D answer 4; item 5 deferred in tab D)
- Collections on
collectionsStatus = null. The plan (Fig. 3) makescollectionsStatusrequired, butclient.ts:441-456(listCollectionsAccountStatusesdoc) says callers proceed without it. The draft follows the plan, which blocks Yardi collections. Answered (D4): no collections text without a status; the safe branch holds it. Yardi'sPaymentAcceptedmight back it once we have the API (U). - Past due vs net balance. May collections and resident balance answers use a net balance (Yardi) when no past-due figure exists? The wording differs ("you owe $X" vs "$X is past due"). Answered (D4): past-due only, never net; with a net-only balance the past-due wording is refused.
renewals.mtm_warningrequiresrenewals(I) so we never warn a resident who renewed off-platform. Answered (D4): no month-to-month warning without renewal data.- Can a ◐ path be on by default at go-live? Answered (D4): no. "Partly" paths are off by default at go-live.
ledger.postChargeand any new money-moving write need a product decision, not just a capability. Deferred (tab D): not needed until the money stage.
2.5 Suggested slicing (vertical, ≤300 lines each, all off origin/main)
PmsKey/PmsDetail/Codedtypes plus thePmsWorkOrderRowmapping for the Yardi adapter only. This fixes H1, H2, H5, H8 and H10 (Yardi side), behind the existing mock. Proof: mock tests.pmsNeedson the 42 rows pluspmsSupportcomputation plus the CLI twin, read-only. Proof: snapshot equals this document's matrix.- Paths page column (Works with / Partly / Not yet) from step 2's function.
- Balance split (
pastDueCents/netBalanceCents) and Yardi H3, ahead of any Yardi collections. - Tenio stage-1 conformance tests for
guestCards,guestCard.create/note,appointments,appointment.*.
Each is one concern. Steps 2 and 3 are the thinnest end-to-end slice of Part 2.
D · Decisions (v10): answered, gated, settled
Fede's Yardi page is a proposal pending his review. On it, the calendar ("Yardi's property calendar is the building's calendar, read and written") and the 5 Oct instruction to build for N vendors are decided, and the robot's core boundary is built (#470–#476 (six PRs; #475 was never used)). This page follows the rest as proposals and doesn't reopen them.
Founder answers (Gera, 2026-11-04)
| # | Question | Answer | What it means in the plan |
|---|---|---|---|
| 1 | Yale 25 day one: paths and mode | Leasing paths only (answer leads, qualify, send the apply link, take tour requests), Supervised for 5 business days, then Live. Everything else Off | R 0.3, R 0.10. Plan additions, not part of Gera's answer: approver = Yale's escalation owner (Fede), approving as a Propflow admin from Slack or the review page (no ConAm login needed), with Gera as backup; a ConAm login only for a ConAm reviewer named at the 11-06 gate; 15-minute target in office hours, paged when missed; Live after day 5 only if the approver rewrote no reply in the last 2 business days. Under Supervised the tour-request note to ConAm is held too, so every tour request passes two people in week one |
| 2 | Which company governs Yale? | ConAm, already: Yale is owned by JP&Co and managed by ConAm through a live link since 9-24 (read 11-04). Nothing to create or move | R 0.0 records it. The Slack channel and the text channel now follow the operator (#25121 and #25122, merged 11-04); reviewer emails and the review page still follow the owner until R 0.3a (#25119) and 0.3b (#25124) land. Yale's mode is set on the building itself (R 0.3), but the company plan above it is a ceiling read from the company the caller passes, which for send doors is JP&Co; so 0.0b prints JP&Co's leasing plan, and 0.3 records it as on |
| 3 | Yardi's API | Robot only; no API plans | The July API adapter stays mock-only; Yardi's transport is the robot. This sets aside the API phases on Fede's page (phase 2 SIPP, phase 3 the Prospect interface) unless Gera reopens it; mentions of them in A3, A6 and A10 are marked accordingly |
| 4 | When a PMS can't supply a fact a path needs | Safe branch + "Partly": no collections text without status, past-due only (never net), no month-to-month warning without renewal data. "Partly" paths are off by default at go-live | R 2.6; the S2 matrix shows "Partly". This also answers A10 §2.4 items 1–4 (collections without status, past-due vs net, the month-to-month warning, "Partly" off by default) |
Gates (owned, dated, chased)
Open calls for Fede, each with a default: Each is due with the ConAm facts on Fri 11-06; silence means the default. (1) Is he Yale's week-one approver (15-minute target in office hours, paged first when missed)? Default if he declines: Gera is primary and Smith pages Gera. (2) Does he say yes to his own §9 per-account gate, which R 1.10s builds? Default: yes, since it is his proposal; a no moves 1.19. (3) Will he add the written-access question (below) to his ConAm list? Default: Propflow sends it to ConAm on his behalf; R 2.0 waits for the answer either way. (4) May Yale go live before ConAm signs? The plan's default is no. That is this plan's proposal, not an earlier ruling: on 5 Oct Fede only parked the questionnaire, and the launch brief's "send once the contract is signed" is a proposal awaiting his review. Until he says otherwise, go-live waits for the signature. If a gate slips, go-live slips. Gera or Fede makes the go/no-go from R 0.9b's receipts, on a stated day:
| ConAm signs | Questionnaire answers back | Yale check + go/no-go (R 0.9b) | ConAm routes leads (go-live) |
|---|---|---|---|
| by Fri 11-06 (and R 0.9a's code blockers merged by then) | Tue 11-10 | Wed 11-11 | Thu 11-12 |
| Mon 11-09 | Thu 11-12 | Fri 11-13 | Mon 11-16 |
| Tue 11-10 (the gate's last day) | Fri 11-13 | Mon 11-16 | Tue 11-17 |
| later | signature + 2 business days, + 1, + 1; Smith pages Fede and Gera once on the bridge when 11-10 passes unsigned | ||
How days are counted: ConAm's steps (answers, routing leads) count ConAm business days, which skip Veterans Day, Wed 11-11. Propflow's steps (the Yale check, the go/no-go) count Propflow business days, which include it. The TEST-pair dry run (R 0.9a) needs no contract: it runs Mon 11-09, or the business day after its last code blocker merges if later, and the go/no-go moves with it.
| Gate | Owner | Due | Chaser if late | Blocks |
|---|---|---|---|---|
| From ConAm now: the apply link, the emergency number, the leasing inbox, and any ConAm person who should review held replies | Fede asks; Propflow chases | Fri 11-06 | Smith pages Fede and Gera once on the bridge | R 0.1, 0.2a |
| ConAm's contract with us signed (at their legal since 5 Oct) | Fede | Fri 11-06 for an 11-12 go-live; Tue 11-10 at the latest (go-live Tue 11-17) | Same | R 0.3 (Yale's mode), R 0.9b, the questionnaire |
| Questionnaire, cut to go-live blockers, sent on signature; answers back | Fede sends; ConAm answers | Signature + 2 business days | Same | R 0.2b |
| ConAm points Zillow and website leads at Clara and sends the switch-day open-prospects list, only after R 0.9b's go | ConAm; Fede owns the ask | Thu 11-12 at the earliest (the date table above) | Same | Go-live |
| ConAm's Yardi user login for Propflow | Fede asks; Propflow chases | Fri 11-13 | Same | R 2.0, fresh prices |
| In writing: ConAm's contract and Yardi license allow automated access on that login (Yardi sued Property Solutions, now Entrata, in 2013 over access to its system: A6's research, from secondary sources). Proposed by this plan; Fede to add it to his ConAm questions | Fede | Fri 11-13 | Same | R 2.0 |
Deferred, not needed until the money stage: ledger.postCharge and any write that moves money (A10 §2.4 item 5). No Yale path needs it | Founders, when a money path is proposed | — | — | Nothing now |
| Tenio replacing his Willows proof (his D3) | Fede's own call | Any time | — | Nothing (Tenio is built either way) |
| Yale's phone line before and during week one: Clara's voice answers can't be held, so a call is answered live in any mode except Off. Default: the number is not given out until lead-routing day; 0.0b records where it points today, and if anything already forwards to it, Gera decides that day whether it stays | Gera | With 0.0b, before 11-09 | — | R 0.9b |
| Yale's on-call list and emergency line set from ConAm's facts (R 0.1: changes live emergency routing) | Founder go (Gera or Fede) | With the ConAm facts, Fri 11-06 | — | R 0.1 |
| Binding Yale's leasing to ConAm's Yardi connection (a prod write) | Founder go | After R 2.0 | — | R 2.1 |
| The first real write into ConAm's Yardi, and marking each capability live there; ConAm names any test record in writing | Founder go, per capability | After R 2.2 | — | R 2.4 |
Already settled (stated so nobody re-asks)
| Settled | By / reason |
|---|---|
| Tenio: own repo, tenio.dev on Vercel, Next.js + Postgres, API + browser both, leasing first; email + password login with a second-step switch; a living building of fake staff and residents (rules decide when, Claude writes words), real time plus fast-forward; designed with Fable | Gera, 2026-11-03 and 11-04 |
| Park West (Tenio's fake building) joins Propflow as a TEST property in prod, under a test client company Resio Residential. No phone tests, and no Twilio or email send can reach any Park West contact | Gera's go, 2026-11-04 |
The robot's writePolicy is an emergency kill switch only, never a rollout tool, and stays out of reach of grants and caller config. With it on, everything is blocked except the requests a driver lists as read-only (method, path, form target) | Gera, robot #639 (10-28); round 6 (Yardi reads use form posts) |
| New shapes are proven on Tenio first. AppFolio is touched before stage 3 only where a stage-1 row says so (R 1.8 note/photo receipts, R 1.12 rent-roll door, R 1.13 wording), each proven by an identical AppFolio replay | Review rounds 1–5 |
Ids: a ref names the system, the record type and a data-space id we mint (vendor server/database names kept as unique aliases, so two connections to one database share an instance), never a credential. At most one active ref per (system, instance, type) on a record, with any number of ended ones. A person's PMS ids stay identity claims (value source:instance:kind:id, many allowed); ref lists live on role rows (occupancy, inquiry), property, unit and vendor; work orders, payments and tours hold one | Architecture critics, rounds 3–6; keeps the existing identity model |
Who operates a building is answered by one resolver with two entry points, the pure operatorOrgOfBuilding (addressed-write.ts:243) and the async governingOrgOfBuilding (thread-governing-org.ts, which #25121 uses); no PR adds a third (founder ruling 09-23): reviewers, Slack channel, text channel and PMS connection all use it, and the fallback is never the owner, with #25119's one stated exception: a company-wide hold (no building) goes to the owner company. Module mode is set on the building row (the owner company's plan stays a ceiling); moving mode resolution between companies is not part of this plan | Founder rulings 09-23/24 in the code; rounds 6–7 |
Replies through a PMS messenger are a text-door carrier (one neutral pms_inbox over an adapter messenger port) on the existing message door, so Supervised holds them and the record rule applies. They are not a PMS write, but messenger.read and message.send are capabilities (connection state, grants, proof ladder). The carrier is the company's one channel label; a reply uses the inbound thread's own connection | Rounds 7–8; ADR-0119/0150; Gera 11-03 (one channel label per company) |
One stored binding per (building, module) → PMS connection, plus one records binding per building for shared data (units, residents, occupancies, balances), is the only answer to "which PMS serves this"; pmsSource becomes derived from the records binding and is retired. Archiving is source-scoped. Records-type capabilities always resolve through the records binding; a module binding to another connection may only stamp match-only refs on records-owned entities (else the bind is refused); the door picks the connection from the target ref. A binding whose connection doesn't belong to the operating company fails closed with an alarm | Round 7: three sources of truth would drift |
Today's capability names stay; only workOrderWrite splits into verbs | Renaming would wrongly fence generic ones like collectionsStatus; one place calls hasCapability today |
The write door wraps the three writer factories and takes their emergency-aware mode decision. It appends one row per status event to a new append-only receipt log (key: capability, connection, idempotency key, sequence); today's PmsWriteIntent stays the guest-card queue; each record's own status is a copy only the door writes. Going live never drains a parked backlog by itself | One table to redrive from and to count for the exit test |
A PMS calendar is read and written (Fede's decision): reads through the calendar engine like Google or Outlook; writes never through the engine, only as appointment.* through the outbox and the door, with the appointment ref stored on the tour | Keeps Fede's decision; one write path per booking |
propflowai's KMS vault owns every PMS credential and connection config. A standing keep-alive grant replays sessions but never yields a password. The robot's login choke point (withLoginBudget) refuses to log in without a one-time, budgeted session.login grant from propflowai, so every caller (scheduled re-mints, refresh routes, a mid-call 401, scripts) needs one; a fence test finds them all | Fede §6; one owner; a leaked robot reaches no password |
| Sandbox stays its own PMS type, as one entry in a single typed registry | Keeps Fede's compile-time exhaustiveness (09-03) |
| Supervised: Gera's 10-30 ruling (PMS writes go) stands. The flip touches three modules, and the code quotes Fede asking for the opposite | Sized M, heads-up to Fede first; tracked outside this plan |
| No repo rename, package move or sync-Lambda rename until a second real system works | Infrastructure risk with no new capability |
| Proof standard: byte-identical replay plus a planted control, and the proof ladder (ch. 10). No phone tests. No test writes on a customer building | Fede's October chain; standing rules 11-01 and 11-03 |
N · Naming options: every new word, three choices, one pick
v3: Capability names stay; the receipt gains queued; sandbox stays a type. v2: the robot keeps its name for now, ids key on the data space, and the receipt gains accepted. Only internal names here, so none of this is a founder call. Words already taken in our world, so avoid them for new layers: driver, worker (fleet roles), agent (Clara), connector (propflow-ingest), harness (voice/bench), readiness (onboarding answers), coverage (sync report), writer (inbound sync in domain/pms/writers/), and "capability" in customer copy (it means three things in code already).
| Thing | Option A | Option B | Option C | Pick | Why |
|---|---|---|---|---|---|
| Layers and places | |||||
The robot repo (today appfolio-browser-agent) | pms-gateway | pms-access | keep the name until Fede's step 7 (the physical package move), then propflow-browser-agents per ADR-0013 | C | A rename is infrastructure risk with no new capability (architecture critic), and Fede already holds step 7 until the Yardi login is dated. Not a founder call: settled with Fable when step 7 runs. |
| Adapter folder | src/lib/integrations/<product>/ + manifest.ts + map/ | src/lib/pms/adapters/<product>/ | packages/pms-adapter-<product> | A | Zero churn; the fence and literal check already key on it. |
| Neutral PMS layer in core | src/lib/domain/pms/ (ports, registry, door, sync engine) | src/lib/pms/ | src/lib/domain/pms-core/ | A | Already the home. Fold the stray src/lib/pms (13 files) and pms-mirror into it over time. |
| Robot recipe packs | lib/vendors/<pms>/recipes/<resource.operation>.ts | playbooks | flows / routes | A | One file per capability; "playbook" and "route" are taken. |
| Test suite | conformance suite (pms-conformance) | acceptance tests | contract tests | A | ADR-0072 already says it; "acceptance" means a Driver's criterion. |
| Sync engine | pms-sync | pms-ingest | mirror-sync | A | The code already uses it (PMSSyncJob, PMS_SYNC_CURSOR#). B collides with propflow-ingest. |
| Sync Lambda | propflow-pms-sync-{env}, at the cutover only | keep appfolio-sync as legacy | propflow-sync-{env} | A (B until then) | C is too generic: several sync-* functions exist already. |
| Our own test PMS | Tenio (from "tenant"; domain tenio.dev, on Vercel) | — | — | Gera | Chosen by Gera, 2026-11-04 (designed 11-03). Real-word-ish and dictation-friendly; no clash found. |
| Contracts | |||||
| Capability names | keep today's read names; new write capabilities as resource.verb (workOrder.create) | rename everything to resource.operation | coarse nouns only (today) | A | A mass rename is churn (only one place calls hasCapability today) and would wrongly fence generic ones like collectionsStatus. Only the one write noun needed verbs. |
| Capability cell in a manifest | adapter facts in code (ordered transports, confirms, marker, freshness) + connection state stored per customer (live | unproven | unsupported | failing) (e.g. api, then robot) + confirms (inline | viaSync | none) + typed facts (freshness, includes pending) | boolean | semver per capability | A | "Live" only after a read-only pass on a test property, so a machine can check it. |
| Path's declared needs | pmsNeeds: { required, optional } | requires / usesIfAvailable | needs | A | requires collides with module entitlement; a future calendarNeeds sits beside it. |
| Path × PMS matrix | pmsSupport: supported | partial | unsupported | not_needed | pathReadiness | pmsCoverage | A | No collision. UI words: "Works with Yardi / Partly / Not yet". |
| Write interfaces | Pms<Resource>Port (PmsWorkOrderPort) | Pms<X>Writer (today) | <X>Connector | A | The core owns the port and each PMS ships an adapter. Avoids the inbound writers/ clash. |
| Write result | WriteReceipt: applied | accepted | unconfirmed | refused | not_written ("queued" is the queue row's state) | PmsWriteResult with ok | throw-or-void (today) | A | applied requires read-back evidence, so a 200 can't mean "saved". |
| The one write door | the write door: a wrapper over the three writer factories | writeToPms() | pmsGate() | A | Short; sits beside the existing message doors. |
| Per-PMS sync contract | PmsSyncAdapter + EntityFetcher | PmsSource | PmsConnector | A | "Adapter" is the house word; PmsSource is wanted for the type below. |
| Data words | |||||
| Which PMS serves a building | a stored (building, module) → connection binding; PmsSource stays the type name, pmsSource the field is derived then retired | pmsType | pmsVendor | A | Already on 4 entities in 88 files; retires 7 parallel unions. |
| A PMS id on our record | pmsRefs: { source, kind, instance, id: string, displayNumber?, secondary?, endedAt? }[] on role rows; a person's ids as identity claims | one pmsRef keyed by credential | externalIds map | A | instance is the data space (AppFolio database, Yardi server and database), never the login, so rotating a key orphans nothing. A list handles two systems and a switch; endedAt keeps the old system's ids as history. |
| A connected login | pmsConnection (org × instance) + pmsAccountKey for budgets only | pmsAccount | pmsIntegration | A | Matches sync-connections.ts. AppFolio's budget key stays byte-identical, and data refs never use it. |
| Sandbox | pmsEnvironment: production | sandbox | isSandbox | appfolio-sandbox as its own PMS type (today) | C | Fede's 09-03 design (types.ts:3-15) keeps compile-time exhaustiveness. v2 proposed reversing it without answering that; v3 keeps it. |
| Person who rents (new fields) | Resident (pmsResidentRef) | Tenant | Occupant | A | What Yardi, RealPage and Entrata say. Existing Tenant* type names stay: renaming them costs churn for no gain. |
| Pre-lease record (AppFolio "guest card") | pmsLeadRef | prospectCard | inquiryLog | A | Every PMS API calls the object a lead. "Guest card" stays inside PMS-record keys, where it is the vendor's own word. |
| Job number people see | pmsRef.displayNumber | pmsNumber | referenceNumber | A | Residents, techs and staff see the PMS's number, never our ticker (Gera 10-30). |
| Raw PMS detail blob | pmsDetail { source, schemaVersion, payload } | pmsEnrichment | sourceSnapshot | A | Opaque to core; the adapter renders it. |
| Photo/note push state | pmsPushStatus | pmsSyncStatus | mirrorStatus | A | WorkOrder.pmsSyncStatus already means something else. |
| Word customers see | their own PMS's name via pmsBrandForProperty, else "property software" | "PMS" | "AppFolio" (today) | A | The Paths list already says "property software"; the helper exists. |
R · Roadmap, PR by PR (v10)
Each part branches off origin/main, works on its own, and opens in parallel. Sizes: S ≤150 lines, M 150–300, L = several PRs of ≤300, each split vertically. risky marks a prod write or a live-auth change, named to Gera before it flips. Blocks lists what each row waits on. Proof is the acceptance test the PR must show; the page doesn't pre-assert how the code will look. Tenio rows run in the new tenio repo.
| # | Step | Where | Size | Blocks | Proof |
|---|---|---|---|---|---|
| Stage 0 · Yale 25 go-live (target Thu 2026-11-12 if ConAm signs by Fri 11-06; later signature dates map to later go-lives in tab D's date table; never before a signed contract) | |||||
| 0.0 | Done 11-04 (read-only): Yale 1773625952029 is owned by org_jpco and managed by ConAm (org_ce7f…) through a live link since 2026-09-24. No pmsSource, no af.propertyId, no AppFolio account. Escalation owner: fede@propflowai.co | prod read | — | — | Recorded here |
| 0.0b | Read-only CLI: for any building, print its effective mode per module, reviewer list, review-page access, Slack channel, text channel and PMS connection, with where each came from (owner or operator via operatorOrgOfBuilding). It also prints which company sets the plan ceiling and that company's plan per module (JP&Co's for Yale, building-module-chain.ts:9-15,221-231), the Review & log places each path uses, whether reviewer notices may go (reviewerNoticeMayGo), the stored platform-admin roster (platform-administrators.ts:201-251) that lets Fede and Gera approve, and Yale's phone line: where the number points, which voice agent and transfer target answer it, and whether anything forwards to it today (a call can't be held under Supervised). Run on Yale and record the output here (an ad-hoc read on 11-04 couldn't reach the settings store, so Yale's current mode is not yet recorded). Branches: if Yale's leasing is not Off before signature, Fede and Gera are told that day and decide whether it stays (the plan never flips a live customer line on its own); if JP&Co's leasing ceiling is below Supervised, Yale can't be Supervised until a founder raises it, after 0.0b prints what that would change at JP&Co's own buildings | scripts/ | S | — | Output for Yale and for a non-co-managed building recorded; the same CLI is 0.9b's check |
| 0.0c | Create the co-managed TEST pair 0.9a and 1.7 use: two TEST companies (owner and operator) with a management link on one TEST building, synthetic contacts only, no phone numbers on its on-call list. Owner: the Park West setup worker (same company-creation path, also TEST-only); due Fri 11-06 | prod config on TEST companies | config | — | 0.0b on the TEST building shows the operator everywhere |
| 0.1 | founder go Emergency line and on-call set; Yale's team-notes inbox (propertyEmail) set to ConAm's leasing inbox; check Yale's existing Slack channel faces ConAm (not JP&Co) | prod config at a customer building | config ×3 | ConAm facts (gate, Fri 11-06); founder go (Gera or Fede) on the on-call list, since it changes live emergency routing | 0.0b shows the inbox and channel; a TwiML or unit walk shows an emergency reaching the on-call list (no call placed) |
| 0.2a | risky Apply link set to ConAm's Yardi application; staff go-live card shows "leasing-only, no PMS needed" instead of a false "no PMS connection" blocker | config, derivePmsRow | config + S | ConAm facts (gate) | A route/render test on a TEST property shows a reply carrying the configured link; card snapshot (no reply is sent at Yale) |
| 0.2b | Questionnaire blockers loaded (application rules, tour process, the four conflicting facts). ConAm-side items (Zillow and website leads routed to Clara, the switch-day open-prospects list, a price re-scrape once their site is fed from Yardi) tracked on the gate, not here | knowledge | config | contract + questionnaire (gate, 11-10) | A draft-only replay on a TEST copy of Yale's loaded facts answers the four conflict questions (nothing runs at Yale) |
| 0.3 | founder go The flip, on lead-routing day, triggered by 0.9b's go: set Yale's leasing mode on the building row (Supervised, per decision 1); until then leasing stays Off; approver Fede as a Propflow admin, backup Gera; a ConAm login only for a ConAm reviewer named at the 11-06 gate | building mode row, Paths page | config | 0.9b's go (which already covers 0.1, 0.2a, 0.3a–d, 0.4 and the contract) | 0.0b (read-only) shows Supervised leasing on Yale under either company's read the moment after the flip; on the TEST pair, a planted hold reaches the approvers, never the owner company; 0.0b records JP&Co's leasing plan as on and Fede and Gera on the admin roster. All read-only at Yale: the planted-hold proof runs on the TEST pair in 0.9a, never at Yale. Until 0.3a–d land, held cards, reviewer notices and texts follow JP&Co's settings, which is why 0.3 waits on them |
| 0.3a | Reviewer emails and the email reply door for a co-managed building resolve through operatorOrgOfBuilding; no reviewers → Propflow staff review, never the owner (the alert for an empty reviewer list is a follow-up; #25119 doesn't add it). #25119 (11-04: open, 🟢 verdict, its red unit tests root-fixed and re-running). 0.3c's #25121 and 0.3d's #25122 merged 11-04; 0.3b is #25124, in review | held-reviewer-email.ts:205-209 | M | — | Regression tests fail on today's code; non-co-managed buildings byte-identical |
| 0.3b | Review-page access check for co-managed holds through the same resolver | held-reviewer-access.ts:76-78 (owner gate) and held-review-access.ts (the getPropertyForOrg call at the end of canReviewHold, :44, lets any owner-company staffer open a hold). #25124 (part 4 of the fix lane, in review) | S | — | Owner company's staff can't open a co-managed hold |
| 0.3c | Slack held-card channel ladder through the same resolver | leasing-activity-channel.ts:138-139. #25121, merged 11-04 | S | — | A co-managed hold never posts to the owner's channel |
| 0.3d | Text channel (pms_text_door) through the same resolver | company-text-door.ts:18-19. #25122, merged 11-04 | S | — | 0.0b shows Yale's carrier is ConAm's choice; owner's AppFolio messenger never used |
| 0.4 | Tour requests with no calendar: Clara names the requested time and when they'll hear back (never a bare "the team will confirm"); the note to ConAm (held under Supervised) carries a confirm link needing no Propflow login; the unconfirmed timer starts when the note is released and pages the approver after 2 office hours | tour-no-host.ts, team-will-confirm.ts | M | — (code, proven on a TEST property; Yale's inbox comes with 0.1) | Route test on a TEST property: wording check; held → released → confirm link closes it; the timer fires |
| 0.5 | Guard: Clara promises a PMS job number only when the building's work-order writer declares issuesPmsNumber. At Yale (no PMS writer) an emergency still pages the on-call list in every mode; Clara says the on-call team has been paged once the page is sent, never that she filed a work order or a number, and the job stays on our side for ConAm to log in Yardi | handle-create-work-order.ts:1266 | S | — | Tested with MOCK_PMS_ENABLED off and on; AppFolio replay unchanged |
| 0.6 | Guard: change_renewal_term refuses a non-AppFolio building | renewal/handlers.ts:2040 | S | — | Unit test |
| 0.7 | Guard: the connection alarm names the real reason ("no adapter for this PMS yet") | sync-connections.ts:620-628 | S | — | Alarm text test, with a property link |
| 0.8 | propflowai CLAUDE.md: replace the ADR-0030 work-order rule with ADR-0031 (local-first) | CLAUDE.md:83 | S | — | Doc |
| 0.9a | Dry run, Mon 11-09 (no Yale config needed): on a co-managed TEST pair (owner and operator are different test companies), walk lead → reply held → approved → sent to a synthetic Propflow mailbox by email (no texts: standing rule 11-03), and tour request → note held → released → confirm link; plus an emergency at the TEST pair as a TwiML/route walk that records the dispatch (no page is sent: standing rule 11-03) and no work-order or number promise. Slip rule: it runs on 11-09 or the business day after its last blocker merges, whichever is later | — | check | 0.0b, 0.0c, 0.3a–d, 0.4, 0.5, 0.10a | Receipts and the carrier used for each step, on this page |
| 0.9b | Stage-0 exit: Yale check and go/no-go, the Propflow business day after both the questionnaire answers are loaded and 0.9a has passed (Wed 11-11 if ConAm signs by Fri 11-06 and 0.9a passes on 11-09), by Gera or Fede from the receipts: 0.0b on Yale shows ConAm everywhere with leasing still Off and the phone line as recorded; 0.2b's draft-only replay passed. A go triggers 0.3's flip and ConAm's lead routing the next day | — | check | 0.9a, 0.1, 0.2a, 0.2b, contract gate | Yale's 0.0b output and the go/no-go, recorded on this page |
| 0.10a | Stale-hold pager: a hold older than 15 office-hour minutes pages the approver (Fede by default), then Gera | hold rail, Smith page | S | — (code, proven on a TEST property) | A planted stale hold pages both in order |
| 0.10b | Week one: a reader counts rewritten replies per day; Gera or Fede flips Yale to Live after day 5 only if the last 2 business days had none (a plan addition to Gera's "then Live"; he can drop it); else Supervised continues and the reasons go on the bridge; still Supervised on business day 10 → Gera is paged once | hold rail | S | 0.10a | The rewrite count matches a hand count |
| 0.11 | Price and availability re-scrape for Yale once ConAm's website is fed from Yardi (Propflow's job) | leasing knowledge refresh | S | lead-routing gate | Clara quotes today's prices from the website |
| Stage 1 · Tenio, and the new shapes proven on it (starts now, in parallel) | |||||
| 1.0 | Tenio foundation: repo, tenio.dev, Postgres, email + password login, app shell, multi-company with instance ids, API keys, seed (Park West, synthetic data), CI. In progress 11-04 | repo tenio | L (scaffold) | — | Deployed; login works; screenshots |
| 1.1 | Tenio leasing slice: leads + activity, showings + calendar, applications, units and availability, in both API and staff screens (per the Fable design spec) | tenio | L (≈4) | 1.0 | Each page has an API twin; Playwright walk |
| 1.2 | Tenio messenger + living building v1: fake staff with roles and fake residents/prospects (seeded rules decide when; Claude on the subscription writes the words; rules-only fallback); real time plus fast-forward | tenio + a worker on the mini | L (≈4) | 1.1 | A seeded run replays identically; a staff reply lands mid-Clara on cue |
| 1.3 | Tenio robot driver (login with the second-step switch, leads/calendar screens) | robot lib/vendors/tenio/ | M | 1.0 | Robot reads a Tenio lead |
| 1.4 | Kill switch per vendor: with the switch on, everything is blocked except the requests a driver lists as read-only (method, path, form target); unknown vendor blocked; Tenio gets an old-style form-post screen mode to prove it | robot readonlyLock.ts:337-350 | M | 1.3 | With the switch on: a listed form-post read finishes, an unlisted form post is refused, a save is refused, and a fresh login with MFA still completes (login and MFA hosts are their own declared class, as today's readonlyLock.ts:346,352 exemptions are); the AppFolio switch still blocks |
| 1.5 | Credentials: the vault takes any PMS's shape (robot logins, MFA secrets, connection config). Tenio connect form first | pms-credential.ts:140 | M | — | Round trip; AppFolio ciphertext untouched |
| 1.6 | Thinnest end-to-end slice: Tenio leads → pms-sync engine (one fetcher, in today's sync job) → existing guest-card writer → inquiry refs stamped (source:instance:kind:id, instance minted by us); the person's PMS id stored as an identity claim | integrations/tenio/, domain/pms/sync/ | L (≈2) | Tenio leads (T1), 1.5 | A Tenio lead appears with a text id; one person with Tenio leads at two properties keeps both under one Person; a lead and a resident with the same id never collide |
| 1.7 | Adapter + connection capabilities; one typed adapter registry; one stored binding per (building, module) → PMS connection, checked against operatorOrgOfBuilding (a binding to a non-operating company fails closed with an alarm); "Works with Tenio" on Paths; pmsNeeds on every PMS-touching path (A10) | domain/pms/registry.ts, clara-paths.ts | L (≈3) | 1.6 | A co-managed TEST building resolves the operator's Tenio connection; ending its management link makes the binding fail closed and alarm; every live building's effective path set is unchanged (Yale as the control) |
| 1.8a | Write door (ADR first: door, receipts, grants and kill switch) wrapping the three writer factories, taking their emergency-aware mode decision, appending to a new append-only receipt log, one row per status event, keyed by capability#connection#idempotency key plus a sequence (the current receipt is the latest event). PmsWriteIntent stays the guest-card queue. Each record's push status is a copy only the door writes, in the same transaction; the sync engine's match-back confirms through a door entry, confirm(ref, evidence), which appends applied. WorkOrderSyncStatus splits into a push state (the door's) and a mirror state (the sync's, e.g. removed_in_pms). First caller: AppFolio work-order note and photo | writer-registry.ts, guest-card-outbox.ts:562, integrations/appfolio/work-order-writer.ts:266,289 | L (≈3) | — | Planted verified:false → unconfirmed; emergency WO still writes; one receipt event per status change, including a workOrder.note with no person; a fence test finds no writer of push statuses outside the door; a viaSync write moves accepted → applied when the sync matches it back; AppFolio replay otherwise identical |
| 1.8b | Tenio lead create through the door | integrations/tenio/ | M | 1.6, 1.8a | A Tenio lead created once even after a planted timeout |
| 1.9 | Hold rail learns a generic write envelope (any capability), plus its executor through the door | held-pms-write.ts, approve-held-pms-write.ts | M | 1.8b | A held Tenio lead create, approved by a person, writes once |
| 1.10s | Per-account robot scheduler: a new KV-backed, cross-instance scheduler in robot lib/core, beside sessionLease, that every request path goes through (SQS jobs, sync polls, live reads, logins). Each adapter declares its sessions per account and its priority classes (live read > write > poll > recipe check), with a reserved live slot that polls give way to. Today there is none: agentJobGroupId serializes only appfolio.* SQS jobs with no priority, and requestPacer.ts is per instance. This is the per-account gate Fede proposes in his §9, so it waits on his yes (tab D). ADR with Fede alongside | robot lib/core/, requestPacer.ts, sqs/agent-jobs.ts | L (≈3) | 1.3 | On Tenio set to one session, a calendar read finishes within 10 s during a polling burst; two jobs on a one-session account never overlap (Yardi's real concurrent-login behaviour is unknown, Fede §8, so its declaration starts at one); AppFolio's job group ids byte-identical |
| 1.10a | PMS calendar provider: widened provider id, neutral event, a no-OAuth resolver path, live reads in 1.10s's live class, a stated place in the selection order | domain/calendar/provider/ | M | 1.3, 1.10s | A booking re-reads the Tenio calendar; a planted conflict is refused; a slow or failed read falls back to "requested time, team confirms" |
| 1.10b | Calendar writes (read and written, per Fede's decision): PMS providers are read-only inside the calendar engine (their write methods refuse); tour writes are appointment.* through the outbox and the door, and the tour stores the appointment ref from the applied receipt; the envelope carries dependsOn, and the drain merges a create and its cancel so neither is sent; AppFolio's schedule_showing kinds map to the same verbs | calendar provider, showing stream consumer, outbox | M | 1.8a, 1.10a | One write path per booking; created once after a timeout; a conflict planted after the read leaves no prospect holding a refused time (team alerted; prospect re-offered times or told the team will confirm, with the time named) |
| 1.11 | Robot grants: a standing keep-alive grant that replays and probes a stored session but never yields a password; the grant check sits inside withLoginBudget (identityLoginLock.ts:291), the one choke point every real login already runs through, so every caller (nightly re-mint, bootstrap and its callers such as resolveUnitUid.ts:239, daily refresh, a mid-call rebuild on a 302 to login, a 401/403 or a 200 carrying a JavaScript redirect (apiClient.ts:1165-1171), refresh-context, refresh-render-context, force-mfa-refresh, reset-context, scripts) needs a one-time, budgeted session.login grant from propflowai; per-call Ed25519 grants bound to a hash of the request; per-account audit. Tenio first | robot runtimeAuth.ts, heartbeat/route.ts, apiClient.ts:1165-1178, loginBudget.ts | M ×2 | 1.3, 1.5 | Enforced per vendor: Tenio enforces from day one; AppFolio runs log-only (it records the grant it would need, refuses nothing) until R 3.6, so AppFolio logins are unchanged, proven by a day of identical login receipts. On Tenio, a login from refresh-render-context with no grant is refused; a fence test finds no login path around withLoginBudget; a standing grant yields no credential; a planted mid-call 401 recovers through a login grant; a grant replayed for another record is refused |
| 1.12 | Rent-roll door: replace shouldAutoApply's AppFolio refusal with "has a live residents fetcher"; add a robot-export entry; a vacant row in an export that isn't marked complete archives nobody; a complete pull archives only rows whose ref came from its own connection | auto-sync-policy.ts:48-51, entity-delta.ts:262 | M | 1.7 | AppFolio buildings still refused; a Tenio export applies; a planted present-but-empty row in an incomplete export archives nobody (today it would); with leasing on Tenio A and maintenance on Tenio B, a complete pull from A archives nothing that came from B, and a work order filed on B carries B's match-only unit ref |
| 1.13 | "AppFolio" in customer text → pmsBrandForProperty: prospects first (Yale), then staff, then screens | clara-unified.ts:359, … | M ×3 | — | Render replay: AppFolio identical, Tenio control differs |
| 1.13b | Messenger: Tenio threads come in as conversations; Clara's replies go out on the existing message door with Tenio's messenger as a text-door carrier (pms_inbox, resolved through the building's binding), so holds and the record rule apply. Park West contacts are synthetic and fenced: no Twilio or email send can reach them | dispatcher.ts carrier, integrations/tenio/ | L (≈2) | 1.2, 1.6, 1.7 | Under Supervised a Tenio reply is held and recorded while a workOrder.note still goes; a planted Park West SMS/email send is refused |
| 1.14 | Resio Residential (test client company) and Park West (TEST property) created in Propflow prod, connected to Tenio (Gera's go 11-04). Leasing paths on. No phone tests | prod config | config | 1.6, 1.7, 1.13b | A fake prospect's Tenio message gets Clara's reply in Tenio; a fake staff reply mid-Clara is handled; zero Twilio/email sends to Park West |
| 1.15 | Build check: add lambda/**; narrow the domain/pms/** and pms-mirror/adapters/** exemptions to the registry | check-pms-literals.ts | S–M | — | Planted literal goes red in each place |
| 1.16 | Tenio maintenance: work orders (notes, photos, vendor assignment, 5-level priority, late confirms), vendors | tenio | L (≈3) | 1.1 | Each page has an API twin; conformance cases for workOrder.* pass on Tenio |
| 1.17 | Tenio residents and money: households, leases, ledger, balances (past due and net), delinquency; Tenio renewals (offers, terms) | tenio | L (≈4) | 1.1 | Conformance cases for balances, leaseStates, renewals pass on Tenio |
| 1.18a | Conformance runner in CI (extending today's pms-conformance.ts scenario) plus the leasing cases (guest cards, showings and calendar, applications, units and availability) on Tenio and on AppFolio's replay | CI, testing-harness/ | M | 1.6, 1.7 | Leasing job green; a planted mapping bug turns it red |
| 1.18b | Stage-1 exit: the remaining cases (work orders, notes and photos, grants, rent roll) and the whole suite green | CI | M | 1.18a, 1.8a/b–1.14, 1.16, 1.17 | Suite green; a planted bug in each area turns it red |
| 1.19 | Ready for Yardi reads and binding (gates 2.1 and 2.2; target Fri 11-20 with three builders in parallel, otherwise the Friday after its last blocker merges; chapter 8's "known gaps" close then): 1.6, 1.7, 1.8a/b, 1.9, 1.10s, 1.10a/b merged and 1.18a's leasing job green on Tenio | — | check | those rows, 1.18a | All merged; the leasing conformance job green |
| Stage 2 · Yardi (starts when the login arrives) | |||||
| 2.0 | Fede's first-hour checklist and the robot's Yardi driver, which lists Yardi's read-only form posts for the kill switch | robot lib/vendors/yardi/ | per Fede | login and access gates; 1.3, 1.4, 1.5, 1.11 | Fede's real-login proof run; with the switch on, a Yardi read flow finishes and an unlisted post is refused |
| 2.1 | risky Bind Yale's leasing, and its records binding (units, residents, occupancies, balances, the data no module owns), to ConAm's Yardi connection (founder go) | prod building binding | config | 0.3a–d, 0.5–0.7, 1.19, 2.0 | 0.0b shows Yale resolving ConAm's connection; Yale's leasing replies unchanged; no write attempted |
| 2.2 | Yardi calendar provider (on 1.10a/b), then fetchers in Fede's order: guest cards, applications, units and availability. Yardi write capabilities stay "unproven", so the door refuses them | integrations/yardi/ | M per fetcher | 1.19, 2.0 | Row counts match Yardi's screens; a planted write is refused by the door |
| 2.3 | Rent roll and residents: Voyager's export through the rent-roll door (1.12), nightly, on Yale's records binding | generic-csv.ts:214 | M | 1.12, 2.0 | Residents created; counts match the export; a truncated export removes nobody |
| 2.4 | First Yardi writes: on a ConAm test record if one exists, otherwise one real write a person approves on the hold rail. Founder go per capability; then mark it live for ConAm's connection. Going live never drains the parked backlog by itself: the founder go shows the backlog count, time-bound verbs whose moment passed (a showing in the past) become not_written: expired, and the rest drain at a set rate | adapter + recipes, door | M per capability | 1.9, 1.10b, 2.2, founder go | Receipt with read-back; a planted backlog of 50 with 10 past-dated showings drains 40 at the set rate and expires 10 |
| 2.5 | Recipe checks scheduled by propflowai outside office hours; a failing check marks that connection's cell failing | robot + connection capabilities | M | 1.11 | Planted broken selector flips the cell |
| 2.6 | Fresh-data gate and decision 4's safe branches (no collections text without status; past-due only; month-to-month warning waits for renewal data) | writers/balance.ts, collections, renewals | M | 1.7 | Stale or missing fixture takes the safe branch |
| 2.7 | Stage-2 exit: Fede's rows 1–10 live for ConAm's connection; daily recipe checks green 5 days running; Yale's prices and availability read from Yardi, not the scrape | — | check | 2.2–2.6 | Connection capability table on this page |
| Stage 3 · AppFolio moves (each piece starts once Tenio passes that capability; never waits on Yardi) | |||||
| 3.1 | AppFolio fetchers replace the sync job's AppFolio branches, one data type per PR, each only after Tenio passes conformance for that capability | lambda/appfolio-sync/handler.ts | L (~8) | 1.18b; per capability 1.16/1.17 | A day of identical run receipts |
| 3.2 | risky AppFolio af → refs, one record type at a time, each dual-write PR opened with its "stop writing af" PR; identity claims move to source:instance:kind:id in the same ladder. One founder go for the ladder | Unit/Property → Vendor → Inquiry/Occupancy (lists); WorkOrder, Payment, Tour (single ref) | L (~12) | 1.6 | Dry run first; counts match; every person still resolves by phone and by PMS id |
| 3.3 | Readers off the af fallback; sibling attributes renamed per A2 §5 (its items 9, 10 and 12 are superseded; see the notes there) | A2 §5 | M ×3 | 3.2 | Fence test: no new af reader |
| 3.4 | pmsSource derived from the building's records binding, then deleted (roughly 110 files mention it; R 3.4's read-only first step recounts), together with the "has af.propertyId so it's AppFolio" inference | registry.ts:345-347, types.ts:3550 | L (~6) | 1.7, 3.2 | Read-only count first; every building resolves the same PMS before and after |
| 3.5 | The ~10 bypass writes (incl. close-all's direct robot calls) and the ~22 Clara/admin tool handlers that call the robot directly move behind the door | src/lib/tools/handlers/**, close-all-for-tenant.ts:330-345 | M ×8 | 1.8a, 1.8b | Bypass scan reaches zero |
| 3.6 | risky AppFolio robot calls switch from the shared token to grants; delete the shared token | robot + propflowai | M | 1.11 | After the switch, AppFolio writes succeed on grants for a day; the shared token is refused; rollback restores it in one step |
| 3.7 | Scattered PMS switches collapse into the registry; the 7 parallel unions and door enums retire | registry, unions | M ×2 | 1.7 | Adding a fake PMS in a test touches only the registry line and its folder; no other file compiles differently |
| 3.8 | Allowlist burn-down per directory (leasing, renewals, comms mirror, onboarding, tools, maintenance, admin, lambda); the AppFolio staff console moves into the adapter | A1 per-area table | ~15 PRs | 3.5, 3.7 | Count drops per PR |
| 3.9 | Exit test in CI: 0 PMS-name branches in core (rule: ===, !== and case on a PMS name outside integrations/, the registry and scripts/); allowlist 0; every write kind through the door; adding a PMS = one registry line + one folder | scripts/ | S | 3.1–3.8 | It passes |
| Later · after a second real system works end to end | |||||
| L.1 | risky Robot step 7 (package move) and any repo or Lambda rename, via alias and cutover | robot repo, schedules.json | M | 2nd real PMS | A day on the new name; rollback |
| L.2 | Each further PMS: one registry line, an adapter folder, a robot driver or API client, a scrubbed replay recording from that vendor, and a conformance pass on Tenio's matching chaos settings plus that recording | — | per PMS | 3.9 | Conformance on both |
V · Review: rounds of three fresh critics, and what each version changed
Three agents with fresh context each read v1 and the code independently: one for architecture, one for the roadmap and decisions, one fact-checking against the code at f528ed1bf34. None of them saw the others' notes or a view from the author.
| Critic | Score | One line |
|---|---|---|
| Architecture and regret | 6/10 | The four-layer shape is right. Ids keyed by credential, no per-customer grants on the robot, and a receipt without "accepted" would each be expensive to undo. |
| Roadmap and decisions | 4/10 | Excellent diagnosis, unrealistic schedule: about 24 PRs in two weeks, Yardi built on legacy shapes, and founder questions that weren't founder questions. |
| Fact-check | 6/10 | Citations mostly exact, but three headline readings were wrong: a retired ADR, "no alarm", and Supervised sized as trivial. Several counts were inflated. |
| Combined | ≈5/10 | Plus a process miss the critics surfaced: v1 argued against Fede's Oct 5 Yardi decision without citing it. |
Round 10 (on v10, the last scored round): 7.1 · 7.0 · 7.9 → average 7.3. The loop ends here (Gera's rule: ten rounds, then keep the latest)
Every round-10 finding below is applied in this published version. These fixes were not re-scored.
| Finding | From | Applied after round 10 |
|---|---|---|
| Yale's phone line is already live and a call can't be held, so "nothing unchecked before signature" may already be false; 0.3 flipped Yale on before the go | Roadmap | 0.0b records the phone line; Gera's call in tab D (default: number not given out until lead-routing day); 0.9b checks with leasing Off and its go triggers 0.3's flip |
| The scheduler leaned on a gate that is only Fede's §9 proposal, not code; regrouping SQS can't give a live slot | Architecture, fact-check | 1.10s is a new cross-instance robot scheduler every request path goes through, sized L, waiting on Fede's §9 yes (his call 2, default yes); ADR with Fede |
| The sync is itself a status writer; a viaSync write would never reach "applied" | Architecture | Door entry confirm(ref, evidence); push status split from mirror status |
A PMS can't be a full calendar-engine provider; no dependsOn for create/cancel pairs | Architecture | PMS providers read-only in the engine; tour stores the appointment ref from the receipt; dependsOn in the envelope; drain merges create+cancel |
| 1.11 and 1.10s would change live AppFolio logins and job grouping unmarked | Roadmap | Grants enforced per vendor, AppFolio log-only until 3.6 with an identical-receipts proof; AppFolio job group ids byte-identical |
| 11-12 depended only on the signature; PR states stale; Fede's calls undated; 1.19 had no slip rule; no ADR rows | Roadmap, fact-check, architecture | Code precondition on the 11-12 row and the verdict; PR states as of 11-04; Fede's four calls due 11-06, silence = default; 1.19 slip rule; ADRs named on the door and the scheduler |
| Minor: a leaked-robot overclaim, owner/operator on one instance, citations, Park West wording, PR range | All | Fixed |
Round 9 (on v9): 7.0 · 7.2 · 7.6 → average 7.3
| Finding | From | v10 |
|---|---|---|
| No scheduler for the robot's one ConAm login: a live calendar read would queue behind polls, or two Yardi jobs would overlap | Architecture | New R 1.10s: per-account sessions and priority classes declared by the adapter, vendor-neutral job grouping, a reserved live slot in Fede's gate; 1.10a and 1.19 wait on it |
| A split building's work orders could never carry a valid unit ref | Architecture | Binding rules: records-type data through the records binding; a module binding elsewhere only stamps match-only refs; the door picks the connection from the target ref |
| "One receipt row per write" can't hold accepted → applied | Architecture | One row per status event, keyed by capability, connection, idempotency key and sequence; record statuses are copies only the door writes |
| Row 0.3b had no PR; the 11-09 dry run had no slip rule | Roadmap, fact-check | 0.3b is the fix lane's part 4, opening now; PRs mapped to rows; 0.9a slips to the day after its last blocker merges |
| Three proofs ran at Yale or placed test pages | Roadmap | Yale proofs read-only; the hold proof on the TEST pair; the emergency step a TwiML walk; 0.2b a draft-only replay on a TEST copy |
| Veterans Day counted in one row and skipped in others | Roadmap, fact-check | One rule: ConAm's steps skip 11-11, Propflow's don't; stated in D and the verdict |
| Fede's approver duty assigned, not asked; 0.1 changes live emergency routing on a heads-up | Roadmap | Approver is Fede's third open call (default: Gera, paged by Smith); 0.1 is a founder go in tab D |
| "Robot only, no API" not carried through; A10 §2.4 still read as open | Fact-check | D says it sets aside Fede's phases 2–3; A3, A6, A10 and the hand-off table marked; §2.4 items rewritten as answered |
| Minor: 1.18 vs 1.19 overlap, no TEST-pair row, 2.7 gap, A2 rows 7 and 26, counts and citations | All | 1.18 split into a/b; new 0.0c; renumbered; A2 rows marked; the renewal count back to 8 (rerun of the script; v9's "9" was wrong); citations fixed |
Round 8 (on v8): 6.7 · 6.5 · 6.4 → average 6.5
| Finding | From | v9 |
|---|---|---|
| "Fede's 5 Oct order" to wait for the contract was the plan's own proposal; "no founder calls left open" was false | Fact-check | Stated as the plan's default and Fede's one open call (default: no launch before signature) |
| The 11-09 dry run and 11-12 go-live came before inputs they waited on; no dated go/no-go | Roadmap, fact-check | 0.9 split: 0.9a TEST-pair walk on 11-09 (no Yale config); 0.9b Yale check and go/no-go the day after the answers load; a date table by signing day in tab D |
| 0.3 didn't wait on the contract or on 0.3a–d, so a Yale hold could reach JP&Co; the company ceiling is read from JP&Co | Roadmap, fact-check, architecture | 0.3 waits on 0.3a–d and the contract; 0.0b prints the ceiling company, its plan, Review & log places and the admin roster; branches stated |
| More robot logins than "four triggers" | Architecture, fact-check | Grant check inside withLoginBudget, the one choke point; a grantless login from refresh-render-context is refused |
The receipt log can't grow PmsWriteIntent (a guest-card queue needing a person) | Architecture, fact-check | A new append-only receipt table; the intent row stays the queue and becomes a view |
| Parked writes would flood out when a capability goes live | Architecture | Backlog count at the founder go, past-dated verbs expire, set-rate drain (R 2.4 proof) |
| Shared data (units, residents) had no binding; archiving not source-scoped | Architecture | A records binding per building; a complete pull archives only its own connection's rows |
| Messenger sends outside the capability model | Architecture | messenger.read / message.send capabilities on the message door; one neutral carrier; reply on the inbound thread's connection |
| One ref per (system, kind) breaks on a manager change within one PMS | Architecture | One active ref per (source, instance, kind), any number ended |
| 1.19 didn't narrow anything; 0.5 missing from the go-live gate; A10 §2.4 still open; A2 §5 contradicted settled picks | Roadmap, architecture | 1.19 names its own leasing set (target 11-20); 0.5 in 0.9a with Yale's emergency wording; §2.4 closed against D4, postCharge deferred in D; A2 items 9, 10, 12 marked superseded |
| Minor: kill switch vs login/MFA, citations, counts, message.send in A9, chapter 10's question list | All | Fixed |
Round 7 (on v7): 6.5 · 7.0 · 6.8 → average 6.8
| Finding | From | v8 |
|---|---|---|
| Messenger replies routed as a PMS write would skip Supervised holds and add a second send path | Architecture | PMS messenger is a text-door carrier on the existing message door; proof: a Tenio reply is held while a WO note still goes (R 1.13b) |
Co-management patched site by site; the canonical operatorOrgOfBuilding already exists; Yale's text channel follows the owner | Architecture | One resolver for reviewers, review page, Slack, text channel and connection (R 0.3a–d, 1.7); sent to the fix lane |
| Three sources of truth for "which PMS serves this building" | Architecture | One stored (building, module) binding; pmsSource derived then retired (R 3.4); fail closed when the operator changes |
| A list of writes makes the kill switch open by default | Architecture | Block everything except listed read-only requests; proof refuses an unlisted post |
| Receipts spread over three record types | Architecture | One receipt row per write; record statuses are views |
| Mode follows the owner in code; the fix lane keeps it there and calls moving it a founder call | Fact-check | Yale's mode set on the building row (works under either read); moving mode between companies dropped from this plan |
| Fede doesn't need a ConAm login: Propflow admins can approve any hold | Fact-check | Fede approves as admin, Gera as backup; ConAm login only for a named ConAm reviewer |
| "No founder calls open" was false: the contract and questionnaire order reversed Fede's 5 Oct call | Fact-check, roadmap | Go-live waits for a signed contract (v9 correction: this is the plan's default and an open call for Fede, not his 5 Oct order; on 5 Oct he only parked the questionnaire) |
| Robot logs in from four triggers, including mid-call | Fact-check | All four ask propflowai for a budgeted login grant; mid-call 401 proof; grants bound to a request hash |
| Go-live order: dry run undated, lead routing could precede it, pager not a blocker, no slip rule; 0.9's "sent" would text a TEST company | Roadmap | Dry run Mon 11-09 by email to a synthetic mailbox; routing only after it passes; 0.10a blocks 0.9; slip rule and go/no-go stated |
| R 2.0 over-blocked; no stage-2 exit; 1.7 could change live buildings' paths | Roadmap | 2.0 waits only on login pieces; 1.19 gates 2.1/2.2; R 2.8 exit; 1.7 proof: live buildings unchanged |
| Minor: Veterans Day gate; prod config rows unmarked; backup approver and timer unnamed; stale citations | All | Fixed |
Round 6 (on v6): 7.0 · 7.0 · 6.5 → average 6.8
| Finding | From | v7 |
|---|---|---|
| Which company's PMS connection serves a co-managed building was never stated (Yale: owner on AppFolio, manager getting Yardi) | Architecture | Building binds per module to a connection, resolving the manager's; R 1.7 proof on a co-managed TEST pair; R 2.1 binds Yale to ConAm's |
| Ref lists on Person clash with identity claims, which already allow many ids per person | Architecture | Person ids stay identity claims (source:instance:kind:id); ref lists on occupancy/inquiry; A9, N, R 3.2 aligned |
| The kill-switch proof couldn't show it works on Yardi, whose reads are form posts | Architecture | Each driver defines writes; proof: reads finish and a save is refused with the switch on (R 1.4, 2.0) |
| A tour slot lost between check and write left the prospect holding a refused time | Architecture | Lost-slot follow-up and proof (R 1.10b, A7) |
| Reviewers must be the company's own staff; ConAm has none; Slack cards fall back to the owner's channel | Fact-check | R 0.3 creates ConAm logins for the approver and backup; 0.1 sets Yale's Slack channel; 0.3a covers Slack with a Propflow-review fallback |
| The tour note is itself held under Supervised | Fact-check, roadmap | Restored in D1; R 0.4 timer starts at release; R 0.9 dry run includes it |
| Stage 0 couldn't finish "this week"; lead routing, switch-day list and price re-scrape had slipped out | Roadmap | Questionnaire sent now; target go-live Thu 11-12; gate rows restored; R 0.11 re-scrape |
| 0.0 didn't record Yale's current mode; 0.3a could change it on merge | Roadmap | R 0.0b read-only CLI for mode, reviewers, channel and connection; 0.3a keeps the mode and lands with 0.3 |
| The go-live dry run never exercised co-management | Roadmap | R 0.9 runs on a co-managed TEST pair |
| Decision 1 had no stall pager, backup approver or measured switch to Live | Roadmap | R 0.10 |
| No gate for the Yardi login; the legal gate was mis-sourced to Fede's §8 | Roadmap, fact-check | Dated gates; legal gate marked "proposed by this plan" |
| Blocks too strict or missing (1.8, 1.6, 2.1); no named "ready for Yardi" subset | Roadmap | 1.8 split a/b; 1.6 waits on Tenio leads only; 2.1 waits on 1.7/1.8a; R 1.19 names the subset |
| New receipt states inconsistent across tabs; R 3.2's key lacked the type | Fact-check | Ch. 4 and R 1.8a name all three; R 3.2 uses source:instance:kind:id |
| Two of my own round-4/5 corrections were wrong (rent-roll deletes; heartbeat logins) | Fact-check | Both corrected in place above, with the proofs that rested on them |
Round 5 (on v5): 6.6 · 6.5 · 7.0 → average 6.7 (this round's critics were told that details a PR's stated proof would catch are not material)
| Finding | From | v6 |
|---|---|---|
| Decision 3 re-asked Gera's 24 Sep arrangement: ConAm already manages Yale through a live link | Roadmap, fact-check (and Gera, 11-04) | Verified (link live since 9-24); decision 3 removed; R 0.0 records it |
Held-reply reviewers follow the owner company (held-reviewer-email.ts:207), so JP&Co could see ConAm's holds; ConAm has no users | Found while verifying | R 0.3a: fix in flight as its own PR; R 0.3 proof: holds reach the approver, not JP&Co |
| Park West proof needs a messenger port nothing built; no fence against real texts | Roadmap, architecture | R 1.13b: messenger in and message.send out through the door; Park West contacts fenced from Twilio and email |
| "Tenio conformance pass" undefined; Tenio had no maintenance or money for stage 3 to lean on | Roadmap, architecture | R 1.16–1.17 (Tenio maintenance, money, renewals); R 1.18 stage-1 exit wires conformance into CI; R 3.1 gated per capability |
| A7 and A10 still described a per-route robot lock | Architecture, roadmap, fact-check | Removed; rollout runs through the door's connection status |
| Stage 0 parked behind the contract; 0.3 not waiting on 0.4; no exit test | Roadmap | Apply link asked now (gate, 11-06); gates dated with a chaser; 0.2 split; 0.3 waits on 0.4; R 0.9 exit dry run |
| Refs lacked a record type; instance tied to the vendor's server | Architecture | source:instance:kind:id; instance minted by us, vendor names as aliases; collision proof in R 1.6 |
| The rent-roll door can delete residents on a partial export (corrected in round 6: it never deletes and ignores missing rows; it archives a tenant when a row is present but vacant) | Architecture | v7: a vacant row in an export not marked complete archives nobody; proof plants such a row (R 1.12) |
| Standing keep-alive grant could fetch any password | Architecture | Keep-alive grant replays only; a one-time login grant under propflowai's budget |
| "Read-only calendar" changed Fede's read-and-write decision | Fact-check | Reverted to read and write; writes go via the outbox and the door |
| Receipt table covered only one not-written reason | Fact-check | Rows for unproven, not supported and held, each with a stored state and redrive rule |
| "Never touch AppFolio until stage 3" contradicted stage-1 rows | Fact-check | Restated with the named exceptions and their proof |
| Minor: counts, labels, the eligibility script's source, slow calendar reads | All | Fixed |
Round 4 (on v4): 6.0 · 6.0 · 7.0 → average 6.3
| Finding | From | v5 |
|---|---|---|
The robot's read-only lock only matches *.appfolio.com: it would block nothing on Yardi | Architecture | R 1.4: hosts owned by each driver, unknown vendors blocked, proven on Tenio |
| Using that lock to stage features contradicts Gera's 10-28 ruling (an emergency kill switch only) | Roadmap | Rollout is per capability in the door ("unproven" refused); the switch is a backstop only |
| Write door anchored to one of three factories; would re-decide mode and could refuse an emergency WO | Architecture, fact-check | Wraps all three and takes their emergency-aware decision (ch. 4, A7, R 1.8) |
Receipt mapping misused partial_sync, missed preview_recorded, ignored per-item note/photo state, and made "unconfirmed" read as "never lands" | Fact-check, architecture | Table rebuilt with new awaiting_confirm / unconfirmed states and a notes/photos column (A7) |
| Bookings had two stacked doors with opposite retry rules | Architecture | PMS calendar provider is read-only; bookings keep tour → stream → outbox → door |
| The hold rail can't hold guest-card or appointment writes, and approval is mode-gated | Architecture, roadmap, fact-check | R 1.9: a generic write envelope; the gate restated as "Supervised or Live" |
The person-lookup key has no instance (PMS_EXTERNAL_ID#appfolio:12345) | Architecture | Moves to source:instance:id inside R 3.2; Tenio/Yardi refs get it from day one (R 1.6) |
| Robot logs in every 2 minutes (heartbeat), not just nightly (corrected in round 6: the 2-minute heartbeat replays a stored session; it logs in only for the nightly re-mint and the bootstrap) | Fact-check | v7: standing keep-alive grant for replay; one-time login grants for its scheduled logins (R 1.11) |
| Yale sits under JP&Co in our data: JP&Co's reviewers and mode apply | Roadmap | Verified 11-04 (no AppFolio fields; org_jpco); decision 3 + R 0.3 |
| Go-live gating: ConAm's facts, the questionnaire, 0.3 not waiting on 0.1/0.2 | Roadmap | New gate row; questionnaire cut to blockers; R 0.3 waits on both |
| Day-one tour requests never close; the note's link needs a login ConAm lacks | Roadmap | R 0.4: login-free confirm link + unconfirmed alert |
| Blocks gaps: Yardi rows before credentials; grants; calendar split; stage 3 waiting on Yardi | Roadmap | Blocks fixed; 1.10 split a/b; stage 3 waits on Tenio's conformance pass |
| No founder gate on the first real Yardi write or marking capabilities live | Roadmap | Gate row in D; R 2.4 |
| Counts: hasCapability has 1 caller; importers 129–144; branches 64–81 by rule | Fact-check | Ranges and the counting rule stated (ch. 1, R 3.9) |
| Stale lines in N and A8; "replaces Fede's D3" stated as fact | Fact-check, roadmap | Fixed; D3 replacement is "proposed, Fede's call" |
| Structural: the page kept pre-asserting how code would look | Review pattern | R rows now name the proof each PR must show, not its internals |
Round 3 (on v3): 6.5 · 6.5 · 6.5 → average 6.5
| Finding | From | v4 |
|---|---|---|
| Yale may still carry JP&Co's AppFolio tag: leads would go to the wrong AppFolio and the WO guard wouldn't catch it | Roadmap | R 0.0 reads both fields; R 0.3 clears them with a founder go |
| Tour notes would go to a Propflow person who can't confirm in Yardi; the approver and the notes inbox are different jobs; under Supervised the note is held too | Roadmap | R 0.1 sets Yale's notes inbox to ConAm's leasing inbox; decision 1 states the double human step |
| Stage 0's real path: questionnaire gate, lead routing, switch-day list, price re-scrape; emergency line is a today problem | Roadmap | Gates table + R 0.1/0.2 |
| Calendar queued behind building the test PMS | Roadmap | Tenio part 1 includes the calendar; Blocks column; Yardi fetchers need only R 1.1a |
| Stage 3 can't reach its exit test; Fede's ~22 tool handlers had no row | Roadmap | R 3.5 (handlers), R 3.8 (per-directory burn-down), exit rule stated in R 3.9 |
| Decision 1 option C and decision 3 re-asked the standing work-order rule | Roadmap | Both removed; maintenance stays Off until the PMS write exists |
Write door had no shape; ignored the two existing write lifecycles; name collided with PmsWriteIntent | Architecture | A wrapper around getPMSWriterForProperty's writers; receipt → state mapping table (A7); "queued" is the queue row's state |
| Tour booking had three write paths; the calendar provider is OAuth-only | Architecture | PMS provider: live reads; writes go through the door; schedule_showing kinds map to the same verbs; R 1.6 spells out the resolver changes |
| "Robot never stores passwords" broke its nightly self-login | Architecture | propflowai schedules session refresh and recipe checks with grants (session.refresh, recipe.check); account config owner named |
| Capability state per adapter, but access varies per customer | Architecture | Split into adapter facts (code) and connection state (stored) |
| Sandbox-as-type contradicted the "zero core edits" exit test | Architecture | One typed registry; exit test = "one registry line + one folder" |
| API → robot fallback could double-write | Architecture | One fallback rule (A7) |
| The last proof step had no real gate: Supervised won't hold PMS writes once code follows Gera's ruling, and the robot lock is all-or-nothing | Fact-check | A person approves on the held-write rail (mode-independent); R 1.8 adds a per-route lock |
| The rent-roll door refuses any building with an AppFolio property id | Fact-check | R 1.9 replaces that check; R 0.0 reads af.propertyId |
| "57 files check capabilities" false (about 4); "117 branches" not reproducible (72 core / 107 all) | Fact-check | Replaced; counting rule stated in R 3.9 |
Lead path wrongly required guestCards (office-originated leads, ADR-0094) | Fact-check | Made optional (chapter 3, A7) |
| "D1 carried out" overstated; apply link and emergency line stated as current fact; mock-pms-service fate unstated | Fact-check | "Boundary built, step 7 not started"; "verify"; ADR-0072 mocks kept for API-shape tests |
Minor: Domain a third taxonomy; pmsRefs[] everywhere overkill; July Yardi adapter's fate | Architecture | Uses ModuleFlag; lists on long-lived records only; R 2.7 |
Round 2 (on v2): 6.0 · 6.0 · 6.5 → average 6.2
| Finding | From | v3 |
|---|---|---|
| Fede's page is "Proposed — pending Fede's review". Only the calendar is decided; D1 is carried out via #470–476; D2, the API phasing and the shared gate are proposals | Fact-check | Re-attributed everywhere (verdict, chapter 2, tab D) |
"Residents only via AppFolio" is false: report-apply.ts:158 loads them through the neutral door | Fact-check | Reworded to "the scheduled sync"; R 2.3 feeds Yardi's rent-roll export into that door |
| Day one skipped the brief: no tour calendar, stale prices, apply link, emergency line, escalation owner, contract | Fact-check, roadmap, architecture | Chapter 8 rewritten; R 0.0–0.3; decision 1 states the gaps |
| Stage 2 would test on ConAm's live Yardi | Roadmap, architecture | Proof ladder: test PMS → read-only lock → ConAm test record or one founder-approved write (chapter 10) |
| Stage 1 horizontal, no callers until the login | Roadmap, architecture | Test PMS is the first caller; R 1.1 is one vertical slice; R 1.2's first users are AppFolio's note/photo receipt gap |
| Write door drawn at the wrong moment; would be a second door; marker doesn't fit every verb | Architecture | queued state; runs at dispatch; grows from resolvePmsWriteTarget; marker policy per capability (chapter 4, A7) |
| Calendar in two seams; no live-read lane | Architecture | Calendar provider with priority on the session (chapter 5, R 1.7, 2.2) |
| Credentials owned three ways; grant signing unspecified | Architecture | propflowai KMS vault owns all; Ed25519 grants; MFA secrets in the vault (chapter 7) |
Capability rename sized wrong; x.* would fence generic collections | Architecture, roadmap | Keep names; split only workOrderWrite; mapping table |
| Stage 3 never finishes (sibling attributes, inference, token, unions, exit test) | Roadmap | R 3.3–3.9, exit test |
No legal gate or "mark Yale as Yardi" row; Yale's current pmsSource unknown | Roadmap | Gates table in D; R 0.0 and 2.1 |
| D1 not neutral, no approver; D2 an action not a decision | Roadmap | D1 names approver and trade-off; D2 = price ceiling + who pays |
| R 0.2 cited a function that doesn't exist; the mock flag would fool its test | Roadmap, fact-check | R 0.4: issuesPmsNumber on the writer; tested mock on and off |
pmsEnvironment reversed Fede's sandbox design | Architecture | Reverted |
| "0 of ~11 receipts" contradicted guest-card read-back | Architecture, fact-check | "0 through pmsWrite (2 read back)" |
Build check also exempts domain/pms/** | Fact-check | R 1.10 |
| R 1.4 (grants) changes live AppFolio auth | Roadmap, fact-check | Stage 1 grants serve Tenio only; the AppFolio switch is R 3.6, marked risky |
Round 1 (on v1): 6 · 4 · 6 → average 5.3 (R numbers in this table are v2's)
Findings and what v2 did
| Finding | From | v2 |
|---|---|---|
| v1 reopened Fede's Oct 5 Yardi transport decision with the opposite pick, uncited | Fact-check | Adopted his decisions; this page is now the product-side half of his plan (chapter 2, tab D) |
| ADR-0030 cited as current; ADR-0031 (local-first) superseded it | Fact-check | Receipt gains accepted · id pending; lookup-by-marker before retry (chapter 4, A7); CLAUDE.md fix added (R 0.5) |
| "No alarm" on a Yardi building: false. An alarm fires, with the wrong reason | Fact-check | Corrected everywhere; R 0.4 now fixes the reason |
| "Repair filed when it never reaches Yardi": overstated. The request is saved; the false part is the promised job number, and only on a building marked Yardi | Fact-check, roadmap | Chapter 8 re-ranked; the guard lands before any building is marked Yardi |
| Supervised flag called a one-line bug; it changes three modules, and the code quotes Fede asking for the opposite | Fact-check | Sized M, heads-up to Fede, taken out of the Yardi plan (tab D) |
| Counts: "222 shipped" included scripts (~140 shipped); "251 branches" not reproducible (117 strict / 159 broad); "773 core" includes eval/dev tooling; "1 of ~11 receipts" is really 0 | Fact-check | Numbers replaced, counting rules stated (chapters 1 and 10) |
"Extend the build check to imports": it already counts them; only lambda/** is missing | Fact-check | R 1.9 |
| Ids keyed by credential: rotating a key orphans every ref, and one ref can't hold two systems | Architecture | pmsRefs[] keyed by data-space instance (chapter 6) |
| Robot has one shared token; anyone holding it can act as any customer | Architecture | Scoped per-call grants and per-account audit in stage 1 (R 1.4) |
Capability rename would drop narrow ones like renewalTruth; per-verb routing is a complexity bomb; no freshness | Architecture | Extensions x.<pms>.*, typed cell facts, routing per (building, domain) (chapter 3) |
| API clients in a separate service add a hop and a second owner of budgets | Architecture | API clients stay in adapters; one shared budget lease |
| Missing: events bus, recipe drift, PMS switch, staleness | Architecture | Engine emits events; daily recipe check (R 2.4); switch story (chapter 6); freshness in needs (R 2.6) |
| Port AppFolio's working sync first is backwards | Architecture | The engine is born on Yardi; AppFolio moves in stage 3 |
| Yardi built on legacy id shape and outside the write door, then migrated | Roadmap | Yardi uses only pmsRefs and pmsWrite from day one |
| Phase 3 a half-migration trap; rename is cosmetic infra risk | Roadmap | One record type at a time, stop-writing PR paired, one founder go; rename moved to "Later" |
| Founder questions: drop naming and the "which product" fact; ask about money, permission and day-1 paths | Roadmap | Tab D rewritten: day-1 paths and mode, pay for API, wording; permission as a check |
| Week-one launch = emailed rent roll in Supervised | Roadmap | Not adopted: ConAm IT allows no email access and there is no report scheduler (launch brief). The leasing-CRM launch needs no rent roll |
| Go-live checklist would show "no PMS connection" as a false blocker | Roadmap | R 1.7 |
Fact-check table (v1 claims)
| Claim | Verdict | Evidence |
|---|---|---|
| Clara promises a WO on a Yardi building; dispatch throws | partly | :1266 pmsManaged: Boolean(prop.pmsSource) gates the number promise; dispatch.ts:309 throws; the promise needs a resident match |
| Sync skips a Yardi building silently | false | handler.ts:3194 counts it; sync-connections.ts:630-636 alerts |
| Residents only via an AppFolio client; 7,044 lines | true | handler.ts:5617-5622; wc -l |
Ids Number()-coerced; letter ids dropped | true | pms-pointers.ts:83-90; the real NaN example is ZZTEST-LM-01 |
| Work-order sync uses AppFolio pointer helpers regardless of PMS | true | run-work-order-sync.ts:343-345 |
Credentials only 'appfolio' | true | pms-credential.ts:140 |
WO writer discards verified:false | true | work-order-writer.ts:266,288 |
| Supervised double flag is a trivial bug | partly | module-mode.ts:122-131,245; three other holding sites |
| Yardi registry mock-gated; writer sends our property id | true | registry.ts:133-137; yardi/work-order-writer.ts:60 |
change_renewal_term unguarded | true | renewal/handlers.ts:2040 |
| 222 shipped importers / 251 branches / 773 core files | inflated | 221 incl. scripts (~140 shipped); 117 strict / 159 broad; 773 incl. eval/dev tooling |
| Allowlist 2,715 mentions / 581 files | true | check-pms-literals.allowlist.json |
| Robot core/vendors sizes; lint fence; no SYSTEMS.md entry | true | 2,851 / 2,799 lines; eslint.config.mjs:68+; SYSTEMS.md mentions only in passing |
| Yardi KB: one login, Voyager 8 + CRM IQ, no SIPP | true | pms-knowledge-base/yardi/README.md:3-5 |
| ADR-0030 is current doctrine | false | ADR-0031 "Supersedes: ADR-0030"; handler comments cite 0031 |
| Rent-roll upload loads residents and balances | partly | Residents at property creation; the page sends no balance (properties/new/client-page.tsx:912-920) |
A1 · Census: where AppFolio lives
Regex census over 26,244 tracked files at f528ed1bf34. Comment versus code is a per-line heuristic, and customer-visible lines are candidates. The full CSVs are on the mini.
| token family | occurrences | files | of which non-test occ. | non-test files |
|---|---|---|---|---|
| appfolio | 53,045 | 5,958 | 26,299 | 2,992 |
| af_ident | 2,464 | 336 | 1,431 | 183 |
| l4 | 7,805 | 783 | 4,010 | 440 |
| yardi | 1,362 | 335 | 997 | 222 |
| realpage | 705 | 345 | 579 | 278 |
| entrata | 87 | 68 | 64 | 47 |
| buildium | 39 | 33 | 28 | 25 |
| other_pms | 165 | 75 | 99 | 48 |
count in files.csv = appfolio + af_ident + l4 (the "AppFolio family"). Files with zero AppFolio-family hits but some other-PMS hit are bucket OTHER-PMS-ONLY.
3. Files and references per bucket (AppFolio family)
| bucket | files | refs | appfolio | af_ident | l4 | refs in code (non-comment) |
|---|---|---|---|---|---|---|
| ADAPTER-LEGIT | 81 | 4,471 | 2,938 | 67 | 1,466 | 2,479 |
| CORE-LEAK | 690 | 12,694 | 10,516 | 627 | 1,551 | 6,258 |
| CUSTOMER-VISIBLE STRING | 83 | 648 | 631 | 2 | 15 | 400 |
| INFRA | 80 | 1,779 | 1,481 | 79 | 219 | 1,052 |
| SCRIPTS | 1,085 | 9,216 | 8,022 | 647 | 547 | 6,299 |
| TESTS | 3,018 | 31,574 | 26,746 | 1,033 | 3,795 | 25,291 |
| DOCS/COMMENTS-ONLY | 1,044 | 2,932 | 2,711 | 9 | 212 | 0 |
| total | 6,081 | 63,314 | 53,045 | 2,464 | 7,805 | 41,779 |
Bucket rules, first match wins: .md/.mdx/.txt -> DOCS; __tests__/__snapshots__/fixtures anywhere, *.test.*/*.spec.*/*.drift.*/.snap, or top-level e2e//evals//tests/ -> TESTS (so src/lib/domain/evals/, src/lib/evals/ and src/app/api/test/ count as shipped code); src/lib/integrations/{appfolio,appfolio-api,appfolio-browser-agent,yardi,realpage,_mock-pms}/ and mock-pms-service/ -> ADAPTER-LEGIT; lambda/ infra/ .github/ config/ vercel.json turbo.json .env* -> INFRA; scripts/ bin/ -> SCRIPTS (an added bucket: ops tooling, not shipped core); every hit in a comment -> DOCS/COMMENTS-ONLY; at least half the code hits are display strings in a UI/prompt/message path -> CUSTOMER-VISIBLE STRING; everything else -> CORE-LEAK. src/lib/domain/pms* is deliberately CORE-LEAK, with a domain_pms_candidate flag for appfolio-named files there.
CORE-LEAK + CUSTOMER-VISIBLE together: 773 files, 13,342 refs, 6,658 in code. Of those files, 36 have AppFolio/af/l4 in their own filename (move-whole candidates); the rest need surgery.
domain_pms_candidate (appfolio-named, non-test, under src/lib/domain/pms or pms-mirror): 20 files, 326 refs: pms-mirror/adapters/appfolio-text-start.ts (38), pms-mirror/adapters/appfolio-source.ts (37), pms-mirror/adapters/appfolio-inbox.ts (34), pms-mirror/adapters/appfolio-tenant-text-start.ts (23), pms/side-effects/appfolio-adapter.ts (22), pms-mirror/adapters/appfolio-text-door.ts (21), pms/appfolio-web-database.ts (19), pms/appfolio-operational-sender.ts (17), pms/print-order/appfolio-executor.ts (16), pms/pm-action-link-appfolio.ts (16), pms/print-order/new-lease-appfolio-executor.ts (16), pms-mirror/adapters/appfolio-media.ts (14), pms-mirror/adapters/appfolio-email.ts (14), pms/print-order/appfolio-history.ts (11), pms/side-effects/appfolio-note-sentence.ts (8), pms/inbox/appfolio-inbox.ts (8), pms/demo-appfolio-access.ts (4), pms-mirror/adapters/appfolio-inquiry-parties.ts (3), pms/tour-card/appfolio.ts (3), pms/legacy-af-inquiry.ts (2)
4. Per top-level area (AppFolio-family refs; files in parentheses)
| area | total refs | adapter | core-leak | visible | infra | scripts | tests | docs/cmt |
|---|---|---|---|---|---|---|---|---|
src/__tests__ |
15,185 | 15,159 (1201) | 26 (8) | |||||
src/lib/integrations/appfolio |
5,065 | 2,705 (71) | 2,360 (62) | |||||
scripts |
3,440 | 3,287 (275) | 153 (15) | |||||
src/lib/domain/pms |
3,372 | 1,531 (92) | 1,640 (148) | 201 (48) | ||||
src/lib/domain/leasing |
3,023 | 960 (78) | 3 (1) | 1,582 (184) | 478 (155) | |||
src/app |
2,908 | 1,740 (81) | 81 (22) | 903 (153) | 184 (90) | |||
scripts/voice-harness |
2,507 | 2,274 (294) | 198 (34) | 35 (18) | ||||
src/lib/tools |
1,883 | 1,713 (58) | 94 (5) | 76 (22) | ||||
evals |
1,766 | 1,593 (260) | 173 (59) | |||||
src/lib/integrations/appfolio-browser-agent |
1,724 | 1,724 (5) | ||||||
lambda/appfolio-sync |
1,666 | 897 (16) | 728 (13) | 41 (1) | ||||
src/lib/data |
1,430 | 835 (25) | 412 (71) | 183 (67) | ||||
agents/clara |
1,336 | 506 (43) | 83 (14) | 640 (94) | 107 (48) | |||
src/components |
1,237 | 444 (30) | 432 (34) | 266 (52) | 95 (54) | |||
src/lib/domain/maintenance |
1,033 | 612 (18) | 300 (30) | 121 (35) | ||||
src/lib/domain/evals |
813 | 373 (12) | 426 (12) | 14 (6) | ||||
src/lib/domain/pms-mirror |
788 | 210 (10) | 549 (52) | 29 (8) | ||||
src/lib/maintenance |
718 | 617 (20) | 44 (7) | 57 (19) | ||||
src/lib/domain/vendors |
710 | 324 (17) | 4 (1) | 291 (39) | 91 (23) | |||
scripts/__tests__ |
599 | 599 (82) | ||||||
src/lib/temporal |
572 | 390 (9) | 95 (16) | 87 (38) | ||||
lambda/agent-runtime |
549 | 305 (15) | 236 (15) | 8 (1) | ||||
src/lib/domain/properties |
423 | 231 (9) | 136 (20) | 56 (24) | ||||
scripts/email-stress |
419 | 212 (29) | 190 (21) | 17 (1) | ||||
scripts/new-lease-draft-harness |
401 | 320 (21) | 63 (3) | 18 (3) | ||||
src/lib/domain/escalation |
360 | 7 (1) | 330 (33) | 23 (11) | ||||
src/lib/platform |
351 | 265 (17) | 47 (10) | 39 (25) | ||||
scripts/identity-harness |
350 | 277 (14) | 62 (7) | 11 (4) | ||||
src/lib/domain/onboarding |
349 | 170 (24) | 155 (26) | 24 (14) | ||||
src/lib/domain/turnover |
335 | 98 (11) | 25 (1) | 155 (27) | 57 (17) | |||
scripts/appfolio-guest-card-harness |
329 | 301 (12) | 18 (3) | 10 (5) | ||||
scripts/canary |
328 | 290 (26) | 38 (5) | |||||
scripts/coworker-harness |
303 | 56 (16) | 208 (23) | 39 (12) | ||||
scripts/temporal |
292 | 249 (16) | 43 (1) | |||||
src/lib/pms |
291 | 170 (13) | 111 (9) | 10 (2) | ||||
scripts/mutation-controls |
279 | 279 (80) | ||||||
scripts/renewal-harness |
274 | 117 (17) | 157 (10) | |||||
scripts/lib |
249 | 188 (40) | 55 (7) | 6 (1) | ||||
src/lib/domain/admin-status |
246 | 133 (11) | 96 (5) | 17 (7) | ||||
src/lib/domain/identity |
238 | 126 (10) | 56 (15) | 56 (17) | ||||
config |
236 | 236 (8) | ||||||
.github |
233 | 176 (14) | 57 (7) | |||||
scripts/journey-harness |
187 | 120 (13) | 54 (5) | 13 (4) | ||||
src/lib/domain/conversations |
185 | 67 (6) | 91 (13) | 27 (19) | ||||
scripts/portfolio-harness |
156 | 74 (26) | 47 (15) | 35 (4) | ||||
src/lib/domain/leads |
156 | 26 (4) | 105 (12) | 25 (4) | ||||
src/lib/domain/renewals |
156 | 95 (4) | 44 (7) | 17 (5) | ||||
src/lib/integrations/voice |
153 | 38 (6) | 81 (10) | 34 (9) | ||||
src/lib/domain/bench |
146 | 141 (4) | 5 (3) | |||||
src/lib/domain/dashboard |
144 | 115 (4) | 4 (2) | 25 (7) | ||||
src/lib/domain/tenants |
140 | 111 (5) | 21 (2) | 8 (2) | ||||
scripts/wholesys |
138 | 127 (12) | 11 (2) | |||||
scripts/eval |
131 | 110 (13) | 21 (6) | |||||
src/lib/integrations/email |
125 | 59 (1) | 43 (13) | 23 (12) | ||||
lambda/metrics-alerts-checker |
121 | 92 (5) | 29 (2) | |||||
src/lib/domain/metrics |
121 | 88 (7) | 20 (1) | 13 (6) | ||||
scripts/appfolio-loop-harness |
109 | 53 (2) | 56 (1) | |||||
src/lib/integrations/sqs |
104 | 100 (1) | 1 (1) | 3 (2) | ||||
src/lib/domain/rent-history |
94 | 40 (3) | 53 (2) | 1 (1) | ||||
scripts/ops |
85 | 69 (6) | 16 (4) | |||||
| ...141 more areas in files.csv |
Rollup by top-level directory
| dir | scope | files | AppFolio-family refs | core-leak+visible refs | yardi+realpage+entrata+buildium+other refs |
|---|---|---|---|---|---|
src |
brief | 3,944 | 45,472 | 12,712 | 1,815 |
scripts |
brief | 1,465 | 11,744 | 0 | 135 |
lambda |
brief | 97 | 2,454 | 0 | 5 |
evals |
brief | 319 | 1,766 | 0 | 171 |
agents |
added | 199 | 1,336 | 589 | 78 |
config |
brief | 8 | 236 | 0 | 0 |
.github |
added | 21 | 233 | 0 | 6 |
apps |
added | 9 | 17 | 14 | 20 |
infra |
brief | 4 | 15 | 0 | 0 |
e2e |
brief | 4 | 13 | 0 | 3 |
packages |
added | 8 | 13 | 13 | 20 |
(root) |
added | 1 | 12 | 12 | 0 |
contracts |
brief | 1 | 2 | 2 | 0 |
bin |
brief | 1 | 1 | 0 | 0 |
mock-pms-service |
added | 0 | 0 | 0 | 105 |
elevenlabs |
brief | 0 | 0 | 0 | 0 |
Other-PMS mentions by area
| area | yardi | realpage | entrata | buildium | other (onesite, resman...) |
|---|---|---|---|---|---|
src/__tests__ |
246 | 96 | 18 | 7 | 23 |
src/lib/integrations/yardi |
302 | 1 | |||
src/components |
74 | 109 | 8 | 6 | 10 |
src/lib/domain/pms |
120 | 23 | 15 | 5 | 17 |
evals |
86 | 35 | 4 | 5 | 41 |
src/app |
44 | 93 | 9 | 7 | |
src/lib/integrations/_mock-pms |
121 | 12 | |||
mock-pms-service |
79 | 26 | |||
src/lib/domain/leasing |
72 | 24 | 5 | 2 | 2 |
src/lib/domain/properties |
20 | 26 | 1 | 49 | |
agents/clara |
21 | 45 | 1 | 3 | 8 |
src/lib/integrations/realpage |
6 | 63 | 1 | ||
scripts/coworker-harness |
65 | ||||
src/lib/data |
20 | 8 | 5 | 2 | |
src/lib/integrations/appfolio |
21 | 1 | 1 | ||
apps/site |
8 | 2 | 8 | 1 | |
packages/marketing |
7 | 5 | 6 | ||
scripts/integration-suites |
16 | ||||
src/lib/domain/maintenance |
3 | 8 | 1 | 1 | |
scripts |
10 | 2 | |||
src/lib/domain/integrations |
7 | 2 | 2 | ||
scripts/mutation-controls |
4 | 4 | |||
src/lib/domain/identity |
6 | 1 | 1 | ||
src/lib/domain/vendors |
5 | 2 | 1 | ||
src/lib/pms |
7 | 1 | |||
src/lib/temporal |
3 | 2 | 1 | 2 | |
scripts/__tests__ |
6 | 1 | |||
src/lib/domain/insights |
3 | 2 | 2 | ||
src/lib/domain/prospects |
7 | ||||
.github |
3 | 1 | 1 | 1 |
| # | file | refs | appfolio | af_ident | l4 | code | comment | display str | name-is-PMS |
|---|---|---|---|---|---|---|---|---|---|
| 1 | src/lib/tools/appfolio.ts |
631 | 490 | 0 | 141 | 572 | 59 | 170 | Y |
| 2 | src/lib/data/types.ts |
554 | 520 | 4 | 30 | 62 | 492 | 0 | |
| 3 | src/lib/maintenance/eval/checkpoints.ts |
214 | 165 | 23 | 26 | 134 | 80 | 18 | |
| 4 | src/app/api/onboarding/pms/appfolio/import/route.ts |
204 | 147 | 57 | 0 | 118 | 86 | 0 | |
| 5 | src/lib/domain/pms/guest-card-outbox-drain.ts |
154 | 137 | 0 | 17 | 26 | 128 | 14 | |
| 6 | src/lib/temporal/activities/maintenance-eval.ts |
152 | 114 | 10 | 28 | 45 | 107 | 6 | |
| 7 | src/lib/tools/types.ts |
125 | 104 | 0 | 21 | 67 | 58 | 1 | |
| 8 | src/app/(workspace)/settings/_components/OrganizationSettingsShell.tsx |
110 | 110 | 0 | 0 | 94 | 16 | 11 | |
| 9 | src/app/(workspace)/settings/_components/OrganizationSettings.tsx |
105 | 105 | 0 | 0 | 93 | 12 | 15 | |
| 10 | src/lib/domain/evals/fake-detector/verifiers.ts |
104 | 80 | 10 | 14 | 66 | 38 | 19 | |
| 11 | src/app/api/admin/dev/tools/run/route.ts |
103 | 65 | 0 | 38 | 54 | 49 | 8 | |
| 12 | src/lib/domain/pms/writers/work-order.ts |
100 | 92 | 3 | 5 | 7 | 93 | 4 | |
| 13 | src/lib/integrations/sqs/agent-jobs.ts |
100 | 89 | 0 | 11 | 27 | 73 | 3 | |
| 14 | src/lib/domain/maintenance/wo-enrich-dispatch.ts |
96 | 46 | 5 | 45 | 37 | 59 | 1 | |
| 15 | src/components/feature/admin-dev/pipeline-lab/_shared/WorkOrdersPanel.tsx |
95 | 90 | 0 | 5 | 80 | 15 | 11 | |
| 16 | agents/clara/lib/email/parse-lead-source.ts |
92 | 88 | 4 | 0 | 39 | 53 | 1 | |
| 17 | src/lib/domain/dashboard/chat/tools.ts |
92 | 92 | 0 | 0 | 71 | 21 | 15 | |
| 18 | src/lib/domain/leasing/rent-roll/apply-entity-sync.ts |
92 | 86 | 6 | 0 | 39 | 53 | 1 | |
| 19 | src/lib/domain/pms/registry.ts |
92 | 89 | 0 | 3 | 53 | 39 | 0 | |
| 20 | src/lib/domain/vendors/vendor-write-invariants.ts |
89 | 77 | 12 | 0 | 46 | 43 | 24 | |
| 21 | src/lib/domain/maintenance/close-all-for-tenant.ts |
88 | 41 | 10 | 37 | 43 | 45 | 5 | |
| 22 | src/lib/domain/evals/screenshot-capture.ts |
82 | 63 | 9 | 10 | 44 | 38 | 6 | |
| 23 | src/lib/domain/pms/types.ts |
81 | 76 | 0 | 5 | 2 | 79 | 0 | |
| 24 | src/lib/tools/handlers/handle-create-vendor.ts |
80 | 46 | 5 | 29 | 41 | 39 | 6 | |
| 25 | src/lib/domain/evals/executor/bench-hygiene.ts |
78 | 67 | 0 | 11 | 29 | 49 | 3 | |
| 26 | src/lib/domain/pms/guest-card-outbox.ts |
77 | 70 | 0 | 7 | 5 | 72 | 0 | |
| 27 | src/app/(workspace)/(operations)/leasing/prospects/[id]/ProspectDetailClient.tsx |
75 | 65 | 10 | 0 | 18 | 57 | 2 | |
| 28 | src/lib/domain/pms/writers/rental-application.ts |
75 | 75 | 0 | 0 | 17 | 58 | 0 | |
| 29 | src/lib/domain/vendors/sync-vendor-from-pms.ts |
74 | 62 | 11 | 1 | 38 | 36 | 20 | |
| 30 | src/lib/domain/bench/diagnose.ts |
71 | 58 | 11 | 2 | 47 | 24 | 14 | |
| 31 | src/lib/domain/maintenance/sync-photos-to-appfolio.ts |
71 | 52 | 5 | 14 | 27 | 44 | 0 | Y |
| 32 | src/lib/domain/maintenance/tech-sms-dispatch.ts |
71 | 61 | 8 | 2 | 22 | 49 | 4 | |
| 33 | src/lib/tools/tool-modules.ts |
70 | 66 | 0 | 4 | 61 | 9 | 0 | |
| 34 | src/lib/domain/tenants/sync-tenant-from-pms.ts |
68 | 56 | 12 | 0 | 34 | 34 | 14 | |
| 35 | src/lib/domain/pms/writers/guest-card.ts |
66 | 59 | 0 | 7 | 3 | 63 | 0 | |
| 36 | src/app/api/onboarding/pms/appfolio/route.ts |
64 | 64 | 0 | 0 | 39 | 25 | 1 | |
| 37 | src/lib/tools/role-matrix.ts |
63 | 63 | 0 | 0 | 61 | 2 | 0 | |
| 38 | src/app/(standalone)/onboarding/(wizard)/(dialog)/connect-pms/appfolio/page.tsx |
61 | 61 | 0 | 0 | 38 | 23 | 2 | |
| 39 | src/app/api/admin/appfolio/work-orders/list/route.ts |
60 | 59 | 0 | 1 | 33 | 27 | 6 | |
| 40 | src/lib/temporal/activities/pms-sync-eval.ts |
60 | 36 | 6 | 18 | 27 | 33 | 2 |
Top 15 by code refs (comments excluded), CORE-LEAK only:
src/lib/tools/appfolio.ts: 572 code / 631 totalsrc/lib/maintenance/eval/checkpoints.ts: 134 code / 214 totalsrc/app/api/onboarding/pms/appfolio/import/route.ts: 118 code / 204 totalsrc/app/(workspace)/settings/_components/OrganizationSettingsShell.tsx: 94 code / 110 totalsrc/app/(workspace)/settings/_components/OrganizationSettings.tsx: 93 code / 105 totalsrc/components/feature/admin-dev/pipeline-lab/_shared/WorkOrdersPanel.tsx: 80 code / 95 totalsrc/lib/domain/dashboard/chat/tools.ts: 71 code / 92 totalsrc/lib/tools/types.ts: 67 code / 125 totalsrc/lib/domain/evals/fake-detector/verifiers.ts: 66 code / 104 totalsrc/lib/data/types.ts: 62 code / 554 totalsrc/lib/tools/tool-modules.ts: 61 code / 70 totalsrc/lib/tools/role-matrix.ts: 61 code / 63 totalsrc/app/api/admin/dev/tools/run/route.ts: 54 code / 103 totalsrc/lib/domain/pms/registry.ts: 53 code / 92 totalsrc/lib/integrations/email/webhook-processors.ts: 48 code / 59 total
A2 · Data model and storage
Where AppFolio's concepts are built into our own records, ranked by what breaks Yardi first, with the full inventory and a safe migration order.
Category: a = already neutral; b = AppFolio-named but generic (rename); c1 = AppFolio-only, correctly named, wrong place (MOVE into adapter, no rename); c2 = AppFolio-shaped concept leaking into core logic (needs a neutral concept). Risk: code = code-only; attr = touches stored attributes (dual-write/backfill, no key change); key = touches a Dynamo key / stored enum segment.
| # | Concept | Where (file:line; count) | Cat | Neutral target | Effort | Migration risk |
|---|---|---|---|---|---|---|
| 1 | Entity PMS pointers stored as af: { xId: number } and written via Number() |
pms-pointers.ts:83-90,127-131,155-158,310-311; decls types.ts:3719 Property, 4715 Unit, 5186 Lease, 5458 TenantBalanceSnapshot, 5581 PaymentHistorySnapshot, 5744 VendorCompany, 7099 WorkOrder, 14063 Tour, 20850 ProspectInquiry. af?.workOrderId 54 files/188 refs, af?.propertyId 54/141, af?.unitId 20/70, af?.vendorId 32/78, af?.occupancyId 24/33, af?.tenantId 15/40, af?.guestCardId 23/82, af?.rentalApplicationId 13/33, af?.inquiryId 8/35 |
c2 | pmsRef: { source, account, id: string } per entity (+ typed secondary refs, §3) |
L | attr on every synced row (dual-write, backfill, switch readers, drop af) |
| 2 | AppFolio pointer fns hard-wired into "agnostic" sync writers | src/lib/pms/run-work-order-sync.ts:27-29,343-345 (after resolving pmsType at :204); domain/leasing/rent-roll/apply-entity-sync.ts:24; domain/pms/writers/rental-application.ts:59-60; 15 core files import stampAppfolio*Pointer/getAppfolio*ExternalId |
c2 | PmsPointerCodec resolved from the adapter registry by pmsType (the writers are already dep-injected; only the runners/imports need changing) |
S | code |
| 3 | Per-PMS lookup maps that only list AppFolio | domain/pms/work-order-by-pms-ref.ts:21-24 (WORK_ORDER_POINTER = appfolio, appfolio-sandbox) and serviceRequestNumberOf regex #<n>-<k> (:26-29) |
c2 | adapter-provided parseHumanWorkOrderRef + pointer codec |
S | code |
| 4 | Credential store admits one PMS; payload is AppFolio-shaped | data/dynamo/pms-credential.ts:140 PmsName = 'appfolio'; :146-152 {database, clientId, clientSecret}; SK = pms.toUpperCase() :258-260 (key is neutral, type is not). 53 files touch pmsCredentialUserId |
c2 | PmsName = PmsSource; payload = adapter-owned opaque secret blob + adapter-declared non-secret accountLocator |
M | attr (new rows only; existing AppFolio rows untouched — SK already APPFOLIO) |
| 5 | Account identity = {database, clientIdLast4} in core keys |
domain/pms/sync-accounts.ts:42-57; PMSFP#{pms}#{database}#{clientIdLast4} data/dynamo/helpers.ts:226; PMSMIRROR#{pms}#{database} pms-mirror-store.ts:32; APPFOLIO_BUDGET#appfolio#<database>#<clientIdLast4> domain/pms/read-pacing.ts:60; ${pms}#${accountKey} pms-write-retry.ts:117; isDemoAppfolioDatabase imported into core sync-accounts.ts |
c2 (shape) / a (accountKey string) |
opaque pmsAccountKey string minted by the adapter (adapter.accountKey(cred)); core never parses it |
M | key only if the string format changes — recommend keeping AppFolio's current format verbatim as its accountKey, so no key rewrite |
| 6 | Seven disagreeing "which PMS" unions + untyped pmsSource?: string |
domain/pms/types.ts:18 PMSType (6 values incl. appfolio-sandbox); types.ts:15937 OrganizationPms (4); types.ts:15955 ProspectRecordOwner (+propflow_crm); domain/pms/record-adapter/types.ts:35 RecordVendor; domain/pms/mirror/read-guest-card-mirror-row.ts:85 LeadEventPmsType; domain/leads/lead-received-event.ts:44, lead-replied-event.ts:64; pms-credential.ts:140 PmsName; isPMSType hand-list registry.ts:351-360; pmsSource?: string on Property 3553, Unit 4560, Lease 5148, WorkOrder 6940. pmsSource 88 files, pmsType 142 files, === 'appfolio' 29 files |
b | ONE PmsSource union derived from the adapter registry (keyof typeof PMS_ADAPTERS); RecordOwner = PmsSource | 'propflow_crm' |
S-M | code (values unchanged) |
| 7 | appfolio-sandbox modelled as a PMS, not an environment |
domain/pms/types.ts:18; 20 files, 35 refs ('appfolio-sandbox') |
c2 | Superseded (v10): sandbox stays its own PMS type in the typed registry (tab N, pick C). Was: pmsSource: 'appfolio' + pmsEnvironment: 'production' | 'sandbox' on the connection/account |
M | attr on prod property rows (pmsSource value) — dual-read |
| 8 | Legacy inference "has af.propertyId ⇒ AppFolio" |
domain/pms/registry.ts:345-347; pms/pms-property-helpers.ts:11-15 (af.propertyId before pmsExternalId) |
c2 | pmsSource + pmsExternalId required for a connected property |
S | attr — needs proof every prod property has pmsSource stamped (not verified; comment says "drop after a one-time backfill") |
| 9 | Work-order display id = <TICKER>-<AppFolio number>, minted only on AppFolio ack |
types.ts:6447-6489 (displayId), domain/maintenance/stamp-display-id.ts (param literally appfolioNumber), format-work-order-display-id.ts:31-35; displayId 232 files/1,150 refs (72 UI); shownWorkOrderNumber 41 files |
b (concept is neutral: ticker + PMS-confirmed number) | rename param appfolioNumber → pmsNumber; keep field displayId |
S | code. Stored values stay (ADR-0030: never renamed). Lookup is a filter (maintenance.ts:403-457), not a key |
| 10 | WO PMS ids pmsId (AppFolio SR#) + pmsNumber (shown #) + af.workOrderId (second AppFolio counter) |
types.ts:6978,6989,7099-7101; pmsId 110 files/592 refs, pmsNumber 40/122 |
a (pmsId/pmsNumber) + c2 (af.workOrderId is AppFolio's two-id-space quirk) |
pmsRef.id (= today's pmsId), pmsRef.displayNumber (= pmsNumber), pmsRef.secondaryIds.workOrderId (AppFolio only) |
M | attr (WO rows) |
| 11 | Work-order sync attribute bags on WO / photo / note | WorkOrderPhoto.appfolioAttachmentId/appfolioSyncStatus/appfolioSyncError types.ts:6215-6226; WorkOrderNote.appfolioSyncStatus/Error 6266-6268; 15 files/55 refs (appfolioSyncStatus) |
b | pmsAttachmentRef, pmsPushStatus, pmsPushError (NOT pmsSyncStatus — collides with WorkOrder.pmsSyncStatus: WorkOrderSyncStatus at 6316/7005) |
M | attr on WO rows (embedded arrays) |
| 12 | WorkOrderAppfolioEnrichment (staff-portal scrape) on the WO |
types.ts:7134 field, 7406-7470+ type (activityLog, preferredTimeSlots, appfolioNotes, estimates…); 33 files/102 refs, 9 UI |
c1 (blob) + c2 (3 fields already denormalised to neutral permissionToEnter, accessInstructions, tenantSchedulePreference per doc 7124-7133) |
pmsDetail?: { source, schemaVersion, payload } opaque to core; UI reads only neutral denormalised fields |
M | attr |
| 13 | ProspectInquiry af (≈160 lines: ids + guest-card sync bookkeeping + assignee + co-applications) |
types.ts:20850-21011 (guestCardId 20870, guestCardUuid 20873, partyId 20877, guestCardSyncHash 20975, coApplications 20992, …); appfolioRentalApplicationId 20606; guestCard 136 files/1,133 refs, GuestCard 212 files/2,784 refs |
c2 | pmsLeadRef (ids) + pmsLeadSync (bookkeeping) — see §3 |
L | attr on every prospect row |
| 14 | Guest-card pointer / position keys | GUESTCARD#<orgId>#<pmsType>#<pmsAccount>#<x_id> data/dynamo/prospect-list-gsi.ts:318-324; PMSREC#…#guest_card#<x_id> (PmsRecordEntityType types.ts:24416, pms-records.ts:5,85, store.ts:8502) |
a (already PMS-qualified); naming is b | keep. guest_card is a mirror of the vendor's own record type; Yardi and RealPage/Knock also call it a guest card (integrations/realpage/client.ts:163, pms-adapter.ts:85) |
— | key if renamed → recommend NOT renaming |
| 15 | Vendor identity per AppFolio account | VendorCompany.af.vendorId: number + account types.ts:5744-5760; VendorCompanyPmsSnapshot.af.vendorId: string 5628 (same id, two types); source: 'appfolio' 5626; ADR-0142 |
c2 | VendorCompany.pmsRefs: PmsRef[] (a vendor can be in several PMS accounts) |
M | attr |
| 16 | Occupancy / tenant ids | TenantOccupancy.pmsExternalId 20208 (a, qualified appfolio:<id>), appfolioTenantId 20215 (b; 35 files/124 refs), pmsOccupancyRef 20229 (a; 97 files); TenantConfirmation.appfolioLeaseHolder.appfolioTenantId 162-164; PaymentReceipt.afOccupancyId/afUnitId/afTenantId 5519-5524 (28 files/115 refs) |
a/b | pmsResidentRef (was appfolioTenantId); receipts: pmsOccupancyRef, pmsUnitRef, pmsResidentRef as strings |
M | attr (PaymentReceipt rows are archived per property-month — backfill or dual-read forever) |
| 17 | Property's AppFolio account binding | pmsAppfolioAccount: {accountId, appfolioSubdomain} types.ts:3666-3677 (46 files/112 refs); resolveAppfolioAccount 18 files; AppfolioAccountUnresolvedError 11 files |
c2 | pmsAccountKey on Property (opaque) → adapter resolves host/subdomain |
M | attr (set by scripts/set-appfolio-account.ts only) |
| 18 | Property messaging / trusted-sender config | appfolioTrustedSenders 3540 (5 files); pmsMessagingSync 3594 (11 files, neutral name, AppFolio-shaped content per 3595-3640) |
c1 | move under adapter-owned pmsSettings[source] |
S | attr |
| 19 | AppFolio-only enum values used as capabilities | PmsMirrorDoor = 'appfolio_text' | 'appfolio_email' 9292; Organization.pmsTextDoor: 'clara_line' | 'appfolio_inbox' 17125; Message.externalSystem?: 'appfolio' 9879; Tour.af.pms: 'appfolio' 14064; OrganizationSettings.leadSource 'appfolio_guest_cards' 16623 |
c2 | PmsMirrorDoor = 'pms_text' | 'pms_email' + pmsSource; pmsTextDoor: 'clara_line' | 'pms_inbox'; externalSystem: PmsSource; leadSource: 'pms_lead_feed' |
M | key-ish: stored enum values on Message / Organization rows → dual-read |
| 20 | Provenance literals naming AppFolio | TenantBalanceSnapshot.source 'appfolio-api-v1' 5398; PaymentHistorySnapshot.source 'appfolio-receivables-activity' 5577; PropertyKnowledge 'appfolio-listings' / 'appfolio-charges' 4028,4032; IdentityClaimVerifiedBy 'appfolio_sync' 19479; HouseholdMemberEvidence 'appfolio_application_group' / 'appfolio_guest_card_group_link' 19809,19813; DeliveryProvider 'appfolio' 12450; guestCardTimelineImports.source 'appfolio_note' … 21509 |
a (provenance is honest) | do not rename; add 'yardi_*' siblings when Yardi writes. Optionally widen to ${PmsSource}_sync template types |
S | none |
| 21 | AppFolio-only stores in core data layer | AF_TENANT_DIR# data/dynamo/appfolio-tenant-directory.ts:14,97; AF_CHARGE_DETAIL# appfolio-charge-detail.ts:19,112; AF_BALANCE_SIDE_REPORTS# appfolio-balance-side-reports.ts:48; APPFOLIO_CRED_CURSOR# appfolio-cred-cursor.ts:21,40; APPFOLIO_BUDGET# (read-pacing.ts:60) |
c1 | move modules under src/lib/integrations/appfolio/store/ (keys unchanged) |
S | code only |
| 22 | AppFolio adapters living inside core domain | src/lib/domain/pms-mirror/adapters/appfolio-*.ts (8 files); 58 core domain/** files import @/lib/integrations/appfolio (pms 15, leasing 14, maintenance 6, pms-mirror 4, …) |
c1 | move to integrations/appfolio/mirror/; core imports only via registry |
M | code |
| 23 | Turnover charge restage status | TurnoverChargeOverride.appfolioRestageStatus types.ts:7923 (7 files) |
b | pmsRestageStatus |
S | attr |
| 24 | Renewal / new-lease refs | RenewalSaga.pmsRef 14612, pmsOccupancyRef/pmsDocumentRef/pmsApplicationRef 301-304,417-420,614,752-754; HeldMoveOutDraft.pmsRef/pmsMoveOutId 23936-23956 |
a | keep (already the model to copy; see docs/architecture/PMS_INTEGRATION.md:287) |
— | none |
| 25 | Person claim PMS_EXTERNAL_ID#appfolio:<id> |
data/dynamo/helpers.ts:469-478 |
a | keep; already source:id qualified |
— | none |
| 26 | Hidden-property / import denylist keys | ${pmsSource}#${database}#${pmsExternalId} company-hidden-property.ts:35; ${pmsSource}#${pmsExternalId} import-denylist.ts:43 |
a (but database = #5) |
v10: use the instance id we mint (A7), with AppFolio's database kept as its alias, so today's keys don't change. Was: swap database → pmsAccountKey |
S | key only if format changes — keep AppFolio's value |
| 27 | Property ticker | types.ts:3037; 135 files |
a (ours) | keep | — | none |
Order of first breakage for a Yardi property (inferred from code paths, not run): #8/#6 (does pmsTypeForProperty say yardi? only if pmsSource is stamped) → #4 (no credential type for Yardi) → #5 (account fingerprint needs database/clientIdLast4) → #2/#1 (sync runs, pointers NaN-dropped, every unit/occupancy/WO unmatched, re-minted each tick) → #3 (WO lookups by PMS ref return null) → #9/#10 (displayId would actually work if the Yardi ServiceRequestId — numeric, yardi/types.ts:325 — is passed as the number) → #13/#15 (leasing + vendor flows).
4.1 Types (src/lib/data/types.ts unless noted)
| Name | file:line | refs (files) | Rename / target |
|---|---|---|---|
WorkOrderAppfolioEnrichment |
7406 | 21 (7) | PmsWorkOrderDetail (adapter) |
AppfolioWoNote, AppfolioWoLineItem, AppfolioPreferredTimeSlot, AppfolioUrgencyDecision, AppfolioActivityLogEntry, AppfolioAttachment, AppfolioVendor, AppfolioAssignedUser, AppfolioWoOwner |
~7406-7470 | (subtypes of above) | move with it into integrations/appfolio/types |
PmsMirrorDoor values |
9292 | 7 files | 'pms_text' | 'pms_email' |
OrganizationPms |
15937 | 42 (15) | fold into PmsSource |
ProspectRecordOwner |
15955 | 71 (22) | PmsSource | 'propflow_crm' |
PMSType |
domain/pms/types.ts:18 |
(142 files use pmsType) |
PmsSource (drop appfolio-sandbox) |
PmsName |
data/dynamo/pms-credential.ts:140 |
(used across credential layer) | PmsSource |
RecordVendor |
domain/pms/record-adapter/types.ts:35 |
— | RecordOwner alias |
LeadEventPmsType |
domain/pms/mirror/read-guest-card-mirror-row.ts:85 |
— | PmsSource |
AppfolioAccountUnresolvedError |
integrations/appfolio/resolve-account.ts |
27 (11; 9 core) | PmsAccountUnresolvedError |
AppfolioWorkOrderRow imported into core runner |
src/lib/pms/run-work-order-sync.ts:60,141 |
— | stays in adapter; runner takes unknown[] prefetched rows |
4.2 Fields / Dynamo attributes on core entities
| Field | Entity @ types.ts line | refs (files) | Target |
|---|---|---|---|
af.propertyId |
Property 3719; also Unit/Lease/WO/Prospect/Balance | 141 (54) | pmsRef.id / pmsExternalId (exists) |
af.unitId |
Unit 4715, Lease 5186, Prospect 20852 | 70 (20) | Unit.pmsRef.id (pmsExternalId exists at 4561) |
af.unitUid |
Unit ~4720 | 5 (3) | Unit.pmsRef.secondary.unitUid |
af.occupancyId |
Unit, Lease, Balance 5458 | 33 (24) | pmsOccupancyRef (exists, 97 files) |
af.tenantId |
TenantBalanceSnapshot 5458 | 40 (15) | pmsResidentRef |
af.workOrderId |
WorkOrder 7099 | 188 (54) | pmsRef.secondary.workOrderId |
af.vendorId / vendorName / vendorTrade / issueType / intakeChannel(Raw) / unitTurnCategory |
WorkOrder 7099-7121 | 78 (32) for vendorId | pmsAssignedVendorRef, pmsVendorName, pmsTrade, pmsIssueType, intakeChannel (neutral already in DTO PMSWorkOrderDraft) |
af.vendorId (+ account) |
VendorCompany 5744 | — | pmsRefs[] |
af.vendorId: string |
VendorCompanyPmsSnapshot 5628 | — | pmsRef |
af.propertyId |
PaymentHistorySnapshot 5581 | — | pmsPropertyRef |
af.{pms:'appfolio', showingId, accountKey, lastSyncedAt} |
Tour 14063-14075 | 10 (3) | pmsShowingRef: PmsRef & { lastSyncedAt } |
af.{rentalApplicationId, inquiryId, rentalApplicationGroupId, guestCardId, guestCardUuid, partyId, guestCardAccountId, guestCardCreatedBy, guestCardMatchReason, guestCardSyncedAt, guestCardUrl, guestCardWrittenAt, assignedUserId, assignedUser, inquiries, mergedGuestCardIds, guestCardIds, guestCardUuids, inquiryIds, guestCardSyncHashByCard, guestCardSyncHash, coApplications, groupIdentityIsCoSigner} |
ProspectInquiry 20850-21011 | guestCardId 457 (48), guestCardUuid 304 (53) | ids → pmsLeadRef / pmsApplicationRef / pmsApplicationGroupRef; bookkeeping → pmsLeadSync; assignedUser* → pmsAssignee |
appfolioRentalApplicationId |
ProspectInquiry 20606 | 16 (9) | pmsApplicationRef (string). Overlaps af.rentalApplicationId; the doc just above af (~types.ts:20843-20848) says the scalar is "RETAINED — a separate Phase-3b sync field", so check its writers before merging the two |
guestCardTimelineImports[].source |
ProspectInquiry 21504-21509 | — | keep values (provenance) |
appfolioTenantId |
TenantOccupancy 20215 | 124 (35) | pmsResidentRef |
appfolioLeaseHolder{appfolioTenantId} |
TenantConfirmation 162-164 | 3 (2) | pmsLeaseHolder{pmsResidentRef} |
afOccupancyId, afUnitId, afTenantId |
PaymentReceipt 5519-5524 | 115 (28) | pmsOccupancyRef, pmsUnitRef, pmsResidentRef (string) |
appfolioEnrichment |
WorkOrder 7134 | 102 (33) | pmsDetail |
appfolioAttachmentId |
WorkOrderPhoto 6215 | 8 (2) | pmsAttachmentRef |
appfolioSyncStatus / appfolioSyncError |
WorkOrderPhoto 6223-6226, WorkOrderNote 6266-6268 | 55 (15) / 23 (13) | pmsPushStatus / pmsPushError |
appfolioRestageStatus |
TurnoverChargeOverride 7923 | 14 (7) | pmsRestageStatus |
appfolioTrustedSenders |
Property 3540 | 10 (5) | pmsSettings.appfolio.trustedSenders (adapter-owned) |
pmsAppfolioAccount{accountId, appfolioSubdomain} |
Property 3666-3677 | 112 (46) / 40 (19) | pmsAccountKey (+ adapter-side host) |
pmsCredentialUserId |
Property 3709 | 175 (53) | legacy routing key; retire with legacy PMSCRED#<userId> rows (already planned, pms-credential.ts:35-45) |
statusRemap.pmsStatus, categoryBackfill |
WorkOrder 7136-7140 | — | keep (neutral-named undo records) |
externalSystem?: 'appfolio' |
Message 9879 | — | PmsSource |
pmsTextDoor: 'appfolio_inbox' |
Organization 17125 | — | 'pms_inbox' |
leadSource: 'appfolio_guest_cards' |
OrganizationSettings 16623 | — | 'pms_lead_feed' |
source: 'appfolio' |
VendorCompanyPmsSnapshot 5626 | — | PmsSource |
| provenance literals | 4028, 4032, 5398, 5577, 12450, 19479, 19809, 19813, 21509 | — | keep, add siblings |
4.3 Dynamo key prefixes touching PMS
| Prefix / shape | file:line | Status |
|---|---|---|
PMSREC#{pmsType}#{pmsAccount}#{entityType}#{x_id} (SK, under ORG#) |
data/dynamo/pms-records.ts:5,85; data/store.ts:8502 |
a — neutral. entityType values 'guest_card' | 'rental_application' | 'tenant' (types.ts:24416) are vendor record types; keep |
GUESTCARD#<orgId>#<pmsType>#<pmsAccount>#<x_id> (SK under PERSON#) |
prospect-list-gsi.ts:318-324; guest-card-person-pointer.ts:16 |
b — PMS-qualified already; rename prefix only if ever needed (key migration) — recommend keep |
PMSCRED#<userId> SK APPFOLIO (legacy) / PMSCRED#ORG#<orgId> vault+hook |
pms-credential.ts:37, 258-260 |
a — SK is pms.toUpperCase(); only the PmsName type blocks Yardi |
PMSFP#{pms}#{database}#{clientIdLast4} |
helpers.ts:226,240 |
c2 shape — keep AppFolio's string as its accountKey |
PMSMIRROR#{pms}#{database} |
pms-mirror-store.ts:32 |
c2 shape — same |
APPFOLIO_BUDGET#appfolio#<database>#<clientIdLast4> |
domain/pms/read-pacing.ts:60; admin admin-metric-catalog.ts:146 |
c1 — AppFolio rate budget; move module, keep key; Yardi gets its own pacing policy from its adapter |
APPFOLIO_CRED_CURSOR#<scope> |
appfolio-cred-cursor.ts:21,40 |
c1 — move |
AF_TENANT_DIR#<accountKey> / CHUNK#<afPropertyId>#<n> |
appfolio-tenant-directory.ts:14,32,49,69,97 |
c1 — move |
AF_CHARGE_DETAIL#<accountKey>#<afPropertyId>, AF_CHARGE_DETAIL_ACCOUNT#, OCC#<partyId> |
appfolio-charge-detail.ts:19,44,75,96,112,116 |
c1 — move |
AF_BALANCE_SIDE_REPORTS#<accountKey> |
appfolio-balance-side-reports.ts:48 |
c1 — move |
PMS_EXTERNAL_ID#<source>:<id> (GSI1PK on claims) |
helpers.ts:469-478 |
a |
PMS_IMPORT#, PMS_IMPORT_ALARM#, PMS_WRITE_INTENT#, PMS_WRITE_INTENT_STATUS#, PMS_WRITE_INTENT_INQUIRY#, PMS_WRITE_BREAKER#, PMS_SYNC_CURSOR#, PMS_SYNC_QUIET#, PMS_CONNECTION_HEALTH#, PMS_OUTAGE#, PMS_HELP#<pmsType>, PMS_NOTE_FINGERPRINT#, PMSHOLD#, PMSHOLDREF#, PMSTEXTOUTBOX#, SANDBOXPMS#, PMS# |
grep of src/lib/data + src/lib/domain key literals (e.g. pms-outage-alarm.ts:35, held-pms-write.ts:23, pms-import-stage-alarm.ts:55, pms-help-snapshot.ts:44) |
a — neutral prefixes; contents not individually audited for database-shaped segments (not verified beyond the ones listed above) |
COMPANY_HIDDEN…{pmsSource}#{database}#{pmsExternalId} |
company-hidden-property.ts:35 |
a with c2 database segment |
IMPORT_DENYLIST#{pmsSource}#{pmsExternalId} |
import-denylist.ts:38,43 |
a |
ACCOUNT#… / ONBOARDING_IMPORT_ARM#{orgId}#{accountKey} |
pms-import-record.ts:39,211; onboarding-import-arm.ts:19,42 |
a (opaque accountKey) |
WO_ENRICHMENT_CADENCE# |
pms-connection-health.ts:226 |
b — AppFolio enrichment cadence; harmless |
Work-order keys PROP#<id>/WO#<uuid>, WOLIST#<propertyId>, WOORG#<orgId>, VWO#<vendor>#<prop>, WO id wo_<pmsType>_<externalId>_<epoch> |
work-order-row-read.ts:34; work-order-list-gsi.ts:96,146; work-order-org-gsi.ts:56; work-order-vendor-gsi.ts:80; domain/pms/writers/work-order.ts:1988 |
a — no AppFolio id in any key |
Tier 0 — code-only, no stored data (do first, any order, each its own small PR):
- Introduce
PmsSourcefrom the adapter registry; aliasPMSType,OrganizationPms,PmsName,RecordVendor,LeadEventPmsType, the two lead-event unions to it; makeisPMSTyperegistry-driven (registry.ts:351-360). Values unchanged. - Introduce
PmsPointerCodecper adapter; moveintegrations/appfolio/pms-pointers.tsbehind it; change the three runners/writers (run-work-order-sync.ts:343-345,apply-entity-sync.ts:24,writers/rental-application.ts:59-60) andwork-order-by-pms-ref.ts:21-24to resolve the codec bypmsType. Behaviour for AppFolio identical; Yardi now fails loudly (no codec) instead of NaN-dropping. - Rename code identifiers only:
stampWorkOrderDisplayId({appfolioNumber})→pmsNumber;AppfolioAccountUnresolvedError→PmsAccountUnresolvedError(alias kept). - Move c1 modules (
AF_*,APPFOLIO_*stores,pms-mirror/adapters/appfolio-*, enrichment types) undersrc/lib/integrations/appfolio/; key strings unchanged. - Make
accountFingerprint/ pacing / mirror-PK take an adapter-minted opaqueaccountKey, with the AppFolio adapter returning exactly today's string (appfolio#<db>#<last4>) → zero key change. - Widen credential
PmsNameand make the payload{ pms, accountLocator, secret }with AppFolio's{database, clientId, clientSecret}as its adapter-specific shape; existing ciphertext and KMS context untouched (pms-credential.ts:250-256warns that context changes break decryption — do not touch it).
Tier 1 — additive attributes (dual-write → backfill → switch reads → stop writing old). No key changes:
7. Add pmsRef (string ids) to Unit, Lease, WorkOrder, VendorCompany (pmsRefs), TenantBalanceSnapshot, PaymentReceipt, Tour, ProspectInquiry; the codec writes BOTH af.* and pmsRef for AppFolio. Backfill script converts af.* → pmsRef (dry-run default, per repo convention). Readers switch to pmsRef with af fallback; drift test fences new af readers; finally stop writing af.
Order by blast radius: Unit & Property (sync join keys) → WorkOrder → VendorCompany → Balance/Payments → Tour → ProspectInquiry (largest, 1,100+ guest-card refs; do it last and split by sub-block: ids, then sync bookkeeping, then assignee/co-apps).
8. Rename sibling attributes the same way: appfolioTenantId → pmsResidentRef, appfolioSyncStatus/Error → pmsPushStatus/Error, appfolioAttachmentId → pmsAttachmentRef, appfolioRestageStatus → pmsRestageStatus, appfolioEnrichment → pmsDetail, pmsAppfolioAccount → pmsAccountKey, appfolioTrustedSenders → pmsSettings.appfolio.trustedSenders.
9. Superseded (v9): pmsSource is derived from the records binding and retired (R 3.4), not made mandatory. Was: make pmsSource mandatory for connected properties: backfill from af.propertyId, then delete the inference at registry.ts:345-347 and pms-property-helpers.ts:12.
Tier 2 — stored enum values / key segments (dual-read forever or rewrite rows; only where it buys something):
10. Superseded (v9): sandbox stays its own PMS type in the typed registry (tab N, pick C). Was: appfolio-sandbox → pmsSource: 'appfolio' + pmsEnvironment: 'sandbox' (property rows, possibly credential SK — verify whether any PMSCRED row has SK APPFOLIO-SANDBOX).
11. PmsMirrorDoor, pmsTextDoor: 'appfolio_inbox', Message.externalSystem, leadSource: 'appfolio_guest_cards': readers accept old + new values; writers emit new; optional backfill.
12. Do not migrate (v9: except PMS_EXTERNAL_ID#appfolio:…, which moves to source:instance:kind:id in R 3.2's ladder): PMSREC#…#guest_card#…, GUESTCARD#… pointer SKs, stored displayId values (ADR-0030: never renamed), provenance literals (appfolio-api-v1, appfolio_sync, …). They are either already PMS-qualified or honest history.
Every Tier 1/2 backfill touches prod rows → by Gera's rules that is a "risky change" requiring his go before --apply.
A3 · Writes and the robot
Every write Propflow makes into a PMS, how it is checked, the interfaces as they really are, and what moves where when the robot becomes the gateway.
2a. Production paths
| # | Write operation | Core caller(s) | Via interface? | Receipt / verification | Mode / held-writes | Yardi-ready? | Fix (effort) |
|---|---|---|---|---|---|---|---|
| 1 | WO create | maintenance/work-orders/dispatch.ts:409 from temporal/activities/maintenance.ts:228, maintenance/work-orders/index.ts:181, turnover-dispatch.ts:122, api/admin/maintenance/work-orders/flush-now/stream/route.ts:186 |
Y: getPMSWorkOrderWriterForProperty |
CreateWorkOrderResult{pmsId,pmsNumber?,pmsWorkOrderId?} (work-order-writer.ts:41). The robot reads back (runWorkOrderL4.ts:110-120 verified). The AppFolio impl threads verifiedWorkOrderId (integrations/appfolio/work-order-writer.ts:228). verifyTarget? checks id drift (work-order-writer.ts:53) |
Off: refusing writer. Preview: preview_recorded (dispatch.ts:239-245). Unresolved: kept pending. Supervised: writes |
Mock-only (registry.ts:396) |
Keep. Add verified to the result (S) |
| 2 | WO attach photo | dispatch.ts:548 |
Y | Promise<void>. Robot verified:false (runAttachDocumentL4.ts:182) is swallowed (integrations/appfolio/work-order-writer.ts:266) |
same as #1 | Yardi = no-op log (yardi/work-order-writer.ts:182) |
Return a receipt. Treat verified:false as "unconfirmed" in persist (S-M) |
| 3 | WO add note / remarks | dispatch.ts:560 |
Y | void. Robot reads back (runAppendRemarksL4.ts, 6 readback refs); result dropped (integrations/appfolio/work-order-writer.ts:288) |
same | Mock-only | same as #2 (S) |
| 4 | WO update field (priority, schedule, vendor, PTE, special instructions, description) | dispatch.ts:533,574 |
Y | void. Robot runEditWorkOrderL4/runAssignVendorL4 read back (12/15 refs) |
same | Mock-only. title/category throw L4FieldNotWired |
same (S) |
| 5 | WO cancel / complete | dispatch.ts:599 |
Y | void. Robot runCancelWorkOrderL4 reads back |
same | Mock-only | same (S) |
| 6 | WO vendor assign on tech acceptance | domain/maintenance/tech-sms-dispatch.ts:1185 → handleAssignVendor → assignVendorL4 |
N: bypasses PMSWorkOrderWriter.updateField('vendor') |
Handler returns raw L4SuccessResponse. Readback happens in the robot; caller does not check verified (not verified) |
Own gate: capabilitiesFor(...).writesGo (tech-sms-dispatch.ts:1160-1162). Not held under Supervised |
No | Route through the WO pipeline as an updateField op (M) |
| 7 | Bulk WO cancel for a tenant | domain/maintenance/close-all-for-tenant.ts:465 → handleCancelWorkOrder |
N | raw L4 response | refuseIfMaintenanceSwitchedOff only (:341). No Preview check (not verified) |
No | Enqueue setTerminalStatus through dispatch (M) |
| 8 | Tenant photo sync to PMS | domain/maintenance/sync-photos-to-appfolio.ts:252 → handleAttachDocument |
N | ok/rejected lists | none in file | No | Script-only importer (scripts/_verify-photo-mirror.ts:33); enrichment.ts:57 and data/types.ts:6205 mention it only in comments. Dead in prod: delete (S) |
| 9 | Guest card create / append note / cancel-tour note / mark inactive / assign / send application / schedule showing (intent outbox) | Producers: integrations/voice/postcall-guest-card-outbox.ts:363, domain/leasing/guest-card-from-email-lead.ts:380, domain/pms/guest-card-message-note.ts (from temporal/activities/{tour,prospect-outreach,first-reply-text}.ts). Drain: guest-card-outbox-drain.ts:2954 resolveGuestCardWriter → getPMSGuestCardWriter (guest-card-outbox-drain-runner.ts:598) |
Y: PMSGuestCardWriter + resolvePmsWriteTarget |
Typed receipts per verb (guest-card-writer.ts:99-543). Robot reads back on most (guestCardSourceSet 13, guestCardAssign 8, recordGuestCardCommunication 12). createGuestCard.ts has no read-back (1,226 lines; 0 hits for verif/re-read/readGuestCard/show page). Receipts carry AppFolio shapes (guestCardPath '/crm/leasing/prospects/<uuid>', integer id, guest-card-writer.ts:100-103) |
Drain reads capabilitiesFor. Preview: preview_recorded (guest-card-outbox-drain.ts:855-856). Supervised: writes |
No: getPMSGuestCardWriter returns null unless appfolio (registry.ts:430) |
Neutralise receipt fields (pmsRecordUrl, opaque pmsRef). Add Yardi branch (M) |
| 10 | Guest card create (post-call, inline) | integrations/voice/postcall-guest-card-write.ts:213 → getPMSGuestCardWriterForProperty (writer-registry.ts:299) |
Y | CreateGuestCardReceipt |
refusal leasing_preview/off/unresolved (guest-card-writer.ts:561) |
No | same as #9 |
| 11 | Guest card reads inside the drain (assignee check) | guest-card-outbox-drain-runner.ts:208-215,292-299 dynamically import readGuestCardL4 + resolveAppfolioAccount |
N: a read, but it sits in the write door and names AppFolio | — | — | No | Move to a PMSGuestCardWriter.readAssignee? / reader (S) |
| 12 | Lead-replied side effects (assign / note / create_card) | domain/pms/side-effects/dispatcher.ts (outbox consumer, register-dispatcher.ts:73) |
Y: PmsSideEffectAdapter |
applied/skipped lists (side-effects/adapter.ts) |
ctx.mode==='shadow' computes without writing (adapter.ts rule 3) |
Yardi stub skips by design (yardi-stub-adapter.ts) |
Already the right shape. Fold into the guest-card capability (M) |
| 13 | Unit availability / market rent / vacancy post / unpublish | api/listings/publish/route.ts:265, domain/turnover/auto-publish-listing-hook.ts:210,247, domain/leasing/auto-unpublish-listing-hook.ts:107, domain/turnover/ready-for-listing-hook.ts:237 |
Y: getPMSWriterForProperty |
Result types with `status:'applied' | 'dryRun' (writer.ts:354-364). Robot reads back (runSetUnitAvailabilityL442,runSetUnitMarketRentL411,runPostVacancyL4` 13) |
writesFor mode check at the door (writer-registry.ts:281-284). Supervised: writes |
No (getPMSWriter yardi → null, writer-registry.ts:486) |
| 14 | Move-out draft / record move-out | domain/turnover/inspection-complete-move-out-draft-hook.ts:221, email-ntv-move-out-draft-hook.ts:151 |
Y | `RecordMoveOutResult{status:'draft' | 'dryRun'} (writer.ts:239-249`) |
Held under Supervised (holdPmsWrite, kind move_out_draft) |
No |
| 15 | Move-out charges | temporal/activities/turnover.ts:1797, domain/turnover/stage-net-new-move-out-charge.ts:235, restage-turnover-charges.ts:784, approved holds held-writes/approve-held-pms-write.ts:141 |
Y | AppendMoveOutChargesResult{status,stagedCount,alreadyApplied} (writer.ts:720). Robot runMoveOutChargesL4 reads back |
Held (move_out_charges) |
No | — |
| 16 | Renewal cancel / convert MTM | api/leasing/renewals/approve/route.ts:366, domain/leasing/renewal-auto-start.ts:1135, renewal adapter (unused) |
Y: PMSWriter.cancelRenewal/convertToMonthToMonth |
void / result. Robot runCancelRenewalL4 (3 refs), runSetLeaseMtmL4 (16) |
door mode check, plus renewalsWriteBlockAt in the adapter (renewal-adapter/resolve.ts:57-63) |
No | — |
| 17 | Renewal prepare + send | SQS job → Lambda lambda/agent-runtime/src/handlers/renewal.ts:128 → renewal-l4.ts:106 handleRunRenewal → runRenewalL4 |
N. PMSWriter.prepareRenewal throws "not yet wired" (integrations/appfolio/pms-writer.ts:161-163) |
RenewalResult from the robot. runRenewalL4.ts has no readback marker; the state machine in apiRenewalOps.ts decides (not verified) |
renewals-module-gate.ts in the Lambda (not verified in detail) |
No | Implement prepareRenewal behind the writer, retire the direct call in the same PR (M) |
| 18 | Renewal change-term / resend for signature | domain/leasing/renewals/change-execution-ops.ts:65-69,81-108 (raw resendRenewalForSignatureL4, changeTermRenewalL4), from api/renewal-change-reviews/[id]/decision/route.ts, plus Lambda renewal-change-term.ts:169 handleChangeTermRenewal, plus renewal-change-term.ts:384 HTTP back to /api/leases/:id/renewal |
N. PMSWriter.changeRenewalTerm throws (pms-writer.ts:170) |
robot changeTermRenewal.ts (10 refs), runResendRenewalForSignatureL4 (8) |
Adapter refuses under Preview (renewal-adapter/resolve.ts:57). The decision route is a staff action |
No | same as #17 (M) |
| 19 | Renewal / new-lease document park and send (print order) | temporal/activities/document.ts:110 → getPrintOrderExecutor → print-order/appfolio-executor.ts:42, new-lease-appfolio-executor.ts:127 (callL4('/api/print-order')) |
Y (own seam) | Robot contract: durable claim before each write, "the read decides" (robot lib/core/printOrder.ts, printOrderPaper.ts read-back of the finished document) |
not verified | No (resolver returns null) | Strongest receipt model in the codebase; reuse it as the template (—) |
| 20 | New lease draft / send for signature | domain/leasing/new-lease/execute-lease-draft.ts:48,59 → handleSendNewLease → runNewLeaseSendL4; from auto-draft-on-approval.ts:261 and api/new-lease-package-reviews/[id]/decision/route.ts:414 |
N | robot runNewLeaseSendForSignatureL4 (17 refs) |
Mode gating on the auto-draft not verified | No | Add PMSWriter.draftNewLease/sendNewLease or fold into print-order (M) |
| 21 | Vendor create | api/vendors/route.ts:301 → handleCreateVendor → createVendorL4 |
N | robot createVendor.ts (22 refs); persistedVendor mirror inside the handler |
refuseIfMaintenanceSwitchedOffForOrg (:235) |
No | New vendorWrite capability (M) |
| 22 | Tenant email from a resident's text | agents/clara/lib/messaging/handle-tenant-email-capture.ts:178 → handleSetTenantEmail |
N | Treats status==='DRY_RUN' as queued:true (:181). A dry run reads as success. It omits allowNonTestTenant, and the robot refuses any occupancy outside its test allowlist without that flag (appfolio-browser-agent/lib/tenantEmailSet.ts:187-189). Verified: Clara captures a resident's email and the PMS write never lands for a real tenant |
No mode check in file | No | Tenant-contact capability. DRY_RUN ≠ success (S) |
| 23 | Text sent through the PMS inbox thread (carrier appfolio_inbox) |
agents/clara/lib/messaging/adapters/appfolio-inbox-text.ts (registered messaging/registry-singleton.ts:15) → domain/pms-mirror/adapters/appfolio-text-door.ts (sendTextingInboxL4), appfolio-text-start.ts (sendGuestCardMessageL4), appfolio-tenant-text-start.ts (sendTenantTextL4) |
Partial: a channel adapter, not a PMS writer | Strong: "sent" only when the robot says sent. The mirror writes the conversation row when it reads the PMS row back (SENT#<pmsRowId>, file header D5) |
Full outbound gate first (runOutboundTextComplianceGate: Supervised holds) |
No | Keep it as a carrier, but resolve the door by PMS type rather than the hard-wired class (S-M) |
| 24 | Guest-card message (async lane) | Lambda agent-runtime/src/handlers/guest-card-message.ts:109 sendGuestCardMessageL4, registered :155 as appfolio.send_guest_card_message |
N | L4ClientError handling |
none in handler | No | No enqueuer found in src//agents//lambda/. Likely orphaned (not verified). Delete or route via #23 (S) |
| 25 | Portal activation invite / password reset | registry.ts:236-255 via PMSClient.sendPortalActivationInvite? (client.ts:387,393); voice tool handleResendPortalInvite |
Y, but a write on the read client | PortalSendL4Receipt{sentTo,verified,replayed} (l4-core.ts:1963-1972) |
not verified | No (not_supported) |
Move to a residentPortal write capability (S) |
| 26 | WO create (SQS fallback) | Lambda agent-runtime/src/handlers/work-order.ts:39 → handleCreateWorkOrder (Clara tool, local-first, then pipeline) |
Y (via pipeline) | as #1 | as #1 | as #1 | — |
2b. Staff / catalog surface (claraSurface: 'staff-only', src/lib/tools/clara-surface.ts:48,85)
All dispatched from src/app/api/admin/dev/tools/run/route.ts through TYPED_WRAPPER_REGISTRY (integrations/appfolio-browser-agent/typed-wrapper-registry.ts:221-702). The route uses refuseCatalogWriteIfModuleOff (route.ts:769,890). Every one is N (direct l4-core) and AppFolio-only. Receipt = raw L4SuccessResponse, logged via recordToolCall.
Handler file (src/lib/tools/handlers/) |
L4 fn | Op |
|---|---|---|
handle-create-work-order-direct.ts:14 |
createWorkOrderL4 |
WO create (bypasses pipeline) |
handle-attach-document.ts:12 |
attachDocumentL4 |
photo |
handle-append-remarks.ts:12 / handle-set-remarks.ts:12 |
appendWorkOrderRemarksL4 / setWorkOrderRemarksL4 |
notes |
handle-assign-vendor.ts:12 / handle-unassign-vendor.ts:12 |
assignVendorL4 / unassignVendorL4 |
vendor |
handle-change-priority.ts:12, handle-schedule-work-order.ts:12, handle-set-vendor-instructions.ts:12, handle-update-service-request-description.ts:12, handle-follow-up-date.ts:12, handle-waiting.ts:12 |
field / status L4s | WO fields |
handle-cancel-work-order.ts:12, handle-mark-job-done.ts:12, handle-mark-ready-to-bill.ts:12, handle-delete-work-order.ts:12 |
terminal / delete | WO status |
handle-create-vendor.ts:24, handle-set-vendor-phone.ts:12, handle-set-vendor-name.ts:13 |
vendor L4s | vendors |
handle-set-tenant-phone.ts:18, handle-set-tenant-email.ts:30, handle-edit-tenant-status.ts:12 |
tenant L4s | tenants |
handle-create-unit.ts:12, handle-run-move-in.ts:12 |
createUnitL4, runMoveInL4 |
units / move-in (seeding) |
handle-run-renewal.ts:12, handle-change-term-renewal.ts:12, handle-send-new-lease.ts:14 |
renewal / lease | leases |
2c. Evals / bench / scripts (test properties only; listed so none is mistaken for prod)
temporal/activities/pms-sync-eval.ts:29 (5 L4 writes), temporal/activities/maintenance-eval.ts:141 (cancelWorkOrderL4), maintenance/eval/robot-bench-prep.ts:43 (deleteWorkOrderL4), domain/evals/executor/bench-hygiene.ts:146,329,419, domain/bench/personas.ts:454,475,611 (handleSetTenantPhone), and in scripts/: the guest-card harness (scripts/appfolio-guest-card-harness/*), _gen-handlers.sh, seed-pms-sync-eval-entities.ts, prove-*, reset-maint-test-state.ts, backfill-wo-handyman.ts, cleanup-*.
2d. Not PMS writes (naming traps)
src/lib/domain/pms/writers/*(balance, guest-card, lease-state, purchase-order, renewal, vendor, work-order…) are inbound sync writers (PMS → PropFlow DynamoDB).purchase-order.ts:1says "PMS purchase orders → the PurchaseOrder read model". Any rename that calls the outbound side "writer" collides with these.integrations/appfolio/writers/*-portfolio.tsare Reports-API readers that write our store.appfolio-operational-sender.tsis a pure From-header domain test, not a sender.
| ADR-0056 said | Reality |
|---|---|
Promote L4Client → PMSWorkOrderWriter in domain/pms/work-order-writer.ts |
Done. It also grew verifyTarget? (id-drift check) and refusal? (Off/Preview/unresolved writers, work-order-writer.ts:62-75) |
Add 'workOrderWrite' to PMSCapability |
Done but inert. Declared by both adapters, read by no caller |
Move impl to integrations/appfolio/work-order-writer.ts, makeAppfolioWorkOrderWriter() |
Done (registry.ts:383) |
getPMSWorkOrderWriter(pmsType) in registry.ts |
Done. Plus getPMSWorkOrderWriterForProperty door with module/mode/subject (emergency) logic (writer-registry.ts:373-421) |
| Status: "Proposed" | ADR file still says Proposed (docs/adr/0056-…md:3): stale |
| (implicit) callers name an action, never AppFolio | True for the pipeline. False for #6, #7, #20 and the staff catalog, which still call AppFolio verbs |
The interfaces as they stand:
domain/pms/
work-order-writer.ts PMSWorkOrderWriter create→result; attach/note/field/terminal→void
guest-card-writer.ts PMSGuestCardWriter create + 10 optional verbs, each → typed *Receipt
writer.ts PMSWriter 4 renewal verbs (3 stubbed in AppFolio) + 8 optional
turnover/listing verbs → typed *Result | void
renewal-adapter/ RenewalPmsAdapter full contract, RenewalResult<T> = {ok,value}|{ok:false,failure{kind,retryable}}
— NO callers
print-order/ PrintOrderExecutor read|park|send-parked|cancel, answer + rendered read-back
side-effects/ PmsSideEffectAdapter onLeadReplied → applied/skipped (+shadow mode)
client.ts PMSClient reads, plus 2 portal-send writes
resolvers: registry.ts (getPMSWorkOrderWriter, getPMSGuestCardWriter, getPMSClient),
writer-registry.ts (resolvePmsWriteTarget = "THE PMS-WRITE DOOR", getPMSWriter*, …ForProperty),
print-order/resolve.ts, renewal-adapter/resolve.ts, side-effects/dispatcher.ts
door fence: src/lib/testing/pms-write-door-scan.ts — checks the 5 RESOLVERS call resolvePmsWriteTarget;
it does NOT check that callers use a resolver (so #6,#7,#17-#24 pass it)
literal fence: scripts/check-pms-literals.ts — ratchet allowlist 581 files / 2,715 hits; treats ALL of
src/lib/domain/pms/** as "adapter layer", so AppFolio code inside domain/pms is invisible to it
Three error models live side by side: PMSTransientError/PermanentError/ValidationError (writer.ts:28-50), RenewalPmsFailure{kind,retryable} (renewal-adapter), and L4ClientError{code,retryable} + isPermanentL4Failure (l4-permanence.ts, imported by core maintenance/work-orders/dispatch.ts:66 and guest-card-outbox-drain.ts:150). So the WO pipeline's retry decision still reads an AppFolio error class.
4a. What is in appfolio-browser-agent
Next.js app on Vercel (package.json @propflow/appfolio-browser-agent, "L4 direct-HTTP AppFolio integration"). 96 app/api/**/route.ts, nearly all AppFolio verbs (run-work-order, wo-action, run-renewal, set-unit-market-rent, texting/inbox…). The runtime model is "L4": harvested session cookies + CSRF and direct HTTP to AppFolio's Rails endpoints. Browserbase/Stagehand is used for login/session minting, plus two Stagehand-debt files (lib/README.md "Stagehand-debt": fetchRenewalLetter.ts, resetRenewal.ts). Crons: /api/cron/heartbeat */2, /api/cron/refresh-if-needed daily (vercel.json).
| Generic transport (already or nearly vendor-neutral) | AppFolio-specific |
|---|---|
lib/core/ (16 files, 2,851 lines): sessionLease.ts, sessionManager.ts, recoveryLadder.ts, selfRenewingCookies.ts, cookieKeepList.ts, loginFlow.ts, loginOutcome.ts, loginIdentityKey.ts, sessionEvents.ts, pageReader.ts, vendorDriver.ts, driverRegistry.ts, plus printOrder.ts/printOrderNewLease.ts/printOrderPaper.ts/documentHistory.ts ("Names no PMS"). ESLint forbids lib/core/** → lib/vendors/** (eslint.config.mjs:68-77) |
lib/vendors/appfolio/ (9 files, 2,799 lines): driver.ts, loginFlow.ts, loginScreens.ts, loopShapes.ts, mfa.ts, session.ts, printOrder.ts, printOrderNewLease.ts, documentHistory.ts |
Flat lib/ infra that is generic in kind but AppFolio-coupled in code (not verified file by file): idempotencyCache.ts, printOrderClaims.ts, requestPacer.ts, loginBudget.ts, credentialPool.ts, sessionJarStore.ts, sessionPool.ts, browserbaseContextReset.ts, browserbaseUsageTracker.ts, stagehandFactory.ts, alerts.ts, aiFallbackAlert.ts, cronHeartbeat.ts, costTracker.ts, apiObservability.ts, apiResilience.ts, sentryScrub.ts, responseSanitizer.ts, readonlyLock.ts (per-account write policy), accountContext.ts/accountRoute.ts/multiTenantConfig.ts (already carries vendor?, defaults "appfolio") |
~140 flat recipe files, ~67k lines: run*L4.ts (≈35), guestCard*.ts (16), tenant*Set.ts, vendor*Set.ts, texting*.ts, createGuestCard/Showing/Unit/Vendor.ts, apiClient.ts, apiRenewalOps.ts, railsFormPost.ts, editFormReadback.ts (Rails edit-form readback, the blockedit fix), appfolioScopeGuard.ts, appfolioResponseSchemas.ts, workOrderCategories.ts, renewalTemplateSelection.ts… |
Total flat lib/*.ts: 69,804 lines. So ≈4% is neutral core, ≈96% is AppFolio-shaped. The design doc docs/planning/session-layer.md ("one owner, one lease, one alert path, for any PMS", Gera 2026-10-17) and issue #469 ("engine split") show the session split is in flight. The recipe split is not started.
The propflowai side mirrors this. src/lib/integrations/appfolio-browser-agent/ (7,491 lines; l4-core.ts alone 6,111) is ~90 typed wrappers, one per AppFolio robot route, with AppFolio field names (appfolioWorkOrderNumber, occupancyId). The only neutral piece is callL4(routeSlug, payload) + telemetry + L4ClientError.
4b. Clean split (proposal)
Reuse the existing lib/core ↔ lib/vendors/<vendor> fence. Do not invent a new one.
pms-access/ (renamed repo; see naming)
lib/core/ session lease, login budget, recovery ladder, cookie jar, Browserbase
context, request pacer, idempotency cache, claim store, receipts,
alerting, write-policy (readonly) lock, account routing, print-order core
receipt.ts NEW: WriteReceipt { status: applied|unconfirmed|refused|dry_run, evidence }
recipe.ts NEW: Recipe<In,Out> { verb, capability, write(ctx,in), readBack(ctx,in) }
lib/vendors/appfolio/ driver (login, MFA, screens) + recipes/ (move the ~140 flat files here)
lib/vendors/yardi/ driver (Voyager 8 + CRM IQ login) + recipes/ (from pms-knowledge-base/yardi)
app/api/v1/[vendor]/[capability]/[verb]/route.ts one generic route; the vendor's recipe registry dispatches
Rule: a recipe is not done until it implements readBack. Core runs write → readBack and grades the receipt. A recipe can't return applied from an HTTP status, which kills the blockedit-200 class mechanically. The existing per-route readbacks (editFormReadback.ts, attach visible_attachments, WO create readback) become the first readBacks.
In propflowai, integrations/appfolio-browser-agent/ splits into integrations/pms-access/transport.ts (callL4, telemetry, error, generic) and integrations/appfolio/robot-recipes.ts (the typed AppFolio wrappers, used only by AppFolio adapters).
4c. Rename inventory (appfolio-browser-agent → neutral name)
| Touchpoint | Where | Count / detail | Risk |
|---|---|---|---|
Vercel project + prod URL https://appfolio-browser-agent.vercel.app |
hard-coded in propflowai/infra/temporal-worker/task-definition.template.json:29 (ECS), plus every env value of APPFOLIO_BROWSER_AGENT_URL (Vercel, Lambda, ECS, GH secrets) |
renaming the Vercel project changes the *.vercel.app host |
High. Every write and the session heartbeat. Keep the old host as an alias, or move to a custom domain first, then flip |
Env var APPFOLIO_BROWSER_AGENT_URL |
propflowai src/lambda/agents: 46 refs in 26 files; infra/temporal-worker/bootstrap-secrets.sh; Lambda env; Vercel env |
read in l4-core.ts and others |
High if renamed in one step. Make the new name canonical with a one-release read of the old name; drop the old one in the same lane |
BROWSERBASE_RUNNER_TOKEN |
27 refs; .github/workflows/audit-runtime-token-parity.yml checks parity |
auth between app and robot | Medium. The name is neutral already; leave it |
BROWSER_AGENT_REPO_PATH, BROWSER_AGENT_URL |
9 + 2 refs (scripts / tooling) | local paths | Low |
Robot-side env APPFOLIO_EMAIL/PASSWORD/SECONDARY_*, APPFOLIO_SUBDOMAIN, APPFOLIO_BASE_URL, APPFOLIO_LOGIN_BUDGET_MODE |
robot lib/, app/ |
legitimately AppFolio: keep under vendors/appfolio |
Low (don't rename) |
GitHub repo PropFlow-Technologies/appfolio-browser-agent |
package.json:10; any CI checkout; webhooks; review-sub; branch protection |
GitHub redirects renamed repos, but Vercel Git integration and Actions secrets need a re-check | Medium |
npm name @propflow/appfolio-browser-agent |
robot package.json:2 |
not consumed as a package by propflowai (HTTP only) | Low |
propflowai client dir src/lib/integrations/appfolio-browser-agent/ |
38 handler imports, ~10 domain/route/Lambda imports, lambda/agent-runtime/src/handlers/* relative imports (../../../../src/lib/integrations/appfolio-browser-agent/…), tests |
a mechanical move. ESLint no-restricted-imports and check-pms-literals key on integrations/appfolio* |
Medium: the literal-check allowlist (581 files) keys on paths and specifiers. A move changes hit counts, and the ratchet fails on any raise |
| CI globs | .github/workflows/harnesses.yml:172,252,261 (src/lib/integrations/appfolio-browser-agent/**) |
a path filter silently stops matching, so the harness never runs | Medium: silent |
| Reviewer lenses | .github/reviewer-lenses/tools-platform.md, evals-and-tests.md |
prose paths | Low |
| Docs | docs/runbooks/appfolio-l4.md, appfolio-browser-automation.md, docs/architecture/L4_DIRECT_HTTP.md, ADR-0056, CLAUDE.md "AppFolio: the L4 write runtime" row |
prose | Low |
| SYSTEMS.md | agentflow/SYSTEMS.md:6758 (only mention: "stays a transport a connector may use"). The robot has no entry of its own, which breaks the CLAUDE.md "if a system is not in it, it does not exist" rule |
add an entry (with liveness signals: heartbeat cron, session state) at rename time | Low (but a real gap today) |
| Fleet tooling | ~/.local/bin/worker-cleaner, ~/.local/bin/start-driver, ~/.local/bin/canon/driver-SKILL.md, ~/.claude/skills/model-pins/{SKILL.md,systems.json}, ~/.claude/skills/visual-diff-gate/SKILL.md |
repo-name literals | Low-Medium (a fleet tool that greps a repo path breaks quietly) |
| Robot-internal names | lib/README.md, CLAUDE.md, docs/, Sentry project name, Upstash KV key prefixes (not verified), Browserbase context ids per account |
KV/Browserbase keys hold live sessions | High if any key embeds the old name (not verified). Do not rename stored keys |
lambda/appfolio-sync |
separate Reports-API sync Lambda | out of scope: not the robot | — |
4d. pms-knowledge-base
appfolio/path-templates.json: 30+ generic "where a policy lives" paths for propflow-ingest (reachapi|public|robot), e.g.appfolio-api › reports/charge_detail › …. Read-side policy discovery, not write recipes.appfolio/help-articles/(739 scraped),renewals.md,work-orders.md(form-field docs),settings-pages.md, a rate-limit measurement.yardi/:README.md(first customer is one Yardi user login, Voyager 8 + CRM IQ, no SIPP credentials until paid → phase 1 is a browser agent),browser-agent-build-notes.md(12 capability rows mapping to PropFlow portsPMSGuestCardWriter,CalendarProvider, PMS read… every Recon celltodo),voyager-8-ui.md,sources.md. Endpoint captures for AppFolio live in the robot repo (scripts/README.md), not here.- Conflict to settle: propflowai's Yardi code is SIPP REST (
integrations/yardi/client.ts, mock-gated). The KB says phase 1 is browser. The adapter seam handles both, because the adapter picks its transport. But nobody has said which one the first Yardi WO write uses. Settled 11-04: the robot (Gera, tab D answer 3: robot only, no API).
A4 · Reads, sync and import
How PMS data gets in: onboarding import, the sync Lambda's jobs, live reads, where Yardi stands, and how to rename infrastructure safely.
| Data flow | Entry point | Engine shape | Yardi status today | Fix + effort |
|---|---|---|---|---|
| Dispatcher / property loop | lambda/appfolio-sync/handler.ts:2388 (groupPropertiesByConnection), :2956-3199 (per-property client) |
Mixed. Iterates any pmsTypeForProperty. Connection key reads pmsAppfolioAccount.accountId (src/lib/domain/pms/sync-connections.ts:133-140). Requires pmsCredentialUserId (handler.ts:3046-3053). |
Would group under owner:<userId> by accident. Client is null in prod, so skipped. |
Neutral connectionKey = pmsType#accountRef from a neutral Property.pmsAccount. M |
| Balances | handler.ts:3387 → syncPropertyBalances (writers/balance.ts:167) |
Generic writer. AF fast path fetchBalancesPortfolioWide (integrations/appfolio/writers/balances-portfolio.ts:217). Fallback client.listBalances (handler.ts:3538). Matches on pmsOccupancyRef (balance.ts:178). |
Adapter has listBalances (yardi/pms-adapter.ts:248). Every row unmatched, because no Yardi resident has pmsOccupancyRef. Decision-gated per ADR-0072 §6. |
Comes free once residents sync. Add a §6 validation flag per adapter. S after residents |
| Work orders (1 min) | handler.ts:3558 → src/lib/pms/run-work-order-sync.ts:200 → writers/work-order.ts:1005 |
Generic writer, but its deps are AF pointer helpers: getAppfolioUnitExternalId / getAppfolioWorkOrderExternalId / stampAppfolioWorkOrderPointer (run-work-order-sync.ts:27-30, 343-345). preFetchedRows is typed AppfolioWorkOrderRow[] (:141). Match-back reads af.workOrderId only (work-order.ts:1113). |
client.listWorkOrders exists (yardi/pms-adapter.ts:335). Ids are minted pms-namespaced (wo_${pmsType}_…, work-order.ts:1988). A Yardi SR id would be stamped into af.workOrderId if numeric (pms-pointers.ts:82-102), so dedup works by namespace accident. Unit match via af.unitId fails for Yardi units (not verified what happens on an unmatched unit). |
Make the pointer helpers adapter-supplied (adapter.pointers). Use neutral pmsRef on WorkOrder/Unit. M |
| Occupancies, i.e. Units/Tenants/Leases (5 min) | handler.ts:5596 → integrations/appfolio/writers/occupancies.ts:200 → computeEntitySyncDelta + applyEntitySync (occupancies.ts:432-433) |
AppFolio-only fetch + mapping (rent_roll + tenant_directory + tenant_tickler → RentRollUnit, occupancies.ts:352,598). The applier is generic but stamps Unit.af.unitId via stampAppfolioUnitPointer (apply-entity-sync.ts:24,1365). RentRollUnit carries appfolioTenantId/appfolioUnitId/appfolioPropertyId (rent-roll/types.ts:108-131). |
No pipeline. listRentRoll/listTenantContacts exist on the Yardi adapter (yardi/pms-adapter.ts:158,189), but no sync calls either. listRentRoll's only callers are lease-terms/hydrate.ts:145 and print-order/new-lease-application.ts:108. listTenantContacts has zero callers. |
New neutral residents stage: PMSRentRollRow + PMSTenantContactRow → RentRollUnit → applyEntitySync, with neutral unit/occupancy refs. L (the headline gap) |
| Unit vacancy / availability | Rides the occupancies branch (handler.ts:5615, "This client also serves the unit_vacancy portfolio pull"). Generic syncPropertyUnitVacancy (src/lib/domain/leasing/unit-vacancy-sync.ts:120-133) |
Generic function, AppFolio-only host branch | Yardi declares no unitVacancy capability (yardi/pms-adapter.ts:119-127) |
Move to its own neutral job. Derive vacancy from the rent roll when the capability is absent. S |
| Lease states (hourly) | handler.ts:3731 |
AF fast path (appfolio.groupTenantTicklerRowsToLeaseStates, :3864). Fallback client.listLeaseStates (:3910). Matches through pmsOccupancyRef (handler.ts:1356-1365). |
Adapter has listLeaseStates via GetLeaseInformation (yardi/pms-adapter.ts:287). ADR-0072 §7 says CSV parseGenericCsv instead (drift). Dead until residents. |
Same as balances. S after residents |
| Renewals, rental apps, guest cards, prospect conversions | handler.ts:3959, 4141, 4752, 5546 |
Generic writers. AF fast paths (renewals-portfolio, rental-applications-portfolio, guest-cards-portfolio). The guest-card and application mirrors run only if pmsType === 'appfolio' (handler.ts:4285, 5085). |
Yardi adapter declares none of these. RealPage has createProspect/findProspect/requestAppointment, which are not on PMSClient (docs/audits/0072-pms-abstraction-summary.md). |
V2. Out of scope for "residents/units/WO/balances". M–L each |
| Vendors (hourly, DISABLED) | handler.ts:6045 → src/lib/pms/run-vendor-sync.ts |
Generic (client.listVendors) |
Adapter has listVendors (yardi/pms-adapter.ts:215). Job parked (schedules.json, state: DISABLED). |
Already generic. S |
| Financials | handler.ts:6158 → getAccountingClient (src/lib/domain/accounting/registry.ts:72-81) |
Neutral registry, AppFolio-only case | null for non-AF |
Add adapter case. M |
| Payments, charge_detail | handler.ts:6520, 6445 |
AppFolio-only (resolveAfPropertyId, src/lib/pms/run-payment-history-sync.ts:128; buildRawAppfolioClient, handler.ts:6450; DDB AF_CHARGE_DETAIL#) |
None | Neutral capability later (paymentHistory, chargeHistory). M |
| PMS inbox mirror (1 min) | src/lib/pms/pms-inbox-watch-job.ts → src/lib/domain/pms-mirror/watch.ts |
The best neutral design in the repo. PmsInboxSource interface with cursor + high-water + per-source error kinds (watch.ts:26-40). Only AppFolio adapters exist (pms-mirror/adapters/appfolio-*.ts). |
n/a. Yardi texting is a different product (not verified). | Template for the engine (below). |
| WO enrichment (daily, browser robot) | handler.ts:570, 2083-2204 |
L4 browser robot. Defaults pmsType to 'appfolio' (:2190). |
n/a | Adapter-optional enrichWorkOrder. M |
| Onboarding import | Wizard src/app/api/onboarding/pms/appfolio/import/route.ts:1527 → src/lib/domain/pms/kick-onboarding-import.ts → lambda/appfolio-sync/onboarding-import-runner.ts |
AppFolio-only. Builds AppfolioClient and calls AF writers directly (runner.ts:108-130). Account key accountFingerprint('appfolio', …) (route.ts:1529). Neutral record/arm/checkpoint layer (pms/import-record.ts, data/dynamo/pms-import-record.ts, onboarding-import-arm.ts). |
None. No Yardi wizard. | Recast as "first full run of the same engine, with backfill windows". The record/arm layer stays. L |
| Rent-roll file import | src/app/api/properties/parse-rent-roll/route.ts → src/lib/domain/leasing/rent-roll/registry.ts (generic CSV, PDF-vision, LLM fallback) → applyEntitySync |
Neutral. parseGenericCsv, proven on Yardi data (src/__tests__/yardi-lease-charges.test.ts) |
Works for Yardi today as a manual upload, without stable PMS refs | Bridge for week 1 (see §8). S |
| Listings sync (15 min, Vercel cron) | src/app/api/cron/listings-sync/route.ts → src/lib/domain/leasing/listings-sync.ts:25 |
Hard-coded customer AppFolio URL + property id (https://jpco.appfolio.com/listings, propertyId: '1773625953462') |
n/a | Per-property publicListingsUrl + per-PMS scraper. S |
| Reconcile (ADR-0051) | Coverage + vendor discovery live in the writers. The Temporal Slice 3 was retired (docs/adr/0051-…md:3,13). |
Folded into the poller | — | Engine owns "absence = removed" only when the fetch reports complete (PMSListParams completeness stamp, types.ts:188-191). |
| Rate limit / budget | src/lib/integrations/appfolio/durable-account-budget.ts:76-77 (6 tokens/15s, DDB APPFOLIO_BUDGET#), request-budget.ts:47, budget-classes.ts, budget-callers.ts; src/lib/domain/pms/read-pacing.ts imports the AF constants |
AppFolio-owned, with no neutral interface. The Yardi client explicitly has no retry or budget (yardi/client.ts:10). |
Nobody owns Yardi's limit | PmsAccountBudget interface owned by the engine, parameterised by the adapter (contract below). M |
| Session / concurrency leases | Tick lease SYNC_JOB_LEASE# (data/dynamo/sync-job-lease.ts, handler.ts:965), neutral. Browser-robot serialization per AF account, SQS FIFO group (src/lib/integrations/sqs/agent-jobs.ts:133-141, ADR-0067 option A). |
Tick lease neutral. Browser lease AF-specific. | n/a for an API-only Yardi | Keep. Expose adapter.concurrency (max parallel sessions per account). S |
| Clara: balance | agents/clara/lib/agent/tools/index.ts:488 → handle-get-tenant-balance.ts:70 → balance-read.ts:33 |
Neutral (reads the BAL# snapshot) | Works once balances sync | — |
| Clara: availability | tools-leasing.ts:1045 → :4412 (getUnits, sync logs) |
Neutral (reads the copy) | Works once units sync | — |
| Clara: resident WO status | handle-list-my-work-orders.ts:40 (getWorkOrders) |
Neutral | Works once WOs sync | — |
| Clara: identify caller / tenant lookup | handle-identify-caller.ts:18-45 (identity spine, occupancy enrichment) |
Neutral | Works once residents sync | — |
| Staff/admin live reads | handle-get-work-order-details.ts:17, handle-get-tenant-details.ts:25, handle-check-unit-availability.ts:17, handle-list-work-orders.ts:17, all appfolio-browser-agent/l4-core; catalog src/lib/tools/appfolio.ts (403 fence hits) |
AppFolio robot, with no PMS branch | Not available | PMSClient.getWorkOrder/getTenant live-read capability, or read the copy. M |
| Piece | State |
|---|---|
integrations/yardi/ (client 269, adapter 407, WO writer 234, types 457 lines) |
Built 2026-07-02 (#2736). Capabilities: balances, leaseStates, workOrders, workOrderWrite, rentRoll, tenantContacts, vendors (pms-adapter.ts:119-127). Mock-only: the factory and registry refuse without MOCK_PMS_ENABLED. |
| Yardi transport fidelity | Written as JSON POST with ImportJson bodies to /ItfResidentData/GetResidents and similar, plus a mock /token that ADR-0072 §7 admits is fictional. Yardi Voyager's standard interfaces are commonly SOAP/XML web services under a per-interface license (not verified here; confirm against Yardi's interface docs or sandbox). If so, yardi/client.ts gets rewritten and the adapter's mapping mostly survives. |
| Known Yardi bug | work-order-writer.ts:60 PropertyCode: req.propertyId sends PropFlow's internal id (P1 in docs/audits/0072-pms-abstraction-summary.md). Still unfixed. |
integrations/realpage/ (Knock CRM) |
Guest-card surface only (createProspect/findProspect/requestAppointment), none of it on PMSClient. No balances/WO/leases, by ADR-0072 §8. |
In-app mock (src/lib/integrations/_mock-pms, src/app/api/mock/pms/*) |
Present, contradicting ADR-0072 §1. |
propflowai/mock-pms-service/ |
Standalone Next app, byte-identical vendored handlers (diff -q silent). CI type/build lane per ADR-0077 (ci-nested.yml). vercel.json has only a build command. Deployed anywhere: not verified. No .github/workflows reference beyond the nested build. |
| Conformance harness | testing-harness/scenarios/pms-conformance.ts (registry-driven, 6 Yardi domains + RealPage leasing) and mock-pms-e2e.ts. 89/89 passed on 2026-06-30 (audit summary). Not wired into any workflow, so it does not gate. It proves the adapter, not the sync: it never runs the lambda's job branches, which is exactly where the AppFolio coupling lives. |
| Fixtures | e2e/fixtures/yardi-yale-scrubbed.xlsx, yardi-with-future.csv |
3a. Sync jobs (PMSSyncJob, handler.ts:1267-1348; AppfolioJob alias :1349) and schedules (lambda/appfolio-sync/schedules.json)
| Job | Class / cadence | Shape |
|---|---|---|
balances |
answer_live, 5 min | generic + AF fast path |
work_orders |
act_now, 1 min | generic + AF fast path + AF pointer deps |
occupancies |
answer_live, 5 min | AF-only (rent_roll / tenant_directory / tenant_tickler) |
lease_states_history |
bookkeeping, hourly | generic + AF fast path (tenant_tickler) |
renewals |
bookkeeping, hourly | generic + AF fast path |
rental_applications |
answer_live | generic + AF fast path + AF-only mirror |
guest_cards |
act_now | generic + AF fast path + AF-only mirror |
prospect_conversions |
answer_live | generic (DB-only) |
vendors |
bookkeeping, DISABLED | generic |
financials |
bookkeeping | neutral accounting registry, AF-only impl |
payments |
bookkeeping (daily) | AF-only |
charge_detail |
bookkeeping (daily) | AF-only |
wo_enrichment |
cron(0 9 * * ? *), excluded from classes |
AF robot |
pms_inbox_watch |
1 min, excluded | neutral engine, AF source |
onboarding_import |
unscheduled | AF-only |
Class names (act_now/answer_live/bookkeeping, schedules.json "classes") are neutral and worth keeping. The rule naming (<FUNCTION_NAME>-<job>-schedule, ensure-schedules.sh:161) inherits the AppFolio function name.
3b. Lambda and deploy
- Function
propflow-appfolio-sync-{prod,preview}(lambda/appfolio-sync/deploy.sh:18-19,67). Rolepropflow-lambda-inbound-role(:70). Tablespropflow-prod/propflow-stage(:88-89).MEMORY=2048(:95),TIMEOUT=900(:148), concurrency alarm 12 (:612area,ensure_concurrency_alarm "$FUNCTION_NAME" "$REGION" 12). - EventBridge class rules:
propflow-appfolio-sync-prod-class-{act-now,answer-live,bookkeeping}-schedule, plus per-job…-pms-inbox-watch-schedule,…-wo-enrichment-schedule,…-vendors-schedule,…-vendor-contact-schedule(theconfig/automations.tomllive_refs). - Alarms keyed on the function name:
ensure_lambda_alarms,ensure_memory_alarm,ensure_concurrency_alarm,ensure_missing_ticker_alarm(deploy.sh:569-617;lambda/lib/observability-alarms.sh). - CI lane
appfolio_sync_preview/appfolio_sync_promote, concurrency groupdeploy-appfolio-sync-*,PROD_FN/PREVIEW_FN(.github/workflows/deploy-lambdas.yml:1030-1054,1272-1280, slug lists:280,:347). config/automations.toml: 18 idspropflow-appfolio-sync-prod-*(e.g.:1203-1209).- Name referenced in code:
src/lib/data/dynamo-client.ts,src/lib/pms/run-work-order-sync.ts,src/lib/pms/pms-inbox-watch-job.ts,src/lib/domain/admin-status/lambda-descriptions.ts,src/lib/domain/pms/pms-job-quiet-pass.ts,src/lib/data/dynamo/pms-sync-quiet-memo.ts,scripts/lib/{appfolio-sync-stop,lambda-stale-deploy-guard,portfolio-arm-coverage}.ts,scripts/appfolio-sync-dry-run.ts,scripts/probe-appfolio-budget-prod.ts. - Outside the repo:
agentflow/SYSTEMS.md:6893,6900(signals read the appfolio-sync CloudWatch log) and agent-smithalert_remediation/thread_title(match the string).
3c. DynamoDB keys (single-table propflow-prod)
AppFolio-named: APPFOLIO_BUDGET#<accountKey> and APPFOLIO_BUDGET_ENFORCE# (durable-account-budget.ts:25), APPFOLIO_CRED_CURSOR# (data/dynamo/appfolio-cred-cursor.ts), AF_TENANT_DIR# (appfolio-tenant-directory.ts), AF_CHARGE_DETAIL# / AF_CHARGE_DETAIL_ACCOUNT# (appfolio-charge-detail.ts), AF_BALANCE_SIDE_REPORTS# (appfolio-balance-side-reports.ts). The account fingerprint embeds appfolio# (sync-accounts.ts:52-57, 27 call sites with a literal 'appfolio').
Already neutral: SYNC_JOB_LEASE#, PMS_CONNECTION_HEALTH#, PMS_SYNC_CURSOR# (pms-connection-health.ts), PMS_SYNC_QUIET#, ORG#…/PMSREC#{pmsType}#{account}#{entity}#{id} (raw-record store, data/dynamo/pms-records.ts:4-5), pms-import-record, pms-mirror-store.
Separate table: propflow-agent-jobs via APPFOLIO_ACCOUNT_CONFIG_TABLE (lambda/agent-runtime/vendors/appfolio/multiAccountConfig.ts:266).
3d. Entity fields (attributes on stored rows)
Property.af, Property.pmsAppfolioAccount{appfolioSubdomain,accountId} (data/types.ts:3666,3719), Unit.af (:4715), Lease.af (:5186), WorkOrder.af (:7099), Tour.af (:14063), ProspectInquiry.af (:20850), TenantOccupancy.appfolioTenantId. Neutral siblings already exist: pmsSource, pmsExternalId (Property :3685, Unit :4561), pmsOccupancyRef (:417,4961,20229). credentialScopeFor reads only pmsAppfolioAccount (registry.ts:103-110). The legacy fallback "has af.propertyId → appfolio" sits in registry.ts:347.
3e. Credentials
PmsName = 'appfolio' only (data/dynamo/pms-credential.ts:140). The input is {database, clientId, clientSecret} (:147-155). The vault row carries provider, but the plaintext shape is AppFolio's. Yardi needs {baseUrl, userName, password|apiToken, serverName, database, platform, interfaceEntity, license}, and yardi/factory.ts takes these from opts with no store path. This blocks Yardi; it is not a rename.
3f. Env vars
APPFOLIO_BROWSER_AGENT_URL (38 refs), APPFOLIO_BASE_URL (17), APPFOLIO_ENABLED (9), APPFOLIO_CRED_KMS_KEY_ID (6), APPFOLIO_SYNC_LAMBDA_NAME (5; defaults to 'propflow-appfolio-sync' with no env suffix at onboarding/pms/appfolio/import/route.ts:1535, so check what prod sets, not verified), APPFOLIO_SYNC_FULL_RESOLVE, APPFOLIO_ACCOUNT_ID, APPFOLIO_EVAL_ACCOUNT_ID, APPFOLIO_OTP_SENDER_NUMBER, APPFOLIO_ACCOUNT_CONFIG_TABLE, PRECLEAN_APPFOLIO_CANCEL_ENABLED. Neutral: MOCK_PMS_ENABLED, MOCK_PMS_STORE_BACKEND/PATH, MOCK_PMS_SIGNING_SECRET, PMS_SYNC_EVAL_TASK_QUEUE_OVERRIDE.
3g. AppFolio formats parsed outside the adapter
AppfolioWorkOrderRowimported into the neutral runnersrc/lib/pms/run-work-order-sync.ts:60,141.- The whole
lambda/appfolio-sync/handler.ts(367 "appfolio" mentions):fetchReportPortfolio(integrations/appfolio/client.ts:653,next_page_urlwalk:14,558),groupTenantTicklerRowsToLeaseStates,paginate_results, AFReceivedlocal-time parsing (types.ts:180-187names it in the neutral contract). onboarding-import-runner.ts:108-178(AF writers,mapAfStatus,parseAppfolioReceivedAt).- AppFolio notice emails parsed as a sync channel:
integrations/appfolio/email-ingestion/{parse-appfolio-ntv,parse-appfolio-renewal-signed,parse-appfolio-lease-countersign}.ts. RentRollUnit.appfolio*fields in the neutral rent-roll types (rent-roll/types.ts:108-131).- The fence allowlist still carries 2,715 hits in 581 files (
scripts/check-pms-literals.allowlist.json). The biggest aresrc/lib/tools/appfolio.ts403,domain/leasing172,tools/handlers131,api/onboarding112. The fence also exempts all ofsrc/lib/domain/pms/**(check-pms-literals.ts:61-66). So the "neutral door" directory is itself declared adapter land: 87 of its 112 top-level files mention AppFolio.lambda/**is not scanned at all.
| Item | Risk | Why | Safe procedure |
|---|---|---|---|
Lambda propflow-appfolio-sync-{prod,preview} |
HIGH (CLAUDE.md "infra blast radius": timeouts, alarms, cron schedules) | A 1-min WO tick and 5-min resident tick feed Clara. The alarms, 18 automations.toml live_refs, the deploy lane, lambda-descriptions.ts, SYSTEMS.md signals and agent-smith remediation all match the name. A naive rename leaves two functions both firing (double writes, 2x AppFolio budget; the account bucket is shared, so mostly a throughput hit) or neither firing. |
Alongside, cut over, retire. (1) Deploy propflow-pms-sync-* from the same bundle with every rule state: DISABLED (the schedules.json state lever already exists, ensure-schedules.sh). (2) Add its alarms and manifest entries. (3) In one PR, flip the new class rules ENABLED and the old ones DISABLED. The tick lease (SYNC_JOB_LEASE#) prevents double work if both overlap for a tick. (4) Watch one day of receipts. (5) Delete the old function and rules. Rollback = flip the states back. Name the risk to Gera before step 3. |
| EventBridge rule names | High (derived from the function name) | Same as above | Rides the function cutover. Never rename in place. |
config/automations.toml ids / live_ref |
Medium | The drift detector flags missing or unknown refs | Add new ids with live_ref to the new rules. Tombstone the old ones, as the file already does (automations.toml:981). |
GitHub deploy-lambdas.yml lane/slug |
Medium | The concurrency group and stale_guard copy are byte-compared (:702-709) |
New lane alongside, then delete the old one. |
DDB key prefixes (APPFOLIO_BUDGET#, AF_*#, APPFOLIO_CRED_CURSOR#) |
Medium–High for the budget (a live token bucket shared by sync, crons and Clara) | Renaming the key resets or splits the bucket, so two buckets equal 2x the allowance, which AppFolio already flagged (schedules.json lease_states note, 2026-10-28). |
Do not rename AppFolio's rows. The neutral PmsAccountBudget takes a key function. The AppFolio adapter keeps returning APPFOLIO_BUDGET#appfolio#…, and new PMSs get PMS_BUDGET#<pmsType>#…. Same for the AF_* caches: they are AppFolio adapter state and may keep AppFolio names. |
Entity attributes af.*, pmsAppfolioAccount |
High (data migration on every Property/Unit/WO row) | A prod data migration, so Gera's call | Add neutral pmsAccount{pmsType,accountRef} and pmsRef beside them. Dual-write and dual-read through PmsPointers. Backfill by script. Leave af.* as AppFolio's adapter-owned copy indefinitely. No rename needed. |
Env vars APPFOLIO_* |
Low | Mostly genuinely AppFolio-specific (robot URL, base URL, KMS key) | Keep. Rename only APPFOLIO_SYNC_LAMBDA_NAME → PMS_SYNC_LAMBDA_NAME, reading both during the cutover. |
Job literal AppfolioJob alias |
Low | Type alias only (handler.ts:1349) |
Delete. |
A5 · Paths, modes and Clara
The consumer side: each path and tool, its PMS dependency, what happens on a Yardi building today, the branch census, and the AppFolio wording a Yardi customer would see.
Effort: S ≤ half a day / one small PR · M 1–3 PRs · L multi-PR program.
2a. Write paths (Paths page rows with channel: 'property software')
| Path / tool | Where | AppFolio dependency | Yardi property today | Fix + effort |
|---|---|---|---|---|
maintenance.file_work_order / tool create_work_order (voice, handyman; text goes through the intake rung) |
path clara-paths.ts:478; tool agents/clara/lib/agent/tools.ts:75; handler src/lib/tools/handlers/handle-create-work-order.ts:1262-1275; dispatch src/lib/maintenance/work-orders/dispatch.ts:305-309; activity src/lib/temporal/activities/maintenance.ts:231-238; workflow src/lib/temporal/workflows/maintenance-workflow.ts:689-699 |
Writer exists for appfolio only; Yardi writer is null outside mock (registry.ts:395-398). pmsManaged: Boolean(prop.pmsSource) (handle-create-work-order.ts:1266) promises a PMS number whenever any PMS is set |
WRONG. The work order is created locally and Clara confirms it. The number promise is stamped and the reply may wait up to 5 min for it (ONE TEXT rule, handle-create-work-order.ts:1270-1275). Dispatch throws NoWorkOrderWriterError → non-retryable InvalidPayload → workflow outcome:'error'; the pending pointer is kept, so every flush repeats the error. Code path (V); what the resident hears (I) |
Gate on a declared workOrder.create before the promise: pmsManaged = "PMS can create", not "PMS is set". With no capability the work order stays PropFlow-only, goes to the team as a note, and no number is promised. M |
maintenance.wo_created_text (number text) |
clara-paths.ts:488; src/lib/domain/maintenance/pms-work-order-number.ts:38 |
Text is neutral ("property-management system's number"). It depends on a PMS-created id | Never fires: no number ever lands (I) | Falls out of the row above. S |
add_note_to_work_order, close_work_order, edit_work_order, request_photo |
tools.ts:258,336,390,317; writer verbs src/lib/domain/pms/work-order-writer.ts:48-61 |
Schema text "Mirrors to the AppFolio Remarks textarea" (tools.ts:272, :277). Photo rows carry appfolioSyncStatus: 'pending' (handle-create-work-order.ts:1394,1414) |
Saved locally; the PMS mirror never happens; same NoWorkOrderWriterError loop (I) |
Capabilities workOrder.addNote / attach / update / close; neutral schema text; rename the field to pmsSyncStatus. M |
emergency.work_order |
clara-paths.ts:804 |
Same writer | Same as filing. The emergency chain (calls/texts) is independent and still runs (I) | Same as filing. Emergency must never depend on a PMS capability: the page/call goes regardless. S once filing is fixed |
maintenance.turnover_writes (move-out draft/charges/unit availability) |
clara-paths.ts:613; PMSWriter verbs src/lib/domain/pms/writer.ts:767-845 |
getPMSWriter handles appfolio/sandbox only; 'yardi' → null (writer-registry.ts:486-491) |
Null writer → turnover stalls (I; turnover path not traced) | moveOut.record, moveOut.charges, unit.setAvailability. M |
leasing.guest_card_write / tools save_prospect, update_prospect, staff desk create_guest_card_for, add_card_note, update_card_contact |
path clara-paths.ts:343; tools-leasing.ts:355,389; src/lib/domain/pms/guest-card-outbox-drain.ts:1215-1220; writer-registry.ts:299-336 → registry.ts:430 (if (pmsType !== 'appfolio') return null) |
Guest-card account resolution is AppFolio-only: pmsAccountForProperty imports appfolio/resolve-account and always answers pms:'appfolio' (writer-registry.ts:146-153). Staff-desk schema text says "AppFolio guest card" (agents/clara/lib/agent/tools/staff-leasing-desk-cards.ts:78) |
Silent drop. The drain's account lookup throws, gets caught, and returns null (guest-card-outbox-drain.ts:1216-1220); the writer is null. The prospect and tour exist in PropFlow, but no card ever reaches the PMS and nobody is told (I for "nobody told") |
guestCard.create / addNote / setContact; make the account resolver per-adapter; refuse at enqueue with a team-visible reason. M |
leasing.application_link / tool send_application |
clara-paths.ts:288; tools-leasing.ts:787; src/lib/domain/leasing/application-link-mode.ts:51-56 |
supportsPmsGeneratedApplicationLink = pmsType === 'appfolio' || 'appfolio-sandbox', a hand-rolled capability check |
Graceful (V for the predicate): a Yardi property cannot be put in pms_generated, so a link or team send is used. The prompt still says "it'll come from AppFolio" (agents/clara/lib/agent/clara-unified.ts:359) and is only reached on that branch |
Replace the predicate with application.send; read the brand through pmsBrandForProperty. S |
leasing.new_lease_paperwork |
clara-paths.ts:333 |
AppFolio-only lease draft. /review copy: "Draft lease created in AppFolio" (src/app/(workspace)/(operations)/review/client-page.tsx:1254,2181) |
Not traced (I: null writer → refusal) | lease.draftNew, lease.sendForSignature. M |
renewals.fill_pms / tools renewal_accepted, send_offer_sms, change_renewal_term |
clara-paths.ts:427; schemas tools/renewal/schemas.ts:29-110; adapter renewal-adapter/resolve.ts:28-32 (yardi → null); send refusal src/lib/integrations/sqs/agent-jobs.ts:280-283 |
Renewal send refuses any non-AppFolio type (V). change_renewal_term has no such guard: for a non-AppFolio property it publishes agent: 'appfolio.renewal.change_term' with accountId = pmsType (agents/clara/lib/agent/tools/renewal/handlers.ts:2008,2039-2041,2065-2069) |
Unreachable (I): no offer can be prepared on Yardi, so the offer block never exists. If it ever did exist, the term change would enqueue an AppFolio job carrying a Yardi occupancy id, the id-collision risk agent-jobs.ts:276-279 warns about |
Guard change_renewal_term on renewal.changeTerm through the renewal adapter (it already has capabilities(), renewal-adapter/types.ts:138-152). S for the guard, L for the Yardi renewal adapter |
renewals.holdover |
clara-paths.ts:437; writer.ts:753 |
AppFolio MTM conversion | Null adapter → skipped (I) | renewal.monthToMonth. S |
leasing.publish_listing |
clara-paths.ts:353 |
setUnitListingPublished on PMSWriter (writer.ts:814) |
Null writer (I) | listing.publish. S |
resident.key_pickup |
clara-paths.ts:686 |
None: manager's calendar. Labelled "property software" by mistake | Works (I) | Relabel the channel. S |
2b. Read-backed answers and sends
| Path / tool | Where | AppFolio dependency | Yardi property today | Fix + effort |
|---|---|---|---|---|
get_tenant_balance, collections.reminder, collections.answer_money |
tools/resident-services.ts:81; handle-get-tenant-balance.ts:143-158; balance-read.ts:34-46; clara-paths.ts:632,641 |
None at the call site: reads balance snapshots the sync writes | Graceful-empty (I): the Yardi read client is null in prod, so no snapshots exist and Clara has no balance. Collections has nothing to send | Path needs balance.read (+ delinquency.read). S (declaration only) |
get_lease_terms |
tools/resident-services.ts:50; handle-get-lease-terms.ts:100 (autopay "requires the AppFolio tenant_directory") |
Lease mirror; autopay is AppFolio-only | Partial/empty (I) | lease.read, optional autopay.read. S |
resend_portal_invite |
tools/resident-services.ts:133; handle-resend-portal-invite.ts:54-55 |
Portal invite resend is AppFolio's resident portal | Refused or not wired (I) | portal.inviteResend. S |
send_portal_link_sms |
tools/renewal/schemas.ts:86; text renewal/handlers.ts:1708: "watch for an activation email from AppFolio" |
Brand literal in a resident SMS | Unreachable (no renewals) (I) | pmsBrandForProperty, or client.getPortalInstructions(), which the adapter already provides (src/lib/integrations/appfolio/pms-adapter.ts:285). S |
get_available_units, check_availability, get_pricing_details, get_term_pricing |
tools-leasing.ts:275,646,340,802; unit-vacancy-sync.ts:120 |
Vacancy sync is capability-gated (the one real hasCapability use). The Yardi adapter declares no unitVacancy (src/lib/integrations/yardi/pms-adapter.ts:119-127) |
Empty listings unless units come from another source (I) | unitVacancy.read, rentRoll.read. S |
identify_caller |
tools.ts:12 |
Reads PropFlow's person/tenant mirror | Works for known contacts; Yardi has no tenantContacts sync in prod, so tenants are unknown (I) |
tenantContact.read. S |
schedule_tour / reschedule_tour / cancel_tour / book_tour, staff book_showing_for |
tools-leasing.ts:517,595,627,711 |
Local tour + connected calendar. The showing mirror is an optional guest-card verb (src/lib/domain/pms/guest-card-writer.ts:616-635) |
Works for the tour (I); the PMS showing is not mirrored | showing.write optional. S |
| Handyman tech dispatch, vendor assign | src/lib/domain/maintenance/tech-sms-dispatch.ts:879,1144 (wo.pmsSource === 'appfolio') |
AppFolio vendor assignment | Graceful skip (V) | workOrder.assignVendor. S |
| WO enrich, WO detail link | src/lib/domain/maintenance/wo-enrich-dispatch.ts:352-354; src/app/api/work-orders/[displayId]/route.ts:79 |
appfolio-only branch | Graceful null (V) | Through the adapter. S |
| Reply on PMS messenger channel | agents/clara/lib/messaging/dispatcher.ts:474 (provider === 'appfolio' || 'appfolio_inbox') |
AppFolio text door adapter agents/clara/lib/messaging/adapters/appfolio-inbox-text.ts |
Falls back to Twilio SMS (V) | messenger.send. S |
| "Is the PMS connected?" | src/lib/domain/pms/org-pms-connected.ts:9-11 |
Called "PMS-neutral" but reads the 'appfolio' credential only |
A Yardi org reads not connected (V) | Iterate the org's PMS types. S |
2c. Exhaustive Clara tool inventory (schema files) with PMS need
- Core / maintenance (
agents/clara/lib/agent/tools.ts)- No PMS need:
identify_caller:12 (mirror),get_unit_appliances:31,get_troubleshooting_steps:41,get_available_vendors:163 (vendor mirror),propose_schedule:179,escalate_to_human:199,withdraw_emergency:219,search_appliance_manual:234,list_my_assigned_work_orders:424,set_my_quiet_hours:434. - PMS write:
create_work_order:75,add_note_to_work_order:258,request_photo:317 (attach),close_work_order:336,edit_work_order:390.
- No PMS need:
- Leasing (
tools-leasing.ts)- No PMS need:
get_property_details:256,get_amenities:310,get_neighborhood:325,link_on_behalf:469,lookup_expected_visitor:498,search_properties:669,get_tour_slots:684,notify_leasing_team:755. - PMS read via mirror:
get_available_units:275,get_pricing_details:340,check_availability:646,get_term_pricing:802. - PMS write via outbox:
save_prospect:355,update_prospect:389. - Tour (local; PMS showing optional):
schedule_tour:517,reschedule_tour:595,cancel_tour:627,book_tour:711. send_application:787 (application send).
- No PMS need:
- Renewal (
tools/renewal/schemas.ts):renewal_accepted:29,renewal_declined:44,renewal_escalate:60,send_portal_link_sms:86,send_offer_sms:98,change_renewal_term:110. All depend on a PMS-prepared offer. - Resident services (
tools/resident-services.ts):get_lease_terms:50,get_tenant_balance:81,list_my_work_orders:111 (local),resend_portal_invite:133. - Key pickup (
tools/key-pickup.ts):get_key_pickup_slots:56,book_key_pickup:77. No PMS need. - Turnover, staff (
tools-turnover.ts)- PMS write:
dispatch_work_order:224,add_charge:251,cancel_work_order:284,confirm_turnover:185 (move-out). - Local:
find_turnover:33,start_inspection:51,append_notes:66,run_projection:85,revise_finding:100,send_summary:138,apply_charge_decision:157,get_turnover_status:209,reject_vendor_call:308.
- PMS write:
- Comms:
draft_mass_message(tools-comms.ts:17). No PMS need. - Staff leasing desk (
agents/clara/lib/agent/tools/staff-leasing-desk*.ts): guest-card read/write and showings. Same needs as the guest-card row. - Module→tool assembly lives in
agents/clara/lib/agent/capabilities/tool-registry.ts:131-139; per-turn strip hooks live on capabilities (tool-registry.ts:20-23). That strip hook is the natural place to drop tools a property's PMS can't back.
These matter most for a Yardi customer. Grouped by who sees them.
Residents / prospects (Clara's own words or instructions that produce them):
agents/clara/lib/agent/tools/renewal/handlers.ts:1708: SMS "watch for an activation email from AppFolio."agents/clara/lib/agent/clara-unified.ts:359: prompt tells Clara to say "it'll come from AppFolio in the next few minutes."src/lib/domain/leasing/send-application-on-ask.ts:176: tool result "on its way from AppFolio. Tell them it should arrive shortly."src/lib/integrations/appfolio/pms-adapter.ts:285,289: "The renewal doc is in your AppFolio resident portal". Correct place (adapter-provided portal instructions).- Voice prompts, model-facing. They shape behaviour and can be echoed aloud:
agents/clara/lib/voice-agents/leasing.ts:310,318,374,469,525(the full-name rule justified by "AppFolio's guest-card form").leasing-many-homes.ts:846,renewal-inbound.ts:225,renewal-outbound.ts:146,renewal-core.ts:132("AppFolio sends ONE term per offer").
src/lib/domain/conversations/mirror-label.ts:128: "[AppFolio notice …]", a label Clara reads.
Customer staff (texts, emails, Clara on calls, desk replies):
agents/clara/lib/agent/conversation-manager.ts:17060-17061: "You'll see the work orders in AppFolio shortly." Known drift, documented atsrc/lib/domain/pms/display.ts:20-39.agents/clara/lib/voice-agents/coworker.ts:70,71,80: "the AppFolio property number" (spoken to staff callers), "AppFolio assignees", "AppFolio status".agents/clara/lib/agent/capabilities/staff-leasing-desk-prompt.ts:81,83,85andstaff-leasing-desk-cards.ts:78: "AppFolio card", "never make staff repeat themselves in AppFolio".agents/clara/lib/agent/staff-desk-persona.ts:52("hasn't synced to AppFolio yet").agents/clara/lib/agent/capabilities/staff-metrics-prompt.ts:22.src/lib/domain/leasing/new-lease/new-lease-draft-notify.ts:107: "drafted in AppFolio and ready for your review."src/lib/domain/turnover/move-out-handoff.ts:48: "Post these in AppFolio when you process the move-out."src/lib/domain/onboarding/collected-questions.ts:175: "How do you mark an eviction in AppFolio?"
Customer UI (settings, review, onboarding):
src/app/(workspace)/settings/_components/OrganizationSettings.tsx:120,122;OrganizationSettingsShell.tsx:1136("PropFlow connects AppFolio for you").src/app/(workspace)/(operations)/review/client-page.tsx:1254,2042,2181-2182.src/app/api/settings/renewal-policy/route.ts:93;src/lib/domain/settings/scoped-fields/leasing.ts:98.src/lib/domain/vendors/appfolio-only-messages.ts:33,96-97.src/app/(standalone)/onboarding/(wizard)/(dialog)/sync/page.tsx:786,807,811.src/components/domain/onboarding/PmsConnectionReminder.tsx:48;property-card/PropertyGrid.tsx:103;pms-credentials/ConnectingStages.tsx:27;pms-credentials/error-copy.tsx:59-81;pms-credentials/ClaraSeatFields.tsx:88.src/lib/domain/onboarding/finalize.ts:291,293.PmsPicker.tsx:38-42: Yardi is listedavailable: false, "Coming soon". Correct as is.- Neutral already:
agents/clara/lib/messaging/transports/welcome-template.ts:26,48("AppFolio, Yardi, or your current system").
The existing fix tool: pmsBrandForProperty / pmsDisplayName (src/lib/domain/pms/display.ts:1-64) and staffDeskPmsBrand (agents/clara/lib/agent/tools/staff-leasing-desk-pms-brand.ts:8-20). Most of the strings above should read through these, or drop the brand ("your property software").
Explicit brand branches (non-test src + agents): 47 x === / !== 'appfolio'|'yardi' comparisons in 28 files, plus 16 case 'appfolio' switches in 15 files.
Top files for === branches:
| Count | File |
|---|---|
| 6 | src/lib/integrations/appfolio/resolve-account.ts |
| 4 | src/lib/integrations/sqs/agent-jobs.ts |
| 4 | src/lib/domain/pms/registry.ts |
| 3 | src/lib/domain/maintenance/tech-sms-dispatch.ts |
| 2 each | property-directory-refresh.ts, writer-registry.ts, pms/enrichment.ts, wo-enrich-dispatch.ts, rent-roll/apply-entity-sync.ts, agents/clara/lib/agent/tools/renewal/handlers.ts |
case 'appfolio' switches: pms/registry.ts, writer-registry.ts, renewal-adapter/resolve.ts, print-order/{resolve,new-lease-resolve,history-target}.ts, accounting/registry.ts, leasing/executed-lease-source/registry.ts, onboarding/answer-source-copy.ts, tools/clara-surface.ts, plus eval/pipeline-lab files. Most switches are the right shape (exhaustive registries in the adapter layer); the stray ones are in maintenance, leasing and Clara.
The broader census already exists as a ratchet: scripts/check-pms-literals.allowlist.json has 581 files / 2,715 literal-or-import hits outside the adapter layer.
| Area | Hits |
|---|---|
src/lib/tools/appfolio.ts |
403 |
src/lib/domain/leasing |
172 |
src/lib/tools/handlers |
131 |
src/app/api/onboarding |
112 |
src/lib/maintenance/eval |
110 |
src/components/feature/admin-dev |
89 |
src/lib/domain/vendors |
80 |
src/lib/domain/evals |
74 |
src/lib/domain/admin-status |
73 |
tools/types.ts / role-matrix.ts / tool-modules.ts |
61 / 60 / 60 |
agents/ (44 files) |
106 |
— of which agents/clara/lib/agent |
37 |
— of which voice-agents |
13 |
src/lib/domain/modules (1 file) |
1 |
Other coupling: 131 files outside the integration import @/lib/integrations/appfolio* directly (38 of them in src/lib/tools/handlers, 21 in domain/pms, 15 in domain/leasing). AppFolio-named fields: .af. 448 refs / 160 files; pmsAppfolioAccount 121 / 49; appfolioEnrichment 100 / 32; afVendorId|afWorkOrderId|afPropertyId 215 / 45.
A6 · PMS landscape, vocabulary, prior art
How each PMS lets us in, what it costs and how long it takes, how each names the same things, and what unified-API companies learned. Each claim carries its source grade.
1a. Access, gating, cost, lead time
| PMS | API surface | Partner program and gating | Cost to us (public figures) | Lead time to access | Sandbox |
|---|---|---|---|---|---|
| Yardi Voyager (enterprise multifamily; ConAm/Yale) | Mainly SOAP web services. Each interface has its own WSDL (ItfResidentTransactions20.asmx, ItfServiceRequests, ILS/Guest Card, Common Data, Lease Renewal…) [secondary: Truto, api-evangelist OpenAPI of ItfServiceRequests]. There is also a "Yardi REST" surface: interface username/password plus an "interface entity" that comes with its own API token [aggregator: Propexo Yardi REST creds]. Our src/lib/integrations/yardi/client.ts (APIToken + UserName headers, /ItfServiceRequests, /ItfResidentData…) appears aimed at that REST shape. Its POST /token is a mock-only convention (ADR-0072 §7). |
SIPP (Standard Interface Partnership Program). One Data Exchange Agreement per interface type [secondary: Yardi partner page as summarised by search, yardi.com/?p=359162, returned 403 to me]. Eligibility conflicts between sources: Truto and Supergood quote "company two years old + at least three active Voyager clients". Propexo's step-by-step says one mutual Voyager pilot client with written authorization (Propexo Yardi approval). unverified which is current. A customer's own login still can't reach the API without our SIPP membership. | Conflicting: Propexo says "SIPP Membership fee is $25,000, which covers the first 2 interfaces; each additional interface is $25,000". Truto and Supergood say "~$25K per interface per year". Both are second-hand. The Yardi page says only "annual license fee per interface… sometimes per-transaction". | 2–4 months from start to signed contract (Propexo), then build, then a one-hour Yardi QA call, then a pilot that must be live on at least one property. After that the integration ships in "the next interface plug-in release". | A dev/test site is given after the DX agreement is signed and paid, plus a QA environment for the QA call (Propexo). No free sandbox. |
| Yardi Breeze / Breeze Premier (SMB; a different product) | No public developer portal found. Third-party guides mention a REST/API-key path and api.yardibreeze.com. unverified (Supergood Breeze card gives an "F, no public API"). Apartments.com documents a "Yardi Breeze Guest Card Lead API" for lead intake (Apartments.com). |
No Breeze-specific partner program found. Supergood says SIPP's Voyager-client requirement rules out Breeze-only vendors [secondary]. | unverified | unverified (ask the Yardi rep) | unverified |
| RealPage OneSite (+ Knock CRM, a RealPage product (k)) | RPX (RealPage Exchange): "secure, standardized APIs across all RealPage products". In Jan 2024 RealPage announced an "event-based framework and modern APIs" rolling out over time, no dates (RealPage press release) [primary]. Older surface: "Online Data Exchanges (ODEs)" with a RealPage-issued API key; per tenant, with OAuth-style refresh on newer ODE endpoints [secondary: Truto]. Some Registered-Vendor feeds are SFTP (e.g. "SDE Convergent Billing") [aggregator]. Our local adapter targets Knock REST and is explicitly best-effort: Knock has no public OpenAPI spec (ADR-0072 §8). | Two tracks (Propexo RealPage notes): Registered Vendor is free to the vendor, "comparatively limited API capabilities", and the client licenses the APIs for us. App Partner is paid, with stricter approval and fuller APIs. Access is approved per named customer, and any expansion needs another review [secondary: Truto]. All non-RPX apps must go through RPX registration [primary press + secondary]. | App Partner: "$2,500–$5,000 annual fee per API plus $0.05–$0.25/unit monthly", at least 1 mutual client (Propexo RealPage approval). Registered Vendor: free to us, and the client pays RealPage (amount unverified). | 4–6 months to go-live for App Partner. SLAs per step: application 10 business days, InfoSec 20–50 days, commercial 15 business days, build 4 wks, pilot 4 wks, certification 2 wks (Propexo). | Credentials and a sandbox come at the build step, after InfoSec and commercial terms. Sandboxes are tied to a mutual customer [secondary]. A "Registered Vendor clone environment" exists (Propexo page title). |
| Entrata | JSON or XML over HTTPS. Every call is a POST to https://{subdomain}.entrata.com/api/v1/<area> with the method name in the body (getLeads, sendLeads, getMitsLeads, getLeadEvents, getWorkOrders, sendWorkOrders, updateWorkOrders under /api/maintenance) [method names primary: docs.entrata.com URLs seen in search (getWorkOrders, sendWorkOrders); body-method format secondary: Tray.ai]. Auth is an internal API user with API keys, with web services switched on per user (Domo) [secondary]. IP whitelisting is reported [secondary]. |
Paid only, needs approval, at least 1 mutual client. The mutual client has to ask their Entrata account manager on our behalf (Propexo Entrata approval). | By call volume: "$5,000/yr under 250K calls/month, $25,000/yr under 1M, $60,000/yr over 1M" (Propexo). | 1–2 months to signed contract (Propexo). | Not guaranteed. "If you do not have a sandbox environment, you will need to build using a client's production environment" (Propexo). |
| AppFolio (today's PMS) | Three doors. (1) Database / Data API, free: the client creates a "Developer Admin" user in Developer Space and hands over Developer ID + Client ID + Secret. Plus = read-only, Max = read/write (Propexo AppFolio) [aggregator]. We already use its reports (e.g. /api/v1/reports/guest_cards.json). (2) Stack partner APIs, paid. (3) Our robot: L4 HTTP to internal Rails endpoints with harvested cookies. |
Data API needs no approval and is "not certified or listed". Stack needs an application, a security questionnaire and a contract (Propexo). | Data API: free to us, but the client must be on Plus or Max. Stack: "$25,000–$50,000 annually, plus potential revenue share" (Propexo). | Data API: as soon as the client makes the credential. Stack: "3+ months" (Propexo). | Stack: unverified. Data API: a TEST property per the standing rule in force. That rule moved three times in three days: 11-01 Willows writes allowed except new tenants; 11-02 repo CLAUDE.md says Willows testing is closed, use Fairhaven; 11-03 no phone tests on any test property. |
| Buildium (SMB; RealPage-owned (k)) | REST Open API (/v1/leases, /v1/leases/{id}/transactions, /v1/workorders, resident requests, tasks, applicants) [secondary: Carly, buildium-ruby gem]. Auth is client ID + secret in headers [secondary: Nango]. |
Two routes (Propexo Buildium). Open API is only for customers on the Premium plan, who can share a key with any vendor, with no approval. Marketplace works for all tiers but needs approval, a revenue share, and a QA showing install from inside Buildium. | Open API: free to us. Marketplace: "percentage-based revenue share", percentage not stated (Propexo). | Open API: immediate. Marketplace: "3+ months" (Propexo). | Free sandbox at apisandbox.buildium.com, capped at 1,500 units [secondary: Carly]. |
| Rent Manager (RM12) | REST WAPI12. Auth token in the X-RM12Api-ApiToken header. Optimistic concurrency: updates must send UpdateDate (RM technical PDF) [primary]. Work orders are ServiceManagerIssues (+ /HistoryNotes). Propexo notes: "the ID of the created record cannot be retrieved… found on the next data sync" for history notes (Propexo RM service requests). |
Paid, needs approval. The finished integration is reviewed in "Partner Web Access" (Propexo RM). | "$3,000 annually, plus $50/month per client" (Propexo). | "1 week" to signed contract (Propexo). The fastest of the group. | A "fully-functional test portal" in the browser and on a Windows VM (Propexo). |
| ResMan | Partner API documented on Postman (partners-api-docs.myresman.com). Basic auth with Integration Partner ID + API key, plus an account header [primary portal, as summarised]. The legacy API takes MITS Lead Management 4.0 XML (PostLeadManagement4_0) (ResMan legacy doc) [primary]. |
Paid, needs approval, needs mutual clients (Propexo ResMan). | "$8,000 annually, scaling with API usage" (Propexo). | "2–4 months" (Propexo). | Not mentioned. unverified. |
| MRI (Residential Management; less likely for us) | API style unverified. Auth by partner key or client key. | Partner Connect: certified, directory listing (Propexo MRI). MIX: no approval, and we use a key the client provides. | Partner Connect: "$5,000–$15,000 annual + 2.5%–15% revenue share". MIX: free to us, "the MRI client pays $10,000/year" (Propexo). | unverified | unverified |
| (also in Propexo's list: Propertyware, Rentvine) | Not researched. Rentvine is a smaller PMS with its own API-key flow (Propexo PMS list). |
1b. Data reach, events, limits, and whether a browser fallback is plausible
R = read, W = write, — = none found. Everything below is unverified at field level unless it carries a source. The vendor docs that would confirm field coverage are paywalled.
| PMS | Residents | Units / availability | Leases | Ledger / balances | Work orders | Prospects / guest cards | Vendors | Webhooks vs polling | Rate limits | Browser fallback plausible? |
|---|---|---|---|---|---|---|---|---|---|---|
| Yardi Voyager | R (ResidentData / GetResidents) |
R (Common Data / ILS feeds) | R; renewals need the separate ItfLeaseRenewal interface, which needs a renewal-module license (ADR-0072 §7) |
R and W (ItfResidentTransactions20: GetResidentTransactions_Login, ImportResidentTransactions_Login) [secondary: apis.io listing] |
R and W (ItfServiceRequests: get + import) |
R and W (ILS/Guest Card: GetYardiGuestActivity, ImportYardiGuest_Login) (yardi ruby gem) |
R (our adapter claims listVendors, but the source is doc-derived) |
Polling. "Neither Yardi nor RealPage have ubiquitous outbound webhooks" [secondary: Truto]. The KB says the browser user gets no events either. | Yardi "announced API rate limits in July 2024" but "have not implemented them in production" (Propexo Yardi notes) [aggregator]. Legacy APIs reportedly return 500s or drop connections rather than 429 [secondary]. | Technically yes. ASPX pages, iframes, SAML SSO + MFA (KB voyager-8-ui.md). Precedents exist (Anchor, Zatanna). Legal flag, not resolved here: Yardi sued Property Solutions (now Entrata) in 2013, alleging it "improperly and without authorization accessed and copied Voyager software" through customer relationships (Yardi press 2013). An earlier RealPage case was about credentials obtained through consultants; per search summaries it settled in 2012 with a $2M license fee (PacBiz). Our phase-1 plan is a robot on the customer's own login, so the customer's Voyager license terms are the thing to check. That is a founder or counsel call. |
| Yardi Breeze | unverified | unverified | unverified | unverified | unverified | lead intake only (Breeze Guest Card Lead API) | unverified | unverified | unverified | Probably the main path for anything beyond lead intake. Same license-terms question as Voyager. |
| RealPage OneSite | R (ODE / RPX, per contracted API) | R ("Pricing and Availability ODE" needs a password credential) (Propexo RV creds) | R | R; some feeds are SFTP only (Convergent Billing) | unverified (OneSite Facilities) | R and W through Knock CRM (create prospect, find, request appointment; local adapter, best-effort) | unverified | Being built: the Jan 2024 "event-based framework", no dates. Assume polling. | unverified | Conflicts with certification. App Partner deliverables require stating that "all calls are made directly to the RealPage server, not via a web browser" (Propexo). A robot on RealPage could block a later certification. Founder call. No file or document upload through the API (Propexo). |
| Entrata | R (getCustomers, unverified) |
R | R | R (AR transactions / AR codes, unverified names) | R and W (getWorkOrders / sendWorkOrders / updateWorkOrders) |
R and W (getLeads, sendLeads, updateLeads, getLeadEvents, getMitsLeads) (Repli) |
unverified | Some webhooks, enabled per API user (e.g. oa.application.events) [secondary: Payscore]. "Doesn't expose a native webhook system for all events" [secondary: Tray]. |
Three windows (minute, hour, day), per API, per credential, and different for each customer. Propexo's example: one customer had 1,000/min, 5,000/hr, 10,000/day for leads/getLeads (Propexo Entrata notes). |
Less needed: Entrata's API is broad, and Zuma calls it "one of the more open API frameworks" (Zuma) [secondary]. |
| AppFolio | R (Data API reports); W through the robot (L4) | R (reports, unitVacancy) |
R; renewals W through the robot | R (reports) | R (reports); W through the robot (L4) | R (Data API guest_cards report, needs status=all); W through the robot (createGuestCard) |
R and W (robot createVendor) |
Polling (our sync Lambda). Webhook availability on Max/Stack is unverified (only third-party mentions). | [primary, ours] the 429 rate climbs above ~9 req/min per account. No Retry-After. Budget is per database. |
Already in production (the robot). |
| Buildium | R and W | R | R and W | R and W (/leases/{id}/transactions: payment, charge, credit) [secondary] |
R and W (work orders + resident requests + tasks are separate objects) | R and W (applicants) | R and W | Webhooks exist, HMAC-SHA256 signed [secondary: Carly] | "10 concurrent requests per second" → 429 [secondary: Nango, Carly] | Rarely needed if the customer is on Premium. |
| Rent Manager | R and W | R and W | R and W | R and W | R and W (ServiceManagerIssues) |
R and W (Prospects; endpoint names unverified) | R and W | Webhooks unverified | unverified | Rarely needed. Cheap and fast official path. |
| ResMan | R | R | R | R | R and W (WorkOrders) |
W (MITS LeadManagement 4.0 post) | unverified | unverified | "scaling with API usage" (fee); limits unverified | unverified |
Takeaways for the architecture
- API access is a commercial track that runs parallel to the build. Lead times run from 1 week (Rent Manager) to 4–6 months (RealPage). Yardi is 2–4 months to contract, plus build, QA and pilot. So a browser robot is the only way to serve a Yardi customer next week. Plaid shows how that ends: it moved about 75% of its connections from screen scraping to bank APIs by 2023 while keeping the same product surface (PYMNTS on Plaid). The robot is a bridge, and the adapter port must not care which transport answered.
- Transport is chosen per capability, not per PMS. AppFolio already mixes them (Data API reads, robot writes). Yardi could mix them (robot now; SIPP REST was Fede's phase 2, set aside by Gera's 11-04 "robot only" answer, tab D). Yale mixes PMSes per domain.
- Every vendor's limits are per credential or per database, and some differ for each customer (Entrata). The request budget belongs in the access layer, keyed by (PMS account, API family). It must not live in callers.
- Writes are asynchronous, and sometimes you can't learn the created ID (Rent Manager notes). ADR-0030 already says the PMS mints the ID and we save the row only after the PMS confirms. The port needs a "created, ID pending, reconcile on next read" result state.
- No webhooks to count on (Yardi, RealPage, mostly AppFolio). The core should assume polling plus write read-back. Webhooks are a bonus that only lowers latency.
2a. The same concept under each vendor's name
Sources: the API and method names cited in §1, plus product knowledge. Rows marked (k) are from general product knowledge and are not verified this session.
| Concept | Yardi Voyager | RealPage OneSite / Knock | Entrata | AppFolio | Buildium | Rent Manager | ResMan | MITS | PropFlow today |
|---|---|---|---|---|---|---|---|---|---|
| Someone shopping for a unit | Prospect / Guest (GetYardiGuestActivity, ILS/Guest Card interface) |
Prospect / Guest Card (k); Knock "prospect" | Lead (getLeads, sendLeads) |
Guest Card (+ "Inquiry") | Applicant (no separate lead object found) | Prospect (k) | Prospect (MITS) | Prospect containing Customers > Customer Type="prospect" (Lead Management 4.0) |
ProspectInquiry (per property, ADR-0018/0027) + Person; capability guestCards |
| A contact event on that lead | Guest activity / event | Activity (k) | Lead event (getLeadEvents) |
Guest card activity / inquiry | Note | History (k) | Event |
Events > Event (with EventType, FirstContact) |
messages / conversation, Tour |
| Person living in the unit | Resident (UI) / "tenant" code t0… (k) |
Resident / household (k) | Customer / resident | Tenant / Occupancy | Tenant (lease tenants) | Tenant | Resident | Customer (in a lease context) |
Tenant + TenantOccupancy; capability tenantContacts (and a vestigial tenants) |
| Rentable thing | Unit (k) | Unit; bed/space in student housing (k) | Unit and Unit Space (student / by-the-bed) (k) | Unit | Unit (rental unit) | Unit | Unit | ILS_Unit / Unit / Floorplan (Property-Marketing/ILS) |
Unit |
| Repair job | Service Request (ItfServiceRequests); "work order" in the UI (k) |
Service Request (k) | Work Order (/api/maintenance) |
Work Order (a portal "service request" becomes a WO) | Resident Request and Work Order and Task (three separate objects) | Service Manager Issue (ServiceManagerIssues) |
Work Order | none: MITS has no maintenance model (RETTC MITS list) | WorkOrder (ADR-0030: id is the PMS's id) |
| Money owed and paid | Resident ledger; charges and receipts (ItfResidentTransactions20) |
Ledger / transactions | AR transactions, AR codes | Tenant ledger, charges, receipts | Lease transactions (payment / charge / credit) | Charges, payments (k) | Ledger / transactions | Resident Transactions 3.0; 2024–25 fee update (Cherre) | capabilities balances, historicalCharges, renewalFeatures |
| Renewal offer | Lease renewal (ItfLeaseRenewal) |
Renewal offer (k) | Renewal (k) | Lease renewal (robot endpoints) | Lease renewal (k) | (k) | (k) | Lease/Application 3.0 (scope unverified) | Renewal saga; capabilities renewals, renewalTruth |
| Outside contractor | Vendor | Vendor | Vendor | Vendor | Vendor | Vendor | Vendor | (Accounts Payable 4.0) | VendorCompany, VendorMembership |
2b. What MITS is and whether to adopt it
- What it is. The Multifamily Information and Transactions Standard was started in 2002 by NMHC and is now maintained by RETTC. It is free to download, and use is voluntary (RETTC MITS page, Cherre).
- Current models (per RETTC): Accounts Payable 4.0, Collections 3.0, Core Data 4.0, Lead Management 4.0 (schema 4.0.1), Lease/Application 3.0, Property-Marketing/ILS 5.1 (latest), Resident Screening 3.0, Resident Transactions 3.0. MITS 5.0 was released 2025-01-10. There is no maintenance or service-request model.
- It is the real interchange format for leads. ResMan's lead endpoint takes MITS LeadManagement 4.0 XML (
LeadManagement > Prospects > Prospect > Customers > Customer(Type="prospect") > Identification/Name/Address/Email/Phone,CustomerPreferences,Events > Event) [primary: ResMan doc]. Entrata exposesgetMitsLeads. Yardi's ILS/Guest Card interface is the ILS lead channel; that it is MITS-shaped is likely but unverified. - Recommendation: borrow MITS's concepts and shape where a model exists. Do not adopt its XML or rename our code.
- For leads, model the neutral record the way MITS does: a Prospect (the inquiry) holds one or more Customers (people), has Preferences, and has a timeline of Events. That matches what PropFlow already has (
ProspectInquiry→Person+ messages +Tour). It means a future MITS export is a mapping, not a redesign. - For units and marketing, follow Property-Marketing/ILS: Property → Floorplan → Unit. Add an optional Space (bed) below Unit now, before a student-housing Entrata or RealPage customer shows up.
- For money, follow Resident Transactions: a ledger is a list of charges, payments, credits and concessions, with a charge code per line. A balance is computed from the ledger; it is not a field.
- For work orders, MITS has nothing, so keep Work Order (what we, Entrata, AppFolio and ResMan say). Name the mappings per vendor in the adapter: Yardi/OneSite "service request", Rent Manager "service manager issue", Buildium's split of "resident request" and "work order".
- External IDs. MITS carries each system's ID next to the record (the
Identificationelement on Customer). Our equivalent ispmsRef(PMS_INTEGRATION.md §10 already killed the overloadedpmsExternalId). Keep one opaquepmsRefper record, scoped to (PMS, account/database).
- For leads, model the neutral record the way MITS does: a Prospect (the inquiry) holds one or more Customers (people), has Preferences, and has a timeline of Events. That matches what PropFlow already has (
2c. Recommended PropFlow-neutral names
The rule: names in code and in the port follow what the repo already uses, unless the name is AppFolio-flavoured or ambiguous. CLAUDE.md says the canonical docs win. Customer-facing words follow hot rule 6 (plain English) and can differ from code.
| Concept | Neutral code name (port / capability) | Customer-facing word | Why |
|---|---|---|---|
| Inquiry for a property | ProspectInquiry (keep). The PMS-side record is the guest card (keep guestCard as the capability resource). |
"lead" or "prospect" | "Guest card" is used by Yardi, RealPage and AppFolio for the PMS's own record, so it is the right name for the PMS object we read and write. MITS and Entrata say prospect/lead for the person or inquiry. Two names for two things is correct here: our inquiry versus their card. The vocabulary doc should state that pairing so nobody "fixes" it. |
| Lead activity | ProspectEvent (new, for the port only) |
"activity" | MITS Event. Covers Entrata lead events, Yardi guest activity, AppFolio card activity. |
| Person on a lease | Keep Tenant / TenantOccupancy in code. |
"resident" | Renaming 469+ references is not worth it. "Resident" is what Yardi, RealPage, ResMan and the customer-facing UI say. Fix the smell: delete the vestigial tenants capability (its own docblock says it has no backing method; the RealPage mock adapter still advertises it at realpage/pms-adapter.ts:52-55, so the delete touches that file) and keep tenantContacts (or rename it resident.list; see §4c). |
| Rentable thing | Unit + optional space |
"unit" | Entrata and student housing put a bed level under the unit. |
| Repair job | WorkOrder (keep) |
"work order" or "repair request" | MITS is silent on maintenance. ADR-0030 already pins the ID rule. |
| Money | LedgerEntry { kind: charge | payment | credit | concession, chargeCode, amount, date }; Balance is computed |
"balance", "charges" | MITS Resident Transactions; matches Buildium, Entrata and Yardi transaction feeds. |
| PMS's own ID | pmsRef |
never shown | Already decided (PMS_INTEGRATION §10). Never show our ticker (standing rule). |
3a. What each system does
| System | How it says what is supported | Escape hatch for unsupported things | Sync model | Write model |
|---|---|---|---|---|
| Plaid | The app asks for products (hard requirement: the institution must support them or linking fails, and the institution list is filtered), required_if_supported_products (used if possible, skipped otherwise), and optional_products (best effort) (Plaid docs) [primary] |
none (product APIs only) | not researched | n/a |
| Merge.dev | Common Models per category; scopes per model; field mapping per integration or per linked account (JSONata) (Merge field mapping) | remote_data (raw vendor payload next to the normalised one, never normalised) and Authenticated Passthrough (call the vendor's own API with stored creds; passthrough_available flag per linked account) (remote data, passthrough, linked accounts) [primary] |
Scheduled syncs; remote_data refreshes only when a normalised field changes |
Per common model |
| Apideck | Coverage = operations (list / get / create / update / delete) and fields per connector per resource. Connector status live / beta / auth-only. Unsupported operation → 501 ConnectorOperationUnsupportedError; unsupported field → null ("sparse response"). The Connector API returns coverage at runtime (Apideck help) [primary] |
Proxy API; auth-only connectors are proxy-only | Mostly live calls | Per operation |
| Nylas v3 | One connector per provider (Google, Microsoft, IMAP, EWS); the same call works across them, but folder names, message-ID formats, admin consent and rate limits leak through (Nylas providers) [primary] | n/a | Provider push (Google Pub/Sub) or polling | n/a |
| Nango (open source) | Per provider: sync functions (pull into your store; incremental where possible, with checkpoints) and action functions (writes, also exposable as agent tools), one execution per (integration, connection) (Nango functions guide) [primary] | You write the function | Sync functions | Action functions |
Propexo (the PMS-specific unified API; probably formerly "Propify", inferred from its asset URLs propify-public-assets, propify-single-data-model.png and the copyright line "RentDrop, Inc. dba Propexo") |
10 PMSes. Field-level support per PMS is in private docs. Generic endpoints are validated per PMS; ?ignore_unknown_fields=true drops fields a PMS lacks instead of returning 400 (Propexo writes) [aggregator] |
Custom data objects (page exists, not read) | Cached snapshot, default sync every 12 hours; "the data you see in Propexo is not live data" (sync frequency) | Async: returns a job_id, you poll GET /v1/admin/write-status/:root_job_id, which carries success, errors and created_record_id. "Propexo does not act as the source of truth." Several credentials per client when one Yardi integration needs several interfaces. |
| Supergood, Anchor, Zatanna | Unofficial reverse-engineered or browser integrations, built per customer (Supergood) [secondary] | n/a | n/a | This is the bucket our robot is in. |
| Airbyte | Each connector implements spec / check / discover / read. Connector Acceptance Tests are a black-box suite every connector must pass, configured per connector in acceptance-test-config.yml (Airbyte protocol, CAT reference) |
n/a | read + state messages | n/a |
| Terraform providers / Kubernetes | Terraform: acceptance tests gated by TF_ACC because they create real (paid) resources; sweepers clean up (HashiCorp). Kubernetes: a fixed [Conformance]-tagged suite that every distribution runs to be certified (CNCF) |
3b. Lessons to steal
- Paths declare what they need with Plaid's three levels. Each Clara path (e.g. "maintenance intake", "renewal outreach", "lead capture") declares
requires(the path is unavailable at a property without them; the Paths page shows why),usesIfAvailable(better with it, degrades cleanly without), andoptional. The repo already has the other half:PMSClient.capabilitiesplushasCapability. This is an addition, not a new system. - Coverage is (resource × operation), with a maturity status per cell, readable at runtime (Apideck). Our cells need statuses that match our proof ladder:
live(proven on a real instance),docDerived(ADR-0072 §6: built to the docs, not checked against a real instance; money decisions blocked),mockOnly,unsupported. Each cell also names its transport (api/robot) so ops can see what is scraped. - Unsupported is loud and typed (Apideck's 501). It is never an empty list or a
nullthat looks like "no data". This matches CLAUDE.md's no-fabrication rule and PMS_INTEGRATION's "loud failures". KeepPMSTransientError/PMSPermanentError/PMSValidationErrorand addPMSCapabilityUnsupported. - Two escape hatches, fenced. Merge's
remote_datais worth copying: store the raw vendor payload beside the mapped record so a missed field can be backfilled without a re-sync. It is PII, so it follows the same retention rules as the mirror. Passthrough (calling native vendor ops) should be allowed only for onboarding ingest and admin tools. Clara paths never use it, because a passthrough in a path recreates the AppFolio-coupling bugs the Yale audit found. - Writes return a receipt, then read-back confirms. Use Propexo's job-ID pattern plus our ADR-0030: the PMS mints the ID, and the result includes "created, ID pending" (Rent Manager can't return some IDs).
- Credentials and budgets are per (PMS account, API family), and one customer can hold several (Yardi interfaces, Entrata per-API limits, AppFolio per-database budget). Merge calls the per-customer connection a "Linked Account"; Nango calls it a "connection".
- The robot is a bridge to the API, behind the same port. Plaid's scraping-to-API migration and our own AppFolio Data-API-first, robot-fallback guest-card read both show it. Moving a capability from robot to API should be a one-line change in the adapter's manifest. No caller should notice.
3c. Pitfalls
- Lowest common denominator. A unified model drops what only one vendor has (Yardi UDFs, Entrata unit spaces, AppFolio delinquency flags). The repo already avoids this with many narrow optional capabilities (
collectionsStatus,purchaseOrders,renewalTruth). Keep that: narrow capabilities, not one fat "Tenant" object. [secondary: DZone, Sacra/Vessel] - Semantics leak even when the shape is unified. Nylas found this with folder names, IDs and rate limits. For us: what counts as "active" on a guest card (AppFolio's hidden
Status=Activedefault), whether a balance includes pending charges, how lease state codes map. Each adapter must document its mapping decisions where the mapping code lives. - A stale cache presented as live. Propexo's default is a 12-hour sync. PMS_INTEGRATION §6 already requires staleness budgets per use case. Keep the core declaring staleness, never assuming freshness.
- Sync logic stays ours. Dedup, merge, "latest stated name wins" and "one record per phone" are standing rules that no vendor or unified API will enforce [secondary: DEV/Panteleev]. Zuma's guidance matches: match on phone, email and name, and append lead sources, never overwrite (Zuma).
- Should we buy Propexo instead? It covers our exact vendor list. But it needs our partner approvals anyway (Propexo's own pages are guides to getting approved), it caches at 12 hours, its writes are optional and not universal, and it has no answer for "API not approved yet". The robot is our differentiator on time-to-serve. Worth a pricing call only as a possible transport for the slow tail (RealPage, ResMan, MRI) under our own port. Never as the port.
A7 · Interface drafts (TypeScript, v10)
Drafts to argue with, not final code; each one extends something that exists. v4: the write door wraps the existing writers, and its receipts map onto the existing stored states; what an adapter can do (code) is split from what works for a customer (stored per connection); transport fallback has one rule; grants cover scheduled robot work; a PMS calendar is a provider whose writes go through the door. v6: refs carry a record type and a minted instance; receipts map every not-written reason to a named stored state; the calendar is read and written (Fede) via the outbox; the keep-alive grant can never fetch a password.
Capabilities: adapter facts in code, connection state in storage
// Today's 21 read capabilities keep their names ('balances', 'guestCards', 'collectionsStatus', …).
// 'workOrderWrite' splits into verbs; new write verbs follow the same style.
export type PMSWriteCapability =
| 'workOrder.create' | 'workOrder.note' | 'workOrder.attach' | 'workOrder.assignVendor'
| 'workOrder.complete' | 'workOrder.cancel' | 'guestCard.create' | 'guestCard.note' | 'guestCard.update'
| 'application.send' | 'appointment.create' | 'appointment.update' | 'appointment.cancel'
| 'renewal.prepare' | 'renewal.changeTerm' | 'vendor.create' | 'workOrder.update'
| …; // plus the A9 §1.3.3 verbs the existing writers already need; each is a capability cell in every manifest
// NOT PMS writes, but capabilities all the same (connection state, grants, proof ladder, a Works-with cell):
export type PMSMessageCapability = 'messenger.read' | 'message.send';
// They run on the existing MESSAGE door, so Supervised holds them and the record rule (ADR-0119/0150) applies.
// One neutral 'pms_inbox' carrier over an adapter messenger port (not a copy of appfolio-inbox-text.ts per PMS).
// Which carrier: the company's one channel label (Gera 11-03, one resolver). When it is the PMS messenger, a
// reply uses the inbound thread's own connection; a first message uses the leasing binding for a prospect and
// the records binding for a resident. 'messenger.read' is a polled feed: the sync engine owns its cursor and lease
// (PmsInboxSource, A4); 'message.send' runs on the message door.
export type PMSCapability = PMSReadCapability | PMSWriteCapability | PMSMessageCapability;
// What the ADAPTER can do — static, in code (integrations/<pms>/manifest.ts)
export interface AdapterCapability {
transports: readonly ('api' | 'robot')[]; // ordered; see the fallback rule below
confirms?: 'inline' | 'viaSync' | 'none'; // writes
confirmWithinMs?: number;
marker?: 'inRecord' | 'naturalKey' | 'idempotentVerb'; // writes
freshnessMs?: number; // reads
}
// What works for THIS CUSTOMER — stored per pmsConnection (one ConAm failure never darkens another Yardi customer)
export interface ConnectionCapability {
status: 'live' | 'unproven' | 'unsupported' | 'failing';
verifiedOn?: string; lastFailure?: { at: string; detail: string };
}
// The one answer core reads: adapter ∩ connection ∩ routing. Module = the existing ModuleFlag (no new taxonomy).
export function pmsCapabilitiesForProperty(propertyId: string, module: ModuleFlag):
Promise<ReadonlyMap<PMSCapability, AdapterCapability & ConnectionCapability>>;
// One typed registry replaces the scattered switches; sandbox stays an entry (exhaustive by type)
export const PMS_ADAPTERS: Readonly<Record<PmsSource, AdapterEntry>>; // 'appfolio' | 'appfolio-sandbox' | 'yardi' | 'tenio' | …
export interface PmsNeed { capability: PMSCapability; maxAgeMs?: number }
export interface ClaraPathPmsNeeds { required: readonly PmsNeed[]; optional?: readonly PmsNeed[] }
// e.g. inbound lead path: required: [], optional: [{capability:'guestCards'}, {capability:'guestCard.create'}]
The write door: wraps the three writer factories that exist
// Today: getPMSWriterForProperty, getPMSGuestCardWriterForProperty, getPMSWorkOrderWriterForProperty
// (writer-registry.ts), each with its own mode refusal; the WO one takes a `subject`, so an emergency
// work order is never refused (ADR-0149). The door wraps all three. It TAKES their mode decision
// (one copy of the mode rule) and adds: hold check, connection-capability check, marker policy,
// the adapter call, a WriteReceipt, and persisting that receipt in the existing stored state.
export type WriteStatus = 'applied' | 'accepted' | 'unconfirmed' | 'refused' | 'not_written';
export interface WriteReceipt {
status: WriteStatus; capability: PMSWriteCapability; ref?: PmsRef; marker?: string;
evidence?: { readBackAt: string; observed: Record<string, unknown> }; // required for 'applied'
confirmBy?: string;
notWrittenReason?: 'preview' | 'off' | 'mode_unresolved' | 'held' | 'not_supported' | 'unproven' | 'test_property_guard' | 'test_call'
| 'binding_mismatch' | 'expired'; // binding fails closed → parked + alarm, redriven when fixed; expired = moment passed
failure?: { kind: PmsFailureKind; retryable: boolean; detail: string };
}
// Receipt log: a NEW append-only table (PmsWriteReceipt), one row per STATUS EVENT, keyed by
// capability#connection#idempotencyKey + an event sequence (accepted → applied, unconfirmed → re-sent are new
// rows); the current receipt is the latest event. Not PmsWriteIntent: that row is a mutable guest-card queue that
// requires a person and an inquiry (guest-card-outbox.ts:562). It stays the queue. Each record's own status (outbox
// state, WO sync status, note/photo push status) is a copy ONLY the door writes, in the same transaction as
// the event (a fence test finds no other writer of push status). The sync engine's match-back confirms
// through the door: confirm(ref, evidence) appends 'applied' (viaSync capabilities). WorkOrderSyncStatus
// splits into a push state (the door's) and a mirror state (the sync's, e.g. removed_in_pms); redrive and the
// exit test read this one table. Going live never drains parked rows by itself (R 2.4: count shown at the
// founder go, past-dated verbs expire, the rest drain at a set rate). The table below shows how each view reads.
// Hold rail (held-pms-write.ts) gains one generic payload: { capability, request, idempotencyKey, dependsOn? },
// executed back through the door on a person's approval. Approval works while the module is
// Supervised or Live (approval is mode-gated today); a capability marked 'unproven' for a connection
// is refused by the door unless that approval names it.
| Receipt | Guest-card outbox (PmsWriteIntentState) | Work-order create (WorkOrderSyncStatus) | WO notes and photos (per item, today appfolioSyncStatus / attachedSynced → pmsPushStatus) | Immediate writers |
|---|---|---|---|---|
| (before the door) | pending / writing | unsynced / syncing | pending | — |
applied | sent + evidence | synced | synced | returned + audit row |
accepted | new awaiting_confirm (not a "never lands" state) | new awaiting_confirm (not partial_sync, which means the id IS set) | new awaiting_confirm | returned + reconcile task |
unconfirmed | new unconfirmed: intentNeverLands() stays false; redrive only after a marker lookup | new unconfirmed + alert (never a blind retry) | new unconfirmed + alert | returned + alert |
refused | failed | sync_failed | failed | returned + alert |
not_written (unproven) | new parked (reason unproven): not drained; redriven when the capability goes live for this connection, at a set rate after the founder go sees the count; past-dated verbs become expired | unsynced + parked reason; redriven when live | new parked | returned; staff told "filed by hand for now" |
not_written (not_supported) | new parked (reason not supported): never redriven; staff alerted once | unsynced + reason; staff alerted | new parked | returned + alert |
not_written (held) | pending + the hold row; the hold's approval runs it through the door | unsynced + the hold row | pending + the hold row | the hold row |
not_written (preview) | preview_recorded (terminal) | preview_recorded (terminal) | preview_recorded | returned |
Immediate writers today include renewal approval (api/leasing/renewals/approve), listing publish and the turnover hooks; they get receipts through the wrapper. close-all-for-tenant is queued, or calls the robot directly with no registry: that direct path moves behind the door in R 3.5.
// Transport fallback rule (enforced in the door):
// a write may move to the next transport ONLY after a refusal that proves nothing was sent;
// after a timeout or any ambiguous failure, it first looks up its marker, and never falls back blind.
Calendar: a PMS calendar is read like Google or Outlook; its writes go only via the outbox and the door
// Fede's 5 Oct decision: "Yardi's property calendar is the building's calendar, read and written".
// CalendarProviderId widens to PMS providers ('yardi', 'tenio', …); CalendarEvent gets a neutral shape; the
// resolver gains a no-OAuth path (from the building's connection) and a stated place in the selection order.
// Reads are live, in the live class of the per-account scheduler (R 1.10s: the adapter declares sessions per
// account and priority classes; a reserved live slot in Fede's gate that polls give way to); a slow or failed read falls back
// to "Clara takes the requested time, the team confirms".
// Writes: a PMS provider is READ-ONLY inside the calendar engine (createEvent must return an event id the engine
// stamps on the tour, which a queued PMS write can't do, so its write methods refuse). Tour writes are
// schedule_showing / reschedule_showing / cancel_showing on the outbox, executed by the door as
// appointment.create / .update / .cancel; the tour stores the appointment PmsRef from the 'applied' receipt.
// The envelope carries dependsOn (as the guest-card drain already does), and the drain merges a create and
// its cancel so neither is sent. One write path and one retry rule per booking.
// Lost slot: if the write is refused after Clara told the prospect a time (staff took the slot in between),
// the team is alerted and the prospect is re-offered times, or told the team will confirm, with the time named.
Ids
export interface PmsRef {
source: PmsSource;
kind: 'property' | 'unit' | 'occupancy' | 'resident' | 'lead' | 'workOrder' | 'vendor' | 'payment' | 'appointment' | …;
instance: string; // a data-space id WE mint per connected PMS database; vendor server/db names kept as aliases
id: string; displayNumber?: string; secondary?: Record<string, string>; endedAt?: string;
}
// Lookup key: source:instance:kind:id. At most ONE ACTIVE ref per (source, instance, kind) on a record; any
// number of ended ones (endedAt set), so a building that moves between two managers on the same PMS keeps both.
// A PERSON's PMS identities stay IdentityClaims (helpers.ts), value 'source:instance:kind:id', many allowed
// (Yardi tenant codes per lease, one lead per (person, property)). Ref lists (pmsRefs: PmsRef[]) live on role
// rows (TenantOccupancy, ProspectInquiry) and on Property, Unit, Vendor. Instance aliases (vendor server/db)
// are unique, so two connections to one database share one instance.
//
// Building → connection: ONE stored binding per (building, ModuleFlag) → pmsConnection id, plus one RECORDS
// binding per building for the data no module owns (units, residents, occupancies, balances). Together they
// are the only answer to "which PMS serves this"; Property.pmsSource becomes derived from the records
// binding and is retired (R 3.4). Archiving is source-scoped: a complete pull archives only rows whose ref
// came from its own connection.
// Resolution rules (decided before R 1.7 stores a binding):
// - records-type capabilities (units, residents, occupancies, balances) always resolve through the records binding;
// - a module binding to a different connection is allowed only if that connection may stamp MATCH-ONLY refs
// (never create, never archive) onto records-owned entities for its own instance; otherwise the bind is refused;
// - the door picks the connection from the target ref's instance, intersected with the operating company's
// connections (owner and operator may both connect to one instance); the binding decides only for creates;
// - a first message to a prospect uses the leasing binding (the lead ref lives there), to a resident the records binding.
// A binding whose connection doesn't belong to operatorOrgOfBuilding(building) fails closed with an alarm
// (e.g. when a management link ends).
export function connectionFor(propertyId: string, module: ModuleFlag | 'records'): Promise<PmsConnectionId | null>;
// WorkOrder, Payment, Tour keep one ref (WorkOrder's existing pmsId/pmsNumber become its PmsRef).
Credentials and robot grants
// System of record: propflowai's KMS vault, holding credentials AND connection config
// (allowed buildings, MFA phone, templates).
interface RobotGrant {
account: string; requestHash?: string;
capability: PMSCapability | 'session.keepAlive' | 'session.login' | 'recipe.check'; // incl. message.send
marker?: string; exp: number; sig: string; // Ed25519; the robot holds only the public key
}
// Keep-alive: the robot's */2 heartbeat replays a stored session and probes it, on a STANDING, revocable
// per-account grant that can replay and probe but NEVER yields a password. The check for a one-time 'session.login' grant sits INSIDE
// withLoginBudget (identityLoginLock.ts:291), the choke point every real login runs through, so every caller needs one: the nightly re-mint, the
// bootstrap, the daily refresh-if-needed cron, a mid-call rebuild (302 to login, 401/403, or a 200 carrying a JS redirect), the refresh-context,
// refresh-render-context, force-mfa-refresh and reset-context routes, and scripts. A fence test finds every
// call site; propflowai owns the login budget. Action
// grants carry a hash of the request, so a grant can't be replayed against another record.
// writePolicy stays the robot's emergency kill switch, out of reach of grants and caller config. With it on,
// everything is blocked except the requests a driver lists as read-only (method, path, form target) and
// its declared login/MFA class (today's readonlyLock.ts:346,352 exemptions), so a fresh login still completes.
A8 · Sources and prior work
| Source | What it is |
|---|---|
| Yardi browser agent: architecture and plan (Fede, Oct 5–6) | Fede's proposed Yardi direction (only the calendar is decided; the robot core boundary is built): shared core + one agent per vendor, the sync job polls, robot phase 1, SIPP phase 2, Prospect interface phase 3; capability rows 1–12; polling cadences; the robot engine split (#470–#476) |
| Yale 25 on Yardi: what we launch now | Clara as the leasing CRM with no Yardi connection; ConAm IT allows no email or calendar access; no report scheduler; one Yardi user login |
| PMS adapter cleanup (Oct 2026) | Fede's rule (2026-10-15) and the 14-PR chain; the check-pms-literals build check and its shrink-only allowlist |
propflowai/docs/architecture/PMS_INTEGRATION.md | Ports and adapters, one translation layer per PMS, the PMS as system of record |
| ADR-0023, 0030, 0039, 0050, 0051, 0056, 0067, 0072, 0094, 0142 | Authority registry; PMS-owned ids (superseded by ADR-0031, local-first); vendors/tenants; WO sync; reconcile; the WO write port (shipped, file still says Proposed); sessions; the Yardi/RealPage mock; guest cards; vendor cards |
pms-knowledge-base/yardi/ | The first Yardi customer: one user login, Voyager 8 + CRM IQ; 12 capability rows, recon all "todo" |
| Audit files (local to the mini, session scratchpad) | audit-data-model.md, audit-writes.md, audit-reads-sync.md, audit-paths-modes.md, research-landscape-naming.md, census/summary.md + files.csv, imports.csv, branches.csv, visible.csv |
| External | Vendor docs where public; Propexo partner guides; Truto and Supergood write-ups; MITS (RETTC). Each claim in A6 carries its link and a source grade |
Limits: everything here is read-only static reading of code at one commit. Nothing was run against prod, and no phone tests. Yardi id formats come from our mock fixtures, not a live Yardi instance.