19 · Performance — measuring the handling, not the person
The owner asked on 2026-09-23 how performance is calculated. The honest answer at that date: it was not. Across 353 migrations and the whole application there was no metric, target, ranking, scorecard or staff report. The only per-person things that existed were two "my work" widgets (dash_my_collections, dash_my_journals) and a 'mine' filter that only ever showed you yourself. The single elapsed-time figure in the database was dash_visa_stages.turnaroundDays — per process, never per person.
This file is written before the code, so the maths is agreed before anyone is measured by it.
Related: 18-work-and-intake.md (the ledger these numbers are derived from), 17-intelligence.md (INT-004 explain every signal, INT-007 measure usefulness), 09-audit-and-data-protection.md (which covers customer data; staff data is PERF-011 below).
Principles
PERF-001 · Four dimensions, and only four
Status: DECIDED 2026-09-23 (owner) · Owner: Management Performance means: speed against the response targets; throughput, weighted by difficulty; outcomes, meaning enquiries turned into bookings and the value booked; and quality, meaning reopened items, escalations, send-backs and corrections on that person's work. Nothing else is a performance metric.
PERF-002 · For coaching and workload balancing only
Status: DECIDED 2026-09-23 (owner) · Owner: Management These numbers are used to coach people and to balance work. They are not an input to pay, commission, bonus or any automatic disciplinary step, and no performance figure is read by any other function in the system.
Stated plainly: software cannot enforce this. Nothing stops a person quoting a number in a pay conversation. The controls that do exist are this rule, the access log (PERF-011), and the deliberate absence of a single score to attach money to (PERF-009). If that changes, the rule changes first, in writing.
PERF-003 · The business clock, waiting excluded
Status: DECIDED 2026-09-23 (owner) · Owner: Management Every elapsed figure uses the working calendar of WRK-004 and subtracts waiting time under WRK-005. Night, Sunday, the Friday prayer break and holidays are not working time, and neither is time spent waiting on a customer or an embassy.
PERF-004 · Difficulty weights are versioned settings
Status: DECIDED 2026-09-23 (owner) · Owner: Management
A visa file is not a phone enquiry. Each work type carries a weight, set by management, reviewed monthly under INT-007. A weight is never edited: a change inserts a new row with a later effective date, and a closed item keeps the weight that was in force when it closed. Otherwise last month's numbers would move whenever a weight changed.
Starter weights (version 1, for review): enquiry 1 · email enquiry 1.5 · lead follow-up 1 · payment follow-up 2 · customer request 2 · visa enquiry 2 · booking correction 3 · group preparation 3 · visa file 5 · incident 5.
Enforced by: WorkTypeWeight (append-only), work_type_weight(typeKey, on).
PERF-005 · Every number drills down to its rows
Status: DECIDED · Owner: Engineering
No figure is shown that cannot be opened to reveal the exact items behind it, with the timestamps and the weight used. A number nobody can check is a number nobody should trust. This is INT-004 applied to metrics: the tile and its rows come from one computation, so they can never disagree.
Enforced by: perf_rows() is the one definition behind every figure; perf_scorecard_rows() opens each; test supabase/tests/the_staff_scorecard.sql checks every figure equals its rows.
PERF-006 · What is never measured
Status: DECIDED 2026-09-23 (owner) · Owner: Management
Never: session or presence time as "hours worked" (UserSession."lastSeenAt" is a heartbeat with no idle/active distinction and must not be read as attendance), keystrokes, screen activity, or anything not traceable to a recorded event. There is no attendance or leave data in this system, and none is inferred.
Enforced by: a test asserting no performance function references UserSession. supabase/tests/the_staff_scorecard.sql fails if any perf_* function mentions UserSession or lastSeenAt.
PERF-007 · Who sees whose numbers
Status: DECIDED 2026-09-23 (owner) · Owner: Management
Everyone sees their own. A queue lead sees the members of the queues they lead. Leadership sees all. A person always sees their own numbers before anyone discusses them with them.
Enforced by: perf_require_view(): your own needs perf.view.own, anyone else needs perf.view.all (CEO, GM). Queue-lead team views are not built yet: members and leads can be named (Work → Queues, WRK-013), but perf_require_view() does not read them.
PERF-008 · Months are frozen, and recomputation never rewrites history
Status: DECIDED · Owner: Engineering At month end each person's and each queue's figures are written once, with the formula version that produced them, and never updated. Changing the maths bumps the version and lands beside the old figures, so a past month cannot silently change.
PERF-009 · No single score
Status: PROPOSED · Owner: Management No composite index, rating or league table is published. Four numbers plus a coverage figure are shown. The reasons: the dimensions move together with whatever work a person was handed, so a weighted sum mostly measures the mix, not the person; projected work has no first-response data in four of six domains, so a composite would average two different populations; and a single number is exactly what pay and discipline attach to, which PERF-002 forbids. Revisit when customer feedback exists and three months are frozen.
PERF-010 · Attribution
Status: PROPOSED · Owner: Management First response is credited to whoever held the item at that moment. A missed target is charged to whoever held it when it fell due. Throughput is credited at close. A conversion is credited to the lead's owner at the time of conversion. Claiming and releasing within 15 working minutes earns no credit and no breach, but the release is counted. Elapsed time is split across holders; the verdict is single.
PERF-011 · Performance data is staff personal data
Status: PROPOSED · Owner: Management / HR
These rows are personal data about employees. AUD-020 and AUD-022 were written about customers and do not cover this. Therefore: frozen figures are kept 24 months and raw events 24 months; looking at another person's numbers is itself recorded; exporting them needs its own permission; and staff are told what is measured and where to see it. Nobody is measured secretly.
Built so far: each look at another person's numbers writes an AuditLog entry (perf.viewed) in perf_require_view(). Retention and the export permission are not built.
PERF-012 · Say what the numbers do not cover
Status: DECIDED · Owner: Engineering
Every scorecard states the date measurement began and what share of that person's work has measurable timestamps. Two absences are named on the page rather than implied:
- Customer feedback and complaints are not included — CX-001…005 are proposed and nothing is built, so no feedback data exists.
- Speed cannot be backdated. No first-response timestamp existed anywhere before this was built, and four of the six projected domains can never produce one retrospectively.
Enforced by: the notice on /performance (always shown), measuringSince and the coverage figure from perf_scorecard().
PERF-013 · Tested by rule ID
Status: PROPOSED · Owner: Engineering Each rule above has a database test named for it, including the "must not" ones: a double freeze is refused, a recomputation lands as a new version, and no performance function reads session data.
Not built yet
The rules above are agreed; the code is not written. Phase 4 builds speed and throughput from the ledger for your own numbers; phase 5 adds the projected domains, the money dimension and the team and leadership views; phase 6 adds freezing, export and retention. Until then this system measures nobody.
What is measurable, and what is not
For work the inbox owns, everything above is measurable from the ledger. For work owned elsewhere (WRK-001) only what history already records can be measured:
| Domain | Measurable | Not measurable |
|---|---|---|
| Booking correction | turnaround, from BookingStatusHistory |
first response; the owner before it was cleared |
| Incident | first substantive response after assignment, from IncidentUpdate |
internal-only handling |
| Payment verification | turnaround, from Payment."verifiedAt" |
reopens |
| Customer request | resolution only, assignedAt → closedAt |
first response, reopens |
| Visa enquiry | assignment only | any duration — no stage timestamps, and VisaStatusHistory."changedBy" only exists from 20260918130000 |
| Inventory hold | acknowledgement of a deadline rung | handling before the alert fired |
System speed (PRF)
PRF rules are about how fast the software is, not about people; they are unrelated to the
PERF rules above. The owner asked on 2026-09-26 for the system to feel as quick as the
large consumer sites. Two things make a screen slow: how many requests it makes to the
database in Mumbai one after another (each costs about 0.24 s before any work is done), and
how much code the browser downloads before it can show anything.
- One call per screen: PRF-002 (the first screen, below) and PRF-010 (the bookings list
and the booking page, migration
20260930050000; the Groups list, one group and its Financials and Invoices tabs, migration20260930150000; customers, Customer 360, leads and the work inbox, migration20260930080000; the operations screens — visa, approvals, airline blocks, seat releases, holds, hotels, a ticket — migration20260930210000; the phone app's Dashboard tab — now the web'sdashboard_screen, one call per tab (20261001180000) — and a departure's readiness, migration20261001150000). - What the browser downloads: PRF-005 (below).
- What happens after the first screen: PRF-011 (a permission check and a refresh cost one round trip) and PRF-012 (finance hears about changes by itself), both below.
- The phone app: PRF-013 (the app opens without waiting for the network), below.
PRF-010 · A screen is one call
Status: DECIDED 2026-09-26 (owner) · Owner: Engineering
A screen gets everything it shows on first paint from the database in one round trip. Each round trip from India to the Mumbai database is about 0.24 s, and reads that wait on each other add up: before this the booking page made 35 calls, 11 of them one after another (about 2.6 s), and the bookings list 6 in a row (about 1.4 s). Now each is one call.
The one call reads as the caller, through the same row-level security and the same permission checks as the reads it replaced, so it never shows more than those did; a section the caller may not see comes back empty or null.
What a screen reads only when asked (opening a row, a tab, a dialog, printing) is not first paint and may be its own call.
Enforced by: booking_list_page() and booking_screen() (20260930050000_a_screen_is_one_call.sql); the counting test src/lib/perf.bookings.test.ts (fails if either screen makes more than one call); parity test supabase/tests/a_screen_is_one_call.sql. How it works: A screen is one call.
Not yet: the bookings list's tiles are a second call beside it (not after it), and the list page still reads the dialogs' pickers when it opens.
Customers, leads and the work inbox follow the same rule (migration 20260930080000). Where a
section used to be refused by the route (403), the one call refuses the same way:
| Screen | Before (calls, one after another) | Now | Function |
|---|---|---|---|
| Customers list | 12 calls, 4 in a row (≈ 0.96 s) | 1 call | customers_list_page (the page, its total and the dialogs' pickers) |
| Customer 360 | 12 calls, 7 in a row (≈ 1.7 s) | 1 call | customer_360_screen (get_customer_360, the tab counts, the first page of activity) |
| Leads list | 5 calls, 3 in a row | 1 call | leads_list_page (the page, the closers' names, the departures picker) |
A lead opened by link (?lead=) |
8 calls, 3 in a row | 2 calls side by side (one round trip) | leads_list_page twice: the list, and the one lead |
| Work inbox | 10 calls, 4 in a row | 1 call | work_inbox_screen (work_inbox_page and the filter-bar options) |
Enforced by: 20260930080000_customers_are_one_call.sql (the functions, all SECURITY INVOKER, not executable by anon); the counting test src/lib/perf.customers.test.ts (fails if any of these screens makes more calls, or answers in a different shape); the parity test supabase/tests/customers_are_one_call.sql (per role: SALES_EXEC, SALES_MANAGER, OPS_EXEC, FINANCE_MANAGER, AUDITOR, a viewer without customers.view, a partner login).
Not yet, for these screens: a search on the customers or leads list is one call per page but does not re-read the pickers; the lead opened by link is a second call beside the list, not folded into it.
Groups follow the same rule (migration 20260930150000). What a screen reads only when a tab
is opened is one call for that tab. Round trips per first load, as the counting test measures
them (each browser permission check is itself three):
| Screen | Before | Now | Function |
|---|---|---|---|
| Groups list | 22 (10 requests) | 1 call | groups_list_screen (the groups, the cards' airlines, which groups have hotels and transfers, the new-group dialog's flight pickers) |
| Opening a group — overview, passengers and manifest, operations, itinerary, documents, check-ins, policy picker | 81 (13 requests) | 1 call | group_screen |
| Financials tab (cost report and P&L) | 53 of those 81, read with every group opened | 1 call, when the tab is opened | group_financials_screen (the rows; api.ts computes both reports with the same code as their routes) |
| Invoices tab | 15 (4 requests, one of them every customer) | 1 call | group_invoices_screen |
| Itinerary tab — the programme (INV-008) | — (new, 2 Oct 2026) | 1 call, when the tab is opened | trv_trip_programme (the same function the app reads) |
A group opened by link (/groups/:id) |
the list, then the group | 2 calls side by side (one round trip) | groups_list_screen and group_screen |
Enforced by: 20260930150000_groups_are_one_call.sql (the functions, all SECURITY INVOKER, not executable by anon); the counting and parity test src/lib/api.groupsOneCall.test.ts (fails if a Groups screen makes more than one call, or if its answer differs from the routes it replaced); the page test src/pages/groups/Groups.oneCall.test.tsx (the list is one request, /groups/:id two at once, Financials one more when opened); the parity test supabase/tests/groups_are_one_call.sql (per role: OPS_MANAGER, SALES_EXEC, TICKET_MANAGER, FINANCE_MANAGER, a login with hotels.view only, a tour leader through the field functions).
Not yet, for Groups: the Passengers tab's traveller journeys and the Pricing, Website and Activity tabs still read through their own routes when opened (4, 7, 6 and 7 round trips).
Operations screens follow the same rule (migration 20260930210000, routes /screens/* —
API). Where a route asked for a permission, the one call asks for the same
one: the screen's own list is refused (403) as before, a side list comes back null. Measured with
the real pages over the test harness (three suppliers, two blocks, two departures, first visit);
requests / round trips one after another:
| Screen | Before | After | Function |
|---|---|---|---|
| Visa queue | 22 / 7 | 1 / 1 | visa_list_screen |
| A visa case | 7 / 7 | 1 / 1 | visa_case_screen |
| A visa group | 5 / 5 | 1 / 1 | visa_group_screen |
| Operations approvals | 6 / 5 | 1 / 1 | approvals_screen |
| Airline blocks | 62 / 22 | 1 / 1 | airline_blocks_screen |
| An airline block | 72 / 30 | 1 / 1 | airline_blocks_screen(block) |
| Seat releases | 8 / 4 | 1 / 1 | seat_releases_screen |
| Inventory holds | 20 / 5 | 1 / 1 | inventory_holds_screen |
| Hotels (and a hotel's calendar) | 39 / 16 | 1 / 1 | hotels_screen |
| Ticketing | 1 / 1 | 1 / 1 | ticketing_departures (unchanged) |
| A ticket | 12 / 12 | 1 / 1 | ticket_screen |
The airline blocks and hotels screens used to grow with every supplier: GET /suppliers checked, and if missing created, a creditor ledger for each supplier on every page load (four requests a supplier). The screens do not; that ledger is still created when a supplier is saved and whenever a posting needs it.
Enforced by: the functions in 20260930210000_operations_are_one_call.sql; handleOpsScreens in src/lib/opsScreens.ts; src/services/opsScreenService.ts. Tests: supabase/tests/operations_are_one_call.sql (for VISA_OFFICER, OPS_MANAGER, TICKET_MANAGER, TICKET_EXEC and a person with no role, every section equals that person's own reads, and a screen is refused exactly when its route was), src/lib/api.opsScreens.test.ts (each screen answers what its separate routes answered), src/test/opsScreensRoundTrips.test.tsx (counts the requests and round trips).
Not yet, for these screens: a dialog opened from a screen (a visa case's documents, a block's payment form, pricing a release) still asks for what it needs when it opens.
PRF-002 · The first screen is one call
Status: DECIDED 2026-09-26 (owner) · Owner: Engineering
The screen every member of staff sees first — the role dashboard at /app and the phone
home at /m — loads with one request, dashboard_screen(). That one answer carries every
tile of the tab on screen, the sidebar badge counts and the unread count, so the sidebar and
the bell make no request of their own on it. Each tile is the dash_* function it always
was, under that function's own permission check: a tile the person may not see is left out
of the answer, never widened (ACC-001). The last answer is kept in memory (never on disk), so coming
back to the dashboard paints at once and refreshes behind the scenes when it is older than
a minute.
One read stays outside the call on purpose: the journey board (get_journey_board), the
slowest read on the dashboard, goes out at the same moment as its own request so it cannot
hold the other tiles back. The dashboard therefore makes at most two requests, side by side
— one round trip.
Before: 16–19 requests, four round trips one after another (SALES_EXEC, OPS_MANAGER,
FINANCE_MANAGER, CEO). After: two requests, one round trip; the phone home one request.
Enforced by: dashboard_screen in 20260930060000_the_dashboard_is_one_call.sql; tests:
supabase/tests/the_dashboard_is_one_call.sql (every tile equals its own function for seven
people, tiles withheld without permission), src/test/dashboardRoundTrips.test.tsx (counts
the requests), src/test/dashboardScreen.drift.test.ts (the database's list of tiles matches
the app's).
Not covered: signing in (the profile and permissions are read once per session, in five requests) and the other screens, which still make their own requests.
Finance (migration 20260930170000). Requests and round trips per load, as the counting test measures them (each browser permission check was itself three waits):
| Screen | Before (requests, waits) | Now | Function |
|---|---|---|---|
/finance first load (PIN, the page's data, the Chart of Accounts tab) |
83, 44 | 1 request for the finance data, sent with the PIN check (one wait) | finance_screen |
| Transactions tab (money log, invoices) | 6, 5 | 1, 1 | finance_screen |
| Payments register | 7, 7 | 1, 1 | finance_screen |
| Journal list | 5, 5 | 1, 1 | finance_screen |
| A voucher | 8, 8 | 1, 1 | finance_journal_screen |
| An account's ledger | 5, 5 | 1, 1 | finance_ledger_screen |
| Approvals tab (vouchers, refunds, airline and B2B cancellations) | 18, 5 | 1, 1 | finance_screen |
| Reports tab (trial balance, aging, balance sheet) | 12, 4 | 1, 1 | finance_screen |
| Profit & loss, cash flow, day book | 4, 4 each | 1, 1 each | their own functions; the browser no longer checks finance.view first |
| Receivables & payables | 10, 10 | 1, 1 | finance_receivables_payables_screen (added up in the database) |
The finance functions are SECURITY DEFINER: each section is guarded by the permission its route checked and the staff branch of the SELECT policy of every table it reads, made with the same database functions the policies use. Not executable by anon.
Enforced by: 20260930170000_finance_is_one_call.sql; the counting test src/test/financeRoundTrips.test.ts (fails if any of these screens waits more than one round trip); src/lib/api.financeScreen.test.ts (the answer's shape, no browser permission read, 403 on a withheld section); the parity test supabase/tests/finance_is_one_call.sql (per person: FINANCE_MANAGER, ACCOUNTANT, CASHIER, CHARTERED_ACCOUNTANT, a SALES_EXEC whose finance.view is withdrawn, a TICKET_EXEC — every section equals what row security shows that person, and the withheld sections are exactly the ones their routes refused). How it works: Finance → How fast the finance page loads.
Not yet, for finance: the first load still reads every booking beside the finance call (the bookings list's own read, PLT-050) and every receipt; the account statement (5 waits), group profitability (5) and GST summary (3) still read through their routes.
PRF-005 · The first screen downloads only what it shows
Status: DECIDED 2026-09-26 (owner: the goal; engineering: the numbers) · Owner: Engineering
Signing in and landing on the dashboard downloads only the code those two screens run. Everything else — other screens, Excel, charts, the error reporter, the two-step sign-in step — is fetched when it is first needed, or quietly after the first screen is up. The total is held under a budget: 550 KB gzip for the entry, /auth and /app together (measured 537.9 KB on 2026-09-26, down from 741.0 KB). A change that goes over it fails CI and must either lazy-load what it added or raise the budget with a reason in the same change.
Caching follows from the same rule: build files are cached by their hashed name; answers from the database are never cached by the service worker; the browser keeps list answers in memory for 30 seconds (fresh) and 10 minutes (kept), and writes to disk only data with no customer, passport, document or money detail in it, cleared at sign-out.
Enforced by: scripts/check-bundle-budgets.mjs (first-load budget, from dist/.vite/manifest.json), vite.config.ts (manualChunks, precache list), src/sw.ts, src/lib/queryClient.ts, src/lib/routePreload.ts, src/lib/observability.ts. Tests: src/lib/queryClient.test.ts, src/lib/routePreload.test.ts, src/components/common/DataTable.test.tsx. How it works: Performance.
PRF-011 · A permission check and a refresh are one round trip
Status: DECIDED 2026-09-26 (owner: "finance takes usually time to load and changes to appear") · Owner: Engineering
A route that is not yet one call checks the user's permission in the browser before it works. That check used to ask the auth server who the user was, then read the user's overrides, then their roles' grants — three round trips one after another, and two more for each extra permission a route would accept. Production had read the User table about 540,000 times and RolePermission about 430,000 times by 26 Sep 2026. Now the user comes from the session the browser holds, the overrides and the role grants are read side by side, and the signed-in user's permissions are kept for 30 seconds. The browser check is not the security boundary: the database checks every read and write, so a right taken away is refused by the database at once, and a right just granted may be refused by the screen for up to 30 seconds. Another user's permissions are never kept.
After an action (verify, record, reject a payment; a voucher; an account change) the finance page refreshes in one round trip: one finance_screen call, beside the bookings list and the open group invoices. It was six routes — 18 calls in 5 round trips — and four routes after an account change.
Enforced by: src/lib/api.ts (getCurrentUserId, effectivePermissions, clearPermissionCache, the /group-invoices?openOnly=1 route); finance_open_group_invoices() (20260930233000_finance_refreshes_in_one_call.sql); src/pages/finance/Finance.tsx (loadPaymentWorkflowData, refreshFinancialAccountData). Tests: src/lib/perf.finance.test.ts, supabase/tests/finance_refreshes_in_one_call.sql.
Not yet: the finance page still reads every payment, invoice and money-log row, and every booking, on each refresh. That is fast at today's size (tens of rows); paging them belongs with a larger register.
PRF-012 · Finance hears about changes by itself
Status: DECIDED 2026-09-26 (owner) · Owner: Engineering
An open finance screen shows another person's receipt, voucher, invoice, refund or cancellation about a second after it is saved, without a reload by hand. The page listens on Supabase Realtime to Payment, JournalEntry, Invoice, GroupInvoice, PaymentRefund, AirlineBlockCancellation and B2BFlightOfferCancellation; a burst of changes (one posting touches several tables) causes one refresh; a hidden browser tab refreshes when it is shown again. Realtime applies each table's row policies to every listener, so nobody hears about a row they could not already read.
Enforced by: 20260930234000_finance_is_live.sql (the tables join the supabase_realtime publication), src/hooks/useLiveRefresh.ts, src/pages/finance/Finance.tsx. Tests: src/hooks/useLiveRefresh.test.tsx, supabase/tests/finance_refreshes_in_one_call.sql.
Not yet: the other screens (bookings, groups, operations) do not listen; they refresh on focus after 30 seconds as before (PRF-005).
PRF-013 · The phone app opens without waiting for the network
Status: DECIDED 2026-09-27 (owner: "the app needs to be … more faster and more professional and user friendly") · Owner: Engineering A phone that was signed in before draws the first screen from what it kept, then refreshes behind it:
- Who is signed in. The last good profile for the saved login (name, roles, permissions, paused or not, which stack) is kept on the phone. At launch it is read with no network, so the first screen is drawn before any request. The fresh profile follows in one call,
app_bootstrap(), and replaces it: a permission taken away disappears from the screens, a paused account gets its banner (ACC-070), a role that changes the stack moves the person to the new one, and a session the server refuses signs the phone out and cleans it. The kept copy is only what the phone shows; the database checks every read and write (ACC-001), so a right taken away is refused at once even before the screens catch up. - What the screens showed. The answers the screens read are kept on the phone for 24 hours and thrown away when the app version or the over-the-air update changes. They are stale on arrival, so every screen re-reads them at once; they only fill the screen while it does. Never kept: anything with a passport, a document, a manifest, a booking or customer record, check-ins, visas, contact details, receipts, payments, vouchers, statements or invoices. Signing out, or a session that ends, drops the kept answers with every other per-person copy.
| Opening the app (signed in) | Before | Now |
|---|---|---|
| Requests before the first screen | 4 (5 for a login with no role), in 2 waves one after the other, after the session refresh | none — drawn from the phone |
| Requests to refresh the profile | 4–5 in 2 waves | 1 (app_bootstrap) |
| Signing in (no kept copy) | 4–5 in 2 waves | 1 |
app_bootstrap() reads as the caller (SECURITY INVOKER) through the same row security as the four reads it replaced (User, UserRole, RolePermission, UserPermission) and merges permissions the same way: the role grants, minus a UserPermission with allowed = false, plus every UserPermission with allowed = true. Not executable by anon. A phone that meets a database without the function uses the four reads.
Enforced by: 20261001110000_the_app_starts_from_one_call.sql; apps/mobile/src/lib/session.tsx, lib/auth.ts, lib/bootstrap.ts, lib/queryPersist.ts, app/_layout.tsx. Tests: supabase/tests/the_app_starts_from_one_call.sql (for staff with a blocked and a granted override, a partner, a traveller, a tour leader, a paused login and a login with no rows, the one call equals that person's own four reads; anon is refused), apps/mobile/src/lib/bootstrap.test.ts (the merge and the stack), apps/mobile/src/lib/queryPersist.test.ts (what is and is not kept).
Not yet: the first launch after installing, or after signing in, has nothing kept and waits for the one call; a screen that was never opened on the phone still waits for its own read.