Calendar Architecture — Current State & the One-Writer Engine

2026-08-08 · evidence: three independent code/history sweeps of propflowai (current state, test harnesses, PR/ADR chronology) · every claim cited to source in the session record
Proposed — pending Fede's review · section 6 (PMS calendar) added 2026-10-05

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.

1 · Current state — four writers, one raw client, no owner

1.1 The coverage matrix

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)CreateUpdateDeleteRetryDLQTeardown on cancelTimezone
sync-tour.ts (Tour)✓✓ no retry/DLQ✓ no retry/DLQcreate onlycreate only✓ (cancel + repropose)none passed → Denver
sync-vendor-visit.ts (WorkOrder)✓✓✓ no retry/DLQ; silent no-op if id/token missingcreate+updatecreate+update; row is tour-shaped (WO id stuffed in tourId)only if window vanishes; 22 of 25 prod events sit on cancelled WOspassed by PATCH route
sync-key-pickup.ts (KeyPickupBooking)✓✗ none✗ none✗✗ (typed result instead — caller refuses booking)✗ — cancelled move-in leaves a live eventpassed
schedule-inspection-event.ts (Turnover) — lives outside the calendar dir✓✗ none✗ none✗✗✗ — moved move-out date leaves a phantom inspectionnone passed → Denver

1.2 The provider layer

1.3 The reader and the one rule that must not drift

Availability skips only 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.

1.4 Failure handling is write-only

1.5 How we got here (chronology in one breath)

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.

2 · The proposal — one calendar writer

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.

2.1 Shape

LayerWhat it isWhat it fixes
Provider interface
CalendarProvider
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 engine
applyCalendarEvent
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 registryPer-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
ReconcilerA 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 fencesCI 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

2.2 What deliberately stays out of the engine

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.

