ADR-0023 — PMS authority registry

Status: Accepted
Date: 2026-05-10
Drives: PR #811 follow-up (audit + structural fix)

Context

PropFlow is the action-layer orchestrator (texts, calls, scheduled jobs, AI decisions). The PMS — AppFolio today, Yardi/RealPage/etc. eventually — is the system of record for entities the PMS-sync writers mirror: work-order status, renewal lifecycle, lease state, balance snapshots. This is canonized in docs/architecture/PMS_INTEGRATION.md §9.

The bug class. When a writer in PropFlow mutates one of those PMS-mirrored fields locally without dispatching the corresponding PMS write, one of two things happens:

  1. Clobber path (PR #811 incident, 2026-05-10). The local write succeeds. The 1-min propflow-appfolio-sync-work-orders-schedule cron then reads AppFolio (which still shows the prior state), and the writer at src/lib/domain/pms/writers/work-order.ts:101 clobbers the local mutation back to the PMS view. Visible to operators only via UI ("the WO I closed reappeared"). Detected: PR #815 added a clobber-warn in the writer.

  2. Silent-non-propagation path (PR #811 follow-up audit). The local write succeeds. The sync writer doesn't touch the field (e.g. the renewal saga writer no longer writes tenant.renewalStatus), so there's no clobber — but the operator-facing source of truth (AppFolio) never sees the change. Discovered on the /api/tenants/[id] PATCH route, which accepted renewalStatus directly without firing triggerRenewalSync.

Both failure modes share a root cause: the codebase had no canonical "is PropFlow allowed to write this locally?" check. Each call site inlined wo.pmsExternalId ? ... : ... (or, worse, didn't check at all). Adding a new code path that touches a PMS-mirrored field could silently re-introduce either failure mode.

Decision

Introduce src/lib/domain/pms/authority.ts as the single source of truth for "who owns this entity's lifecycle?" Every code path that locally mutates a PMS-mirrored field consults the helper instead of inlining the check.

Helpers

type PmsManagedEntity = 'workOrder' | 'renewal' | 'leaseState' | 'balance';

function pmsIsAuthoritativeFor(property, entity: PmsManagedEntity): boolean;
function isPmsAuthoritativeWorkOrder(wo): boolean;
function isPmsAuthoritativeRenewalForTenant(tenant, property): boolean;

const TENANT_RENEWAL_FIELDS_PMS_AUTHORITATIVE = ['renewalStatus', 'reasonForNonRenewal'] as const;

Policy (uniform today)

Boolean(property.pmsSource) — when a property is wired for PMS sync, the PMS owns every entity in the PmsManagedEntity union for that property. The per-entity API shape is intentional so a future override (e.g. a property where PMS is configured but renewals are PropFlow-only) lands without changing call sites.

Field set

TENANT_RENEWAL_FIELDS_PMS_AUTHORITATIVE enumerates the fields on Tenant whose mutation requires PMS dispatch. Today: renewalStatus, reasonForNonRenewal. Outreach state (renewalOutreachStage, renewalOutreachSentAt) is PropFlow-only workflow scaffolding — not in the set.

Call sites updated

Site Before After
close-all-for-tenant.ts partition inline wo.pmsExternalId isPmsAuthoritativeWorkOrder(wo)
close-work-order.ts drift-guard inline wo.pmsExternalId isPmsAuthoritativeWorkOrder(wo)
/api/tenants/[id] PATCH no check (the audit gap) rejects with 409 + remediation hint when fields ∈ TENANT_RENEWAL_FIELDS_PMS_AUTHORITATIVE and property is PMS-managed

Call sites NOT updated (intentionally)

The sync writer's clobber-warn (PR #815) detects a related but distinct shape: "PMS reports the WO as open; local was terminal." That's an outcome check, independent of the property-level authority question. Keeping them as separate detectors gives belt-and-suspenders coverage with minimal coupling.

Consequences

Pros.

Cons.

Alternatives considered

  1. Property.workflowAuthority as a stored field on every property record. Rejected as YAGNI — the policy is uniform across properties today. The helper API leaves room for this when a real per-property override case shows up.
  2. Throwing in the writer instead of logWarn. Rejected — legitimate test-DB callers may have mirrored prod data with pmsExternalId set; we want a flag, not a hard break. The PATCH route, by contrast, IS hard-rejecting (409) because the misuse there is unambiguous (no test scenario should be PATCHing real renewal fields on a PMS-managed tenant outside the proper SQS-mirroring routes).
  3. Property-specific override on pmsSource value. Rejected — pmsSource is descriptive (which PMS is wired); authority is a separate orthogonal concern (which entities does the wired PMS own?).