0031 — Local-first work orders (PropFlow owns the operational record; PMS is sync destination)
- Status: Accepted — Temporal-backed runtime cutover landed (see Update 2026-05-27)
- Date: 2026-05-20 (proposed) · 2026-05-27 (accepted, Temporal cutover)
- Deciders: Jose
- Supersedes: ADR-0030
- Detailed plan: docs/planning/maintenance-on-temporal/plan.md (the runtime cutover) · docs/planning/agent-smith/plan.md (the original local-first plan)
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:
- The work-order pipeline (gather → judge → dispatch → persist) now runs as
maintenanceWorkflowon thepropflow-maintenanceTemporal task queue, co-tenanted in the existing Fargate worker alongside renewal/tour/turnover. - Triggers are signals, not a cron.
scheduleWorkOrderPipeline→signalDirtyBitsChanged(idle-debounce, resets per turn) /signalCallEnded(voice flush-now), viasignalWithStart+USE_EXISTING. The DDB-timer + the 1-min Vercel sweeper cron are deleted (the sweeper never ran in prod — it returned503 NOT_WIRED). - PII boundary: conversation content rides a DDB scratch row keyed
(conversationId, runId)with a 1h TTL — never the workflow history. Activities pass only IDs + counts/enums. - Cutover gate:
MAINTENANCE_AUTONOMOUS_SENDING(fail-closed, mirrorsRENEWAL_AUTONOMOUS_SENDING) gates the dispatch activity's L4 writes. Ships disarmed — the workflow runs gather + judge + persist fleet-wide in prod-shadow with zero AppFolio writes until Phase 9 arms it (per-propertyProperty.autonomousMaintenanceEnabledsecond half + a human-reviewed worker deploy).
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):
- Inline L4 floor is ~22s —
buildApiCtxFromBrowserbasedoes ~19s of Chromium boot per call. - Inline budget is 8s — Clara's SMS-response timing demands a quick ack.
- 8s < 22s → every
create_work_orderfalls through to SQS-fallback. The "exception" path is the path. - SQS-fallback dup-creates against AppFolio because the L4
idempotencyCacheis per-Vercel-instance-in-memory (explicitly deferred caveat indocs/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, thenappfolio-syncpoller mirrored it back). - 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:
- ADR-0030 made AppFolio's response time load-bearing on every tenant interaction. AppFolio is not under our control.
- The "no PropFlow row until PMS confirms" rule forced us into an inline-then-SQS-fallback dance that's racy by construction (per-instance idempotency cache).
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:
- Mint or load the WorkOrder row
- Apply the mutation (replace field, append note, attach photo, change status)
- Mark the row dirty (
pmsSyncDirtyarray for replace fields; per-itemsynced: falseflag for append-only collections) - Return success to the caller (~50ms total)
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:
- 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.
- 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.
- DISPATCH (deterministic) — executes L4 calls in dependency order (create → attach_doc → add_note → field updates), stores
pmsIdon success, clears dirty markers, markspmsSyncStatus: 'synced'. Partial failures getpmsSyncStatus: 'partial_sync'and only the failed bits stay dirty for retry.
Judge phase guardrails:
- ✅ Polish artifacts of Clara's iterative process (stale mentions after corrections, redundancies, verbose phrasing)
- ✅ Catch obvious inconsistencies (priority vs description severity)
- ✅ Refuse to ship conflicts (PMS poller says cancelled but local has updates)
- ✅ Map photos to WOs when binding is ambiguous (fallback only — handler binds at tool-call time when possible)
- ❌ Do not change semantic meaning of Clara's choices
- ❌ Do not add information that wasn't in the conversation or local state
- ❌ Do not override explicit Clara decisions without strong evidence
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
- SMS: conversation-idle debounce — 5 min, resets on each new tenant message. Post-cutover (see Update 2026-05-27) this is a
signalDirtyBitsChangedto themaintenanceWorkflow, whose durable timer owns the debounce; the workflow flushes when the window elapses. (Pre-cutover v1: aConversation.pendingWorkOrderRunAttimer + a 1-min sweeper cron — now deleted.) - Voice: ElevenLabs
call_endedwebhook firesrunWorkOrderPipelineimmediately. Clara can quotePROP-XXXXXin real-time during the call. - Operator dashboard: save/cancel/close actions fire
runWorkOrderPipelinesynchronously (operator latency budget allows the 3-5s L4 round-trip). - Background sweeper: safety net for conversations that escape the inline triggers, plus retry for
partial_syncandsync_failedrows.
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:
- The architectural pivot (ADR-0030 → ADR-0031) is itself a significant change. Adopting Temporal as the runtime for maintenance simultaneously stacks "becoming the second customer of infrastructure still being hardened for the first customer" on top of the migration risk.
- Per CLAUDE.md, the Temporal renewal workflow is shadow-only today; production renewals still use the legacy saga. Temporal's Phase 5 cutover for renewals hasn't landed yet.
- Next.js ships in 6-8 working days vs 10-14 for Temporal. Faster validation cycle for the local-first model.
- The hard parts of this ADR (handler rewires, schema, docs sweep, drift guard inversion, Pipeline Lab integration,
appfolio-syncpoller update) happen once regardless of runtime. The Temporal migration, when it happens, is a runtime + trigger swap of ~2-3 days — not a re-architecture.
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:
- 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.
- Renewals' Temporal cutover lands cleanly in prod for 2-4 weeks. Validates the infrastructure for the first customer; safer to add a second.
- 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
- Inline path is fast. No L4 wait.
create_work_orderreturns in ~100ms. - No dup-create race. Single L4 call per WO operation, after the conversation settles.
- Robust to PMS outages. AppFolio down? PropFlow keeps creating + tracking WOs locally. Sync catches up when AF is back.
- One identifier for the life of the WO. Tenant sees
PROP-00001in the SMS ack, operator sees the same in the dashboard, audit trail uses it forever. - Photos batch naturally. Multiple photos in close succession → one the work-order pipeline flush attaches them all.
- Judge phase cleans up artifacts that Clara's turn-by-turn process can't avoid (corrections, redundancies, residual mentions).
- PMS-less prototyping works. Demo properties, dry-runs, evals all function without an AppFolio connection.
Harder
- Identifier divergence between PropFlow and AppFolio. Operator in PropFlow sees
PROP-00001; opens AppFolio and sees WO 823. UI must surface both explicitly. ADR-0030's primary motivation (avoiding this divergence) is consciously traded away here in favor of inline latency + PMS independence. - Conflict resolution surface. Status drift between PMS poller and local the work-order pipeline changes. Small but non-zero. Handled by "PMS poller wins on synced WOs."
- Judge phase requires careful prompt tuning. Wrong guardrails → either too rigid (defeats the point) or too aggressive (drifts from Clara). Bake phase needs to observe adjustment patterns.
appfolio-syncpoller must learn the new contract. Skip AF WOs that PropFlow already has withpmsSyncStatus != 'synced'(else it dup-creates rows for WOs the work-order pipeline is about to sync — exact inverse of today's PROP-822 incident).- Test rewrite surface is large. PR #1217-era handler tests all assert L4 was called; full rewrite required.
Implies
- Documentation sweep. Every doc that asserts ADR-0030's framing (CLAUDE.md, L4_DIRECT_HTTP.md, PMS_INTEGRATION.md, handler comments, drift guards) gets rewritten in the same PR as the code change. ADR-0030 stays in the repo with a SUPERSEDED by ADR-0031 banner; not deleted (history matters).
- Drift guard inversion.
src/__tests__/no-propflow-minted-wo-ids.drift.test.tseither deleted or inverted (new contract pins the local-id format). - PR appfolio-browser-agent#139 (L4 ctx cache) stays useful. the work-order pipeline still calls L4 endpoints, just async. Faster L4 = faster sync convergence = shorter "unsynced" window. Ships independently.
- Temporal stays on the table for v2. Trigger conditions documented (post-create lifecycle features, renewal Temporal cutover landing, Next.js-specific reliability incident). Migration cost when triggered: ~2-3 days. The v1 module structure (gather/judge/dispatch as standalone async functions) is designed to wrap into Temporal activities cleanly.
Drift guard
src/__tests__/local-first-work-orders.drift.test.ts (new) enforces:
WorkOrder.idmatches^[A-Z]{3,4}-\d{5,}$(PropFlow-minted format)WorkOrder.displayId === WorkOrder.idfor all rowsWorkOrder.pmsSyncStatusis one of the five enum values- Maintenance handler implementations do NOT import L4 client modules (
@/lib/integrations/appfolio-browser-agent/l4-client) — only the work-order pipeline's dispatch module may - No source file contains the forbidden phrases pinning ADR-0030's framing ("PMS owns the WO id" in present tense, "no PropFlow-minted ids", "WorkOrder.id is the PMS's identifier")
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
- docs/planning/agent-smith/plan.md — detailed plan with phased migration, blast radius, cost estimates, diagrams
- ADR-0030 — PMS is the source of truth for work order IDs — the predecessor decision this supersedes
- ADR-0025 — Self-hosted Temporal for renewal workflows — the precedent for Temporal as a runtime option for the work-order pipeline
- docs/architecture/L4_DIRECT_HTTP.md — §"Concurrent retries — idempotency caveat" explicitly flagged the dup-create race that materialized in production
- PR PropFlow-Technologies/propflowai#1217 — the handler canonicalization that implemented ADR-0030
- PR PropFlow-Technologies/propflowai#1221 — handlers-only-L4 drift guard for ADR-0030
- PR PropFlow-Technologies/propflowai#1223 — IAM PutItem fix that unblocked SQS-fallback writes; landed 2026-05-20 in response to today's incident
- PR PropFlow-Technologies/appfolio-browser-agent#139 — L4 ctx cache, ships independently as a Pareto-improvement