0056 — Unified PMS work-order write capability (caller-agnostic mirror leg)

Context

A maintenance work order is mirrored from PropFlow into the PMS through a set of writes — create, attach a photo, append a remark, change priority, assign a vendor, set permission-to-enter, schedule, mark complete, mark ready-to-bill, cancel. Today those writes are bound to AppFolio vocabulary.

The seam is half built already. The dispatch phase (src/lib/maintenance/work-orders/dispatch.ts) is already PMS-agnostic — it depends on an injected L4Client interface (5 verbs: createWorkOrder / attachDocument / addNote / updateField / setTerminalStatus) via DispatchDeps.l4 and imports no L4 client. The AppFolio binding lives entirely in src/lib/maintenance/work-orders/l4-adapter.ts (makeProductionL4Client()), the only thing importing @/lib/integrations/appfolio-browser-agent/l4-client and calling createWorkOrderL4 / attachDocumentL4 / appendWorkOrderRemarksL4 / changePriorityL4 / assignVendorL4 / setPermissionToEnterL4 / scheduleWorkOrderL4 / markReadyToBillL4 / cancelWorkOrderL4 / etc.

So the gap is not "no abstraction." It is (a) the interface is named for AppFolio (L4Client) and lives in the maintenance leg rather than the PMS domain, and (b) the implementation is wired by a free function imported from the maintenance leg (makeProductionL4Client) rather than resolved through the PMS registry. The change is rename + relocate + registry-wire — a true same-shape wrap, not a from-scratch abstraction.

This surfaced from a small thing — in the maintenance launch video, the photo attach reads as two different tools: PropFlow's add_note_to_work_order on our side, AppFolio's attach_document on theirs. That difference is real and correct per system (PropFlow has a single unified note/photo/access tool; AppFolio splits text remarks from file attachment), but it exposed the deeper asymmetry: the caller has to know AppFolio's vocabulary.

