The 6-Hour Blind Spot

A full-product audit of date and timezone handling: what fires in production every evening, what detonates when we onboard a property outside Mountain time, and the class-level fixes.

2026-09-01 · six parallel audit agents, ~800 file-examinations across app, agents, lambdas, infra · read-only, no code changed · triggered by the Kurt Schmittgen misfired hold

Our servers live six hours in the future. Production runs on UTC; every property runs on Denver time. From ~6 PM to midnight at the property, the server's calendar already says tomorrow — and dozens of places in the product ask the server "what day is it?" when they mean "what day is it at the property?" Everything in this report is one bug wearing forty costumes.

The incident that started this

On Aug 31 at 7:51 PM Denver time, Clara escalated a "tour not on file" question to the Camellia team about Kurt Schmittgen — whose confirmed 2:00 PM tour was on file the whole time (he'd attended it and already received an application link). The verification asked "does Kurt have a tour today or later?" using the server's calendar; at 7:51 PM Denver the server said Sept 1, so his Aug 31 tour looked like the past → zero live tours → false mismatch → spurious hold. The new hold-for-review gate did its job and parked the bad email. This audit asked: where else does this exact mistake live?

Firing in production today — ranked

Everything below hits with the current all-Denver fleet, concentrated in the 6 PM–midnight window. "Confirmed" = an auditor traced the full code path; the headline item was additionally re-verified by hand.

#What happensWho it hurtsWhereStatus
1Prospects get "thanks for touring — here's your application" before their tour. The 15-minute sweeper that repairs dropped tour workflows judges "has this tour happened?" on the UTC day. From 6 PM on, today's tours look finished: it emails the post-tour application link early, stamps it sent, and the real follow-up is suppressed forever. The same skew also un-arms the tour's reminder — both halves of the sweeper's job fail at once.Prospects, directlytour-cohort-walker.ts:223,153,183confirmed · live
2Clara's promise-keeper hallucinates missing tours in the evening (the Kurt bug) — false "tour not on file" escalations to the team, and via the same helper, a duplicate pre-tour text to the prospect on top of the workflow's own reminder.Team (noise), prospects (double texts)promises/ground-truth.ts:295-302, decide.ts:329confirmed · live
3A lease reads as "over" before it is. Lease-end math anchors at the start of the last day, on the UTC clock: the daily scan calls a lease lapsed on the morning of its last valid day (evening ad-hoc runs: two days early). Opens the month-to-month branch — a past-tense "your lease ended" text and, if the conversion arm were on, a repricing at the MtM premium while the lease is in force. The repo has no single answer to whether a lease-end date is inclusive: the workflow says yes, four read paths say no.Residents (improper notice / rate); currently blunted by the disarmed RENEWAL_HOLDOVER_CONVERSION flag, but classification and escalations are liverenewal-auto-start.ts:507,1188, renewal-cohort-walker.ts:485confirmed · partly gated
4A fully paid-up resident can be presented as a collections case. The ledger reader counts the next month's rent charge as arrears with zero grace, starting 6 PM on the last evening of the month — and the collections walker runs right after charges post on the 1st. Post-ADR-0125 this creates a draft for PM review, not a sent letter, but the reviewer re-checks with the same math and confirms the mistake. Related: Clara can state "0 days delinquent" to someone a month behind (or overstate by one) because days-late reads the UTC day-of-month.Residents, PM trustfeature-extractor.ts:240,316, handle-get-tenant-balance.ts:117confirmed · draft-gated
5The hourly AppFolio renewal reconciler runs a day ahead for 6 of its 24 daily runs — a lease ending today (tenant in the unit) reads as already lapsed; worst path feeds "renewed externally" auto-close a day early.Renewal pipeline integrityexternal-reconcile.ts:166,183confirmed · live
6Wrong dates written into AppFolio every evening: a work order closed at 7:30 PM records completion tomorrow (vendor billing, month-end spend attribution); an auto-published listing can advertise the wrong move-in date; offered renewal terms are stamped one day long (start + 12 months, where AppFolio computes start + 12 months − 1 day). Plus one live contradiction: two parts of the code disagree whether AppFolio's completion field takes MM/DD/YYYY or ISO — one of them is wrong today; a 5-minute live probe settles it.PMS as system of recordwork-order-writer.ts:322, auto-publish-listing-hook.ts:96, renewal-rent.ts:93, tools/appfolio.ts:316 vs l4-core.ts:1770confirmed · live
7The move-in-day protection hole (found independently by three auditors). After 6 PM on move-in day the guard that stops the rent-roll sync from archiving an arriving resident's lease evaporates — the unit-612 "you have no lease" failure, shifted into the exact evening the resident picks up keys, with the alarm built to catch it blind in the same window. Adjacent: the evening before move-in a future resident can be stamped as already living there; on a lease's last evening it can drop out of "current."New residents at the doorentity-delta.ts:139, future-arrivals.ts:192, plan.ts:327, stranded-future-lease/sweep.ts:185, occupancy-derivation.ts:61, lease.ts:54confirmed path · likely end-to-end
8Clara tells people wrong dates about their own history. A work order reported at 7:12 PM enters her context dated tomorrow — "when did I report the leak?" gets a date that hasn't happened. Cross-channel labels ("we spoke on Sept 1" about an Aug 31 evening call), "N days remaining" on a lease off by one every evening, raw UTC timestamps handed to her for work-order lookups.Tenants & prospects, in conversationclara-unified.ts:695, conversation-manager.ts:6059, format.ts:587 (+ mirror)confirmed · live
9The operator's numbers go wrong every evening. Lease-expiry countdown (drives the expired badge, pipeline stages, renewals sort, and when Clara starts renewal outreach) counts UTC days — every lease reads a day closer to expiry after ~6 PM. Maintenance labels flip to "1d overdue" while still due today. Dashboard "Upcoming tours" silently drops tomorrow's tours; the same function computes two tour counts on two different clocks. Turnover stat cards inflate the overdue count. Renewals board can drop the current month's column on month-end evenings. Global search labels a lease "Expired" on its last evening.PMs acting on wrong numbersdynamo/leasing.ts:1352, renewal-status-display.ts:213, map-renewal.ts:596, RoutineMaintenanceClient.tsx:73, compute.ts:1174, compute-light.ts:260, list-stats.ts, board/live.ts:75, search/route.ts:223confirmed · live
10Every daily metric row is labeled with the wrong day. Snapshot jobs run ~10–11 PM local but stamp the next day's date: the "Sep 1" number is Aug 31's state, fleet-wide, silently self-consistent. The weekly PM report's week runs Sunday-6 PM to Sunday-6 PM, so the busiest boundary-evening activity lands in the wrong week.Every chart & reportlambda/metric-snapshot/handler.ts:218, portfolio-insights-snapshot/handler.ts:104, build-report.ts:93, agent-metrics.ts:560confirmed · live
11Tour calendar invites carry no timezone at all. The .ics attached to confirmation emails and the Google Calendar link are "floating" times — a prospect whose phone is set to Eastern gets a 2:00 PM Mountain tour on their calendar at 2:00 PM Eastern and arrives two hours early. Live today for any non-Mountain device, evening or not.Prospects with out-of-zone devicestour-calendar.ts:53-137confirmed · live
12A malformed tour time becomes a 1970 anchor → instant application link. A tour whose start time isn't "H:MM AM/PM" (e.g. "14:00" or the literal <UNKNOWN> — at least one such Camellia row is known) anchors its workflow at epoch zero; the app-link timer fires immediately, and that leg has no "has the tour happened" guard. Three different fallbacks exist for this one data error.Prospectstour-cohort-walker.ts:131, tour-workflow.ts:1112, activities/tour.ts:724likely

Further confirmed mediums, same class: MtM notice period one day short of 14 (renewal-holdover.ts:164); the renewal workflow's "+1 day grace" timer actually fires on the lease's final evening (renewal-workflow.ts:1854); an eligibility verdict is never written on a lease's last valid day, so it's wrongly held with "expired while held for review" (renewal-cohort-walker.ts:483); holdover re-offer can leave a one-day gap in the offered term; a rescinded move-out notice at 8 PM on move-out day flags for manual review instead of auto-cancelling the turnover; voice transcripts can interleave into the wrong call when a call ID is missing; an admin backfill script that texts prospects targets the wrong day's tours if run in the evening.

Landmines — armed, waiting for the first non-Mountain property

A second cluster is explicitly pinned to Denver's clock (not UTC), so it's correct for today's fleet — verified live: all four production properties carry timezone: America/Denver — and wrong the day one isn't:

Why this keeps happening — five root causes

What's already right (build on these, don't reinvent)

