0030 — PMS is the source of truth for work order IDs

Context

PropFlow operates as a mirror of the property management system (PMS — AppFolio today; Yardi / OneSite / RealPage / Entrata in the future). The PMS holds the authoritative roster of work orders, their statuses, their canonical identifiers. PropFlow surfaces, routes, and orchestrates around that data — texts the tenant, routes vendor dispatches, ages the work, runs the agent loop — but the work order itself lives in the PMS.

A WorkOrder row in PropFlow corresponds 1:1 to a work order in the PMS. Their identifiers are the same identifier.

Decision

WorkOrder.id is the PMS's identifier (AppFolio's af.workOrderId for AppFolio-managed properties). It's a string; AppFolio returns it as a number, PropFlow stores it as String(af.workOrderId). No separate opaque DDB key.

WorkOrder.displayId is <Property.ticker>-<WorkOrder.id> (CAM-604). It's stamped on the row at write time and never changes for the lifetime of the WO.

Creating a work order goes through the handler: handleCreateWorkOrder(input, ctx?). The handler calls the PMS's create endpoint synchronously, then persists the WorkOrder row to PropFlow's DDB using the identifier the PMS returned. The handler emits recordToolCall with the outcome and returns the canonical id to the caller.

When the PMS doesn't respond in time — e.g. AppFolio is slow or returns 5xx — the handler publishes an SQS message containing the input + an idempotency key + the conversation context, and returns { ok: false, queued: true } to the caller. The caller (Clara, the admin UI, etc.) tells the user "submitted, you'll get the number shortly." PropFlow's DDB has no record of this WorkOrder yet — the SQS message holds the entire state of the request.

The SQS Lambda consumer picks up the queued message later (seconds, minutes, or whatever the AppFolio outage lasts), calls handleCreateWorkOrder with entrySource: 'sqs-fallback', and the handler tries the PMS again. When the PMS confirms, PropFlow saves the WorkOrder row with the real id and fires a follow-up SMS to the tenant with the canonical displayId. If the message exhausts SQS retries, it lands in the DLQ; the DLQ alarm fires, ops investigates.

Implications

Drift guard

src/__tests__/no-propflow-minted-wo-ids.drift.test.ts enforces this at CI time:

Alternatives considered

PropFlow as a system of record (not a mirror). PropFlow assigns its own identifiers and synchronizes them to the PMS asynchronously. Rejected: it creates a window where a PropFlow identifier exists for a work order that doesn't yet exist in the PMS, which forces every consumer to handle "PropFlow says X exists, AppFolio doesn't" as a real state. The mirror model collapses that window to zero.

Compound identifier (<propflow_id>+<pms_id>). Carry both, render whichever is set. Rejected: every consumer becomes "branch on which id is present," and tenants who see the PropFlow form first get confused when the canonical form arrives later via a follow-up SMS. The mirror keeps every consumer rendering one identifier always.