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

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.

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