0072 — Standalone vendor-faithful mock PMS for Yardi and RealPage: hosting, persistence, test data, and correctness posture
- Status: Proposed
- Date: 2026-06-30
- Deciders: Fede
Context
PropFlow's PMS integration layer (ADR-0023, ADR-0056) is built for multi-PMS expansion beyond AppFolio. The registry (src/lib/domain/pms/registry.ts, writer-registry.ts) already lists 'yardi' | 'onesite' | 'realpage' | 'entrata' as valid PMSType values but returns null for all of them — no adapter exists on main today.
Yardi and RealPage are the two nearest onboarding candidates. We do not have vendor dev-sandbox access yet, and getting it is a business/partnership step on an unknown timeline. We cannot let "no sandbox" mean "can't build" — building an adapter to the published API documentation is the legitimate and only path pre-sandbox.
Purpose of this work (why the mock exists). The real deliverable is not the adapters in isolation — it is proving that the entire PropFlow platform runs correctly against any PMS through the abstraction. That proof comes from two things this ADR enables:
- A platform-wide production conformance harness that drives every PropFlow flow (renewals in all shapes, leasing/tours/guest-cards, maintenance/work-orders, turnovers/charges/deposits, voice/email/SMS, delinquency/evictions, accounting) × each PMS × the meaningful entity-state permutations through the abstraction, and verifies each call routes correctly — right operation, right auth, right field mapping, right domain shape back.
- An adversarial audit that attacks the result for gaps, bugs, bad assumptions, and AppFolio couplings leaking into supposedly PMS-agnostic layers.
For both to be meaningful, the mock must be vendor-faithful: the adapter must talk to it exactly as it will talk to the real vendor.
What already exists and informs the design:
e2e/fixtures/yardi-yale-scrubbed.xlsx— an anonymized Yale Station rent roll already in the repo, with real Denver-market structural distributions (unit-type mix, charge codes, balance aging) and all tenant PII replaced. The unscrubbed source must never be committed (tracked separately for cleanup).src/__tests__/yardi-lease-charges.test.ts— a regression test for Yardi's multi-rowRentRollwithLeaseChargesformat that already caught a real production defect (100+ tenants withrent: undefinedafter a clean sync). It usesparseGenericCsvon the scrubbed fixture's structure — demonstrating that real fixture data catches real bugs in ways invented data does not.src/lib/domain/pms/client.ts— a rich, optional-capabilityPMSClientinterface; callers guard withclient.capabilities.has(...)before dispatching.
Decision
1. Hosting: a standalone, vendor-faithful mock service — NOT in-app route handlers
The mock is a separate, standalone service, deployed as its own isolated Vercel project (not the PropFlow app), reachable at a dedicated vendor-shaped external domain via Cloudflare DNS:
yardi-sandbox.propflow-pms.dev— Yardi SIPP mockrealpage-sandbox.propflow-pms.dev— RealPage mock
(Subdomains of an owned zone are acceptable if standing up a new zone is slower; the host is config, not code.)
No mock code ships in the PropFlow app bundle or deploy. The mock handler logic (request dispatch, fixtures, store) lives in a module exercised only by the standalone mock service and the test harness — never imported into the production app's route graph. There are no src/app/api/mock/** route handlers in the deployed app.
Why this over in-app route handlers (the originally-chosen design, now rejected — see Alternatives). Two reasons:
- Isolation: test infrastructure and scrubbed fixture data must not live inside the production app's deploy surface.
- Fidelity: the adapter must call a vendor-shaped base URL in every environment so tests exercise the real production transport path (host, TLS, headers, error shapes), not a
propflowai.co/api/mock/...internal shortcut that only works because we point it at ourselves.
2. Adapter contract: base-URL-driven, vendor-shaped in every environment
Adapters take { baseUrl, fetchImpl } and have no knowledge of "mock" vs "real." The base URL comes from per-environment config:
- Production: the real Yardi/RealPage endpoint.
- Stage / test / local: the mock domain above.
The adapter code path is identical across environments. This is what makes the conformance harness's results trustworthy.
3. Persistence: pluggable store — KV when deployed, JSON-file locally; never a module-singleton Map
Mutable mock state (work orders, prospects, appointments) goes through a small store interface with two backends, selected by environment — mirroring the repo's DATA_BACKEND JsonRepository-vs-DynamoRepository pattern:
- Deployed (serverless): a KV backend (Vercel KV / Upstash Redis). The deployed mock runs on serverless functions, so state must be external — a
/tmpfile or in-process Map does not survive cold starts or multi-instance routing. - Local dev: a JSON-file store at
process.env.MOCK_PMS_STORE_PATH ?? '/tmp/propflow-mock-pms-state.json'(zero deps, fast).scripts/reset-mock-pms.shclears it.
Module-singleton Map is banned for mutable state — it resets silently on Next.js HMR reload and on serverless cold start (a create returns 201, the next read 404s, no error). Idempotent fixture reads stay module-level constants.
4. Test data: Yale scrubbed fixture as the required source
All Yardi fixture data MUST derive from e2e/fixtures/yardi-yale-scrubbed.xlsx, not be invented from scratch. Invented distributions don't preserve the structural truth (charge-code variety, balance aging tail, continuation-row pattern) that catches real bugs.
PII rule (zero tolerance): no real tenant name, real rent, or real balance from any customer property in any committed file. The unscrubbed source is never committed. RealPage/Knock fixtures (community sources, agents) are static and minimal.
5. Correctness posture: build to the docs; validate against the first real instance
Building an adapter to the published vendor API documentation is the correct pre-sandbox approach. A mock written from those docs proves the adapter handles the documented shape — necessary, and legitimate, work.
The honest gap is documented vs. a real instance, not documented vs. sandbox. The validation gate is therefore first-real-instance validation, satisfied by either:
- a vendor dev sandbox (if/when obtained), or
- a read-only check against the first onboarded real property (e.g. pull balances/lease states and eyeball them against that customer's own PMS screen — cheap and zero-risk for reads).
Cassette recordings of that validation are a useful artifact, not a blocker. "Mock tests pass" is necessary but not sufficient to declare a capability validated.
6. Capability + the money-decision guard (balances)
Adapters implement and advertise capabilities per the documented API — including balances. We do not omit a documented capability for lack of a sandbox.
The one guardrail: an automated money decision — delinquency flag, eviction step, renewal eligibility — must not act on a balance number from a PMS adapter that has never been validated against one real instance (§5). This gates the decision, not the capability, and the check is a one-time onboarding step. A code marker (// ADR-0072 §6: balances doc-derived; verify vs first real instance before automated money decisions) flags doc-derived mappings.
7. Yardi scope (V1)
Implemented + advertised: leaseStates (via parseGenericCsv on RentRollwithLeaseCharges, already proven), workOrders (read), workOrderWrite (CreateOrEditServiceRequests; attachDocument a no-op pending real validation), balances (doc-derived, decision-gated per §6).
Deliberately omitted: renewals (/ItfLeaseRenewal, requires renewal-module license), vendors, renewalFeatures/historicalCharges (per-occupancy charge-detail feeds; defer to V2).
Token endpoint is a testing-only convention. Real Yardi SIPP uses pre-provisioned static tokens; there is no POST /token. The mock's /token (HS256 JWT signed with a dev secret) exists for local convenience only; YardiSippClientOptions.apiToken accepts a static token to skip issuance in production. Documented in the handler and not a model for the real integration.
8. RealPage scope (V1)
Implemented + advertised: Knock CRM guest-card surface — createProspect, findProspect, requestAppointment (Bearer auth).
Deliberately omitted: balances, leaseStates, workOrders (require SOAP products outside Knock); all write ops requiring extra vendor approval; the Revenue Management API path (DOJ antitrust risk per project_realpage_integration_application.md).
RealPage Knock has no public OpenAPI spec; the mock is explicitly best-effort and scoped to the leasing guest-card surface.
9. Production-readiness posture
The mock service itself needs no retry/circuit-breaker/Sentry — it is a test tool. The real adapters inherit resilience at the registry call site (claudePolicy/dynamoPolicy from src/lib/platform/resilience.ts) plus timeouts; non-2xx/partial responses map to typed errors. Structured logging at adapter boundaries via src/lib/platform/logging.ts; never log tokens or raw bodies (PII). Add pmsType as a log dimension. Per-property opt-in only: a property's pmsSource must be set explicitly by an admin.
Consequences
Easier:
- The adapter exercises the real production transport path in tests (vendor-shaped URL everywhere), so the conformance harness's green is meaningful.
- No mock code or scrubbed data in the main app's deploy.
- Building proceeds now, against the docs, without waiting on sandbox access.
- The scrubbed Yale fixture is already proven; new scenarios add rows, not invented numbers.
Harder / real costs:
- A second deployable (the mock Vercel project) + a Cloudflare DNS record + a KV store to provision and keep cheap.
- Two store backends (KV deployed, JSON-file local) to keep behind one interface.
- First-real-instance validation is still owed before any automated money decision trusts a balance; until then, balances are advertised but decision-gated.
attachDocumentis a no-op in V1.
Committed to:
- Mock is a standalone service on a vendor-shaped domain; not in the app bundle.
- Adapters are base-URL-driven; no internal-API shortcut.
- No module-singleton Maps for mutable state.
- All Yardi fixture data traces to the scrubbed fixture; zero PII committed.
- No automated money decision acts on an unvalidated balance.
Follow-up work:
- Stand up the mock Vercel project + Cloudflare DNS + KV.
- Build the platform-wide production conformance harness (all flows × PMS × permutations).
- Run the adversarial audit (AppFolio-leakage etc.) → prioritized report.
- Obtain Yardi/RealPage sandboxes (business track); record cassettes when available.
- Wire
attachDocument; writee2e/fixtures/README.md(scrubbing process);scripts/reset-mock-pms.sh.
Alternatives considered
A. In-app Next.js App Router mock handlers, gated + deployed with the app (the originally proposed design).
Mock routes under src/app/api/mock/pms/**, runtime-gated by MOCK_PMS_ENABLED, plus a build-time assertion to block the flag on Vercel.
Rejected. It bakes test infrastructure and scrubbed fixtures into the production app's deploy surface, and — more importantly — it makes the adapter call an internal propflowai.co/api/mock/... URL in tests, a different transport path than production. Tests would pass because we point the app at itself, not because the adapter handles a real vendor endpoint. A standalone service on a vendor-shaped domain removes both problems.
B. Module-singleton in-memory Map for mock state.
Rejected. Silent reset on HMR and serverless cold start; create-then-read becomes unreliable exactly when the mock matters. The pluggable KV/file store costs the same and works.
C. EC2 / a new VM to host the mock. Rejected. EC2 is retired in our architecture (Lambda+SQS, Vercel runner). Reintroducing a VM to patch, secure, and pay for — just to host a mock — is the heaviest option and against our direction. A separate Vercel project on the stack we already run is lighter and isolated.
D. In-repo mock as the durable correctness gate. Rejected as a correctness claim. A mock built from our reading of the docs proves we handle what we assumed. The real gap closes only against a real instance (§5). The mock is for fast local/CI iteration and demos; it is not proof of fidelity.
E. Invent synthetic fixtures from scratch. Rejected. The scrubbed Yale fixture already exists and has already caught a real bug; invented data lacks the structural truth that reproduces real defects.
F. Omit balances (and any unvalidated capability) until a sandbox exists.
Rejected. Building to documented APIs is the legitimate pre-sandbox path; omitting documented capabilities indefinitely blocks useful work. Instead: implement and advertise per docs, and gate only the automated money decision on first-real-instance validation (§5–§6).
References
e2e/fixtures/yardi-yale-scrubbed.xlsx— anonymized rent roll; required source for Yardi fixture datasrc/__tests__/yardi-lease-charges.test.ts— multi-row aggregation regression; value of real fixture structuresrc/lib/domain/pms/client.ts/types.ts—PMSClient,PMSType,PMSCapabilitysrc/lib/domain/pms/registry.ts/writer-registry.ts—getPMSClient/getPMSWriter; stub cases returnnullsrc/lib/integrations/appfolio/pms-adapter.ts— reference adapter patternsrc/lib/platform/resilience.ts—claudePolicy/dynamoPolicy- ADR-0023 — PMS authority registry; agnostic naming rule
- ADR-0056 — PMS work-order write capability
- ADR-0058 — no mock theater; mock-passing tests are not correctness proof
project_realpage_integration_application.md(memory) — RealPage vendor registration; DOJ RevMgmt exclusion