0031 — Local-first work orders (PropFlow owns the operational record; PMS is sync destination)

Update 2026-05-27 — Temporal runtime cutover

The local-first model below shipped on the Next.js-async-function runtime as v1 (§"Runtime — Next.js for v1"). It then migrated onto Temporal Cloud as the production runtime — the v2 path the original ADR left open. The migration (docs/planning/maintenance-on-temporal/plan.md) is build-on-the-side, phase-per-PR:

Everything below this section is the original local-first decision (the identifier model, handler semantics, judge guardrails) — unchanged by the runtime swap. The note here supersedes the "Runtime — Next.js for v1" + "Future migration to Temporal" sections only.

Context

ADR-0030 (accepted 2026-05-19, shipped via PR #1217 on 2026-05-20) established the PMS as the source of truth for work order identifiers: WorkOrder.id = String(af.workOrderId), no PropFlow row before AppFolio confirms, inline L4 with SQS-fallback on timeout.

Within hours of ADR-0030 landing in production, the architecture's tradeoffs surfaced as concrete incidents (2026-05-20 prod):

  1. Inline L4 floor is ~22sbuildApiCtxFromBrowserbase does ~19s of Chromium boot per call.
  2. Inline budget is 8s — Clara's SMS-response timing demands a quick ack.
  3. 8s < 22s → every create_work_order falls through to SQS-fallback. The "exception" path is the path.
  4. SQS-fallback dup-creates against AppFolio because the L4 idempotencyCache is per-Vercel-instance-in-memory (explicitly deferred caveat in docs/architecture/L4_DIRECT_HTTP.md). The same idempotency key hits two different cache instances; both miss; both reach AppFolio. Today's incident: PROP-823 (correct, from SQS-fallback) + PROP-822 (orphan AF WO from the inline timeout, then appfolio-sync poller mirrored it back).
  5. Photos don't attach on the SQS-fallback path — the inline path's getInboundImages() AsyncLocalStorage is empty inside the Lambda re-invocation, so the photo-attach side effect silently no-ops.

Two architectural problems made these incidents:

The right model is the inverse: PropFlow owns the operational record locally, syncs to the PMS as a background concern. This matches how every modern app handles "offline-tolerant" state (Linear, Notion, Figma) and decouples Clara's response latency from AppFolio's API performance.

Decision

PropFlow is the source of truth for the operational record of a work order. AppFolio (and other PMSes) are sync destinations, not sources of identity.

Identifier model

WorkOrder.id is a PropFlow-minted local identifier, formatted <TICKER>-<6-digit zero-padded> (e.g., PROP-00001, CAM-00237). Minted at handler invocation via an atomic per-property counter row at PROPCONFIG#<propertyId>.

WorkOrder.displayId equals WorkOrder.id. Tenants, vendors, operators, and audit logs all see the same identifier for the life of the WO.

WorkOrder.pmsId is the PMS-assigned identifier (e.g., AppFolio's af.workOrderId as a string), nullable, populated by the work-order pipeline post-sync. Operator UI shows it as a small badge ("AF #823") when present; "Syncing…" indicator when absent.

Local-first handler semantics

Maintenance handlers (handleCreateWorkOrder, handleAddNoteToWorkOrder, handleCancelWorkOrder, photo-attach, etc.) mutate local PropFlow state immediately and return success. They do not call L4 endpoints and do not publish SQS messages. The inline-then-SQS-fallback path from ADR-0030 is removed entirely.

On each handler invocation:

The work-order pipeline — the back-end sync agent

A new back-end pipeline — the work-order pipeline — assembles the local state into a coherent PMS-side submission once the conversation goes idle.

Three phases:

  1. GATHER (deterministic, stateless) — reads dirty WOs touched in this conversation since last flush, reads conversation messages + Clara's tool call sequence for context, assembles a draft package per WO.
  2. JUDGE (LLM call — small Claude invocation) — reviews the draft + conversation context, makes within-bounds adjustments (strips artifacts of Clara's turn-by-turn process, catches obvious inconsistencies, refuses to ship conflicts), logs every adjustment with a rationale.
  3. DISPATCH (deterministic) — executes L4 calls in dependency order (create → attach_doc → add_note → field updates), stores pmsId on success, clears dirty markers, marks pmsSyncStatus: 'synced'. Partial failures get pmsSyncStatus: 'partial_sync' and only the failed bits stay dirty for retry.

Judge phase guardrails:

Every work-order pipeline adjustment gets logged to the WO timeline with the rationale. Operator can trace every difference between Clara's tool calls and what landed in AppFolio.

Sync triggers

No tenant-side "immediate flush" exception. All tenant-conversation flows wait for the idle window.

Runtime — Next.js for v1 (HISTORICAL — superseded by the Temporal cutover, see Update 2026-05-27)

The work-order pipeline runs as a standard async function in the PropFlow codebase. The 5-min idle is implemented as Conversation.pendingWorkOrderRunAt + a 1-min sweeper cron at /api/admin/maintenance/work-order-sweeper. Voice call-end and operator dashboard actions fire runWorkOrderPipeline directly (no debounce).

Reasoning:

Future migration to Temporal (DONE — landed 2026-05-27, see Update at top)

This section is the original v2 rationale, kept for history. The migration has since landed; the trigger conditions below are what justified it at decision time.

Temporal stays on the table as a v2 consideration. Trigger conditions that justify the migration:

  1. Post-create WO lifecycle features get prioritized — vendor 24h reminders, scheduling escalations, completion follow-up, auto-close. Each is a durable-timer + signal pattern; Temporal handles them natively.
  2. Renewals' Temporal cutover lands cleanly in prod for 2-4 weeks. Validates the infrastructure for the first customer; safer to add a second.
  3. Reliability incident in the Next.js work-order pipeline that Temporal would have prevented — durable timer surviving Vercel restart, signal-based reset semantics needed under load, replay safety required.

The detailed plan (docs/planning/agent-smith/plan.md) covers the migration path: schema, handlers, docs, drift guards, Pipeline Lab UI don't change; only the trigger (sweeper cron → workflow signals) and runtime (async function → activity) swap. ~2-3 working days when triggered.

If Temporal is later adopted, this ADR is extended (not superseded) with the workflow + activity boundary specifics.

Entity classification

Entity Class Naming Spine trace OR derived-from
WorkOrder (existing, schema augmented) canonical bare name Spine trace: via personId (tenant FK)
Conversation (existing, schema augmented) canonical bare name Spine trace: via personId
WorkOrderPipelineFlushEvent (new — per the work-order pipeline invocation, for audit) derived *Event suffix from: WorkOrder + Conversation state at flush time · Rebuilt by: not rebuildable (immutable audit record) · Drift tolerance: N/A

No new canonical entities; the migration augments existing schemas (WorkOrder gains pmsId, pmsSyncStatus, pmsSyncDirty, etc.; Conversation gains pendingWorkOrderRunAt, agentSmithInProgress, lastAgentSmithFlushAt; Message gains attachedToWorkOrderId, attachedSynced).

Consequences

Easier

Harder

Implies

Drift guard

src/__tests__/local-first-work-orders.drift.test.ts (new) enforces:

The third assertion is the load-bearing one: it's the structural equivalent of no-propflow-minted-wo-ids.drift.test.ts from ADR-0030, inverted.

Alternatives considered

Keep ADR-0030, just make L4 faster. Ship the L4 ctx cache (PR appfolio-browser-agent#139) and reduce the inline floor from 22s to ~3-5s. Inline budget of 8s would then usually clear. Rejected as the primary fix because: (1) it doesn't solve the dup-create race (per-instance idempotency cache remains); (2) it makes AppFolio's API performance load-bearing on every tenant interaction; (3) it doesn't enable PMS-less prototyping or PMS-outage tolerance. The ctx cache ships anyway as a Pareto-improvement, but as a complement, not the answer.

Streaming sync — each handler invocation enqueues an SQS message that fires the corresponding L4 immediately. Considered + rejected: produces N L4 calls per WO (one per change), each ~3-5s; no batching of related changes; weird partial-failure semantics ("create landed but attach-image failed — now what?"); more complex retry logic.

Per-message sync sweep — fire the work-order pipeline after every Clara turn instead of waiting for idle. Considered + rejected: doesn't batch related changes (photo + note arriving in successive messages would sync separately); higher L4 load; the user's "friend takes notes then handles it" mental model says wait for the conversation to settle.

Compound identifier (carry both PropFlow ID and PMS ID, render whichever is present). Already considered + rejected by ADR-0030 for the same reason: every consumer becomes "branch on which id is present," tenants who see the PropFlow form first get confused when the canonical form arrives later. The local-first model keeps the consumer-facing identifier stable (always the PropFlow ID) and treats the PMS id as operator-visible metadata only.

Continue using SQS-fallback path with shared idempotency cache (Upstash/DDB-backed) to fix the dup-create race. This is the architecturally complete fix the L4 doc explicitly proposes. Rejected as the primary path because it leaves the inline-then-SQS architecture in place, which still makes AppFolio's response time load-bearing on tenant latency and doesn't enable PMS-less prototyping. The shared idempotency cache may still ship as a separate hardening for any remaining SQS-based flows (renewals, etc.).

References