Thesis. PropFlow writes to property calendars from four independent writers, each with its own (incomplete) retry, failure-recording, and teardown rules, over a raw Microsoft Graph client with no provider abstraction and a hardcoded Denver timezone header. No ADR was ever written for any of it — every load-bearing decision lives in PR bodies and file headers, and PR #4622's own header asks for this document to exist. The fix is one structural change: a single calendar engine that owns every event mutation — generic add/edit/delete over a provider interface — with every event type (tour, vendor visit, key pickup, inspection, and new standalone vendor scheduling events) as a thin spec on top.
Retry = wrapped in calendarSyncPolicy (3 attempts, backoff, breaker). DLQ = writes a CalendarSyncFailure row on exhaustion. Every cell marked ✗ is a place where a failed calendar write disappears silently.
| Writer (anchor) | Create | Update | Delete | Retry | DLQ | Teardown on cancel | Timezone |
|---|---|---|---|---|---|---|---|
sync-tour.ts (Tour) | ✓ | ✓ no retry/DLQ | ✓ no retry/DLQ | create only | create only | ✓ (cancel + repropose) | none passed → Denver |
sync-vendor-visit.ts (WorkOrder) | ✓ | ✓ | ✓ no retry/DLQ; silent no-op if id/token missing | create+update | create+update; row is tour-shaped (WO id stuffed in tourId) | only if window vanishes; 22 of 25 prod events sit on cancelled WOs | passed by PATCH route |
sync-key-pickup.ts (KeyPickupBooking) | ✓ | ✗ none | ✗ none | ✗ | ✗ (typed result instead — caller refuses booking) | ✗ — cancelled move-in leaves a live event | passed |
schedule-inspection-event.ts (Turnover) — lives outside the calendar dir | ✓ | ✗ none | ✗ none | ✗ | ✗ | ✗ — moved move-out date leaves a phantom inspection | none passed → Denver |
createEvent/updateEvent/deleteEvent from outlook-client.ts directly. No interface, no dispatch, no registry.graphFetch sends Prefer: outlook.timezone="America/Denver" on every request, and the client defaults writes to Denver. The availability reader derives busy minutes from local getters on the returned wall-clock — correct only while every connected property is in Denver. Latent bug for the first non-Denver customer.provider:'google' onto a property — but zero Google event code exists. Until PR #4921 this leaked Google refresh tokens to graph.microsoft.com; the provider guard now fails safe (DLQ + page) instead. The seam exists in data and UI, just not in code.getCalendarToken is documented as the single entry, but the inspection writer bypasses it and calls the token layer directly. No drift test pins the convention.showAs:'free'. One string literal in the vendor projection matched against one !== in check-availability.ts keeps vendor mirrors from eating tour slots. tentative, oof, and a missing showAs all count busy — so softening the mirror would silently re-block tours with no test failure (PR #5177's warning). No shared constant, no drift test. Also unstated: key-pickup and inspection events default to busy and therefore do consume tour availability — plausibly intended, recorded nowhere.CalendarSyncFailure rows are written by two writers — and nothing ever reads them. No sweeper, no reconciler, no healer. Recovery is a Sentry page and a human.2026-03: Outlook client + tour sync land in one commit, no ADR. 04: calendar becomes property-scoped; Google OAuth UI ships without a writer. 05–06: hardening era (slot-ID handshake, 429 fixes, a long tail of silent wrong-time bugs — am/pm mis-detects, a parser that turns unparseable input into 10:00 AM, still live in the tour path). 07-23: #4380 sets the projection doctrine (one-way, read-only, showAs:'free'). 07-26: #4622 adds the vendor mirror after a live-demo miss and explicitly requests this doc. 07-30: #4921 provider guard after the Google token leak. 07-31: #5177 CTO ruling — vendor visits never block tours, same-unit blocking removed. 08-08: #5547 key-pickup writer, third divergent sibling. Four writers, zero ADRs, decisions scattered across seven PR bodies.
Ruling this encodes (Fede, 2026-08-08): there is ONE calendar writer. A single engine owns every calendar mutation for every event type, against a generic provider interface. Nothing else in the codebase may create, edit, or delete a calendar event. Vendor scheduling additionally becomes a standalone calendar event — no work-order or PO dependency: a vendor saying "Friday 10–12, fridge in 210" produces a calendar event, full stop.
| Layer | What it is | What it fixes |
|---|---|---|
Provider interfaceCalendarProvider | create / update / delete / get / list against a connected calendar. Outlook adapter = today's Graph client behind the interface, with the property's timezone passed per call (Denver hardcoding dies here). A Google adapter becomes an implementation task, not an architecture change. | Raw-Graph coupling · Denver header · Google half-build |
The engineapplyCalendarEvent | The only door: book / move / cancel, taking an event spec + an anchor ref. One retry policy on all three verbs, one DLQ on all failures, an idempotency key per (anchor, intent) so unawaited callers can't double-create, id-stamp with verified persistence, and cancel as a first-class verb (teardown is never optional again). | The coverage matrix's every ✗ · double-event races · phantom events |
| Event-type registry | Per-type spec: subject/body builder, showAs, category, anchor entity + id field, timezone source. Tours = busy; mirrors = free — stated once, as data, with a drift test. Existing types: tour, vendor visit, key pickup, move-out inspection. New type: vendor scheduled event (standalone — property + vendor + window + free-text about; covers unsolicited calls, voicemails, emails, and WO-less cases like a utility outage). | Unstated showAs decisions · per-writer copy drift |
| Reconciler | A sweep that reads the DLQ and diffs anchors ↔ provider events: heals lost stamps, deletes orphans (events whose anchor is cancelled/gone), retries dead-lettered writes, reports what it can't fix. The DLQ stops being write-only. | Write-only DLQ · 22 orphaned Willows events · manual hand-deletes |
| Drift fences | CI tests pinning: (1) only the engine may import the provider adapters (allowlist, tours-style), (2) the showAs:'free'-skip contract as a shared constant across writer and reader, (3) every registered event type declares all three verbs. | Convention-only chokepoint · the #5177 silent-reblock hazard |
Availability, slot tokens, holds, and booking intent stay where they are — that's the tours booking spine, and it keeps working unchanged, calling the engine for its projection writes like everyone else. The engine is the calendar's hands, not its brain. If a unified booking-intent layer (applyEventIntent) is ever built, it sits above this engine; nothing here blocks or presupposes it.
Each step is one PR, one event type, old writer deleted in the same PR — the two-writers-coexisting failure mode is banned by construction.
Verdict: extend, don't build. A real chassis exists: scripts/vendor-calling/demo-loop-harness.ts already does real Graph create → read-back → update-on-reschedule → verified delete on The Willows (appfolio-45, org_sandbox), with property guards, a test-marker discipline, and a reap leg that re-GETs the event to confirm deletion. The stress family (scripts/stress-common/) has the only sweeper that can find calendar events with no DB pointer. The prod-e2e harness contributes the fidelity-ledger discipline (real vs shim, INCONCLUSIVE fails) but never touches the calendar.
PropFlow Test) stamped by the harness path, so sweepers can distinguish test events from real ones. Today's vendor-visit subjects carry no marker; orphans are unreachable by any tooling.no-token teardown failure.The vendor-scheduling ticket (Trello zGuMFnUI) collapses onto step 1: inbound calls, voicemails, and vendor emails all feed one pipeline — identify vendor, extract window, book a standalone vendor scheduled event through the engine (confident) or drop a review item (fuzzy). No work-order matching, no PO lookups. The Denver Water utility-outage case is just a normal event. The 12 dropped Camellia vendor calls replay cleanly against exactly this shape.
Update 5 Oct, evening: the Yardi calendar is now one capability of the Yardi browser agent (architecture, options, phases, refactor list). This section stays as the calendar-specific design.
Why now. ConAm will give PropFlow one Yardi user login for Yale 25 Station and nothing else: no mailbox, no Outlook or Google calendar, IT blocked personal calendars. Tours at Yale therefore have to live where the staff already look, the property calendar inside Yardi (Voyager 8, CRM IQ). This is a general need, not a ConAm one: every PMS has a showings calendar, and a customer who books in it today should not have to connect a second calendar to use Clara. The ask today is a design and a proof-of-concept plan, not a build.
The abstraction Fede hoped for is already there. One CalendarProvider interface (create, update, delete, get, list events) with two adapters, Outlook and Google, chosen by a factory from a provider id stored on the calendar connection. Tours pick a calendar from an ordered pool: the leasing agents' own calendars first, then the building's calendar, else the company calendar. Availability reads every required calendar and unions the busy time; booking writes one event to one destination and pins that destination on the tour. A separate, flagged, write-only path mirrors Willows tours into AppFolio as showings through the browser robot. Nothing reads a PMS calendar. Yardi has no calendar code and only a prospect stub that does nothing yet.
appfolio and yardi join outlook and google in the provider list, the guard, the factory (with an explicit branch, no more "anything else is Outlook") and the connection row's provider field. The adapter implements the same interface; event times stay local wall-clock in the property's timezone; a read that cannot reach the PMS throws, it never returns "free".| Path | What it can do | Status |
|---|---|---|
| Browser robot over CRM IQ (one Yardi user) | CRM IQ has a calendar with month, week and day views where appointments are added against agent availability. Whether a user login can read and write it is our assumption from the AppFolio robot, not something seen. | Realistic v1. Not yet seen: screens unverified, and whether the PropFlow user gets the CRM calendar role is a question for ConAm's Yardi admin. |
| Official API (SIPP, "Prospect" family) | Guest cards carry events with types Appointment, Show, GuidedTour, SelfGuidedTour and more; we can import a guest with an Appointment event and read activity by date range and event type. | Our Yardi onboarding plan (29 Jun) is Maintenance and Master Data first with Prospect deferred; the onboarding is not paid or complete; Prospect was quoted at $25K a year with no sandbox before payment. No endpoint lists open tour slots or an agent's calendar (guest activity does return Appointment events, and unit availability exists), and whether an imported Appointment event appears on the CRM calendar is unverified. v2. |
| Calendar feed (iCal, ICS, REST) | None found for Voyager. | Not an option. |
Questions for ConAm's Yardi admin, to ask with the user request: does the PropFlow user get CRM IQ calendar rights at Yale 25; does it see the property calendar or only its own; is a second factor or IP allowlist on logins; are tours booked as Appointment or Show. Questions for Yardi's account manager: does an imported Appointment event land on the CRM calendar and block the agent; can the interface read future appointments. The ask to Yardi is Fede's.
Writing showings into AppFolio already works at the Willows. What is missing to make AppFolio a calendar source is the read. The proof of concept is: list the Willows showings through the robot, wrap that in the provider interface as a read-only adapter, and show Clara refusing a slot that an AppFolio showing already occupies. A live read-only recon of the AppFolio showings screens was attempted on 5 Oct and did not get in: the robot's one login attempt from the Mac did not land, so no screen or list endpoint was captured and the attempt was not retried (a second uncoordinated login on the shared identity risks the production session). What the robot code already proves without a live run: a showing is read one at a time at GET /api/showings/<id>.json with start, end, unit, status and assigned agent, and the showing id is the plain integer our own create returns, so matching our tours is a direct id match. The open question for rung 2 is only whether a list by property and date range exists; the recon re-runs from the Mac mini on the warm session.
| Rung | One concern, dark, under 300 lines | Proof (with a mutation control) |
|---|---|---|
| 1 | Admit the ids: provider list, guard, factory with an explicit branch, connection provider field. No adapter yet. | Drift tests updated; a planted unknown id is refused, not sent to Outlook. |
| 2 | Robot read route: list showings for a property and date range, returning start, end, unit, agent, status and showing id. | Willows: the showings our own robot created come back with matching ids; a planted bad date range returns an error, never an empty list. |
| 3 | AppFolio adapter built from the PMS credential: list showings as busy time first (writes still flow through the existing mirror until rung 4). | Acceptance matrix: a Willows showing blocks the slot; a transport failure refuses the day instead of offering it. |
| 4 | Fold the existing showing mirror into the adapter's write methods; delete the separate write path. | Same replays as the mirror had, one writer left; the flag removed when proven. |
| 5 | Settings row on the building page: "Calendar: AppFolio showings" with role, status and last read; atlas page. | Browser-verified on preview and prod at the Willows. |
Yardi then repeats rungs 2 to 5 with the CRM IQ screens behind the same adapter interface, once a Yardi login exists. Which calendar a building books into is already a per-building choice (the connection row), so no new switch is added: connecting the PMS calendar at a building is the turn-on, and that connection at a customer is Fede's call.
Sources: code map of src/lib/domain/calendar and the AppFolio showing writer at origin/main 6a43724 (5 Oct), the Google calendar onboarding handoff page, Gera's portfolio architecture page (attachments, resolver, ALIGN4), the Yardi SIPP swagger in the local skill, Yardi and vendor documentation read 5 Oct, Rachel Butler's email of 24 Sep, and Fede's report of the 1 Oct call.
Status: Proposed — pending Fede's review. Session c46c9bb9 (agents/010). Evidence: three sweeps over PropFlow-Technologies/propflowai — current-state (file:line cited), harness inventory, PR/ADR chronology — recorded in the session transcript. This doc supersedes nothing; it is the first written record of the calendar architecture (requested by PR #4622's header).