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