ADR-0059: Vendor dispatch for turnovers
- Status: Proposed
- Date: 2026-06-16
- Deciders: Fede, Sean
- Parent ADR: ADR-0034 (Turnover Orchestration Architecture)
- Related:
- ADR-0031 (local-first work orders)
- ADR-0033 (VendorCompany + VendorMembership model)
- ADR-0039 (AppFolio is source of truth for vendors)
- ADR-0044 (End-to-end turnover close-out)
- ADR-0051 (Unified PMS reconcile — vendor roster sync)
- ADR-0052 (Handyman quiet hours as Temporal hold-and-release)
- ADR-0053 (Clara as dumb I/O; Maestro owns maintenance-lifecycle orchestration)
- ADR-0054 (Inbound dispatcher — one entry for every inbound package)
- ADR-0056 (Unified PMS work-order write capability)
- ADR-0057 (PropFlow owns all work-order dispatch)
streams/A-maintenance-architecture.md— Maintenance WO pipeline mapstreams/B-maestro-handyman.md— Maestro + HandyDemand in-house dispatchstreams/C-vendor-model.md— Vendor data model + gapsstreams/D-turnover.md— Turnover system end-to-end mapstreams/E-adrs-git.md— ADR index + gate patterns + git historystreams/F-drive-transcripts.md— Feb–Mar 2026 strategy + Sean/Fede Zoom transcriptsstreams/G-calls-texts.md— Real turnover calls (Camellia + Willows)streams/N-email-infra.md— Email infrastructure investigation
Context
The gap today
When a property manager confirms a turnover scope ("Approve work & charges"), PropFlowAI mints
turnover child WorkOrder rows, assigns WorkOrder.assignedVendorCompanyId from either the
PM's explicit voice/SMS vendor name, the per-property turnoverPolicy.defaultVendors list, or
autoAssignVendor fallback (streams/D-turnover.md, §3c — scope-work-order-decision.ts:70).
For in-house WOs the system already closes the loop: dispatchInHouseTurnoverWorkOrders
(src/lib/domain/turnover/in-house-dispatch.ts) fires an SMS to the handyman's phone,
idempotency-stamped on WorkOrder.inHouseDispatchPingedAt, gated on
Property.autonomousTurnoverEnabled (ADR-0034). The handyman knows about the job within
seconds of the PM's approval.
For external vendor WOs there is nothing. decideScopeWorkOrder Rule 3b
(scope-work-order-decision.ts:124) sets status='pending_dispatch' with reason
"assigned to X — waiting to be sent out" — an explicit placeholder, not a bug. The WOs
land in AppFolio, a vendor is listed, and a PM or coordinator contacts the vendor manually.
This is the gap vendor dispatch must fill (confirmed by stream D §3c).
Why it matters — real call evidence
Thirteen turnover intake calls were analyzed across Camellia (real) and the Willows test
property (streams/G-calls-texts.md, §1–§2). The pattern is unambiguous:
- Named vendor + scope + charge decision arrive in one breath: "We need HD Painting. There's some peeling paint in the living room and in the master. Charge the tenant 150." (Willows TEST-101 walk, 2026-06-10, stream G §2-A).
- When the PM doesn't know the vendor they say "call the painter" or "the handyman" (generic). When they know the vendor they say the company name exactly.
- After the PM confirms the turnover, today's Clara reply is: "The vendor assignments (HD Painting, Anchor Pest Control, Alpine Glass, and the handyman) are captured in the notes and the work orders, but your team will need to verify the vendor-to-WO mapping on the dashboard and dispatch from there." (stream G §2-A). This is the exact gap: Clara knows the vendor, the WO is created, and nothing is dispatched.
Vendor strategy (founder spec, verified in drive transcripts)
From the Feb–Mar 2026 strategy document ("PropFlow.ai - Agentic Property Management",
streams/F-drive-transcripts.md §Sources 1 and 6–8):
- Dispatch model is tiered by priority + cost: direct dispatch for P0 emergencies, work below the property-specific cost threshold (~$300 is the cited example), or preferred-vendor contracts. Quote/bid request for P2/P3 work above the threshold. For turnovers specifically: "cleaning crews, painters, and carpet cleaners are the default auto-dispatched set" (Sean, March 25 Zoom — stream F §Source 8).
- Handyman-first triage: "troubleshoot first if a smaller issue before escalating. Many vendors have minimum callout fees." (Sean, JP Questions section — stream F §Source 1).
- Vendor communication is email-based: "send out an email to [vendors] with summary of issue and a request to provide estimate / go ahead" (strategy doc — stream F §Source 1).
- Turnover timing: "vendors should be scheduled 3 days before the lease end date, with work aiming for a 7–10 day total turnover time" (March 19 Zoom — stream F §Source 6).
What already exists (reuse inventory)
The investigation (streams A–E, N) confirmed substantial reusable infrastructure:
| Component | Location | Status |
|---|---|---|
VendorCompany / VendorMembership entity model |
src/lib/data/types.ts:1120, :6842 |
Production, ADR-0033 |
resolveVendorContact(vendorId, orgId) |
src/lib/domain/vendors/vendor-contact.ts:144 |
Production |
PropertyKnowledge.turnoverPolicy.defaultVendors |
src/lib/data/types.ts:334 |
Production |
send-vendor-dispatch.ts (stub) |
src/lib/integrations/email/send-vendor-dispatch.ts |
Stub — routes to vendor-poc@mailpit.local |
vendor-dispatch-template.ts |
agents/clara/lib/messaging/transports/vendor-dispatch-template.ts |
Production (unbranded) |
buildBrandHeader / buildBrandFooter |
agents/clara/lib/messaging/transports/brand-components.ts |
Production |
MicrosoftInboxClient.sendStandalone() |
agents/clara/lib/email/inbox-client.ts |
Production (no attachments yet) |
EmailAttachment interface + SendGrid path |
src/lib/integrations/email/client.ts:160 |
Production (incl. attachments) |
in-house-dispatch.ts (the dispatch template) |
src/lib/domain/turnover/in-house-dispatch.ts |
Production |
createDomainGate factory |
src/lib/temporal/autonomous-gate-core.ts |
Production |
| Quiet-hours logic (ADR-0052) | src/lib/maintenance/tech-sms-dispatch.ts |
Production |
WorkOrder.inHouseDispatchPingedAt (send-once stamp) |
src/lib/data/types.ts (area :1811) |
Production |
syncTurnoverWorkOrders |
src/lib/maintenance/work-orders/turnover-dispatch.ts:172 |
Production |
AppFolio vendor email via IdentityClaim |
synced hourly from vendor_directory.Email |
Production (stream C §10) |
The biggest single gap: send-vendor-dispatch.ts line 19 hardcodes
POC_RECIPIENT = 'vendor-poc@mailpit.local'. The real vendor email IS available via
resolveVendorContact(vendorId, orgId).email — it just isn't wired as the to: address
(stream C §11, Gap 1).
Phase roadmap (all six phases)
This ADR covers all six phases but Phase 1 is the only thing built during the overnight run that produced this document. Phases 2–6 are design-only here; the architecture below is intentionally shaped so they slot in without rearchitecting Phase 1.
Phase 1 — Dispatch email (BUILD)
On turnover confirmation, resolve the external vendor's email address and send a
job-request email branded as the property (not PropFlow), including all turnover notes
and WO photos from S3. NL vendor assignment: PM names a vendor in free text during the
walk → LLM maps it to a real VendorCompany → confirmed in the turnover recap before
confirm. Feature-flagged, default OFF, fail-closed, Willows/appfolio-45 ONLY, all
recipient addresses restricted to propflowaicalendar@outlook.com.
Phase 2 — Vendor-aware Clara / Maestro extension
Inbound vendor email/voice reply → answerOrRelayActivity answers scheduling questions
from WO context (unit, access notes, window) or escalates to PM. Reschedule/cancel →
PM notification via the existing relayMessageToTenant primitive (repurposed for PM-relay
in this context). Extends maintenanceCommsWorkflow (ADR-0053) from resident↔tech to
vendor↔PM for the external vendor lane. The inbound router fork on isInHouse
(inbound-router.ts:176) is the only code gate to open: route non-in-house vendor replies
through tryDivertInHouseComms rather than the current handleVendorMessage stub (stream
B §4).
Phase 3 — Quote/invoice parsing + accounting loop
Parse vendor quote/invoice reply → structured VendorQuoteRequestEntry row → loop in
accounting team to approve/act. LLM extracts dollar amounts, line items, and timeline from
vendor PDF/email. Over-threshold WOs that were pending_dispatch now get a quote-request
variant instead of a direct dispatch (Phase 1 sends direct dispatch only). Connects to
AppFolio invoice management. The VendorQuoteRequestEntry.vendorEmail field already
exists (src/lib/data/types.ts:1432–1439).
Phase 4 — Handyman-initiated escalation
Handyman texts Clara "couldn't do the plumbing — need a licensed plumber" → Clara
auto-escalates to the specialist trade via the same dispatchExternalTurnoverWorkOrders
pipeline. This is the in-house → external handoff within a single turnover. Uses
VendorMembership.isInHouse to detect the escalation direction; the existing
classifyVendorMessage → handleVendorTenantInquiry signal path carries the escalation
intent to the Maestro workflow.
Phase 5 — Complex scheduling (vendor + tenant availability reconciliation)
Vendor replies with available windows → send to tenant → tenant picks → confirm to both.
Reuses the handleVendorAvailability → composeAvailabilityRelayActivity → tenant-pick
flow already built for in-house handymen (stream B §3). Extension point: the
awaitingScheduleTenant sticky phase on the comms machine already tracks the
round-trip; external vendors just need the same Maestro wiring as in-house.
Phase 6 — Completion + QA loop
Vendor finishes → Clara requests evidence (notes + completion photo) → notify tenant →
ask satisfaction → close WO if good; reopen + re-engage vendor + handle complaint if not.
The handleVendorCompletion accumulate-gate-close path (handle-vendor-completion.ts)
plus signalRatingDue on the comms machine already implement this for in-house handymen.
Extension: same path, external vendor lane.
Decision
Recommended architecture: Extend VendorCompany/Maestro — Option A
Extend the existing VendorCompany / VendorMembership / in-house-dispatch.ts /
Maestro machinery to cover external vendors over email. No new top-level domain,
no new entity types, no new Temporal workflow for Phase 1. The in-house handyman is
already a VendorCompany with VendorMembership.isInHouse=true; external vendors are
the same model with isInHouse=false. The dispatch function
dispatchExternalTurnoverWorkOrders mirrors dispatchInHouseTurnoverWorkOrders at the
same call site and with the same idempotency/gate pattern.
The architecture is a single chokepoint per send direction: all external turnover
vendor emails flow through dispatchExternalTurnoverWorkOrders → sendVendorJobRequest
→ one of two send paths (Graph white-label or SendGrid fallback). This single chokepoint
stamps the send-once guard (WorkOrder.vendorNotifiedAt) on every successful send,
exactly mirroring handymanNotifiedAt from ADR-0057.
Dispatch chokepoint and call site
The attach point is turnovers/[id]/scope/route.ts:269, the same seam where
dispatchInHouseTurnoverWorkOrders fires today (stream D §3b):
// turnovers/[id]/scope/route.ts (after syncTurnoverWorkOrders + dispatchInHouseTurnoverWorkOrders)
after(dispatchExternalTurnoverWorkOrders({ turnoverId, propertyId, orgId }));
This fires as a fire-and-forget background job via the existing after() helper, after
the AppFolio sync has confirmed the WO exists in the PMS and pmsId is stamped.
The same call is added to the Temporal turnoverWorkflow as a
dispatchExternalTurnoverWorkOrdersActivity after syncTurnoverWorkOrdersActivity
in src/lib/temporal/activities/turnover.ts.
Why wait for pmsId? The dispatch email references the job using the local display
ID (e.g., CAM-000042) until the AppFolio-canonical ID (CAM-604) is stamped. Waiting
for syncTurnoverWorkOrders to complete before dispatching means the email can include
the AppFolio WO number if the sync is fast, otherwise falls back to the local display ID.
The email is sent regardless; the vendor does not need the AppFolio number to start work.
Email send path
Phase 1 default: Graph white-label with SendGrid fallback.
resolveVendorContact(vendorId, orgId) → contact.email
if property.emailIntegration.provider === 'microsoft' && tokens valid:
addAttachments(sendStandalone(...)) // FROM property mailbox
else:
sendEmail(clara@propflowai.co, attachments) // SendGrid fallback
The property's Outlook mailbox (propflowaicalendar@outlook.com for Willows) is the
preferred send path because it lands in the vendor's inbox as the property, not as
PropFlow. The MicrosoftInboxClient.sendStandalone() method needs ~20 lines of
attachment support added to the Graph /me/sendMail body payload (stream N §4 confirms
this is the only gap). SendGrid already supports EmailAttachment[] today.
Property branding
Replace emailCardLayout with buildBrandHeader(propertyName) + buildBrandFooter({ propertyName, propertyAddress, propertyPhone, propertyWebsite }) in the vendor dispatch
template. These primitives already exist in brand-components.ts and are used by the
renewal and tour reply templates. The vendor dispatch template currently uses
emailCardLayout with footerNote: 'Powered by PropFlow' (stream N §3) — that is
replaced wholesale. No per-property logo or per-property color palette exists yet
(global colors from src/lib/brand/tokens.ts); this is noted as later polish.
NL vendor assignment and LLM reasoning
The PM names a vendor in free text during the walk intake call. The agent captures
the vendor name verbatim in the condition report notes (e.g., "Vendor: HD Painting" in
the append_notes payload — stream G §2-A). At scope time, resolveScopeTaskVendors()
must map this free-text name to a real VendorCompany in the org's roster.
Resolution ladder (already partially exists in resolve-scope-vendors.ts:100–103,
stream C §6):
- Exact match against
VendorCompany.company(case-insensitive, trimmed). - Fuzzy match via a single Haiku LLM call: provide the free-text name + the org's
vendor roster (company names + trades), ask for the best-match
VendorCompany.idwith a confidence score. Returnnullif confidence < 0.7 (unresolved). - Policy default: if no named vendor resolves, check
PropertyKnowledge.turnoverPolicy.defaultVendors[trade]for the task's trade. - autoAssignVendor fallback: in-house first, then specialist by trade.
- Unresolved: WO is created with
assignedVendorCompanyId=null,status='pending_dispatch', PM sees it on the dashboard. No email sent.
"Our preferred X" language: when the PM says "our preferred plumber" or "our usual
painter," the LLM resolver should first check turnoverPolicy.defaultVendors for a
vendor of that trade before attempting a free-text name match against the roster.
Disambiguation/confirmation UX: After walk intake and before the PM's final confirm, Clara's recap SMS includes resolved vendor names: "Paint ($150) → HD Painting; Pest control ($250) → Anchor Pest Control; Window repair → Alpine Glass." The PM's "yes" reply to the recap locks the vendor assignments. If a vendor couldn't be resolved, Clara calls it out: "I couldn't match 'Miracle Method' to a vendor in your system — the bathroom refinishing WO will need manual dispatch." This is the single confirmation gate; no separate disambiguation round-trip is required.
Tool/data shape for the resolver:
interface VendorResolveInput {
freeTextName: string; // e.g., "HD Painting"
trade: string; // e.g., "painting"
orgId: string;
propertyId: string;
}
interface VendorResolveResult {
vendorCompanyId: string | null; // null = unresolved
vendorName: string | null;
confidence: number; // 0.0–1.0
method: 'exact_match' | 'llm_fuzzy' | 'policy_default' | 'auto_assign' | 'unresolved';
}
The resolver calls listVendorsForOrg(orgId) (existing API) to build the roster input
for the Haiku call. The LLM call is wrapped in claudePolicy (retry + circuit breaker,
src/lib/resilience.ts) and traced via tracedClaudeCall().
Options considered with trade-offs
Option A — Extend existing VendorCompany/Maestro machinery (RECOMMENDED)
What it is: Add dispatchExternalTurnoverWorkOrders mirroring in-house-dispatch.ts,
wire real vendor email into send-vendor-dispatch.ts, add Graph attachment support,
branding-swap the template, gate via createDomainGate. All phases use the same
VendorCompany entity model and the same chokepoint pattern.
Pros:
- Minimal net-new code: Phase 1 ≈ 4–5 files changed, ~200 lines added.
- The
VendorCompany/VendorMembershipmodel is already correct post-ADR-0033; no entity migration. - The gate factory
createDomainGateis proven across renewals, turnovers, and holdover; copy-paste, change three strings. - In-house handyman dispatch becomes the working reference implementation — external vendor dispatch is structurally identical, just over email instead of SMS.
- Phases 2–6 all extend the same Maestro
maintenanceCommsWorkflowpattern that is already running in production; the extension point is a single inbound-router fork (isInHousecheck ininbound-router.ts:176). - AppFolio vendor email is already synced hourly via
vendor_directory.Email→IdentityClaim(ADR-0051, stream C §10); no new sync work. - The
PropertyKnowledge.turnoverPolicy.defaultVendorspreferred-vendor concept exists exactly for turnovers; no new schema needed for Phase 1.
Cons:
- Inbound vendor reply routing is a Phase 2 gap (the
handleVendorMessagepath today does NOT signal themaintenanceCommsWorkflow). Phase 1 dispatch goes out; replies land in the Outlook inbox and are not routed to any WO automatically. This is explicitly scoped out of Phase 1 and noted as a known open item. - No per-property vendor logo or color palette (global brand only). Noted as later polish.
- AppFolio vendor roster is account-global (all JP & Co properties share it); a vendor
in
VendorMembership.propertyIds=[]is reachable from any property — thepropertyIdsscope filter must be respected inresolveVendorContactto prevent cross-property dispatch.
Option B — New greenfield vendor-dispatch domain and workflow
What it is: A new VendorDispatch entity, a new Temporal vendorDispatchWorkflow,
a new vendor-dispatch Temporal task queue, a new inbound routing domain, new DDB tables.
Pros:
- Clean separation of concerns; vendor dispatch could evolve independently.
- Future-proof if vendor dispatch grows into a multi-property marketplace.
Cons:
- Directly contradicts the locked decision: "bias to SIMPLICITY + REUSE — minimal extension of existing infra, NOT greenfield."
VendorCompany/VendorMembership/WorkOrder.assignedVendorCompanyIdalready carry all the state needed; a parallel entity just duplicates them.- New Temporal queue means a new worker deployment surface; adds operational complexity for zero added capability in Phase 1.
- The in-house handyman is already a
VendorCompany; a parallel vendor-dispatch domain would mean the two codebases diverge for what is fundamentally the same operation (notify a vendor about a job). - Rejected.
Option C — Thin email bolt-on with no Maestro integration
What it is: A standalone function called at confirm time that fires a vendor email
directly from send-vendor-dispatch.ts with minimal refactoring; no gate, no idempotency
stamp, no Maestro wiring, no quiet-hours.
Pros:
- Fastest to ship: ~30 lines of change.
- Zero new abstractions.
Cons:
- No idempotency stamp → Temporal retries cause duplicate emails to vendors (the exact failure pattern ADR-0057 was written to prevent, burned in the CAM-1006 incident).
- No quiet-hours respect → emails arrive at 3am, burning vendor goodwill.
- No gate → any environment misconfiguration sends real emails to real vendors.
- The dispatch is untraceable (no
vendorNotifiedAtfield → the dispatch-health-check scriptscripts/dispatch-health-check.tscannot include external vendor WOs). - ADR-0039 mandates AppFolio-synced vendor identity; this option needs the same
resolveVendorContactwalk regardless. - Phases 2–6 would require a full rewrite of the bolt-on to wire into Maestro. Phase C is a dead end.
- Rejected.
Phase 1 — Detailed implementation spec
File-by-file change set
1. src/lib/temporal/vendor-dispatch-gate.ts (NEW)
Copy src/lib/temporal/turnover-gate.ts verbatim, change:
envVar: 'VENDOR_DISPATCH_AUTONOMOUS_SENDING'propertyFlag: 'autonomousVendorDispatchEnabled'armField: 'vendorDispatchSending'
Exported functions:
isVendorDispatchArmed(): booleanisPropertyVendorDispatchAutonomous(propertyId): Promise<boolean>isVendorDispatchAllowed(propertyId): Promise<boolean>
The gate is fail-closed by the createDomainGate factory (stream E §2): only exact
true in the DDB arm row OR exact 'armed' token in the env var opens it. Unset, empty
string, "true", "1", or any typo = BLOCKED.
Recipient hard-allowlist (defense in depth for Phase 1):
const VENDOR_DISPATCH_RECIPIENT_ALLOWLIST = (
process.env.VENDOR_DISPATCH_RECIPIENT_ALLOWLIST ?? ''
).split(',').map(s => s.trim()).filter(Boolean);
function isAllowedRecipient(email: string): boolean {
if (VENDOR_DISPATCH_RECIPIENT_ALLOWLIST.length === 0) return false; // fail-closed
return VENDOR_DISPATCH_RECIPIENT_ALLOWLIST.includes(email.toLowerCase());
}
During Phase 1 the env var is set to propflowaicalendar@outlook.com (the Willows test
inbox) and nothing else. Any vendor email that is NOT on this list causes the dispatch
function to log a warning and return without sending. Fail-closed by default (empty
allowlist = no sends).
2. src/lib/data/types.ts — three additions
// Property interface — add:
autonomousVendorDispatchEnabled?: boolean;
// WorkOrder interface — add (alongside inHouseDispatchPingedAt):
vendorNotifiedAt?: string; // ISO timestamp; stamped on successful external vendor email send
// RenewalArmState interface (in autonomous-gate-core.ts) — extend armField union:
vendorDispatchSending?: boolean;
3. src/lib/integrations/s3/vendor-email-photos.ts (NEW)
S3 → EmailAttachment[] helper. This bridge is missing (stream N §4 confirms no such
helper exists today):
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getS3Client } from './client';
import type { EmailAttachment } from '@/lib/integrations/email/types';
const MAX_PHOTOS_PER_EMAIL = 5; // stay under SendGrid 30MB ceiling
const PHOTO_MAX_BYTES = 4 * 1024 * 1024; // 4MB per photo
export async function fetchWoPhotosAsAttachments(
photos: WorkOrderPhoto[],
): Promise<EmailAttachment[]> {
const eligible = photos
.filter(p => p.s3Key && p.appfolioSyncStatus !== 'failed')
.slice(0, MAX_PHOTOS_PER_EMAIL);
const settled = await Promise.allSettled(
eligible.map(async (photo) => {
const cmd = new GetObjectCommand({ Bucket: 'propflow-photos', Key: photo.s3Key! });
const res = await getS3Client().send(cmd);
const bytes = await res.Body!.transformToByteArray();
if (bytes.byteLength > PHOTO_MAX_BYTES) return null; // skip oversized
const ext = photo.s3Key!.split('.').pop() ?? 'jpg';
return {
filename: `photo-${photo.id}.${ext}`,
content: Buffer.from(bytes),
contentType: photo.contentType ?? `image/${ext}`,
} satisfies EmailAttachment;
}),
);
return settled
.filter((r): r is PromiseFulfilledResult<EmailAttachment | null> => r.status === 'fulfilled')
.map(r => r.value)
.filter((a): a is EmailAttachment => a !== null);
}
4. agents/clara/lib/messaging/transports/vendor-dispatch-template.ts — branding update
Replace the emailCardLayout wrapper with property-branded header/footer:
// Before: emailCardLayout(... footerNote: 'Powered by PropFlow')
// After:
export function buildVendorDispatchHtml(params: VendorDispatchEmailParams): string {
const header = buildBrandHeader(`Job Request — Unit ${params.unitNumber}`, params.propertyName);
const footer = buildBrandFooter({
propertyName: params.propertyName,
propertyAddress: params.propertyAddress,
propertyPhone: params.propertyPhone,
propertyWebsite: params.propertyWebsite,
});
// ... body unchanged (job sheet table, notes, access instructions) ...
return `${header}${body}${footer}`;
}
The job sheet in the email body should include: WO display ID, task type, unit number,
move-out date, access instructions, all WO notes (author != 'system'), estimated cost
(if set), and a reference code (D-<displayId>) in the subject line for future Phase 2
reply matching (following the QR-<displayId> pattern in gmail-client.ts:extractWorkOrderId).
Subject line format: Job Request — <PropertyName> Unit <unitNumber> (<D-displayId>)
5. agents/clara/lib/email/inbox-client.ts — Graph attachment support
Add attachments?: EmailAttachment[] to SendStandaloneOpts and inject into the
/me/sendMail body:
interface SendStandaloneOpts {
to: string;
subject: string;
html: string;
text?: string;
attachments?: EmailAttachment[]; // ADD THIS
}
// In sendStandalone(), inside the body object:
...(opts.attachments?.length ? {
attachments: opts.attachments.map(a => ({
'@odata.type': '#microsoft.graph.fileAttachment',
name: a.filename,
contentType: a.contentType ?? 'application/octet-stream',
contentBytes: Buffer.isBuffer(a.content)
? a.content.toString('base64')
: a.content,
})),
} : {}),
This is approximately 20 lines added. The existing Mail.Send OAuth scope is sufficient
for attachments on /me/sendMail (stream N §4 open question, conservative assumption —
verify against the Graph docs before arming for real properties).
6. src/lib/integrations/email/send-vendor-dispatch.ts — wire real email + brand + photos
Key changes (currently routes to vendor-poc@mailpit.local):
// BEFORE (line 19):
const POC_RECIPIENT = 'vendor-poc@mailpit.local';
// AFTER: resolve real contact email
const contact = await resolveVendorContact(wo.assignedVendorCompanyId, orgId);
if (!contact?.email) {
logWarn(ctx, 'vendor-dispatch: no email on contact, skipping', { woId: wo.id });
return { skipped: true, reason: 'no_vendor_email' };
}
const recipientEmail = contact.email;
// Guard: allowlist (fail-closed in Phase 1)
if (!isAllowedRecipient(recipientEmail)) {
logWarn(ctx, 'vendor-dispatch: recipient not in allowlist', { recipientEmail, woId: wo.id });
return { skipped: true, reason: 'not_in_allowlist' };
}
Replace template call to use property-branded HTML (buildVendorDispatchHtml updated above).
Add photo fetching:
const attachments = await fetchWoPhotosAsAttachments(wo.photos ?? []);
Send path:
const property = await getProperty(wo.propertyId);
const useGraph = property?.emailIntegration?.provider === 'microsoft'
&& !!property.emailIntegration.accessToken;
if (useGraph) {
const client = createInboxClient(property.emailIntegration.accessToken, 'microsoft');
await client.sendStandalone({ to: recipientEmail, subject, html, text, attachments });
} else {
await sendEmail({ to: recipientEmail, subject, html, text, attachments });
}
The AppSettings.vendorDispatchEmails gate (line 125) remains; it must be explicitly
enabled per deployment.
7. src/lib/domain/turnover/external-dispatch.ts (NEW — mirrors in-house-dispatch.ts)
export async function dispatchExternalTurnoverWorkOrders({
turnoverId,
propertyId,
orgId,
}: {
turnoverId: string;
propertyId: string;
orgId: string;
}): Promise<void> {
// Gate check (fail-closed two-factor)
if (!(await isVendorDispatchAllowed(propertyId))) {
logInfo({ turnoverId }, 'vendor-dispatch: gate closed, skipping external dispatch');
return;
}
const workOrders = await getWorkOrdersByTurnover(turnoverId);
const externalWOs = workOrders.filter(wo =>
wo.assignedVendorCompanyId &&
wo.status === 'dispatched' && // under cost cap — dispatched by decideScopeWorkOrder
!wo.vendorNotifiedAt && // idempotency: not already dispatched
!isInHouseVendor(wo.assignedVendorCompanyId, orgId) // external only
);
// Group by vendor: one email per vendor covers all their WOs on this turnover
const byVendor = groupBy(externalWOs, wo => wo.assignedVendorCompanyId!);
await Promise.allSettled(
Object.entries(byVendor).map(async ([vendorId, vendorWOs]) => {
try {
await sendVendorJobRequest({ vendorId, workOrders: vendorWOs, propertyId, orgId });
// Stamp send-once guard on each WO (non-atomic — tolerate partial success)
await Promise.allSettled(
vendorWOs.map(wo =>
patchWorkOrder(wo.id, wo.propertyId, {
vendorNotifiedAt: new Date().toISOString(),
})
)
);
} catch (err) {
logError({ turnoverId, vendorId }, 'vendor-dispatch: failed to send job request', err);
// Non-fatal: other vendors still dispatched; this vendor flagged in health check
}
})
);
}
Quiet-hours: For Phase 1 (email dispatch), email is not time-sensitive in the same
way as an SMS page — emails are read when the vendor checks their inbox. The quiet-hours
hold (ADR-0052) is therefore not applied to the email send in Phase 1. When Phase 2
adds SMS dispatch for vendors, the same resolveHandymanMembershipQuietHours +
resolveHandymanQuietWindow three-state logic applies verbatim (stream B §1.6).
Idempotency: vendorNotifiedAt is the send-once guard. Temporal retries see
vendorNotifiedAt already set → skip. The dispatch-health-check script
(scripts/dispatch-health-check.ts) must be extended to include external vendor WOs
(any open WO with assignedVendorCompanyId set, isInHouse=false, and
vendorNotifiedAt null, age > 1h).
8. src/app/api/turnovers/[id]/scope/route.ts — add the after() call
At line 269 (after the existing two after() calls):
after(dispatchExternalTurnoverWorkOrders({ turnoverId: id, propertyId, orgId }));
This is a one-line change at the confirmed attach point (stream D §3b, §8).
9. src/lib/temporal/activities/turnover.ts — add Temporal activity
export async function dispatchExternalTurnoverWorkOrdersActivity(input: {
turnoverId: string;
propertyId: string;
orgId: string;
}): Promise<void> {
await dispatchExternalTurnoverWorkOrders(input);
}
Called in turnoverWorkflow after syncTurnoverWorkOrdersActivity completes:
await ctx.executeActivity(dispatchExternalTurnoverWorkOrdersActivity, {
startToCloseTimeout: '2 minutes',
retry: { maximumAttempts: 3, initialInterval: '10s' },
}, { turnoverId, propertyId, orgId });
10. AppSettings — add vendorDispatchEmails control
Confirm AppSettings.vendorDispatchEmails exists (referenced in send-vendor-dispatch.ts:125)
and is set to false by default. Enable it in the Willows test environment config only.
Safety / test strategy
Phase 1 safety stack (four layers, defense in depth)
AppSettings.vendorDispatchEmailsflag — global kill-switch, defaultfalse. Nothing sends until this is explicitly flipped.- Two-factor gate —
VENDOR_DISPATCH_AUTONOMOUS_SENDING=armedenv var (or DDB arm row) ANDProperty.autonomousVendorDispatchEnabled=true. Both must be true. Neither is set in any deployed environment at the time this ADR is merged. - Per-property opt-in —
autonomousVendorDispatchEnabledis ONLY set totrueforappfolio-45(Willows) via thescripts/set-renewal-arms.tsoperator script; Camellia and all other properties remainfalse. - Recipient hard-allowlist —
VENDOR_DISPATCH_RECIPIENT_ALLOWLISTenv var set topropflowaicalendar@outlook.com. Any vendor email that is NOT on this list causes a log warning and a no-send return. The fail-closed behavior is: empty allowlist = no sends.
Defense-in-depth invariant: any single layer failing closed is sufficient to prevent a real vendor from receiving an email. All four layers must open simultaneously for an email to send. Misconfig of layers 1–3 is blocked by layer 4; misconfig of layer 4 is blocked by layers 1–3.
Test coverage required before merging
All of the following must pass before the branch is opened for Fede's sign-off:
Unit test:
external-dispatch.test.ts- Gate closed → no send,
vendorNotifiedAtnot stamped. - Gate open + recipient not on allowlist → no send, logged warning.
- Gate open + recipient on allowlist + no vendor email →
{ skipped: true, reason: 'no_vendor_email' }. - Gate open + valid recipient →
sendVendorJobRequestcalled with correct WO list. vendorNotifiedAtalready set → idempotent (no second send).- In-house WO → filtered out (not dispatched by this function).
- Gate closed → no send,
Unit test:
vendor-dispatch-gate.test.ts- Both factors false →
isAllowedreturnsfalse. - Global armed, property
false→isAllowedreturnsfalse. - Global armed, property
true→isAllowedreturnstrue. - Typo in env var →
isAllowedreturnsfalse.
- Both factors false →
Unit test:
vendor-email-photos.test.ts- Returns at most
MAX_PHOTOS_PER_EMAILattachments. - Oversized photos are skipped.
- S3 fetch errors on individual photos don't fail the entire batch.
- Returns at most
Regression test for in-house dispatch:
dispatchInHouseTurnoverWorkOrderstests must pass unchanged (verifies the external dispatch didn't inadvertently affect in-house path).Integration test: Willows fake-vendor → Outlook test inbox loop
- Create test turnover on appfolio-45 with a fake vendor whose email is
propflowaicalendar@outlook.com. - Arm the gate (set both factors true in test environment).
- Fire
dispatchExternalTurnoverWorkOrders. - Verify email arrives in
propflowaicalendar@outlook.com. - Verify
vendorNotifiedAtstamped on the WO. - Re-fire → idempotent (no second email).
- Create test turnover on appfolio-45 with a fake vendor whose email is
Willows fake-vendor setup (required before testing)
Three fake vendors are created in appfolio-45 AppFolio with emails pointing at
propflowaicalendar@outlook.com. They are synced into PropFlow via the hourly
syncAccountVendors lambda (ADR-0051). These vendors are the only ones reachable from
appfolio-45 for dispatch testing.
What MUST be true before arming for a real property (Camellia or any future client)
- The Willows test loop (above) has run at least 3 end-to-end cycles without errors.
scripts/dispatch-health-check.tsextended to include external vendor WOs passes on Willows.- Vendor email addresses in the target property's AppFolio are confirmed correct and
current (manual spot-check of
vendor_directory.Emailfor the property's vendor roster). Mail.SendOAuth scope on the property'semailIntegrationis confirmed sufficient for attachments (or Graph attachment support tested independently).- Fede has explicitly approved removing the per-property allowlist restriction for the target property (Camellia). This is a high-risk change (real vendor emails) and requires explicit sign-off per the global CLAUDE.md.
- The dispatch-health-check shows zero external vendor WOs on Willows with
vendorNotifiedAtnull and age > 1h (confirms no ghost WOs from test runs).
Consequences
What this ADR makes true
- External turnover vendors will receive a job-request email branded as the property immediately after the PM confirms the turnover scope, for properties opted into vendor dispatch.
- The email includes all WO notes and up to 5 S3 photos from the condition walk.
WorkOrder.vendorNotifiedAtis the durable send-once truth for external vendor dispatch, matching the pattern ofhandymanNotifiedAtfor in-house dispatch (ADR-0057).- The dispatch-health-check script can now cover external vendor WOs.
- The NL vendor assignment flow (free-text name →
VendorCompanyvia LLM fuzzy match or policy default) closes the "your team will need to dispatch manually" UX gap in the current turnover recap.
Risks and mitigations
| Risk | Mitigation |
|---|---|
Real vendor receives email from wrong property due to misconfigured autonomousVendorDispatchEnabled |
Recipient hard-allowlist (layer 4) blocks send if email not in VENDOR_DISPATCH_RECIPIENT_ALLOWLIST; gate is fail-closed |
| Temporal retry sends duplicate emails | vendorNotifiedAt idempotency stamp prevents second send |
| Vendor email is stale (AppFolio has old address) | Pre-arm spot-check of vendor_directory.Email required; vendor can reply to the email thread to correct |
| Vendor has no email address in AppFolio | resolveVendorContact returns null email → dispatch function logs + returns { skipped: true } → WO stays pending_dispatch → PM sees it on dashboard |
Graph /me/sendMail rejects attachment due to missing OAuth scope |
SendGrid fallback fires; email sends without white-label branding |
LLM vendor fuzzy-match maps PM's vendor name to wrong VendorCompany |
Confidence threshold 0.7; low-confidence = unresolved + PM sees on dashboard; PM's final recap confirmation is the correction gate |
| S3 photo fetch adds latency to dispatch | after() call is fire-and-forget (non-blocking); photo fetch is async; max 5 photos |
| Vendor replies not routed to WO (Phase 2 gap) | Email subject contains D-<displayId> reference code; manually findable; explicitly scoped to Phase 2 |
Open questions (for Fede)
Quiet hours for email in Phase 1: The current spec does NOT apply a quiet-hours hold to email (emails are not real-time like SMS). Should we add a "business hours only" send window for vendor emails anyway (e.g., 7am–7pm vendor-local time)? Assumption: no hold for Phase 1; revisit in Phase 2 when SMS may be added.
Over-cap WOs:
decideScopeWorkOrderholds WOs over theautoDispatchCostCapatpending_dispatch. When the PM manually approves an over-cap WO from the WorkOrder detail page, that approval path does NOT currently trigger external dispatch. Should it? The consistent UX would be: any explicit PM approval of a WO → dispatch fires. This requires a one-lineafter()addition on the WO PATCH/approve endpoint. Proposed: yes, add this in Phase 1 for completeness. Mark as assumption-to-verify.AppFolio vendor email scope: AppFolio vendors are account-global (all JP & Co properties share the same vendor_directory). A vendor's email set in AppFolio for Camellia is the same vendor email for Willows. If we create fake test vendors in appfolio-45 with emails pointing at
propflowaicalendar@outlook.com, those vendors appear in the org-wide roster and could be accidentally assigned to Camellia WOs. Is this acceptable for Phase 1 (test vendors in the shared account)? Assumption: yes, these are clearly named as test vendors in the company field; the fail-closed gate prevents accidental dispatch to real Camellia vendors.Mail.SendvsMail.ReadWriteOAuth scope for Graph attachments: The stream N investigation flagged this as unknown. The assumption isMail.Sendis sufficient for/me/sendMailwith inlineattachments[](the base64-attachment path, not the createDraft → upload → send path). Verify before arming for real properties.Property-branded FROM address for SendGrid fallback: When a property has no Microsoft integration, the SendGrid fallback sends FROM
clara@propflowai.co. Should we set aReply-To: <propertyEmail>header on the SendGrid path so vendor replies go to the property's email? Proposed: yes, setReply-To: property.propertyEmailon the SendGrid send path. Assumption: implement in Phase 1 for best UX.NL vendor resolver confidence threshold: The 0.7 threshold is an assumption. Should we tune this based on real call data? The stream G calls show PMs name vendors clearly and precisely ("HD Painting" not "that painting guy we use") — a 0.7 threshold may be conservative. Assumption: start at 0.7, tune after first 10 dispatches.
Entity classification
No new entities are introduced. Fields added to existing entities:
| Entity | Field | Type | Notes |
|---|---|---|---|
Property |
autonomousVendorDispatchEnabled |
boolean? |
Gate per-property flag |
WorkOrder |
vendorNotifiedAt |
string? |
ISO timestamp; send-once durability |
RenewalArmState (DDB arm row) |
vendorDispatchSending |
boolean? |
Global arm field |
New file src/lib/integrations/s3/vendor-email-photos.ts (utility, no entity).
New file src/lib/temporal/vendor-dispatch-gate.ts (gate, no entity).
New file src/lib/domain/turnover/external-dispatch.ts (domain function, no entity).
New file src/lib/temporal/activities/turnover-vendor-dispatch.ts (Temporal activity shim).
Summary (what Phase 1 ships)
Phase 1 closes the "waiting to be sent out" gap for external turnover vendors by:
- Resolving the real vendor email from
resolveVendorContact(wiring the existing data, not adding new data). - Sending a property-branded email (Graph white-label or SendGrid fallback) with WO
notes + photos at the confirm seam (
turnovers/[id]/scope/route.ts:269). - Stamping
vendorNotifiedAtfor idempotency and health-check observability. - Gating behind
createDomainGatetwo-factor + per-property flag + recipient allowlist — fully fail-closed, Willows only, fake vendors →propflowaicalendar@outlook.com. - Wiring NL vendor assignment (free-text name → LLM fuzzy match → policy default) into the recap confirmation gate.
Phases 2–6 slot into this foundation without rearchitecting: Phase 2 extends the
inbound-router fork; Phase 3 extends the quote-request flow; Phase 4–6 reuse the same
VendorCompany/Maestro patterns.