Why the browser robot that writes into AppFolio locked itself out twice in two days, and what changes so it doesn't happen a third time.
Written 2026-09-11 morning from three read-only investigations (change history, live system evidence, and a walkthrough of how the pieces fit together). All times America/Denver, 12-hour clock. Every number below traces to a database query, a Sentry error record, a Browserbase session log, a Twilio record, a code/deploy record, or a session transcript. Anything marked OPEN is still being run down.
Since 4:53 PM on Sep 10 (JP&Co and Situs Group) and 3:58 PM on Sep 10 (Western Slope), the robot that writes into AppFolio can't sign in. Every write it attempts gets refused.
No tenant, prospect, or vendor was affected. This was checked directly — a full scan of 6,941 automation-run records since the lockout, logs across five backend jobs, all 3,022 work orders, and all three retry queues (empty). Test-property and stress-test traffic was excluded from every count.
Not affected at all: Clara's answers about unit availability (that comes from a different, read-only feed that ran 488 straight successful pulls at Camellia through this whole window), rent roll data, and renewals data. Nothing needs a manual redo, and nothing is lost.
A real sign-in failure. The robot's saved browser profile got stuck in a redirect loop with AppFolio's login page — 20 attempts in 37 seconds, never even reaching a password box. Chasing it created 21 throwaway browser profiles that day, and every new profile costs a fresh text-message security code: 23 AppFolio codes went to the fede@ number in 53 minutes, and 28 went to the Western Slope number the night before.
A different cause: a loop that keeps re-triggering itself without any actual login happening inside it.
Confirmed from the code, from Browserbase's own session recordings, and from Sentry's error trail. Roughly every 6 hours, when the safety lock expires, a routine "keep the session alive" check gets one attempt through — and inside a single already-open browser window, in about 8 seconds, this happens:
REFRESH_DID_NOT_TAKE, with the message "Drove login successfully but the session it produced is dead (api door 401)." That message is false — nothing was actually driven through a login.Evidence backing this:
Both logins failed in parallel because they share the same code path, not because they share a password. First trip: Western Slope at 3:58:24 PM, triggered by the keepalive check. JP&Co/Situs Group: 4:53 PM, triggered by a real incoming message about a prospect that needed a guest-card write.
Things we built that made it worse: our own calling code treats REFRESH_DID_NOT_TAKE as worth retrying, so it forces another login attempt. The robot's refusal comes back as an HTTP 503 error, which the message-queue side then redelivers up to four more times. And the keepalive check itself was widened on Sep 9 at 3:53 PM from covering just the JP&Co login to covering all three accounts every 2 minutes — so now three customer accounts sharing two logins are all hammering the same login-budgets constantly.
Found. The 24-hour cap itself is inferred at high confidence; everything else below is directly verified.
Signing into AppFolio sets three cookies: one company-wide sign-on cookie good for about 29 days, one "remember this device" cookie good for about 30 days, and one short-lived per-database session cookie. Our keepalive check pings the data API every two minutes, which resets that session cookie's idle clock (about 3 minutes) — but it cannot reset its separate absolute clock. Once that absolute clock runs out, the company-wide sign-on cookie is still perfectly valid, so the staff dashboard still loads and the basic access check still passes — while the data API flatly refuses it. That mismatch is what section 3 calls "hybrid-stale." We proved this exact behavior once before, on 2026-05-25, and it's written into our own troubleshooting notes: a full password-and-code login can restore the data API door.
The timing lines up almost to the minute. The last real logins were Sep 9 at 3:56 PM (clara@ login) and 5:20 PM (fede@ login). The break happened Sep 10 at 3:58 PM — 24 hours and 2 minutes after the clara@ login. All three accounts were separately confirmed working on both doors at 11:13 AM and 11:18 AM on Sep 10, 19 hours in. Our own troubleshooting notes had already clocked the longest a session had ever stayed warm at about 23.3 hours, and had an open question asking whether there's a hard ceiling somewhere past 23 hours. This incident answers that question: yes, 24 hours.
Why this never showed up before, and why all three accounts broke at once: until Sep 8 at 10:11 PM, every write ran a full fresh login and started from a throwaway browser profile each time — so no session ever lived long enough to hit the 24-hour wall. A change that night switched to reusing a saved, already-logged-in browser session instead of logging in fresh every time. The next afternoon, a second change made that saved browser profile persistent and put all three customer accounts on the same indefinitely-reused keepalive. The first 24-hour boundary after both of those changes landed was Sep 10 at about 3:56 PM — which is exactly when it broke.
Why it can't fix itself: the login routine decides a session is fine purely from seeing the normal dashboard on screen — the exact same shortcut that was deliberately removed from a different check back in May for producing false positives. The code calling it throws that "already logged in" answer away, no password is ever actually typed, and that non-attempt then gets counted as if it were a failed login with bad credentials.
Ruled out: duplicate same-name cookies colliding across the different AppFolio sites we log into (the mechanism for that exists in our cookie-handling code, but the same saved session worked fine for 19 hours on the same path first, and a naming collision would fail immediately, not exactly at hour 24); a cookie missing a security flag it needs (same reasoning, and it would point the opposite direction from what we saw on Sep 9); and any code deploy or scheduled job at 3:58 PM (there wasn't one — the last deploy was 4.7 hours earlier and had already passed its own after-the-fact proof check). A previously-logged mistake about "a fresh login isn't the fix" turns out to be about the staff-webpage door, not the data-API door that's actually failing here — there's no prior case of a truly fresh login failing the data-API door.
What we still can't see: there's no way today to just look up the stored session's cookie names, which sites they're scoped to, and when each one expires — the tool that touches them only knows their names well enough to hide them in logs. Before the next real recovery login, we need to capture that information before and after, plus confirm directly that a password was actually typed and that the work-orders endpoint of the API comes back healthy afterward.
Fifteen changes to the browser-robot code shipped in 46 hours (Sep 8 afternoon through Sep 10 lunchtime). Fourteen of them touched login, sessions, or cookies; nine touched the same three files. Almost every individual change fixed a real bug. Stacked on top of each other with no time to let any one of them prove itself, they produced a system that shuts itself off.
| When | What |
|---|---|
| Sep 8, 2:43 PM | Western Slope credentials added to production |
| Sep 9, 1:14 PM | Login budget + lockout rule goes live (3/hour, 8/day, 30-min lock then 6-hour lock) |
| Sep 9, 1:28 PM | Change requiring an explicit account on every call goes live; JP&Co fallback removed |
| Sep 9, 3:06 PM | Outage A: lockout trips for fede@ login (30 min) |
| Sep 9, 3:07 PM | A person deliberately extends the lock to 12 hours |
| Sep 9, 3:53 PM | Keepalive check widened to warm all three accounts every 2 minutes; browser profile kept between runs |
| Sep 9, 4:15 PM | Staff-page check added to startup, keepalive, and warm-session logic; login-loop detector added |
| Sep 9, 4:19 PM | Escalation alert observed, 4 minutes after the above change |
| Sep 9, ~4:50–5:00 PM | Restore: browser profile rotated, lockout cleared, one watched login run, real write proven at Willows test property |
| Sep 9, 5:20 PM | Last AppFolio security-code text ever received for fede@ |
| Sep 9, 9:52 PM | (Separate system) sync dispatch changed to run per-connection: two workers hitting one AppFolio account; rate-limit errors jump from 30/day to 976/day |
| Sep 9, 9:53 PM | Fix goes live: one login attempt now counts as one budget unit, not double-counted |
| Sep 9, 10:09 / 10:40 PM | All three accounts confirmed warm, no lockouts active |
| Sep 10, 10:02 AM | Health check: warm, not locked, 6 of 8 daily logins already spent |
| Sep 10, 11:05 AM | Change goes live: every login must now prove itself at both doors; a dead verdict counts as a credential failure; the once-per-flow counting fix from the night before is removed |
| Sep 10, 11:15 AM | Next change goes live (current production version) |
| Sep 10, 11:18 AM | Post-change proof check: all three accounts warm, zero lockouts, fede@ still at 6 of 8 |
| Sep 10, 3:58 PM | Outage B begins: Western Slope's saved session fails the data API door; falsely counted as a failed login; lockout trips |
| Sep 10, 4:00 PM | Browser session rate jumps from ~15/hour to ~135/hour and stays there |
| Sep 10, 4:32 PM | Western Slope escalates to the 6-hour lockout |
| Sep 10, 4:53 PM | JP&Co session dies mid guest-card message; fede@ lockout trips |
| Sep 10, 5:24 PM | JP&Co/Situs Group escalates to the 6-hour lockout |
| Sep 10, 11:11 PM | (Separate system) fix goes live: one worker per AppFolio account |
| Sep 11, 1:51 AM | Turnover check alert: third red night in a row |
| Sep 11, 3:30–7:46 AM | Four more alerts; automated summaries only, no human action |
| Sep 11, 10:32 / 11:25 AM | Next scheduled lockout expiries — the loop re-triggers again unless the fix lands first |
A surgical chain of small changes to the robot's codebase, each with its own test that fails before the fix and passes after:
LOGIN_NOT_DRIVEN, exempt it from counting against the lockout, and remove the false "Drove login successfully" message. (#356)SESSION_REMINT_ENABLED. (#358)REFRESH_DID_NOT_TAKE, the new LOGIN_NOT_DRIVEN, and the 503 refusal as things worth automatically retrying into an active lockout — login-breaker refusals are terminal, no forced refresh, no retry, queue does not redeliver. (#7794)PMS_OUTAGE_ALARM. (#7796, #7797)See section 8 for the full merge record and per-account recovery evidence.
Prevention going forward:
SESSION_REMINT_ENABLED (9:14 AM)merged The further PR making the redirect-loop landing not count as a credential failure — #363, production deploy 6397822869.
PMS_OUTAGE_ALARM=on to arm)JP&Co / Camellia — attempt 1 at 9:27 AM failed with ERR_TOO_MANY_REDIRECTS before the login form (stale browser profile, same family as Sep 9); attempt 2 at 9:37 AM after rotating the profile: real login, 89 seconds, 3,057-byte jar, both doors warm at 9:39 AM, still warm at 10:40 AM with no further logins.
Western Slope — same redirect loop on attempt 1, profile rotated, attempt 2 refused because its pause had re-armed; stopped at two attempts; paused until 3:42 PM, expected to self-heal on the next allowed attempt now that #360 is live.
Situs Group — not attempted: it shares JP&Co's login, whose daily allowance reached 8 of 8; expected to self-heal after the window rolls (~3:00 AM Sep 12).
Neither Situs nor Western Slope has live customer traffic.
Two internal caches at Camellia self-heal on their next runs: vendor-contact refresh at 1:30 PM Denver, work-order enrichment at 3:00 AM Denver.
Fede rulings recorded: alerts are grouped per customer organization, never per job or property; no customer-outage page.