0056 — Unified PMS work-order write capability (caller-agnostic mirror leg)
- Status: Proposed
- Date: 2026-06-15
- Deciders: Gera (Jose), Claude
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.
Promote the existing
L4ClienttoPMSWorkOrderWriterin a new modulesrc/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 bydispatch.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 indispatch.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 usepmsId— neverappfolioWoId— so no field renames are needed.Add
'workOrderWrite'toPMSCapabilityinsrc/lib/domain/pms/types.ts, so a future PMS can advertise WO-write support before a caller dispatches.Relocate the AppFolio implementation into the integration layer. Move
src/lib/maintenance/work-orders/l4-adapter.ts→src/lib/integrations/appfolio/work-order-writer.tsand renamemakeProductionL4Client()→makeAppfolioWorkOrderWriter(): PMSWorkOrderWriter. The body is verbatim — same L4 mapping, same idempotency keys, sameL4FieldNotWiredfor the unwiredtitle/categoryfields. The L4 import is legal here (integration layer), which is the whole point: it leaves the maintenance leg.Resolve via a dedicated
getPMSWorkOrderWriter(pmsType)(NOTgetPMSClient(..., { userId })) insrc/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-APIAppfolioClient(the read transport) and requires auserId. Routing writes through it would couple the write path to auserIdit 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 adminflush-*routes,turnover-dispatch.ts) to resolve via the registry. DeletemakeProductionL4Clientfrom the maintenance leg in the same PR — one path, noif appfoliofallback, no paralleldispatchOld/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
(L4Client → PMSWorkOrderWriter) 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:
- Onboarding a second PMS for maintenance = implement
PMSWorkOrderWriter+ add acasetogetPMSWorkOrderWriter. The maintenance pipeline doesn't change. - The "what really happened on the WO" provenance stays honest — the adapter still
maps to AppFolio's real
attach_document/mark_complete; we abstract the caller's vocabulary, not the system's truth (the launch video's two-tool display is a downstream presentation choice, unaffected). dispatch.tswas already unit-testable against a fake writer (the injectedL4Client); that property is preserved verbatim. Per the no-mock-theater gate, the real-harness run (pipeline-lab/ maintenance-eval againstappfolio-45) remains the proof, not the unit test.
Harder / cost:
- A refactor touching the maintenance dispatch leg + turnover-dispatch + the 5 wiring
sites + the new PMS-domain interface module +
PMSCapabilityenum. Substantive multi-file change → needs this ADR + the linked plan as its Lens-25 plan. - A new drift guard (
src/lib/maintenance/work-orders/**must not import the L4 client) must land in the same PR, mirroring the domain-import rule. - Other WO-write callers outside maintenance (if any — renewal/tour legs, ops tools
at
/admin/dev/tools) are out of scope for the first PR and keep calling L4 directly until a follow-up; the AppFolio catalog tools (appfolio.attach_documentet al.) intentionally stay AppFolio-named (they are AppFolio-specific ops surfaces, not domain code).
Follow-up implied:
- A drift guard asserting
src/lib/maintenance/work-orders/**no longer imports the AppFolio L4 client directly (mirror of the existing domain-import rule). - Decide whether renewal/tour mirror legs migrate onto the same capability shape.
Alternatives considered
- Build a "unified attach" endpoint on AppFolio's side. Rejected — we don't own
AppFolio's API;
attach_documentvsappend_work_order_remarksis their split and we can't merge it. The unification belongs in our adapter layer, not theirs. - Leave writes AppFolio-direct; branch per PMS at the call site. Rejected — an
if (pmsType === 'appfolio') … else if (yardi) …ladder in the maintenance pipeline is exactly the parallel-implementation pattern the repo forbids, and it re-rots every time a write is added. - Do nothing until a second PMS actually lands. Rejected on timing: the migration is cheapest now, while AppFolio is the only implementation and the change is a same-shape wrap. Deferring means doing it under onboarding pressure with two systems live — strictly harder.
- Abstract at the SQS agent-job layer instead of
PMSClient. Rejected — the capability registry is the repo's designated seam (ADR-0023); adding a second abstraction layer for the same concern violates one-source-of-truth.
References
- ADR-0023 — PMS authority registry / agnostic naming.
- ADR-0031 — local-first WO writes (the fan-out this sits under).
- ADR-0030 — PMS owns WO identifiers.
docs/architecture/PMS_INTEGRATION.md— the adapter-not-integration rule.docs/planning/pms-wo-write-capability.md— the implementation plan (file table, drift guard, proof plan).src/lib/maintenance/work-orders/dispatch.ts— defines the existingL4Clientinterface (promoted toPMSWorkOrderWriter); already PMS-agnostic, consumes viaDispatchDeps.src/lib/maintenance/work-orders/l4-adapter.ts—makeProductionL4Client(), the AppFolio impl to relocate intosrc/lib/integrations/appfolio/work-order-writer.ts.src/lib/domain/pms/types.ts(PMSCapability),src/lib/domain/pms/registry.ts(newgetPMSWorkOrderWriter).src/lib/integrations/appfolio-browser-agent/l4-client.ts— the AppFolio write surface the relocated adapter keeps wrapping.