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:
Clobber path (PR #811 incident, 2026-05-10). The local write succeeds. The 1-min
propflow-appfolio-sync-work-orders-schedulecron then reads AppFolio (which still shows the prior state), and the writer atsrc/lib/domain/pms/writers/work-order.ts:101clobbers 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.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 acceptedrenewalStatusdirectly without firingtriggerRenewalSync.
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.
- One file to extend when adding a new PMS-mirrored entity. No grep-and-update across N call sites.
- Per-property override is a one-line addition (mutate
pmsIsAuthoritativeForto consult aProperty.workflowAuthorityfield) when the first such case lands. - The
TENANT_RENEWAL_FIELDS_PMS_AUTHORITATIVEconstant doubles as a registry of fields that must NEVER ship through the generic PATCH route on a PMS-managed tenant — drift-guarded by the route guard's tests.
Cons.
- Extra indirection. A reader who wants to know "is this WO PMS-managed?" now navigates from the inline check to
authority.ts. Mitigated by the single short helper file with prominent doc comments. - The 409 rejection on
/api/tenants/[id]is a behavioral break for any external caller that was relying on the silent-non-propagation behavior. None known today (route is internal-only); the response body'sdetailfield directs callers to the correct PMS-mirroring endpoint.
Alternatives considered
Property.workflowAuthorityas 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.- Throwing in the writer instead of
logWarn. Rejected — legitimate test-DB callers may have mirrored prod data withpmsExternalIdset; 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). - Property-specific override on
pmsSourcevalue. Rejected —pmsSourceis descriptive (which PMS is wired); authority is a separate orthogonal concern (which entities does the wired PMS own?).