# Queue performance and freshness

## Navigation contract

`WorkspaceController` passes lazy Inertia props. Opening or closing a customer requests only `selected` and `preview`; customer search requests only `candidates`. The selected customer is re-authorized, loaded from current sources, and read in a consistent transaction without a write lock. Mutation repositories retain their own lock and authorization checks.

Background progress requests only `sync`. It reads release controls and the ingestion progress marker; it never projects customer rows. New-data notifications show an update prompt. They do not repeatedly reload the table.

## Queue summaries and current sources

The default view is Queue. For an unrestricted Super Admin, the queue ranks compact persisted readiness summaries, current customer/owner/contact-policy data, and a batched paid-membership/webinar/previous-lost cohort projection. It checks rule version, registry version, input revision and customer identity before using a snapshot. Missing or incompatible summaries use the current reader, so a quiet customer never disappears merely because a snapshot is missing.

Only the displayed page is then hydrated from current sources. Quotes, channel counts, message direction and the displayed readiness are never taken from a cached response. A row that no longer matches the selected readiness filter is removed and the page is filled from subsequent eligible rows.

The response reports `projectionMode` and `snapshotAt`. Ranking, aggregate counts and filter discovery can lag new source evidence until ingestion refreshes the stored snapshot. The displayed customer and opened detail are checked against current evidence. Due dates and do-not-contact restrictions are evaluated at read time.

Snapshots contain the service's full eligible channel view. Restricted viewers therefore use the fully scoped reader. Email mailbox/campaign ownership remains narrower even for Super Admin: every customer with an email source is also evaluated through the current viewer-scoped reader before queue counts or ranking. WhatsApp account scope grants Super Admin access to operational channels; CEO and sandbox channels are excluded by the canonical source adapter. Zoom, calls and F2F follow current lead scope and channel permissions.

Day plan, Team and an operations-attention drilldown use the current cohort projection. Their operating metrics are not calculated during a normal Queue or customer-only request. Cohort/readiness filters apply to queue totals, tier counts and summary cards; scope totals describe all accessible work.

## Release and validation

After changing readiness rules, run the existing `journey:sync --full` reconciliation with the new code. An old-version snapshot is not treated as current merely to improve performance. No schema migration or customer send is required.

Performance regression tests cover customer-only partial navigation, closing the drawer, lightweight sync, current row authorization, no read-time write lock, stable global ordering across bounded batches and filtering out a stale Ready snapshot after current-source hydration.

The original whole-cohort implementation took 16.529 seconds and 833 SQL queries for 1,494 production contexts. Read-only measurements of this implementation with compatible stored snapshots took approximately 4–5 seconds for the initial queue and 0.69–0.80 seconds for the reported customer detail; progress checks took 0.023–0.027 seconds. These are application-projection measurements, not HTTP p95 acceptance results. The specification's 300 ms queue target has not been met, and restricted-viewer/Day/Team paths remain more expensive. Production HTTP measurements belong in the release acceptance record.

## The source memo, and the page that 500'd (2026-09-18)

An ingestion on 2026-09-17 took the cohort from a few dozen contexts to **2,376 intakes**, and `/manage/ai-copilot/work` began returning **HTTP 500** — `PHP Fatal error: Maximum execution time of 30 seconds exceeded` (Apache runs mod_php here, `max_execution_time = 30`). Measured on the production data: **1,473 queries, 25.8 s** for one page of 25 rows.

The cost was not the cohort scan. Snapshot coverage was complete (2,377 of 2,377, `projectionMode: snapshot`, zero contexts falling back to the current reader). It was the **per-row source re-read**: `SourcePacketLoader::material()` opened with an unconditional `catalog->flush()`, and `AiSourceFindings` calls it once per displayed row per AI-completed source. `hydrate()` primes every displayed customer's channels in one batch; the first re-read threw that batch away, so each following row re-fetched its customer one at a time — **~17 queries per customer**. Measured directly on 25 customers: **32 queries / 0.40 s primed against 711 queries / 4.4 s cold.**

The flush protected a real rule — *never authorize a read with a memo somebody else primed* — but the memo is keyed `lead:viewer`, so a re-read for the SAME actor inside one request can only return what the flush would have gone and fetched again. `JourneySourceCatalog::flushFor($actor)` now discards the memo only when it belongs to another actor; `flush()` still empties it outright and clears the claim, so every write path (`ChannelReviewRepository`, `ChannelIngestionRepository`, `JourneySync`, `OperationsRunner`, `HandoffWorkflowRepository`) forces the next read to re-fetch, unchanged. Result on the same page: **389 queries, 11.2 s**, with the rendered rows byte-identical to the old implementation apart from each row's own `evaluated_at` stamp. Pinned by `JourneySourceCatalogTest::test_flush_for_keeps_the_memo_of_the_actor_that_primed_it`.

⚠️ **Day and Team are still unusable at this cohort size, and the obvious fix does not work.** `?view=day` — the URL that first 500'd for the founder — costs **9,204 queries and 107–139 seconds** even after the memo fix, because `WorkspacePresenter::read()` (two `AiSourceFindings` queries per context among them) runs for all 2,376 contexts to produce two lists of at most twenty rows.

Ranking Day from stored summaries the way Queue does was tried on 2026-09-19 and **reverted**. It worked, and it was fast — 112 queries, 5.4 s — but a live-vs-snapshot diff of the whole payload showed the **Follow-through panel silently reading zero**: `OperationsWorkspace` counts `$row['operations']['checks']`, `['alerts']` and `['clock_is_mine']`, which `WorkspacePresenter` builds from `DailyOperations::evaluate($input, $result)` per context. Snapshot rows carry `'operations' => []`, so every check, section and owner attention count collapsed to 0 while the page looked healthy (`checks[0].count` 2377 → 0, `needs_reply` 123 → 0). **A day plan that prints nothing is indistinguishable from a quiet day**, which is exactly the failure this page must not have.

So Day's shortlist can be bounded, but its panel cannot: the panel is a cohort-wide aggregate of a per-context evaluation. Making Day cheap means giving `OperationsWorkspace` a source that is already per-context and already persisted. One exists — `OperationsRunner` walks every context on a schedule and records `operations.alert` (per alert episode) and `operations.eod` (carrying `checks`) as journey events — but those are episode-deduplicated rather than a current-state row per context, so counting from them is a deliberate change to what the panel means, not a refactor. That is the piece of work Day needs; it was not attempted here.
