Turnover System — Architecture Diagnosis & Simplification Plan
1. The system today — five planes, five unverified seams
Nothing below is a bad component. Each plane works, is tested, and deploys. The failures live where they meet: two of these planes change production behaviour without passing CI at all, and no test in any lane spans two planes.
- The voice config plane publishes ahead of the app.
sync-specialists.ymlis a barepush: branches: [main]while Vercel, Lambda and ECS all gate on CI. On 07-31 at 15:41 MT the live Triage agent — fronting three numbers including Camellia's — hadreschedule_vendor_visitbound while production served a build that answered{"error":"Unknown tool"}. The window closed at 16:16 MT; the cause did not. (P1-42) - Two recipient records, and code reads the one humans do not edit.
pm-contact-email.tsdeclares itself the one resolver (ADR-0104 §3.2) and has three call sites; the vendor-outcome, PO-required, forward-to-PM and escalate senders readProperty.propertyEmaildirectly. A fourth resolver exists with different semantics again. One notification splits its two legs across both surfaces — phone from LEASING_SETTINGS, email from META. This bit us again on 07-31: the phone leg was repaired that evening and the email leg still pointed at a@test.localsink. - Nothing owns the slow downstream. ADR-0068 D1 deliberately moved the work-order mint inline at PM approval — "PM approval and WO creation become one atomic, runtime-independent unit" — leaving Temporal the owner of AppFolio sync, in-house and external dispatch, and the charges wait. But
TURNOVER_WORKFLOW_ENABLEDis unset in both runtimes: prod holds exactly twoturnoverWorkflowexecutions, both RUNNING, zero closed, one parked to 2026-08-21 and one permanently wedged on a patch marker. And on the path that does run, the workflow hits two earlyphase = 'done'returns before it ever reaches the scope step. The half that was decided is unowned in practice. - Senders reported success unconditionally.
sendEmailis total and non-throwing for eight suppression reasons plus hard bounce — one{messageId}return against ten barereturn;statements, so a caller cannot tell delivered from suppressed from failed. Three PM notifiers discarded the outcome and returnedemailDelivered: true, including the gas-emergency page that tells a tenant "your property manager has been paged." ADR-0119 measured the same class across the SMS side: the dispatch fence covers 7 of 27dispatch()sites, and email is invisible to it entirely. Fixed and verified 08-01 (#5186, #5178). - Five calendar writers, no shared lifecycle. Create retries three times and writes a DLQ row; update swallows every non-2xx and still reports
calendarSynced: true; cancel has no teardown at all on the work-order path. Prod today: 25 Willows work orders hold a live Outlook event id, 22 of them cancelled.
2. Booking should be one engine
The CTO's instruction was to look at how tours do it and standardise on a single way to move events around a calendar. That is the right read: tours already have the spine, and nothing else uses it. Every tour intent — SMS, voice, web, PM edit — enters through applyTourIntent and lands via setTourSchedule, which is guarded twice over: a Symbol capability token that throws at runtime if a caller lacks it, and a CI drift fence that allowlists the five files permitted to import that token, so a new writer fails the build. The schedule row carries an optimistic scheduleVersion; a stale version at timer-fire time skips the effect rather than acting on a moved tour. Slot tokens are HMAC-signed with a 10-minute TTL and fail closed in production — the booking tool has no time parameter to hallucinate.
Vendor visits carry five separate representations of one time (free-form scheduledDate/scheduledTimeSlot, a structured scheduledStartAt/scheduledEndAt pair with a different owner, the call session's own outcome times, the tenant's free-text preference, and the Outlook event itself), written by four unreconciled paths. No write authority, no version, no booking-status model — booked-ness is inferred from Boolean(scheduledDate) — no availability check of any kind, and no CI fence anywhere on the path.
The proposal is not a new subsystem. It is the tours chokepoint, generalised. One applyEventIntent door that every event type enters — tours today, vendor visits and move-out inspections next, whatever comes after — with one booking-status model, one projection lane and one teardown path. Every property the tours spine already has comes along for free; every gap the calendar findings named gets fixed once instead of five times.
3. What to simplify — keep, consolidate, remove
| Component | Verdict | Why |
|---|---|---|
Tours booking spine (applyTourIntent → setTourSchedule) | Keep + generalise | The only scheduler with one door, write authority, a version lock, signed slot tokens, an availability engine and drift fences. It is the template, not a competitor. |
| Vendor-visit scheduling (post-call, tenant slot-pick, reschedule tool/PATCH, AppFolio mirror) | Consolidate | Four writers, five representations of one time, no status model, no availability check. The AppFolio mirror nulls a locally-booked window within 60 seconds; on cancel, 22 of 25 mirrored Willows work orders still hold a live calendar event. |
| Move-out inspection event | Consolidate | Create-once: a moved move-out date leaves a phantom inspection with no update and no delete path. One prod turnover's two date fields disagree by 27 days with a live event stamped. |
| Five calendar projection modules, each with its own retry/idempotency/teardown rules | Consolidate | Create retries and DLQs; update swallows every non-2xx and reports calendarSynced:true; the tenant path is fire-and-forget inside a serverless handler. createEvent sends no idempotency key, and the event id is stamped afterwards by a best-effort write whose failure is swallowed — so a lost stamp orphans one event and creates a second. |
| PM recipient config — Property META vs LEASING_SETTINGS (plus two more resolvers) | Consolidate | One notification splits its phone and email legs across two config surfaces. Humans edit the one the vendor senders do not read. |
| Two vendor-name matchers (walk path vs dispatch path) | Consolidate | They give opposite answers to the same spoken company name, with inverted safety postures — one auto-assigns at 1.00 confidence while the other reports no such vendor. |
| Vendor Calling ElevenLabs agent, outside the specialist registry | Consolidate | No repo config governs it; a different model at temperature 0.3 where every sibling runs 0.0; no tool a branch adds can ever reach it. |
| Eight overlapping harnesses + nine promptfoo suites | Consolidate | None reads state in a PR-blocking lane. Replace with one nightly front-door run (§5) plus advisory prose evals. |
ElevenLabs sync workflow (bare push trigger) | Keep, gate it | workflow_run: [CI] with a success guard. A config plane must never publish ahead of the app that serves it. |
/api/simulate/sms as a verification path | Remove | Bypasses Twilio, signature validation and SQS while driving real production side effects. Retired as proof, globally. |
| Vendor arm gates | Already removed (#5149) | CTO ruling: the review queue is the only approval gate. The compensating work is validation inside the approve — shipped 08-01 — not a new gate. |
| Same-unit tour blocking | Already removed (#5177) | CTO ruling: over-engineered. The connected calendar is now the only conflict source. |
| Turnover durable workflow | Decide: own its lanes, or remove it | ADR-0068 D1 correctly pulled the WO mint out of it; what it was left owning — AppFolio sync, both dispatch lanes, the release, the re-list — it has never run, because the flag is unset in both runtimes. Either arm it and let it own those, or move them to the cohort walker and delete it. A workflow that owns nothing is worse than none: it reads as coverage on an architecture diagram. |
4. Determinism — the tiered ladder, applied
Every voice- and text-driven failure on 07-31 sat on the same fault line: a model was asked to decide something a lookup could have decided, or was left to narrate an outcome no code had produced. The ladder is the standing answer — deterministic code first, a strong model only where the ambiguity is genuine, a panel when confidence is low, and a tie fails closed.
"Sure, take your time!" as the model line for a hold. The ban lost on every call. Never demonstrate a phrasing you ban. Corollary from the same fix (#5182): a prompt file that no sync target reads is a written record of a dashboard edit, not a control — the flagged filler survived being flagged because editing the file changed nothing anywhere.linked_tool_ids: [], so there was no reschedule tool to call. Deterministic rung 4 is what makes this catchable — every completion claim must trace to a tool result.5. Proving it — one nightly end-to-end run
The CTO asked for nightly tests that walk the whole pipeline including the comms — phone, text, email. The foundation exists: PR #5174 adds scripts/prod-e2e, a single runnable prod harness with numbered legs, a pre-flight that reads every gate before a phone minute is spent, and — the important part — a fidelity ledger: every hop declares itself real or shim, a shim must state what it did not exercise, and a passing assertion on a shimmed hop renders PASS(shim), never bare PASS.
javascript, 92 not-contains, 92 llm-rubric, 2 contains-any, and none of them touching DynamoDB, Twilio, SendGrid or a calendar. And this week's audit published two P0s over its own real:false verify verdicts. A summary that drops the verdicts underneath it manufactures confidence in both directions.6. Standing rulings, and the decision debt
Rulings that are live and enforced in code but written down nowhere central — recorded here so the next branch does not relitigate them:
- The review queue is the only approval gate (#5149). No arm gates in front of it. The compensating work is validation inside the approve, not another gate.
- Prospect-tour availability is never affected by vendor visits (#5177). The connected calendar is now the only conflict source; transparency is the only signal read, never category or unit. Our vendor projections write
showAs:'free'and the engine skips only'free'—'tentative','oof'and a missing value all block. No same-unit blocking — explicitly ruled over-engineering. Worth knowing: this ruling is currently enforced by one string literal in the vendor projection matched against one!==in the availability engine — no shared constant, no type link, no drift test joining them. The engine is the natural home for it. - Work orders are internal; external vendors get PO references. The property stays on
work_ordermode until the PO sync is repaired — five of nine Willows POs have no resolved vendor binding today, so a mode flip would refuse vendors citing a PO that exists and is approved for exactly them. - Vendor-lane inbound precedence is intended behaviour, not a defect — it is an artifact of testing every lane from one phone number. The real fix is multi-number personas (§5), plus a staff rung above exact-dial on the voice path.
- The simulate route is retired as verification proof, globally. Front-door SIDs and persisted side effects only.
Decision debt is a live hazard, not paperwork
The corpus can no longer answer "what did we decide?" There are 148 ADR files on disk using 121 distinct numbers — 22 numbers are duplicated across 49 files, including three-way collisions on 0059, 0060 and 0097 and a four-way on 0101. The README index carries 42 rows, so 106 files are unindexed. Two of the collisions are turnover's own: 0059-vendor-dispatch-for-turnovers.md shares its number with two unrelated ADRs, and 0070-guarantee-move-out-charge-filing.md collides with a conversation-closure ADR. A citation like "see ADR-0059" is now ambiguous on disk — and ADR-0111 makes exactly that citation twice.
Worse than the filing: decisions that never became code, and code that never became a decision.
| Decision | State | What that costs in production |
|---|---|---|
| ADR-0058 — every tool an agent can call really does what it claims | Proposed 2026-06-16, taxonomy never built | Zero effectClass references exist in source; no generalized drift test. The two tools hand-fixed under ADR-0057 are real, but the generalization that would stop the class recurring is not. The vendor lane then shipped with no reschedule tool at all, and Clara said "Done — Wednesday, August 5." |
| ADR-0111 — outbound vendor calling agent | "Proposed (held for Fede's review — do not merge without his explicit approval)" — and running live | Its business-day approval expiry is not implemented; sessions sat 66 hours before a human bulk-cancelled them. It also carries a 2026-07-31 amendment recording the arm-gate deletion, so the founder ruling lives inside a doc marked un-approved. |
| ADR-0042 — inbound thread routing | Proposed since 2026-05-28 — but substantially built and depended on by two later ADRs | The opposite failure: a decision the system already relies on is still labelled a proposal, and its named decision helpers are exercised only by tests while production runs a parallel adapter. Nobody can tell from the corpus that this is settled. |
| ADR-0104 §3.2 — one PM-recipient resolver | Declared, bypassed (and its number is duplicated) | Four vendor and maintenance senders read the property row directly. Seam 2 above. |
| ADR-0092 — universal suppression | Proposed, partially inert | Its own gap map names the holes: voice has no consent layer at all, and Microsoft Graph email bypasses sendEmail entirely — which is turnover vendor dispatch's primary leg. |
| ADR-0068 — guarantee turnover WO creation | Proposed; D1 shipped, the other half unowned | The inline mint landed. Temporal was left owning "the slow downstream" and is switched off. Seam 3. |
| ADR-0120 — no in-process side-effect routes as proof | Named, not enforced | /api/simulate/sms is its own stated proof case and is still gated by requireUser alone. |
Proposed. None of that is a decision — it is the filing system a decision record needs to be worth writing.7. Where we stand — 2026-08-01
- All five confirmed P0s are fixed, deployed and prod-verified today: the uncapped AppFolio sync loop (#5183/#5188), the PM notifications that reported success while delivering nothing (#5186), the approve that dialled about cancelled jobs plus the 14-day rebind (#5187/#5190), the workflow's move-out timer racing the inspection signal (#5184), and the identity sentinels that made a phone number unclaimable (#5185/#5189).
- Two published P0s were refuted by the audit's own verification layer — the Graph subscription renewer exists and works (an EventBridge-scheduled Lambda with a five-day zero-error record, per ADR-0003), and the vendor dispatch double-disarm is the safety architecture behaving as designed, not an outage.
- Open for the CTO, deliberately not decided here: the dispatch posture for Monday (arm external dispatch with the allowlist populated, or turn vendor voice off so the two lanes agree — and fix the toast that claims work orders were sent when nothing was); whether to set
TURNOVER_WORKFLOW_ENABLEDnow that the timer race is closed, or move what the workflow owns onto the cohort walker and retire it; and the 20 orphaned identity sentinels held for a deliberate go (8 Camellia, 10 unrouted, 2 staff). - Open debt: 42 P1 and 65 P2 as published. The consolidations in §3 close a disproportionate share of them because so many are the same defect in different modules.
PropFlow-Technologies/propflowai. Live prod numbers were read 2026-07-31 16:30 MT and move; re-read before acting on them. This is a diagnosis and a proposal — nothing here is a decision, and no ADR is superseded by it.