2.3 Migration order — no event type ever has two writers

  1. Engine + the new standalone vendor scheduled event (greenfield first client — no legacy writer to coexist with; immediately unblocks the vendor-scheduling ticket for calls, voicemails, and email).
  2. Vendor-visit mirror (messiest legacy writer; its four call sites become engine calls; the reconciler adopts its DLQ rows and cleans the 22 orphans).
  3. Key pickup (gains the update/delete verbs it's missing).
  4. Move-out inspection (comes inside the fence, gains update/delete; phantom inspections end).
  5. Tours last — a mechanical swap of its projection calls once the engine has proven itself on everything else. Tour booking logic untouched.

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.

3 · Proving it — extreme testing on Willows + real Outlook

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.

What must be added for extreme-volume permutation testing

  1. A test discriminator on events — a dedicated category (e.g. 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.
  2. An id-based reconciling sweeper — list provider events, diff against anchors by event id (not subject substring), delete orphans. This doubles as the production reconciler's first implementation, tested at volume before it ever runs on a customer calendar.
  3. Permutation coverage that exists nowhere today — update is exercised on exactly one path; never tested for real: null-window→delete, word slots ("Morning"), multi-day windows, the stale-structured-window trap (all three documented production breaks, proven only against mocks).
  4. Concurrency legs — two writers racing one event id; the engine's idempotency key is exactly what's under test.
  5. A write budget + notification gate — Graph throttles, and every current demo-loop run pages the PM twice (email + SMS); at volume that's the first thing to switch off.
  6. Token-freshness pre-flight — refresh the Willows calendar token before the first write, so a long run can't end in a silent no-token teardown failure.

4 · Decisions

D1 — Does the v1 engine ship a Google adapter?

RECOMMENDED Interface now, Outlook adapter only. The provider interface is designed so Google is an implementation task; the half-built OAuth stays refused by the guard until a real adapter exists. No customer needs Google today.
Ship Google write support in v1. Closes the dangerous half-build properly, but adds a second provider's quirks to the engine's shakedown period.
Rip out the Google OAuth routes/UI entirely until an adapter exists — no connectable provider the runtime refuses.

D2 — Do tours migrate onto the engine?

RECOMMENDED Yes, last (step 5) — after the engine has proven itself on four event types. One writer means one writer; leaving tours out permanently recreates the two-disciplines disease.
Tours stay on their own writer indefinitely. Lowest risk to the healthiest flow, but the engine is then "one writer except the biggest one."

D3 — Vendor scheduled events: how standalone?

RECOMMENDED New standalone entity, optional free-text job reference. Property + vendor + window + what-they-said. If a call obviously references a known job, that lands as text in the event body — context, not coupling. The legacy WO-anchored mirror migrates in step 2 and both types coexist as different event types, one writer.
Extend the WO-anchored mirror with a nullable WO. Fewer types, but re-couples scheduling to work orders — the thing just ruled out.

D4 — Reconciler cadence and blast radius

RECOMMENDED Nightly, report-first. Nightly diff of anchors ↔ events per property; auto-heal lost stamps and retry DLQ rows immediately; orphan deletion runs report-only for two weeks, then flips to auto-delete once its precision is proven on Willows.
Auto-delete from day one. Faster cleanup, but a reconciler bug that deletes a PM's real events is the worst possible introduction.
On-demand only. No standing sweep; run manually after incidents. Keeps the DLQ effectively write-only.

5 · What this unblocks

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.

6 · PMS calendar: the property's own calendar as a provider Proposed — pending Fede's review · 2026-10-05

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.

What exists today

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.

Three facts that shape the design. (1) The factory sends any provider id that is not "google" to Outlook, so a new id must be admitted explicitly before anything else. (2) The selection contract already separates "calendars that must be free" from "the one calendar that receives the event", so a PMS calendar can be a busy source, a destination, or a mirror without new concepts. (3) Gera's portfolio model says the PMS, the calendar provider and the mail provider are a provider field on one attachment behind one seam. A PMS calendar is a new provider value on that attachment, never a parallel graph.

Before and after

Today With a PMS calendar provider Tour engine: availability → hold → book → pin destination one CalendarProvider interface · pool: person → building → company PersonOutlook · Google BuildingOutlook · Google CompanyOutlook · Google AppFolio showings (robot)write-only mirror, flagged, Willows only Yale 25: no person, building or company calendar allowed → no tours Yardi: no calendar code; AppFolio: never read Same tour engine, unchanged pool: person → building → company, where "building" may be the PMS calendar PersonOutlook · Google Building = PMSappfolio · yardi CompanyOutlook · Google PMS calendar adapter (CalendarProvider) built from the property's PMS credential, not a mail token list showings → busy time · create / move / cancel showing → event AppFolio robotproof of concept at the Willows Yardi CRM IQrobot first, paid API later

The design in five rules

  1. A PMS calendar is a provider id, not a new kind of calendar. New ids 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".
  2. It attaches at the building, from the PMS credential. Today the building level is a field on the property holding Outlook or Google tokens. The PMS calendar reuses the existing "store the secret once, point at it" credential row the AppFolio sync already uses, so connecting the PMS connects its calendar. No second login, no separate token refresh.
  3. Its role is explicit, per property. Using the existing selection contract: busy source only (Clara never books into it, only avoids its showings), destination (Clara books into it), or mirror (Clara books elsewhere and copies the showing in). A property can hold several at once. A required busy source that cannot be read means no slots offered, as today.
  4. One writer. The existing write-only AppFolio showing mirror folds into the adapter's create, update and delete. There is then exactly one place that writes a showing to a PMS, which is this page's thesis applied to the PMS.
  5. Vendor ids stop at the adapter. The showing id, agent id and unit id from AppFolio or Yardi live on the tour's PMS stamp, as the AppFolio showing id does today. The tour engine never sees them.

D5 — Where does a Yale 25 tour live? Decided by Fede, 5 Oct 2026

DECIDED The Yardi property calendar is the building's calendar, exactly like Outlook or Google. Clara reads it for busy time and writes the booking into it; reschedule and cancel go back to the same event. Fede: "it's the same as Google or Outlook." Version one is a browser robot over the CRM IQ calendar behind the provider interface; the adapter is stubbed until the Yardi login exists. The official API only gets involved if Yardi ever exposes a calendar (today it does not, see the table below).
Considered and not chosen: PropFlow's own tour record as the destination, Yardi as busy source and mirror. Degrades more gracefully if the robot write fails, but it adds a second place where a tour lives. Rejected for the same reason the rest of this page rejects second writers.
Considered and not chosen: a PropFlow-owned Google calendar as the destination. Nobody at ConAm would look at it.

Yardi, honestly

PathWhat it can doStatus
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.

Proof of concept on AppFolio: the read half

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.

RungOne concern, dark, under 300 linesProof (with a mutation control)
1Admit 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.
2Robot 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.
3AppFolio 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.
4Fold 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.
5Settings 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).

PropFlow Docs