0072 — Standalone vendor-faithful mock PMS for Yardi and RealPage: hosting, persistence, test data, and correctness posture

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:

  1. 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.
  2. 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:

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:

(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:

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:

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:

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:

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:

Harder / real costs:

Committed to:

Follow-up work:

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