← All docs

Architecture

Mapvest is a three-tier product: iOS client, HTTP API, shared TS packages. Everything else is glue.

┌────────────────────┐          HTTPS           ┌────────────────────────┐
│  iOS (Expo/RN)     │  ───────────────────►    │  apps/api (Bun + Hono) │
│  camera · map ·    │  ◄─── json + jpegs ───   │   /v1/identify         │
│  list · admin      │                          │   /v1/nearby           │
└─────────┬──────────┘                          │   /v1/resolve-comparable
          │                                     │   /v1/auth · /v1/admin │
          │                                     └────────────┬───────────┘
          │                                                  │
          │                              ┌───────────────────┼────────────────┐
          │                              │                   │                │
          │                              ▼                   ▼                ▼
          │                    packages/vision      packages/finance   packages/search
          │                    (OpenRouter)         (ticker / ETF)     (Exa)
          │                              │                   │                │
          │                              ▼                   ▼                ▼
          │                       OpenRouter          Market data        Exa Web Search
          │                       (Gemini 2.5 /       SEC · Yahoo
          │                        Claude 5 vision)
          │
          └── landing (Next.js) ── mapvest.app — docs, TestFlight link

Request flow — “what is this?”

  1. Client sends POST /v1/identify with image + optional location {lat, lng}.
  2. API validates via packages/core zod, applies auth + the global limiter (300/min, keyed by session then device then IP; see SYSTEM_DESIGN D14).
  3. packages/vision.identifyFromImage(bytes, {location}) calls OpenRouter with a multimodal model. Prompt asks for {brand, product, sector, visible_text, confidence}.
  4. packages/finance.resolveTicker(brand) looks up a first-party mapping table. On miss, calls packages/search.searchBrand() (Exa) and asks the LLM to extract a ticker with citations.
  5. If the brand is private, packages/finance.resolveComparable() finds the closest public co and an ETF with meaningful exposure. Sources attached.
  6. API returns a single IdentifyResponse with investable[]; each returned match carries its Source[] citations and a rarity tier (common / uncommon / rare / legendary) from the same classifier as GET /v1/dex. GET /v1/finds stamps the same field on each journal row. Older payloads omit it; the client falls back to a rare/null local check. Signed-in identifies also write the top investable through recordFind. Guests keep a local journal and replay it with POST /v1/finds after sign-in (identity-idempotent with that same writer).

Request flow — “what’s around me?”

  1. Client sends GET /v1/nearby?lat=..&lng=..&radius=...
  2. API calls Google Places (server-side, using the server’s Maps key).
  3. Places results are joined with a brand→ticker table (cached in Redis/Postgres).
  4. Unknowns are batched to packages/finance.resolveTicker (Exa + LLM).
  5. Response is a NearbyResponse with markers, tickers, and sector tags.

UX mean (client)

Photos → Share → Mapvest is the same identify loop as Camera (/share-intent). The share extension is native; JS alone cannot appear in the iOS share sheet. Camera always opens a live shutter (last snap is not the landing state). The annotator is optional: snap identifies immediately, Refine (circle + hint) re-runs it, and roi / hint / location now reach the API. The result card leads with a meaning line (“you can own this” / closest public cousin), then price, a confidence badge, a concise Evidence section, and “Added to your finds.” Evidence names only the returned providers, explains each source confidence, shows its fetch date, and links only valid HTTP(S) citations; it never presents a returned source as independent verification. If none are returned, Camera and Detail say so and treat the match as low confidence. It gives the primary match one explicit detail CTA while listing every additional match; zero matches explain how to Refine or Retake. During identify, the camera only marks client-observable work complete (photo ready, then request started) and names the public-match lookup as the next step — it does not fabricate server-side progress. Result-card motion is disabled when the system requests Reduce Motion. A signed-in public result with an attached positive quote can also show one static, per-user/device Find evolution offer. The result merely qualifies the offer: this device-global preference covers all of the person's finds, not the brand that triggered it. Capture never requests iOS permission: only its direct CTA may do so, and Mapvest writes only notifications_enabled plus find_evolution after permission, device registration, an exact-token preference read, and server persistence have all succeeded. Failed, denied, lost-response, and Settings recovery states never claim that an alert is enabled. On iOS, success additionally requires the OS alert-presentation capability, not merely top-level notification authorization. Push preferences are always scoped to this install's stored push-token id; a missing or stale id does not select another device. Camera result state/cache is scoped to the identity that started identify, clears on an auth transition, and uses one bounded vertical result scroller so actions and cited evidence remain reachable on compact phones. A signed-out Save from Camera or Detail carries the ticker and save source through magic-link auth, shows a verified-then-saving state, upserts the watchlist entry idempotently, and returns to that ticker’s Detail context. The client never accepts an arbitrary post-auth redirect URL. If saving fails after verification, the code field stays locked and Retry save uses the verified session rather than re-running the magic link.