That asymmetry contradicts the architecture the repo already commits to. CLAUDE.md ("Domain code must go through the PMS adapter, not the integration directly") and docs/architecture/PMS_INTEGRATION.md mandate that src/lib/domain/** talk to the PMS through the PMSClient capability registry (getPMSClient(pmsTypeForProperty(prop))), never the integration. The read side already honors this — PMSClient exposes listWorkOrders?(params) (verified: it is the only WO method on the interface today). The write side never got migrated; it predates the rule and violates it.

Why now: Yardi / Buildium / Entrata are real onboarding candidates (per ADR-0023 / the renewal PMS-agnostic-naming rule). With writes hardcoded to AppFolio, onboarding a second PMS means either (a) a parallel mirror leg per PMS, or (b) a giant if (pmsType === 'appfolio') ladder in the maintenance pipeline — both of which the "one source of truth, no parallel implementations" rule forbids. The clean unlock is to lift WO writes into the capability interface now, while AppFolio is the only implementation and the migration is a same-shape wrap.

Scope note — this is NOT about Clara. Clara already makes one call (maintenance.add_note_to_work_order) and returns; the local-first pipeline (ADR-0031) does the PropFlow write + the async PMS mirror. She is already ignorant of the mirror. This ADR is about the layer below her — the mirror leg — which should be equally ignorant of which PMS it is writing to.

Decision

Promote the existing 5-verb writer interface out of AppFolio vocabulary into the PMS domain, and resolve its implementation through a dedicated registry function — so the caller names an action and the registry dispatches to whichever PMS the property uses.

  1. Promote the existing L4Client to PMSWorkOrderWriter in a new module src/lib/domain/pms/work-order-writer.ts. Keep the 5 verbs verbatim — they are already PMS-agnostic in shape and battle-tested, with idempotency-key derivation owned by dispatch.ts:

    export interface PMSWorkOrderWriter {
      createWorkOrder(req: CreateWorkOrderRequest): Promise<{ pmsId: string }>;
      attachDocument(req: AttachDocumentRequest): Promise<void>;
      addNote(req: AddNoteRequest): Promise<void>;
      updateField(req: UpdateFieldRequest): Promise<void>;       // priority / schedule / vendor / permissionToEnter / specialInstructions / description / (title|category → L4FieldNotWired)
      setTerminalStatus(req: SetTerminalStatusRequest): Promise<void>; // 'cancelled' | 'completed'
    }
    

    Why NOT the 9 fine-grained methods originally drafted here (attachWorkOrderDocument, setWorkOrderPriority, scheduleWorkOrder, …): the live code already has this clean 5-verb surface with a passing contract test and idempotency keys derived in dispatch.ts. Re-shaping into 9 methods would move that key derivation around and turn a same-shape wrap into a rewrite. The 5 verbs ARE the PMS-agnostic seam; a future PMS that needs finer capability advertisement gets the 'workOrderWrite' enum (below) + a later split, not this PR. The request DTOs (CreateWorkOrderRequest, etc.) already use pmsId — never appfolioWoId — so no field renames are needed.

  2. Add 'workOrderWrite' to PMSCapability in src/lib/domain/pms/types.ts, so a future PMS can advertise WO-write support before a caller dispatches.

  3. Relocate the AppFolio implementation into the integration layer. Move src/lib/maintenance/work-orders/l4-adapter.tssrc/lib/integrations/appfolio/work-order-writer.ts and rename makeProductionL4Client()makeAppfolioWorkOrderWriter(): PMSWorkOrderWriter. The body is verbatim — same L4 mapping, same idempotency keys, same L4FieldNotWired for the unwired title/category fields. The L4 import is legal here (integration layer), which is the whole point: it leaves the maintenance leg.

  4. Resolve via a dedicated getPMSWorkOrderWriter(pmsType) (NOT getPMSClient(..., { userId })) in src/lib/domain/pms/registry.ts:

    export function getPMSWorkOrderWriter(pmsType: PMSType): PMSWorkOrderWriter | null {
      switch (pmsType) {
        case 'appfolio': return makeAppfolioWorkOrderWriter();
        default: return null;
      }
    }
    

    Why a dedicated resolver, not getPMSClient: WO writes ride the browser-agent L4 (module-level, token-authenticated functions — no per-user credential). getPMSClient('appfolio', { userId }) builds the Reports-API AppfolioClient (the read transport) and requires a userId. Routing writes through it would couple the write path to a userId it doesn't need and eagerly construct a read client it never uses. The dedicated resolver matches the actual transport and keeps the write seam minimal. The two transports stay honestly distinct.

    Migrate the 5 call sites (temporal/activities/maintenance.ts, the 3 admin flush-* routes, turnover-dispatch.ts) to resolve via the registry. Delete makeProductionL4Client from the maintenance leg in the same PR — one path, no if appfolio fallback, no parallel dispatchOld/dispatchNew (CLAUDE.md "ONE SOURCE OF TRUTH"). The L4 client survives only as the AppFolio writer's private dependency.

After this, the mirror leg consumes a PMSWorkOrderWriter resolved from the registry — and a new PMS is "implement the interface," zero changes to the maintenance pipeline, no per-PMS function names to memorize.

Implementation plan: docs/planning/pms-wo-write-capability.md — the 10-file change table, drift guard, and no-mock-theater proof plan.

Entity classification (per ADR-0027)

This ADR proposes no new entities — it promotes an existing interface (L4ClientPMSWorkOrderWriter) and relocates its request DTOs (CreateWorkOrderRequest, AttachDocumentRequest, AddNoteRequest, UpdateFieldRequest, SetTerminalStatusRequest) into the PMS domain unchanged. These are transport shapes at the adapter boundary, not persisted rows, so they carry no spine trace and need no @canonical/@derived tag. Naming rule that DOES bind and already holds: the WO reference is the PMS-agnostic pmsId, never appfolioWoId / occupancyId — the same review lens that guards PreparedOffer.

Consequences

Easier:

Harder / cost:

Follow-up implied:

Alternatives considered

References