Fix plan — superseded by the centralization plan above (2026-09-15)

The wave numbers below are the original 2026-09-01 sketch, kept for history. As of 2026-09-14 the plan is the one described in "The fix converges on one module" above: the central module + CI guard PR, then the 2–3 slice PRs. Wave 0's items are the same work as slices ①–⑥ in the Lane status section. D1 and D2's fixes don't get their own PR — they ride along inside whichever slice touches that code (lease-end comparisons and collections in the money-paths slice). The old Wave 3 "make regression impossible" line item (the drift-test extension, Property.timezone ownership) is not deferred to last — it's the guard PR, first in line.

Wave 0 — stop the wrong sends and wrong PMS writes (small diffs, ship first). Per-property day key in the tour sweeper (#1); property-local day in the promise ledger's live-tour check (#2 — the correct twin already exists in tour-state-scope.ts); property-local dates on the AppFolio write path — work-order completion, listing availability, and the five-site rent-roll/move-in cluster (#6, #7); Clara's work-order date and days-delinquent (#8, #4-partial). Plus the 5-minute live probe on AppFolio's completion-date format.

Wave 1 — money and operator truth. Apply the lease-end inclusivity decision (D1) and the collections grace decision (D2) inside the money-paths slice; timezone parameter on daysUntilExpiry/daysUntilDate (fixes the expired badge, pipeline, sort, auto-start window, tenant tiles together); maintenance/turnover day labels; TZID in calendar invites; metric snapshot day keys (or move the crons to local morning).

Wave 2 — close the seam, don't patch its shadows. Timezone parameter on buildDayAvailability (signature change, both callers + replay harness); route parseTourDateTime through resolveTourTimeZone; unify the Chicago/Denver fallback; property-zone "Today is…" in Clara's text prompts; one policy for unparseable tour times (currently three).

Decisions — made

Put to Fede on 2026-09-14; he ruled these engineering-owned and directed the recommendations be executed (relayed via the orchestrator session, under the finish-it rule). Recorded here as decided; not to be re-asked.

The fix converges on one module (decided 2026-09-14)

Centralization is the deliverable, not an afterthought. One small PR (≤300 lines) extends src/lib/platform/property-local-date.ts into the single owner of every "today / day key / days until / is before" decision in the product, always taking the property's timezone explicitly — plus a ratcheted CI guard that bans the raw patterns that caused this whole audit (toISOString().slice(0,10), local date getters used without a timezone, parseDate called on date-shaped fields). Existing offenders are baselined so the guard doesn't fail the build on day one; any new offender fails CI; the baseline may only shrink, never grow.

Then the ~245 naive call sites migrate through that module in 2–3 sequential slice PRs, grouped from the audit slices (tours/promises + outbound timing; money paths + lambdas/crons; web rendering + maintenance/AppFolio/Clara's clock). Each slice is dark, and each merges before the next opens (Fede: "not that many PRs though, they waste review tokens"). Along the way, Property.timezone becomes an owned, validated field written at onboarding instead of free-text nobody writes.

Migration count: the merged guard measured the real baseline — 306 files / 489 naive day-key occurrences when it merged, grandfathered so CI doesn't fail on day one. That's higher than the audit's original ~245 estimate; the guard counts every occurrence, not just files, so it's a truer number. Now down to 298 files / 477 occurrences after the three approved migration slices (below). The baseline can only shrink from here — any brand-new naive pattern fails CI immediately.

Lane status — Wave 0 (updated 2026-09-15, morning)

Lane: timezone correctness, Wave 0 — stop the wrong sends and wrong AppFolio dates. Owner: Fede's audit session (agents-011). Bench: The Willows. Kicked off 2026-09-14 under the standard playbook: each fix lands dark (behavior-affecting paths corrected only for test properties), is proven at the Willows, and turning it on for real properties stays Fede's explicit call, logged here.

Migration slices — all three approved slices merged 2026-09-15

Deliberately not migrated (triage, decided)

What broke and got fixed along the way (night of 09-14)

The repo's own CI went red twice on date/parallelism issues while we shipped this: an expired drift-ledger entry at the UTC midnight rollover (fixed with prod Temporal drain evidence, PR #8544), and a test that planted a scratch file inside the source tree, tripping unrelated scans (fixed by Gera's #8559). Our own #8557 then briefly turned main red because its new dark gates weren't classified in the voice-path gate registry — fixed in #8576. The lesson, classify new gates in the same PR that adds them, is now baked into the lane's builder instructions. Deploys were stalled behind red main from 01:45Z to ~05:29Z, then all resumed.

Method & coverage

Six audit agents ran in parallel, each owning one slice: (1) tours & the promise ledger, (2) outbound comms timing & cadence, (3) renewals/leases/collections money paths, (4) lambdas/crons/infra/digests, (5) web-app rendering & API serialization, (6) maintenance/turnovers, AppFolio browser automation, inbound processing, and Clara's own clock. Together they examined roughly 800 file-instances (~500 distinct files, with deliberate overlap — three findings were confirmed independently by two or three auditors, and one auditor's two retracted claims were caught by its own line-by-line re-verification pass). Every finding above names its file and line; "checked and cleared" inventories exist in the six underlying reports for anything that looked suspicious and wasn't. The one deliberately accepted deviation worth knowing: dashboard chart day-buckets are UTC by design (labels pinned to match), so evening activity charts to the following day — a known trade, not an oversight.

Underlying detail (all six full reports, including per-finding failure scenarios and cleared lists) lives in the audit session; ask any agent to pull specifics from this page's file references. Fleet timezone verified live against prod Property rows on 2026-09-01. The Kurt Schmittgen hold (eschold_acbd90a4) was rejected with the diagnosis on record (agents-003 for Fede; confirmed 2026-09-14).

PropFlow Docs