The product is four layers, in this order: (A) identify anything around you via camera or map (public ticker, or private → public comparable + ETF) so the user builds an investable universe from the world; (B) agentic research on that company or the local economy (regional quirks that make a street better or worse for a business); (C) a finance agent with tools that writes briefs and memos, with saved chats and Chat about / Chat with on the map and everywhere else; (D) analytics and charts (trends, levels, auction / ridge / regression) so they can think about how and why to own or trade the name. iOS charts draw with React Native Views (apps/ios/src/chartkit), not react-native-svg — that native module flashed and SIGABRT'd on current Xcode / iOS builds. Mapvest sits on three APIs: location/image → public + private identity and comparables; a finance agent with stats tools; and a charts stack. First session still teaches one loop: camera or map → one identity → one ticker card with sources. Every successful identify is recorded server-side as a find (user_finds, GET /v1/finds). Home is discovery-first: snap hero → map link → Your universe (recent finds and streak; the header row is tappable and opens /universe) → ticker search → Local Economy Brief → watchlist (+ daily brief / movers / backtest after saves). Search follows discovery, never precedes it. /universe is the full finds journal — day-grouped, with Δ since found — reachable from Home’s universe header, the sidebar, and the camera result card. The loop strip under the snap/map hero is gone, and so is Home’s top-bar camera icon — the snap/map hero card remains Home’s camera entry. Map and List are blended (bottom sheet on the map + View as List / View as Map toggles) rather than duplicated as Home widgets. There is no bottom tab bar — destinations live in the profile drawer (left-sliding) and a camera icon sits in the top-right of Map / List. The sidebar is consolidated to Home, Map, Camera, Your universe, Watchlists, Saved places, Research, Alerts, Profile — the “Nearby list” and “Find ticker” rows are removed (list view lives inside Map; search lives on Home). The Local Economy Brief sits above the watchlist on every Home load (featured chrome, always refreshable) and is neighborhood-scoped (Nominatim zoom 16 + suburb/neighbourhood, map viewport center when the user has panned the map — not “New York, New York”). Opus writes it first. Daily and local briefs both carry “sources cited · research, not advice” and never name model providers. The client heartbeats its last location via push prefs so the moved-2km “new neighborhood” push can fire. Research chat proxies Derivation; if that service returns a machine error (MODEL_BUDGET_EXHAUSTED) we fall back to OpenRouter (Grok 4.6 → GPT-5.6 Luna → Opus 4.8) and never show the raw code. Every screen has a menu burger (shared AppTopBar or the tab header). Map refetch follows zoom (viewport radius + zoom bucket); Apple/Google POIs are hidden so only our pins carry tickers; same-brand locations inherit a resolved ticker; overlapped chips go to the pin closest to the viewport center. The map renders a My finds layer — small jade camera badges where the user found things — toggleable from the nearby sheet. Overview shows a native provider-routed price series; analyzer PNGs live in an Analytics section with auction / ridge / regression chips. A listed ticker page is ticker → name → chart → save/research/Robinhood/alert → comps → analytics → glance → financials → news → full brief. Detail is progressive disclosure: Financials, SEC, and cited Evidence collapse by default; Analytics stays open with a plain-language explainer per chart type. Evidence opens when no citations were returned so the low-confidence state is visible; it is never silently omitted. Empty Comparables / ETF sections are hidden entirely for listed names. Comparables lead only when the name is actually private. News opens an in-app reader (Safari is optional). A one-screen first-open sheet (mapvest.firstOpen.v1) appears once and routes to Camera or Map — never a carousel. Guests keep identifying without an account. After first-open, a convert sheet (Keep what you've caught) can appear on the second find in a session, a rare or legendary catch, or a foreground return ≥3 days after the last find — never at cold boot, never when signed in, 24h cooldown per moment. Guest catches live in mapvest.guestFinds.v1 and move onto the account via POST /v1/finds after magic-link sign-in. Comps, news, and the AI brief on Investable / Prism stay locked until the first successful identify (mapvest.firstFind.v1 plus any server-side find). The lock panel reuses EmptyState and routes back to Camera. Price, fundamentals, and evidence stay open. Mapvest Daily and Local Economy Brief both collapse behind a chevron. Mapvest Daily is list-scoped: GET /v1/watchlist/brief?listId= writes the column for that list's tickers (omitted → the default list), each watchlist detail page mounts its own lazily (the brief for a list is only generated when its page is opened), and Home's card follows the selected list chip. Under the brief, each watchlist detail page also mounts a "Headlines" card fed by GET /v1/watchlist/headlines?listId= — the same per-ticker headline batch the brief is written from, newest first. Headlines there and on GET /v1/news may carry an optional jev_materiality { level: noise|minor|material, score, confidence } tag from ONE batched Jev call per page (apps/api/src/lib/headline-materiality.ts, memoized 15 min per headline url + content hash). The key is absent — never null — when Jev is unconfigured, errored, or below 0.55 confidence, and both endpoints accept ?materiality=<level> which keeps items at or above that level plus every unscored item, so a Jev outage can never hide news. Clients render the tag as a "Material · 82%" pill and offer a "Material only" toggle only once something on the page is scored. Any list can be promoted to default — tap the ★ in a watchlist detail page's header, or tap ••• on any row on the Watchlists screen (a long-press shortcut still works too), then "Make default"; POST /v1/watchlist/lists/:id/default demotes the old default in the same call. The Watchlists screen shows a one-time dismissible tip once a second list exists, and the currently-default list carries a visible "★ Default list" badge on its detail page — discoverability was a launch-week complaint, so the affordance and the "why" (it powers Home and Mapvest Daily) are both surfaced, not just documented. The 7am daily-brief push, Home's "All" view, and every default-list consumer follow the new default immediately, and only the default list's brief may fire the "Your morning read" push. Leading $ cashtags stay on map pins and in prose; list rows show the ticker without a prefix. Overlapped map tooltips use a two-tap sequence (reveal → open summary). Home search suggests tickers as you type. Detail staggers section render so the sheet never hangs on a blank spinner. Investable (/detail/[id]) is a card push, not a UIKit page sheet. TestFlight was dying on that loading spinner because the stack presented the screen as a modal, then nested Research / news-reader page sheets and hot-swapped a Reanimated header while resolve returned. Expo/web (www.mapvest.app/app/ticker/RLX) never used those native presenters, so it kept working. Charts still live behind an error boundary; SVG polylines with empty/NaN points are not mounted.

