> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Cfo 7362 dead session redirects

# Monitoring CFO-7362 dead-session redirects

This runbook answers one question. Of the chats we redirected away from, which
were genuinely dead, and did we bounce anyone off a chat that was actually
alive?

Run the DB audit daily. It is the authority, because it compares each redirect
against what the database actually holds. The PostHog query is the fast first
look and tells you why a redirect fired.

***

## 1. DB audit (authoritative)

Paste the redirected session ids, timestamps, **and the viewing user's clerk
id** from the PostHog query below into the `values` list, then run in Supabase.
Worst verdicts sort first.

The `clerk_id` column is not optional. The session GET scopes rows to the
signed-in user, so a session belonging to someone else correctly reads as
empty and is correctly redirected. An audit that ignores ownership reports
those as `REVIEW` and sends you chasing a bug that is not there. This happened
on 2026-08-14 with `session_0e670456`, which had 9 rows owned by a different
user.

```sql theme={null}
with redirected(sid, redirected_at, viewer) as (values
 ('session_xxxxxxxx', timestamptz '2026-08-14 13:30:42+00', 'user_xxxxxxxx')
 -- , ('session_yyyyyyyy', timestamptz '...', 'user_yyyyyyyy')
),
rows_for as (
  select r.sid,
         r.redirected_at,
         count(l.id) as live_rows,
         min(l.created_at) as first_live_row_at
  from redirected r
  left join ai_chat_logs l
    on l.session_id = r.sid
   and l.clerk_id = r.viewer
   and coalesce(l.deleted, false) = false
  group by r.sid, r.redirected_at
)
select sid,
       live_rows,
       round(extract(epoch from (first_live_row_at - redirected_at))::numeric, 1)
         as first_row_delta_s,
       case
         when live_rows = 0
           then 'CORRECT: truly dead'
         when first_live_row_at between redirected_at - interval '5 s'
                                    and redirected_at + interval '30 s'
           then 'WRONG: bounced a LIVE turn'
         else 'REVIEW: rows predate redirect'
       end as verdict
from rows_for
order by case
           when live_rows = 0 then 3
           when first_live_row_at between redirected_at - interval '5 s'
                                      and redirected_at + interval '30 s'
             then 1
           else 2
         end,
       sid;
```

Deleted rows are excluded from the join itself, not just from the count, so a
soft-deleted historical row cannot become the minimum and disguise a bounced
live turn as a `REVIEW`.

**Reading it**

| verdict | meaning | action |
| - | - | - |
| `CORRECT: truly dead` | no live rows the viewer can see | none, working as designed |
| `WRONG: bounced a LIVE turn` | first row lands within seconds of the redirect | regression, the redirect raced the INSERT |
| `REVIEW: rows predate redirect` | the viewer's own rows existed before we redirected | worst case, we abandoned a real chat. Investigate immediately, this is never expected |

When a verdict looks impossible, check ownership before anything else:

```sql theme={null}
select session_id, clerk_id, count(*) as rows
from ai_chat_logs where session_id = 'session_xxxxxxxx'
group by session_id, clerk_id;
```

`first_row_delta_s` carries the whole signal. Positive and small, roughly 0 to
2 seconds, means the row landed after we called the session dead. That is a
race, not a dead session.

***

## 2. PostHog, daily counts and the reason each redirect fired

```sql theme={null}
SELECT toDate(timestamp) AS d,
       event,
       properties.recheck_outcome AS outcome,
       count() AS n,
       uniq(distinct_id) AS users,
       -- Under roughly 5000ms means a turn was dispatched from this tab
       -- moments before the redirect, the signature of a live chat.
       median(toFloat64OrNull(properties.ms_since_last_send_this_tab))
         AS p50_ms_since_send,
       countIf(properties.was_streaming_this_session = true) AS while_streaming,
       median(toFloat64OrNull(properties.recheck_attempts)) AS p50_recheck_attempts,
       -- distinct_id is needed for the DB audit's ownership scoping
       arrayStringConcat(arraySlice(groupArray(
         concat(toString(properties.session_id), '|', toString(distinct_id))
       ), 1, 20), ', ') AS sessions_and_viewers
FROM events
WHERE event IN (
        'chat_session_not_found_fresh_fallback',    -- redirected, confirmed empty
        'chat_session_recheck_failed_fallback',     -- redirected, could not confirm
        'chat_session_not_found_recheck_recovered', -- race caught, NO redirect
        'chat_session_load_skipped_in_flight'       -- load skipped, NO redirect
      )
  AND timestamp >= now() - INTERVAL 7 DAY
GROUP BY d, event, outcome
ORDER BY d DESC, n DESC
```

**Healthy shape**

* `chat_session_not_found_fresh_fallback`, a small trickle. These should all
  come back `CORRECT: truly dead` in the audit.
* `chat_session_load_skipped_in_flight` fires on sends whose load would have
  raced. Harmless. High counts just mean the guard is earning its keep.
* `chat_session_not_found_recheck_recovered` counts races the re-check caught.
  These are saves, so a steady trickle is the system working. Watch
  `p50_recheck_attempts` instead of the raw count: if recoveries routinely need
  the last attempt before the deadline, `DEAD_SESSION_CONFIRM_WINDOW_MS` is too
  tight and should be raised. Redirects sitting at the attempt ceiling say the
  same thing louder.
* `chat_session_recheck_failed_fallback` should sit near zero. These are
  redirects we could not confirm. Sustained non-zero means the session GET is
  failing, so check Supabase and PostgREST health rather than this code.
* `while_streaming` above zero, or `p50_ms_since_send` under roughly 5000, is a
  red flag that we are redirecting live chats. Cross-check in the audit above.

***

## Baseline for comparison

| date | redirects | notes |
| - | - | - |
| 2026-08-13 | 8 (3,466 sends) | release day. Audit returned 6 WRONG and 2 correct, the regression, inside the 13 minutes after the 13:25 deploy |
| 2026-08-14 before 14:22 | 0 (1,058 sends) | quiet, no deploy skew |
| 2026-08-14 14:22 to 15:01 | 2 (139 sends) | release day again. One WRONG, a live turn bounced when a single 3s re-read missed the row by 50ms, which is why the re-check now polls to a deadline. One CORRECT that first audited as REVIEW because the query ignored ownership |

Redirects cluster after deploys, when Next.js forces hard navigations on
clients still running the old bundle. A page remount is what makes a load fire
for a session the context does not hold. Check the day of and the day after
every release, not just quiet days.

***

## If the audit returns a WRONG verdict

1. Pull that user's event stream around the redirect: `chat_message_sent`,
   then `workflow_dispatch`, then the fallback event, then the `$pageview` to
   `/chat`.
2. Check whether they returned to the session afterwards. If not, they lost
   that answer. The row exists but was never rendered.
3. The guards live in `lib/chat-dead-session.ts`, with the reasoning in
   `contexts/shared-chat-context.tsx` under `loadSession`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.