1. What enrichment pulls
Two ways to check on a delivery: the cheap tracking-number lookup tells you it shipped and roughly when it'll arrive; someone actually calling the warehouse tells you if the loading dock is locked and who has the key.
- Robot scrapes AppFolio's staff portal — a place the cheap Reports API can't reach.
- Vendor name and schedule already exist on the cheap 1-minute report feed.
- Urgency is captured but nothing reads it — Clara classifies urgency herself.
- The fastest access source is the tenant's own words in chat, not AppFolio.
Proves: field-by-field code trace, enrichment-mapper.ts, wo-enrich-dispatch.ts, tech-sms-dispatch.ts, access-extractor.ts.
Deeper → Appendix: full field table with file:line citations
2. How it runs today
A mail carrier who checks your box the moment a package arrives, then a separate truck that sweeps the whole neighborhood once a night for anything the first pass missed.
- Creation capture fires once per new work order, ~5-10s, never re-runs on that WO.
- Daily sweep refreshes anything stale after 20 hours, flags itself degraded past 48.
- Vendor-contact sweep runs 4×/day, paced 1.5s between calls, org-wide.
- All three ride the same three-account login budget — the thing the Sep-10 outage broke.
Proves: lambda/appfolio-sync/schedules.json cron expressions, CloudWatch 7-day run counts, rca-appfolio-robot-login-2026-09-10.html.
3. What Clara needs to decide a work order
A person will hand you their spare key over text in ten seconds; a background check on the same person takes a day and still won't tell you about the key.
- Vendor and schedule are always fresh — they ride the cheap 1-minute feed.
- Permission-to-enter and preferred windows only exist behind the robot's staff-portal pull.
- Clara's own chat extractor is fastest but only fires if the tenant volunteers it.
- Fede's worry is real: dispatch can proceed with zero access information today.
Proves: tech-sms-dispatch.ts, access-extractor.ts, wo-enrich-dispatch.ts docstring naming staff-portal-only fields.
4. Options at scale
A nightly street sweeper works fine for one block; on a hundred blocks it can't finish before sunrise, no matter how early it starts.
- The daily sweep's own drain window fits ~120 calls — below the 10x estimate.
- Event-driven re-enrich scales with how often things change, not standing backlog.
- Moving report-carryable fields off the robot shrinks every option's login exposure.
- Options 2 and 3 combine — narrowing the robot's job makes event triggers cheaper.
Proves: drain-window math from run-wo-enrichment-sweep.ts, Reports API column list, 10x density assumption per task brief.
Deeper → Appendix: option-by-option detail
5. Recommendation for Gera
Fix the smaller, cheaper problem first — it also makes the bigger problem cheaper to fix next.
- Move vendor name/trade and scheduled window onto the existing 1-minute report poll.
- Shrink the robot to permission-to-enter, preferred slots, urgency, and activity log.
- Add re-enrich triggers: vendor-assignment change, tenant reply, dispatch decision.
- Keep the daily sweep, but as backstop — not the primary mechanism — once triggers exist.
- None of this is time-sensitive; it's a scale precaution, not a live bug.
Proves: synthesis of the three research passes below; recommendation, not a decided architecture.
A1 · Appendix — full record
Full field table, run statistics, and file citations for whoever builds this next.
Full enriched-field table
| Field | Source | Written to | Read by | Before dispatch? |
|---|---|---|---|---|
| Vendor name / trade | Reports API work_order report (VendorName, VendorTrade) | WorkOrder.assignedVendorName — writers/work-order.ts:1105-1108 | Dispatch, "Vendor on File" card, 70+ consumers via assignedVendorCompanyId | Yes |
| Rich vendor object (phone, email, trades, do-not-use flag) | Staff-portal robot | appfolioEnrichment.vendor — enrichment-mapper.ts:62,402 | WorkOrderDetailClient.tsx:1022-1070 | Yes, for contact detail |
| Scheduled appointment window | Reports API (ScheduledStart/ScheduledEnd) | WorkOrder.scheduledDate | Dispatch, tech SMS | Yes, once set |
| Preferred time slots (pre-scheduling) | Staff-portal robot, JSON:API only | WorkOrder.tenantSchedulePreference — enrichment-mapper.ts:309-311 | tech-sms-dispatch.ts:217-218 | Yes |
| Permission to enter (AppFolio's own flag) | Staff-portal robot, JSON:API only | WorkOrder.permissionToEnter — enrichment-mapper.ts:296-298 | tech-sms-dispatch.ts:214-215; also written independently by Clara's conversation extractor | Yes |
| Entry / access instructions | Staff-portal robot (free text) and/or tenant chat | WorkOrder.accessInstructions — enrichment-mapper.ts:302-304 | tech-sms-dispatch.ts:145-156,208-211 | Yes |
| Urgency (AppFolio rules-engine decision) | Staff-portal robot, JSON:API only | appfolioEnrichment.urgencyRulesDecision — enrichment-mapper.ts:52,398 | None found — dead read path; Clara runs her own intake classifier (intake-classify.ts/life-safety-classify.ts) | No, functionally unused |
| Issue descriptor | Staff-portal robot | appfolioEnrichment.issueDescriptor | work-order-issue-text.ts:51-67 → tech-sms-dispatch.ts:170 | Informational |
| Activity log | Staff-portal robot, HTML scrape (.js-activity-log-row) | appfolioEnrichment.activityLog | healedCreatedAtFromEnrichment (date heal only); detail-page audit trail | No |
| Attachments | Staff-portal robot | appfolioEnrichment.attachments, capped at 25 | Detail page only | No |
| Vendor instructions | Staff-portal robot | appfolioEnrichment.vendorInstructions | WorkOrderDetailClient.tsx:1085-1113 | Useful, not blocking |
| AppFolio notes | Staff-portal robot | appfolioEnrichment.appfolioNotes, capped at 100 | WorkOrderDetailClient.tsx:1121-1132 | No |
| Tenant's own words on access | Clara's conversation with the tenant — not an AppFolio surface at all | Same canonical fields (permissionToEnter, accessInstructions) via a second writer | tech-sms-dispatch.ts, same as robot-sourced values | Yes — fastest available, fail-closed, never fabricates |
Sanitization: sanitizeVendor strips bank/tax PII (enrichment-mapper.ts:331-362); line items and attachments are capped and redacted before caching.
Trigger model and run cadence
| Path | Trigger | Cadence / latency | Notes |
|---|---|---|---|
| Creation capture | onCreated hook off the 1-minute work_order report poll | ~60s worst case to first enrich, then ~5-10s L4 call | run-work-order-sync.ts:206-209 → wo-enrich-dispatch.ts; fires once per WO, never on re-sync |
| Daily sweep | cron(0 9 * * ? *) = 3 AM Mountain | Refreshes anything >20h stale; flags degraded past 48h | Serial, one L4 call in flight; drain window ~640s (900s Lambda minus 260s reserve) — fits ~120 calls/run |
| Vendor-contact sweep | cron(30 1,7,13,19 * * ? *) | 4×/day, 1.5s between calls | Org-scoped, stalest-first; L4 cost p50 0.4s / p90 0.8s / max 1.0s per vendor |
CloudWatch — last 7 days (/aws/lambda/propflow-appfolio-sync-prod)
| Job | Daily run counts (Sep 5→12) | Duration | Errors |
|---|---|---|---|
wo_enrichment | 16, 61, 83, 35, 57, 73, 45, 39 | avg 1.44s; max 127.1s (the actual 9 AM sweep kickoff) | 0 |
vendor_contact | 1, 4, 4, 4, 4, 4, 4, 3 | 240s–640s per full run (6-12 properties) | 0 |
The many sub-second wo_enrichment invocations are opportunistic single-WO enrichments riding other job ticks, not the 3 AM sweep itself. No error/warn log lines matched for either job during the Sep 9-11 login-lockout incident — the robot's own login failures surface on its Vercel/Sentry side, not in this Lambda's logs.
Session / login exposure (from the 2026-09-10 RCA)
- Login budget: 3/hour, 8/day per identity; 30-min breaker on first failure/24h, 6-hour breaker on a second.
- Session absolute lifetime ~24h regardless of a 2-minute keepalive that resets only the idle clock.
- Root cause of the incident: a non-login ("already logged in") was miscounted as a credential failure, tripping repeated 6-hour breakers.
- Fixes shipped:
LOGIN_NOT_DRIVENexemption, dual-door warmth check, nightly proactive re-mint (2-5 AM), bounded retries (max 3), per-org alarms, no forced retry on503/LOGIN_NOT_LANDED. - Western Slope stays read-only in the robot; JP&Co and Situs Group share one login identity.
Files worth starting from
src/lib/domain/maintenance/wo-enrich-dispatch.ts · src/lib/domain/maintenance/sync-work-order-from-pms.ts · src/lib/domain/maintenance/run-wo-enrichment-sweep.ts · src/lib/domain/vendors/run-vendor-contact-sweep.ts · src/lib/integrations/appfolio/enrichment-mapper.ts · src/lib/data/types.ts (lines 6420-6560, 6860-7170) · src/lib/integrations/appfolio/pms-adapter.ts · src/lib/domain/pms/writers/work-order.ts (1042-1120) · src/lib/domain/maintenance/tech-sms-dispatch.ts · src/lib/maintenance/work-orders/access-extractor.ts · lambda/appfolio-sync/schedules.json · lambda/appfolio-sync/handler.ts · ~/.claude/appfolio-browser-agent/lib/runWorkOrderEnrich.ts · ~/.claude/appfolio-browser-agent/docs/playbooks/appfolio-login-and-sessions.md · rca-appfolio-robot-login-2026-09-10.html. The literal DOM/JSON:API scraping selectors live in the separate appfolio-browser-agent repo, not in propflowai.