Crash-hardening (TestFlight builds 64–86 all aborted on com.facebook.react.ExceptionsManagerQueue, i.e. an uncaught fatal JS error, per the device .ips logs — not a chart/native-view bug):

  • react-native-reanimated is pinned to the 3.19.x line. 3.17.x only supports RN ≤ 0.79 and the app runs RN 0.81 with the old architecture; worklet errors from a mismatched Reanimated are rethrown on the JS thread outside React's render phase, where no error boundary can catch them, and release builds abort. Do not re-pin to 3.17 and do not move to 4.x while newArchEnabled is false (4.x is Fabric-only).
  • src/util/fatalGuard.ts replaces the release-mode global JS error handler: an uncaught fatal error now renders a recovery screen (with the error message, so a TestFlight screenshot doubles as a crash report) instead of abort()ing the process. Dev builds keep RedBox.
  • The Investable header (Stack.Screen options) is memoized on the ticker so the native RNSScreenStackHeaderConfig is not re-configured on every render mid push-transition.
  • Home's Local Economy Brief reads the map region from the react-query cache only (queryFn returns undefined, enabled: false). A missing queryFn threw on every Home mount and is the same class of fatal JS error that aborted TestFlight.

Jev signals: rating, snap verdict, search intent

Three more Jev "System 1" call sites sit beside headline materiality. All of them share one contract: fail open (no JEV_API_KEY, any error or timeout, or confidence below 0.55 → the optional field/section is omitted and the response shape is otherwise unchanged), one batched systemone call per request, bounded context, and an in-process TTL cache. scripts/jev-candidate-scan.ts lists every call site.

  • Rating — GET /v1/rating/:ticker → RatingResponse (apps/api/src/lib/rating.ts). The evidence packet is assembled in parallel from sources that are either cheap and live (quote + 3-month history stats via the market-data router, financial ratios, the headline batch filtered to material headlines, the stored Prism recommendation / Situate posture read from /summary, and the sibling Underlying service's GET /api/tabular/peer-forecast/:ticker?horizon=3 — a TabICL v2 peer forecast, treated as absent on any non-200) or already cached in-process (synthesis memo, demand pulse, environment brief — never generated here, so the rating never spends a build). Each source has a ~3s timeout and drops out on failure. Jev then answers ONE request: a score over strong_sell < sell < hold < buy < strong_buy, a choice for the primary driver (valuation | momentum | fundamentals | narrative | macro | local_demand | peer_forecast, only among drivers present), and one noul per driver ("does this evidence argue UP?"). rating is PrismRecommendation-shaped: conviction = Jev confidence, strength from the probability margin (≥0.25 strong, ≥0.10 normal, else weak), and one_line composed deterministically from the drivers ("Buy · momentum + fundamentals; macro headwind") — never model prose. status: "insufficient_signal" with rating: null when fewer than two sources resolved, Jev failed, or confidence < 0.55. Cached one hour per ticker (five minutes for an insufficient result). Every response carries disclaimer: "AI-generated research signal, not investment advice." Clients render it as the hero chip at the top of Investable ("BUY · 72%"; tap to expand drivers, evidence sources, disclaimer; "Not enough signal yet" when insufficient).
  • Snap → investable verdict — POST /v1/identify may attach verdict: { exposure: direct|parent|proxy|none, probability, worth_a_look, watchlisted? } to each Investable (apps/api/src/lib/identify-verdict.ts). One batched call across all detections (≤20) with the resolution as state (brand, parent, ticker, comparables, ETFs, rarity, and — for signed-in callers, read within 500 ms — whether the ticker is already on their watchlist); per detection a choice over the exposure and a noul "worth a closer look". Cached 15 minutes per resolution content hash; the identify response never waits on Jev longer than 4 s. The camera result card shows "Investable via parent · NKE · 88%" / "Proxy exposure via XLY" and emphasizes Save when worth_a_look >= 0.7.
  • Search intent — POST /v1/search/intent { q, lat?, lng? } → SearchIntentResponse { intent, probability, resolved, route, method } (apps/api/src/lib/search-intent.ts). Deterministic first: cashtag or ticker shape + a live-quote probe (1.5 s) → ticker; a brands.json seed hit (direct, then longest substring) → brand with its ticker; an explicit locator ("near me", "nearby", …) or venue words / geo hint → place; question shape → question. Only the ambiguous remainder asks Jev one choice; any failure falls open to intent: "ticker" with today's behavior (route.screen: "detail" for the raw text). Cached five minutes per normalized query. Home search calls it on submit and routes to /detail/{id}, the Map tab (q), or the Research composer pre-filled with the question; a debounced hint under the box previews the intent while typing.

MCP: Mapvest in the constellation

Mapvest is one member of a small constellation of sites (the lattice animals, Reflect-Search, Admissible, Underlying Analyzer, the Derivation console) whose MCP servers can call one another's and be called by them. The contract (endpoints, the hop rule, the registry, the conformance vectors) is the lattice animals' docs/constellation.md. Mapvest carries the shared library apps/api/src/lib/mcp-lite.mjs (types in mcp-lite.d.mts) verbatim and answers the same 26 vectors (apps/api/tests/mcp-vectors.test.ts runs tests/fixtures/constellation-vectors.json; both files and the library are pinned by hash, so an edit or a formatter pass fails the test). Biome ignores the three copies for the same reason. Nothing here is a second implementation of the protocol: apps/api/src/routes/mcp.ts mounts the library, apps/api/src/lib/mcp-tools.ts is the tools.

  • Routes (root paths, not /v1; public, no auth, stateless). POST /mcp is this site's tools: one JSON-RPC 2.0 message per POST, one JSON reply (initialize, ping, tools/list, tools/call; a notification is 202; GET and DELETE are 405 with Allow: POST). POST /mcp/lattice relays a message to the lattice hub's own MCP, one hop deeper. GET /.well-known/mcp.json (also /.well-known/mcp/server-card.json) says where everything is: the endpoint, the tools with readOnly, the peers, the hop limit. Every failure on these paths is a JSON-RPC error (-32600 for a batch or an unreadable body, -32602 for an unknown tool, -32001 for a relay at the limit), never the API's flat { error }; a tool that cannot do its job is a result with isError: true instead. Every tool result carries _meta.constellation = { server, hop, path }. The global rate limiter still counts these calls; the library adds 40 tool calls a minute per address.
  • Tools (all read-only; arguments are .strict() zod schemas and results are parsed before they go out: Mcp* in packages/core/src/schemas). They call the in-process functions the REST routes call, never HTTP.
    • search_intent { query } — resolveSearchIntent: is the text a ticker, a brand, a place or a question, and what does it resolve to. Returns intent, probability, method, resolved, sources, confidence (the REST route is app navigation and is left out).
    • brand_lookup { brand } — seedLookup over brands.json, no network: ticker, exchange, parent, sector. found: false means only that the seed lacks the brand.
    • rating { ticker } — buildRating: the research-signal rating, drivers and evidence, with the disclaimer verbatim (AI-generated research signal, not investment advice.). insufficient_signal (no JEV_API_KEY, fewer than two sources, low confidence) is passed through as no rating, never turned into a hold.
    • ask_lattice_animals { question, to? } — the library's relay tool: asks the lattice hub's ask_the_minds. Arguments are checked here first, so an empty question or a stray key never goes out.
  • Sources and confidence. A result that names a ticker or a brand says what it rests on (sources, section 6 of AGENTS.md). A brand's ticker cites the curated seed (manual); a typed ticker cites the market-data provider that confirmed it is listed (provider and time, never a price); a reading with nothing to cite, or a fail-open guess, is confidence: "low" with a note. Nothing invents a price, a rating or a ticker.
  • Not offered. Quote, quote history, financials, options, market data and market events (licensed upstream data whose redistribution terms are unverified, so rating also lists its quote and ratios evidence by name and withholds their summaries, which restate prices and ratios); every bearer, optional-auth or metered route and anything with a user id (identify, memo, graph, pulse, environment, agent, finds, watchlist, settings, robinhood, billing, push, alerts, photos); resolve-comparable and the Prism and Situate generators (each spends on a model); images and other binary data. No tool takes a URL or an address. A call to any of them is -32602.
  • The hop rule. Every call between sites carries x-mcp-hop and x-mcp-path. Mapvest calls out only while the hop is under 2, sends hop + 1 with its own name added, and at the limit refuses a relaying tool as an error result beginning too-deep (a relay path answers -32001); pure tools still answer. The registry is the operator's: lattice is the only peer, its address is LATTICE_MCP_URL (default: the hub's public address), https only, no redirects, and plain http on loopback only when MCP_ALLOW_LOCAL=1. Anything else is not in the registry (404), and a peer with no usable address is listed as not configured, never guessed.
  • Bounds. The lattice animals take 5 to 20 s to answer, so the wait for another site (ask_lattice_animals, /mcp/lattice) is the library's own 25 s, not a cap of ours; a silent hub comes back as did not answer (timeout). To match, the API's Bun server sets idleTimeout: 30 in its export (apps/api/src/index.ts). Why: Bun's default closes a connection that has been idle for 10 s (it counts in 4 s steps, so the cut lands at 12 s) and a reply is idle until its handler is done. The setting is per server, so it applies to every route (a slow GET now gets about 32 s, not 12). We measured on Bun 1.2.23, 1.3.11 and 1.3.13 that the cut applies to a request with no body (a GET, an empty POST) and not to a POST that carries one, even at 70 s; every MCP call is such a POST, so today the setting is a guard rather than what lets a slow answer through. A test sends a 14 s answer through the real server and another pins the setting. The quick tools (search_intent, rating) keep their own 7 s budget so a slow upstream never leaves a caller waiting on a tool meant to be fast: a rating that runs past it (nine sources at 3 s each, then Jev) is cut off with an error result while its computation carries on into the one-hour cache, so the retry is instant. A fresh rating is the one tool that can spend upstream, so a process computes at most 200 a UTC day for MCP callers (a cached one is free and does not count).
  • Configuration (all optional, none secret; see docs/SECRETS.md). MCP_PUBLIC_ORIGIN is the origin the manifest advertises (default https://api-production-4b27.up.railway.app, so endpoint is <origin>/mcp; never taken from the request's Host), LATTICE_MCP_URL is the hub's MCP address, MCP_ALLOW_LOCAL=1 allows a loopback peer for a laptop or a test.
  • Wire contract. openapi.yaml documents the four MCP routes and the Mcp* schemas (tag mcp); a tool result is JSON text in result.content[0].text, at most 8000 characters (the library clips), and every result here is well under that.

Layering rules

  • apps/* may import packages/*.
  • apps/* may not import from another apps/*.
  • packages/* may import from other packages/* only if the DAG stays acyclic. core is the leaf.

Market data follows the same boundary: packages/finance/src/marketData owns provider selection, response normalization, and the explicit Yahoo fallback. HTTP routes consume that interface and project stable zod response schemas; clients do not know which upstream provider supplied a response.

Storage

DataStoreRetention
SessionsPostgres (Railway)30d
User photos (opt-in)Signed S3 bucket keyed by uid7d default
Brand→ticker cachePostgres∞ (versioned)
Request log (admin)Postgres30d
Cost telemetryLogfire90d

Push notification account isolation

An Expo token represents one physical application installation, not an account. push_tokens retains its historical per-user rows, while push_token_claims is the authoritative global ownership record: a token can have one active (token_id, user_id) claim, or a tombstone after unlinking. Every push-delivery, preference-read, preference-write, and token-list query joins the claim, so an old or duplicate row is never deliverable.

Preference reads require an exact opaque tokenId: omitted or stale ids return an explicit empty result and never select a sibling device. iOS first reads its local id, then may re-register its physical Expo token without prompting for permission to recover a missing id. A location heartbeat likewise writes only an exact or recovered current-device token; it otherwise no-ops. deviceId is never part of authorization.

Location-derived work stays scoped to that same token. The movement scheduler uses each device's own heartbeat, anchor, local-brief dedupe, uncaught ticker dedupe, and uncaught daily/weekly budgets; a phone cannot move, spend budget, or cause a local push on its sibling tablet. Nearby Discovery requires at least one saved find or watchlist entry and is capped at one alert per UTC day and three per Monday-based UTC week. Local-brief request responses do not send a redundant push because the requesting device is already displaying the brief. Non-location events retain their account-wide opted-in fan-out.

Every Expo handoff is assembled per recipient. Its additive data.mapvest envelope contains schema version, unique delivery claim ID, opaque installation ID, issue/expiry timestamps, event kind, and a bounded typed destination. The legacy top-level fields remain for older binaries. Current iOS clients accept only the typed envelope after session hydration, current claimant verification, expiry checks, and account/installation matching. They record an accepted item as pending before navigation, mark it handled after router handoff, retain both states through expiry plus clock skew, and retry unexpired pending work on startup. Corrupt, duplicate, over-capacity, stale, or mismatched state fails closed. Sign-out clears the replay ledger before another account may activate.

Settings exposes intent bundles rather than nine undifferentiated switches: Nearby Discovery (uncaught_nearby, local_brief, identify_done), My Universe (watchlist_mover, find_evolution), and Research Ready (memo_finished, agent_response). Daily Brief and user-created Price Alerts remain individual. iOS provisional authorization is usable; denial keeps a visible System Settings path. Notification actions only foreground the app and navigate to Map, company, or notification settings. A Nearby destination opens the supplied coordinates, highlights the exact place ID (ticker fallback), and shows an explicit retry state rather than substituting an unrelated result.

Startup serializes lazy push-schema work with a transaction advisory lock, elects one deterministic legacy row, then mutes every other row. CREATE OR REPLACE FUNCTION plus idempotent trigger installation avoids a DROP/CREATE enforcement gap. The trigger repeats that mute on transfer, unlink, and every preference write that does not match the active claim, so a mixed-version API that still writes push_tokens directly cannot re-enable an old account's delivery path. push_delivery_claims stores the short lease and dedupe ownership used by the central dispatcher; expired leases are retryable and never confer account ownership.

Registration for the already-claimed account is idempotent and keeps that installation's choices. Registration under another account runs in a Postgres transaction guarded by a token-specific advisory lock, switches the claim, and resets the product switch and every event opt-in to false. This deliberately never carries notification consent, scheduler state, or location from the prior account. Existing duplicate database rows are not mass-deleted: the first claim elects the most recently seen row and leaves the rest inert, avoiding a risky lossy migration.

Explicit iOS sign-out runs before the session is cleared. The app first writes a same-key SecureStore cleanup envelope containing the old owner and an immutable push snapshot, then attempts authenticated DELETE /v1/push/token/:id or claimant-bound POST /v1/push/revoke-device. An expired bearer may use public POST /v1/push/revoke-device only with both the Expo token and its opaque server tokenId; a valid bearer that lost that id may use POST /v1/push/revoke-current-device. Both return a typed outcome: revoked and already-revoked are safe completed cleanup, while claim-mismatch is fail-closed because a different active owner has claimed the physical token and is an HTTP 409 rather than a 2xx response, so older clients cannot mistake it for completed cleanup. deviceId is advisory telemetry only: it may rotate after a reinstall or SecureStore loss, while the opaque id plus Expo token (or authenticated user plus Expo token) is the authorization proof. A cryptographically valid former session may instead call POST /v1/push/revoke-expired-session-device with its bearer and either an Expo token or the opaque registration id when no current Expo identity is available. Expo-token-only recovery is limited to 90 days after expiry; exact opaque-id recovery has no age limit because it identifies one historical row and the route still verifies that row's signed subject owns the current claim. That route checks the signature, HS256 algorithm, purpose: "session", and sub with expiry validation disabled only for this deletion, then can revoke only that subject's still-active claim; an old id whose physical token now belongs to another owner returns 409. It reads no user state, and a fresh account's bearer is never substituted for a different owner.

Native unregistration, Expo auto-registration shutdown, and notification or response dismissal are defense-in-depth only, never server revocation proof. The cleanup envelope and push snapshot remain until server revocation and local deletion are verified, so a force-quit retries idempotently rather than resurrecting authenticated UI. New registrations persist their physical claim before the server write, so permission loss cannot erase the recovery path. If any token, marker, snapshot, or SecureStore read cannot prove cleanup, the app stays on a retryable cleanup screen. This only removes the current installation's claim; another phone or tablet remains registered independently.

Notifiers do not send from a list snapshot. The central delivery facade claims one Expo-sized batch (at most 100 tokens) at a time, then holds a reserved Postgres session advisory lock for each physical token through the Expo handoff. Registration, unlink, preference writes, claiming, and finalization take the same sorted advisory keys before row locks, so account changes cannot commit between validation and the irreversible handoff and cannot deadlock against finalization. The lock is session-level rather than a 45-second row transaction; its reserved connection is cleaned with pg_advisory_unlock_all() and dispatcher concurrency is bounded per process. Each handoff lease is at least 90 seconds from advisory-lock acquisition, exceeding Expo's three-attempt retry window even after waiting behind a prior handoff, and later batches are selected only after the previous batch finishes. A request already accepted by Expo/APNs cannot be retracted after that handoff; the gate closes the server-side selection/state race without claiming impossible downstream timing guarantees.

Production Expo dispatch is disabled unless EXPO_PUSH_SECURITY_ENABLED=1 and a non-empty Doppler-provided EXPO_ACCESS_TOKEN are both present. Request logs redact opaque push token IDs in paths and query strings, and push spans do not attach tokens, delivery IDs, envelopes, titles, or bodies.

Observability

  • Logs: Logfire via pydantic-logfire on the API. Structured spans per request.
  • Metrics: Railway service metrics + Logfire counters (identify latency p50/p95, hit rate on brand cache).
  • Errors: Logfire issue tracking, Slack channel #mapvest-alerts.

Failure modes

ModeDetectionResponse
OpenRouter down5xx or timeoutFall back to Gemini direct via GEMINI_API_KEY.
Exa quota exhausted429Serve cached brand map, mark confidence: "low".
Google Places quota429Serve nearest-cached-tile response, refresh async.
Vision returns low confidencemodel outputAsk user to reframe; do not return a ticker.