ADR-0059: Vendor dispatch for turnovers


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:

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):

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 classifyVendorMessagehandleVendorTenantInquiry 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 handleVendorAvailabilitycomposeAvailabilityRelayActivity → 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

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 dispatchExternalTurnoverWorkOrderssendVendorJobRequest → 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):

  1. Exact match against VendorCompany.company (case-insensitive, trimmed).
  2. 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.id with a confidence score. Return null if confidence < 0.7 (unresolved).
  3. Policy default: if no named vendor resolves, check PropertyKnowledge.turnoverPolicy.defaultVendors[trade] for the task's trade.
  4. autoAssignVendor fallback: in-house first, then specialist by trade.
  5. 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

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:

Cons:

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:

Cons:

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:

Cons:


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:

Exported functions:

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)

  1. AppSettings.vendorDispatchEmails flag — global kill-switch, default false. Nothing sends until this is explicitly flipped.
  2. Two-factor gateVENDOR_DISPATCH_AUTONOMOUS_SENDING=armed env var (or DDB arm row) AND Property.autonomousVendorDispatchEnabled=true. Both must be true. Neither is set in any deployed environment at the time this ADR is merged.
  3. Per-property opt-inautonomousVendorDispatchEnabled is ONLY set to true for appfolio-45 (Willows) via the scripts/set-renewal-arms.ts operator script; Camellia and all other properties remain false.
  4. Recipient hard-allowlistVENDOR_DISPATCH_RECIPIENT_ALLOWLIST env var set to propflowaicalendar@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:

  1. Unit test: external-dispatch.test.ts

    • Gate closed → no send, vendorNotifiedAt not 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 → sendVendorJobRequest called with correct WO list.
    • vendorNotifiedAt already set → idempotent (no second send).
    • In-house WO → filtered out (not dispatched by this function).
  2. Unit test: vendor-dispatch-gate.test.ts

    • Both factors false → isAllowed returns false.
    • Global armed, property falseisAllowed returns false.
    • Global armed, property trueisAllowed returns true.
    • Typo in env var → isAllowed returns false.
  3. Unit test: vendor-email-photos.test.ts

    • Returns at most MAX_PHOTOS_PER_EMAIL attachments.
    • Oversized photos are skipped.
    • S3 fetch errors on individual photos don't fail the entire batch.
  4. Regression test for in-house dispatch: dispatchInHouseTurnoverWorkOrders tests must pass unchanged (verifies the external dispatch didn't inadvertently affect in-house path).

  5. 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 vendorNotifiedAt stamped on the WO.
    • Re-fire → idempotent (no second email).

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)

  1. The Willows test loop (above) has run at least 3 end-to-end cycles without errors.
  2. scripts/dispatch-health-check.ts extended to include external vendor WOs passes on Willows.
  3. Vendor email addresses in the target property's AppFolio are confirmed correct and current (manual spot-check of vendor_directory.Email for the property's vendor roster).
  4. Mail.Send OAuth scope on the property's emailIntegration is confirmed sufficient for attachments (or Graph attachment support tested independently).
  5. 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.
  6. The dispatch-health-check shows zero external vendor WOs on Willows with vendorNotifiedAt null and age > 1h (confirms no ghost WOs from test runs).

Consequences

What this ADR makes true

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)

  1. 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.

  2. Over-cap WOs: decideScopeWorkOrder holds WOs over the autoDispatchCostCap at pending_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-line after() addition on the WO PATCH/approve endpoint. Proposed: yes, add this in Phase 1 for completeness. Mark as assumption-to-verify.

  3. 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.

  4. Mail.Send vs Mail.ReadWrite OAuth scope for Graph attachments: The stream N investigation flagged this as unknown. The assumption is Mail.Send is sufficient for /me/sendMail with inline attachments[] (the base64-attachment path, not the createDraft → upload → send path). Verify before arming for real properties.

  5. 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 a Reply-To: <propertyEmail> header on the SendGrid path so vendor replies go to the property's email? Proposed: yes, set Reply-To: property.propertyEmail on the SendGrid send path. Assumption: implement in Phase 1 for best UX.

  6. 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:

  1. Resolving the real vendor email from resolveVendorContact (wiring the existing data, not adding new data).
  2. 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).
  3. Stamping vendorNotifiedAt for idempotency and health-check observability.
  4. Gating behind createDomainGate two-factor + per-property flag + recipient allowlist — fully fail-closed, Willows only, fake vendors → propflowaicalendar@outlook.com.
  5. 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.