# MapMap Nav — end-user findings

Product findings from building MapMap Nav as an outside customer would.
Severity: blocker / major / minor / papercut.

> **Renumbered** during the integration pass (App.tsx/src/screens/index.ts
> rewiring): this file previously had two independent F1..F13 sequences
> (one from the T4/T5/T8/T9 builders, a second appended by T7) that
> collided on F6, F7, F9, F10, F12 and F13. Content and reading order are
> unchanged below; only the numbers were made unique, into one F1..F21
> sequence. F17, F18, F20 and F21 (the T7 prop-contract gaps) were also
> closed by this same integration pass — see the "RESOLVED" note on each.

## F24 · major (toolchain, not MapMap) — Expo SDK 57 iOS pod won't build on Xcode 26.0.1 seed
- **Symptom**: `Expo` Obj-C module fails (`'Expo/EXAppDelegatesLoader.h'
  file not found`); before that, `weak let` across `ExpoModulesJSI` and
  `ExpoModulesCore` is rejected ("`weak` must be a mutable variable") under
  the seed's stricter Swift 6 concurrency checking.
- **Cause**: not MapMap — a React Native 0.86 / Expo 57 ↔ Xcode 26.0.1
  (pre-release) incompatibility. Worked around ~10 build cycles
  (SWIFT_VERSION overrides, `nonisolated(unsafe)` + a Sendable weak box via
  patch-package, header mirroring); the umbrella-header failure survives a
  clean `pod install`.
- **Impact on this demo**: the *existing* iOS dev-client binary runs the
  app (gate passes, online map + chrome proven — see showcase/ios/*.png),
  but a *fresh* binary picking up the newest native deps (expo-speech) is
  blocked until Xcode 26 GA or the RN/Expo patch lands. Android is
  unaffected (full journey recorded). A competent shop on Xcode 16.x would
  not hit this — it's a bleeding-edge-toolchain artefact, logged for
  completeness.

## F1 · CRITICAL (platform, not app) — hosted routing ignores the request origin
- **UPDATE (17 Jul 21:45)**: after a gateway redeploy, this is **FIXED** —
  London→Bank now returns 3.68 km with a correct central-London first
  maneuver ("Drive northeast on King Charles I Island"). Real routes now
  follow real roads from the real origin. The demo can point at London
  again (the Manchester workaround is no longer required, though harmless).
  Below is the original finding for the record.

- **Severity**: blocker (for hosted routing; app uses on-device routing)
- **Expected**: `POST /route` London→Manchester ≈ 330 km.
- **Actual** (17 Jul ~17:00 UTC, fresh provisional key): every request's
  first maneuver is "Drive northwest on Slade Lane/A5079" (Manchester),
  whatever origin is sent. London→Manchester = 10.7 km (Manchester-local
  hop); London→Bank (a 3 km city hop) = 320.9 km (Manchester→London);
  London→Birmingham = 139.8 km (matches Manchester→Birmingham).
- **Repro**: `curl -X POST https://api.mapmap.ai/route -H "Authorization:
  Bearer $KEY" -d '{"costing":"auto","locations":[{"lat":51.5074,"lon":-0.1278},{"lat":51.5155,"lon":-0.0922}]}'`
- **Read**: origin snapping appears pinned to one fixed edge — possibly a
  world-build graph artefact or a gateway→Valhalla request-translation bug.
  Affects the public /map directions too. Needs box-side investigation.

## F2 · CRITICAL (platform, not app) — signed territory channel not served
- **Expected**: `GET /territories` (authenticated) returns the signed
  channel index the mobile SDKs install from (a UK package was
  channel-published 15 Jul).
- **Actual** (17 Jul evening): 404 "no such territory channel object" on
  `/territories`, `/territories/index.sig`, and every manifest path tried.
  The channel store appears displaced by the world build / `/opt`
  migration. **No end user can install any territory on any platform**
  until the box republishes the channel. Rendering tiles
  (tiles.mapmap.ai) are unaffected.
- **Impact on this demo**: criterion 2 (signed territory install) is
  blocked platform-side; the app's install flow is built and will be
  exercised the moment the channel returns. UI runs proceed against the
  module's mock in the interim, labelled as such.

## F2b · MAJOR (platform/product) — online vector tiles are unusable on a metered key
- **Context**: added an "online maps" source (no download) over the
  gateway's XYZ vector endpoint `/tiles/{id}/{z}/{x}/{y}.pbf?api_key=…`.
  Wiring is correct — single tiles + sequential bursts return 200/204
  (`application/vnd.mapbox-vector-tile`), and the hosted "MapMap Light"
  style loads.
- **Problem**: a single interactive map view fires a **concurrent burst**
  of 20–70 tile requests. Two consequences on a self-serve key:
  1. **429s** — the provisional 60 req/min limit is blown instantly, so
     most tiles fail and the map renders blank/patchy (observed:
     `Failed to load tile … HTTP status code 429`).
  2. **Quota burn** — each tile is a metered call; one map view consumed
     **~74 of the 1,000** provisional-key calls. A verified key (50k/mo)
     lasts ~600 map views — still not viable for a real user.
- **Read**: serving interactive map tiles through the *same* metered,
  rate-limited per-key API as routing calls is fundamentally unworkable —
  competitors (Mapbox/MapTiler) meter tile *loads* on a separate, far
  higher allowance. MapMap needs either an unmetered/high-limit tile tier
  or to lean fully on the offline-territory path (which is the design
  intent — and would sidestep this entirely, but the channel is down, F2).
- **Unlock for the demo**: verifying the key (click the magic link emailed
  to matt@precode.co) lifts the quota; the burst-429 still needs a tile
  tier or the offline path. App-side mitigation applied: district-level
  default zoom (11) to shrink the first burst.

## F3 · major — promised env credentials absent
`GPR_USER`/`GPR_TOKEN` (GitHub Packages PAT for `ai.mapmap:core`) were not
set in the build environment. Android dependency resolution blocked until
supplied. (Also: nothing at mapmap.ai/docs tells an Android customer they
need a *GitHub account + PAT* at all — significant onboarding friction vs
`npm install`.)

## F4 · major (expected, to confirm) — UK territory exceeds the free download allowance
UK signed package ≈ 3 GB vs 2 GiB/month verified-free allowance (and the
demo needs it twice, once per platform). Free-tier users likely cannot
install the flagship territory. Confirm on install; fallback: ireland.

## F5 · minor — no public replay corpus or corpus schema
Turn-by-turn testing on simulators requires a `.drive.jsonl` corpus; the
public docs don't ship a sample or document the line schema. We synthesise
our own from route geometry (see src/replay/) — schema guessed.

## F6 · papercut — org rename residue
Docs/READMEs still reference `weareprecode` in places after the Mapmapai
transfer (redirects mask it for git, not for Maven URLs typed by hand).

## F7 · minor (T4/T5, resolved by rewiring) — native.ts throws at import time
`modules/mapmap-react-native/src/native.ts` resolves `MapmapNav` with the
*throwing* `requireNativeModule` at module-evaluation time (top-level
`export const MapmapNav = requireNativeModule(...)`). Importing that file
(or anything that re-exports it, e.g. the module's `index.ts` barrel) in an
environment without the native module registered — Expo Go, before a
prebuild — throws immediately on import, before any try/catch in a
consumer can run. `src/state/mapmapNav/useMapmapNav.ts` (T5) works around
it: it never imports `native.ts`/`index.ts`'s value exports, only
`import type`s from them (erased at compile time) plus the *optional*
`requireOptionalNativeModule` probe and `mock.ts`'s values directly (which
has zero native/runtime dependency by its own doc comment). Confirmed via
`npx expo export --platform ios` (855 modules, 0 errors) and `tsc --noEmit`
clean. Worth a look from T3/integration: `native.ts` could use
`requireOptionalNativeModule` + a lazy getter instead, so any consumer can
import it safely.

## F8 · papercut (T4/T5) — plugin/withMapMapNav.js fails `tsc --noEmit`
`modules/mapmap-react-native/plugin/withMapMapNav.js` (implicit `any`s,
an `ExpoConfig`/`ConfigPlugin` type mismatch — 12 errors) is the only
source of `tsc --noEmit` failures repo-wide as of this build; every file
under `src/**`, `App.tsx` is clean. Out of T4/T5's ownership
(`modules/**` is read-only for this task) — flagging so whoever owns that
plugin file knows it's the sole blocker on a fully green `tsc --noEmit`.
**Still the sole blocker** after the integration pass (confirmed again via
`tsc --noEmit`, same 12 errors, same file) — `modules/**` remained out of
scope for integration too.
**RESOLVED** (iOS vendoring pass): the plugin was rewritten (its SPM-injection
half is now dead — see F22 — leaving only the Info.plist logic) and typed with
JSDoc; `tsc --noEmit` is now fully green (0 errors).

## F9 · blocker (Android onboarding) — territory verifying key unpublished
Docs say the ed25519 channel verifying key must be "baked in at build
time" — but the hosted channel's public key is published nowhere. An end
user cannot verify signed territories at all. (Demo proceeds with the key
obtained out-of-band; wired as a gradle property `mapmap.factoryPubkeyHex`.)

## F10 · major — Android SDK docs lag the release
- /docs/sdks still documents **0.1.0** and calls the credential property
  `gpr.key` (the working name is `gpr.token`).
- The sdk-v0.2.0 GitHub release is iOS-only; no public Android 0.2.0 API
  reference or release notes exist.
- /docs/territories shows **Rust** sample code labelled "Kotlin/Swift".
- Channel-index JSON schema and installed-package on-disk layout are
  unpublished — both had to be reverse-engineered defensively.

## F11 · minor — Android SDK gaps found while bridging
- No on-device search API is exposed (packages bundle geocode indices but
  no Kotlin surface; hosted /geocode is 501). `search` degraded.
- No offline-style API: `getTerritoryStyle` had to resolve style JSON from
  the installed package on disk.
- `VoiceGuidance` exposes muted/volume but no "speaking" signal.
- No documented territory removal API.
- SDK requires minSdk 26 (Expo default is lower — forces a bump).
- Gradle trap for module authors: a module-local `repositories{}` block is
  silently ignored — the GPR repo must be injected at the root project.

## F12 · major (T9) — expo-splash-screen plugin not installed; splash config best-effort only
SDK 57's versioned docs (docs.expo.dev/versions/v57.0.0/config/app/) no
longer document a top-level native `splash` key — Expo now drives native
splash screens exclusively through the `expo-splash-screen` config plugin
package, which is **not installed** in this project (`node_modules` has no
`expo-splash-screen`, and T9 owns `app.json`/`assets/**`/`store/**`/
`privacy/**` only, not `package.json`). `app.json` still declares a
top-level `splash` block (`./assets/splash.png`, contain, white) because
(a) it does no harm — `npx expo config --type public` parses it cleanly
with zero errors/warnings, exit 0 — and (b) it documents intent for
whoever adds the dependency. But **it will not generate a native splash
screen on `expo prebuild`** until someone adds `expo-splash-screen` to
`package.json` dependencies and an entry to the `plugins` array, e.g.
`["expo-splash-screen", { "image": "./assets/splash.png", "resizeMode":
"contain", "backgroundColor": "#FFFFFF" }]`. Flagging for whoever owns
`package.json`/integration (Fable, per CONTRACTS.md task table) — the
asset (`assets/splash.png`, 1284×2778, correct brand tile-on-white design)
is ready and waiting; only the wiring is missing.
**RESOLVED**: `expo-splash-screen` (`~57.0.4`) is now in `package.json`
dependencies and `app.json`'s `plugins` array already carries the matching
config-plugin entry — confirmed present in both files as of the
integration pass; not something this pass added, but flagging that the
gap this finding described is closed.

## F13 · minor (T9) — eas.json submit credentials are placeholders
`eas.json`'s `submit.production` block has placeholder
`REPLACE_WITH_APPLE_ID` / `REPLACE_WITH_APP_STORE_CONNECT_APP_ID` /
`REPLACE_WITH_APPLE_TEAM_ID` fields and a Play service-account path
(`./store/play-service-account.json`) that doesn't exist yet — correct,
since T9 has no EAS account access and was told not to run `eas`
commands. `eas build` itself needs no credentials to run (build profiles
are complete and valid); only `eas submit` needs these filled in, by
whoever holds the Apple/Google developer accounts.

## F14 · papercut (T9) — app.json raced with another in-flight edit mid-build
While writing this task's `app.json` changes, a re-read after an `Edit`
conflict (harness reported "File has been modified since read") showed
`app.json` had briefly reverted to something close to the original Expo
template — `name`/`slug` back to `MapMapNav`, `ios.bundleIdentifier`/
`android.package` removed entirely — consistent with another concurrent
process (plausibly a prebuild or another agent) rewriting the file mid-
task. T9's final write went through afterwards and `npx expo config
--type public` now reflects the intended values cleanly, but if the
integration pass sees `ai.mapmap.nav`/`MapMap Nav` missing from
`app.json` again, this is why — re-apply from this file's final state
rather than assuming corruption.
**Not recurring**: `app.json` was not touched by the integration pass
(out of scope per the integration brief) and still reflects the intended
`ai.mapmap.nav`/`MapMap Nav` values.

## F15 · minor (T8) — TerritoriesScreenProps.onCheckUpdate has no result channel
`onCheckUpdate: (territoryId: string) => void` is fire-and-forget — no
promise, no `deltaBytes`, no `updateAvailable`, no error. Spec S10 asks for
"check-for-updates row with delta size display" and the T8 acceptance
rules ask for an "update-check failure" state to be designed; neither can
be shown honestly against this contract without fabricating numbers on a
screen whose screenshots feed the mapmap.ai showcase. `TerritoriesScreen`
(`src/screens/territories/TerritoriesScreen.tsx`) works around it: it
remembers each territory's `version` at the moment `onCheckUpdate` fires,
shows a "Checking for updates…" spinner, and clears it the moment
`installed[].version` for that id changes (the one real signal the
contract does carry) or after a 4s timeout — no delta size, no
success/failure copy is ever asserted. Recommend extending the bridge
contract before a real update-check UI can be built: either make
`onCheckUpdate` return `Promise<{ updateAvailable: boolean; deltaBytes:
number } | BridgeError>`, or add a `updateCheck?: { checking: boolean;
deltaBytes?: number; error?: BridgeError }` field alongside `TerritoryInfo`
that the container keeps current.

## F16 · papercut (T8) — AppSettings has no themeMode; S9's theme row omitted
Spec S9 lists "Theme: Light / Dark / System (if dark shipped; else hide)".
`darkTheme` exists in `src/theme.ts` but is unwired — `AppSettings` (T5,
`src/state/types.ts`) has no `themeMode` field and nothing switches
`getTheme()`'s argument anywhere in the app. Per the spec's own "else hide"
clause, `SettingsScreen` omits the theme row entirely rather than adding a
control with nothing behind it. Flagging in case a future task wires dark
mode — the theme row is the one S9 element intentionally not built here.

## Positive findings
- Self-serve key issuance is genuinely one call, no card (201, provisional
  1,000 calls / 72 h; magic-link upgrade). Agent-friendly.
- iOS SPM channel resolves unauthenticated with a checksum-pinned binary —
  end-to-end verified today.
- npm web SDK 0.2.0 installs clean.

## F17 · minor (T7) — RoutePreview/GuidanceScreen have no map style or
territory reaching them
`RoutePreviewProps`/`GuidanceScreenProps` (src/screens/index.ts) carry no
territoryId, no bridge handle, and no style JSON — only route/guidance
data and UI callbacks. Both screens render their own MapLibre `Map`, but
with an offline-safe placeholder style (flat background, zero sources —
`src/screens/mapChrome.tsx`'s `FALLBACK_MAP_STYLE`) since there's no way
to call `getTerritoryStyle(territoryId, theme)` from a pure presentational
screen. Result: route lines, the puck, alt-route chips and the chase
camera all work correctly; there are no basemap tiles/roads/labels
underneath them yet. Fix is a container-side change (thread the resolved
style string through as a prop), not a T7 change — flagging per
CONTRACTS.md's "if the native SDK cannot honour a contract item, STOP and
report" rule (here it's the JS contract, not the SDK, that's short a
field).
**RESOLVED by integration pass**: `RoutePreviewProps`/`GuidanceScreenProps`/
`ExploreScreenProps` now carry `styleJson?: string | null`. App.tsx's
`MainScreen` fetches `getTerritoryStyle(activeTerritoryId, "light")` once
per active territory and threads the same resolved style down to Explore,
Preview and Guidance — `ExploreScreen`'s own internal fetch was removed in
favour of the prop, per CONTRACTS.md's "smallest diff wins."

## F18 · minor (T7) — S7 guidance screen has no route geometry, only the
latest GuidanceEvent
`GuidanceScreenProps` has no polyline/route prop — just `guidance?:
GuidanceEvent` (one point in time) plus `place`. Spec S7 asks for
"consumed-route dimming: second LineLayer up to the snapped point" and
"Overview toggle = fit-route north-up". Implemented as faithfully as the
contract allows: GuidanceScreen accumulates a breadcrumb trail from the
sequence of snapped locations it's actually given and dims that behind
the puck (not the true route line — there's no way to know what's
"ahead" without geometry), and Overview recentres/zooms out on the last
known location rather than fitting true route bounds. Screenshots will
show a puck, a trail behind it and a live chase camera, but no
full-route line ahead of the vehicle. Real fix: add the active `Route`
(or at least its geometry) to `GuidanceScreenProps`, or thread it via a
container that already has it from S6.
**RESOLVED by integration pass**: `GuidanceScreenProps` now carries an
optional `route?: Route`. `GuidanceModeState` (src/state/appMode/types.ts)
carries the route selected on S6 through the `START` transition
(reducer.ts resolves it from the Preview state's `routeResult` +
`activeRouteIndex`), and `App.tsx`'s `GuidanceContainer` passes it through.
When present, `GuidanceScreen` splits the real geometry at the point
nearest the current snapped location — dimmed "consumed" behind the puck,
full-colour "ahead" in front — and Overview fits the route's true bounds
instead of just centring on the last known point. The breadcrumb-trail
fallback is kept for the (should-not-happen) case where `route` is absent.

## F19 · papercut (T7) — no pan-break signal on the state machine
`SET_FOLLOWING_CAMERA` (src/state/appMode/reducer.ts) is only ever
dispatched with `following: true` (App.tsx's `onRecentre`); nothing sets
it `false` on a user map pan. GuidanceScreen tracks "user broke chase
follow" as fully local state (`followOverridden`, reset on Re-centre tap
or entering Overview) since the prop contract has no callback for it.
Works correctly end-to-end, just not visible to the rest of the app if a
future screen needs to know follow status too.
Still open — out of scope for the integration pass (not one of its four
listed contract gaps); `followOverridden` remains screen-local.

## F20 · papercut (T7) — OSM attribution has nowhere to navigate to
UI spec: "small-print OSM attribution always visible (tappable → About/
Licences)". Neither `RoutePreviewProps` nor `GuidanceScreenProps` carries
an About-navigation callback, so `MapAttribution`
(src/screens/mapChrome.tsx) opens the OSM copyright page directly via
`Linking.openURL` instead — closest in-spec stand-in without inventing a
prop on a contract this task doesn't own.
**RESOLVED by integration pass**: `ExploreScreenProps`/`RoutePreviewProps`/
`GuidanceScreenProps` now carry `onShowAbout?: () => void`. App.tsx's
`MainScreen` wires it to `navigation.navigate("About")` and passes it to
all three; `mapChrome.tsx`'s shared `MapAttribution` takes an `onPress`
prop and calls it when supplied, falling back to `Linking.openURL` on the
OSM copyright page only when absent (e.g. a future caller that hasn't
wired navigation).

## F21 · papercut (T7) — S6–S8 have no `units` (km/mi) prop
None of `RoutePreviewProps`/`GuidanceScreenProps`/`ArrivedCardProps`
receive `AppSettings.units`, so all distance/duration formatting in
`src/screens/mapChrome.tsx` is hardcoded metric. Settings' km/mi toggle
(S9) has nothing to flow through to these three screens yet.
**RESOLVED by integration pass**: `RoutePreviewProps`, `GuidanceScreenProps`,
`ArrivedCardProps`, `PlaceCardProps` and `SearchSheetProps` all now take a
required `units: Units`, threaded from `settings.units` (each App.tsx
container calls `useSettings()` directly). `mapChrome.tsx`'s
`formatDistance`/`formatDistanceToTurn` take a `units` parameter
(`"km"` default) and format miles/feet when `"mi"`; `PlaceCard`/
`SearchSheet` dropped their locally-duplicated metric-only `formatDistance`
in favour of the shared, unit-aware one.

## F22 · CRITICAL (platform/DX) — the MapMap iOS SDK ships SPM-only, which is unusable from a React Native / Expo native module
- **Severity**: major (developer experience; a hard wall for RN/Expo integrators)
- **What**: MapMap iOS is distributed *only* as a Swift Package
  (`github.com/Mapmapai/mapmap-ios`, products `MapMapKit` + `MapMapValhalla`,
  binary target `MapMapFFI`). Expo native modules are **CocoaPods pods**, and a
  podspec cannot depend on an SPM *product*. Every documented/greppable
  workaround was exhausted: injecting the SPM package into the Pods project via
  pbxproj surgery (the pod target can't see the app target's packages → module
  invisible), and `s.spm_dependency` (private / test-specs-only in CocoaPods
  1.16). On Xcode 26 there is **no supported path** to give a pod an SPM-product
  dependency.
- **Impact**: to ship the SDK inside an Expo module we had to **vendor the
  entire SDK** — Swift sources + the `MapMapFFI.xcframework` — into the pod, and
  additionally vendor the whole valhalla-mobile chain it pulls in (wrapper
  sources, `valhalla-wrapper.xcframework`, the two OpenAPI model packages,
  AnyCodable, Light-Swift-Untar). That is ~150 vendored Swift files plus two
  static-lib xcframeworks, and required mechanical source surgery (import
  neutralisation, three type-name de-collisions, a `Bundle.module` shim, a
  duplicate-filename rename, a module-qualifier rewrite) — all documented in
  `modules/mapmap-react-native/ios/Vendored/README.md`. It builds
  (`xcodebuild -scheme MapmapNav` and the full app scheme both **BUILD
  SUCCEEDED**), but this is fragile and re-does itself on every SDK bump.
- **What MapMap should ship to spare customers this**: (a) a real **CocoaPods
  podspec** for MapMapKit/MapMapValhalla (even a wrapper podspec pointing at a
  prebuilt xcframework), and/or (b) a **prebuilt `MapMapKit.xcframework` +
  `MapMapValhalla.xcframework`** (compiled Swift modules, not sources) so
  integrators link a binary instead of recompiling the SDK + Valhalla + boost
  inside their app. Either removes the entire vendoring exercise for RN/Expo,
  Capacitor, and any non-SPM iOS host.
- **Bonus finding**: the bridge had *guessed* two SDK API shapes because the
  UniFFI-generated bindings weren't in the public package when it was written.
  The real (now-vendored) bindings differ: `LayerFetcher` is
  `fetch(relPath:expectedBlake3:destPath:)` (not `fetch(url:) -> Data`), and
  `TerritoryInfo` exposes `displayName` / `totalBytes` (not `name` /
  `sizeOnDiskBytes`). Publishing the generated bindings (or docs for them) would
  have avoided the guesswork. (Both corrected in the bridge; JS contract
  unchanged.)

## F23 · major (build env, not app/SDK) — RN/Expo build scripts break on the repo's spaced path
- **Severity**: major (blocks *any* iOS build here until worked around)
- **What**: the checkout lives at `…/Cursor Projects/MapMapNav` — a path with a
  space. React Native / Expo build script phases don't quote paths, so several
  fail here: Expo's `EXConstants` app-config phase runs
  `bash -l -c "<unquoted spaced path>"` (argv[0] becomes `/Users/matt/Cursor`
  → "Permission denied"), the same script's internal `basename $PROJECT_DIR` is
  unquoted, and the RN "Bundle React Native code and images" phase executes a
  ``node --print``-produced spaced path via bare backticks. None are related to
  the SDK vendoring — they'd fail for any RN/Expo app at a spaced path.
- **Worked around** (to reach `BUILD SUCCEEDED`): a Podfile `post_install` that
  rewrites `bash -l -c "…"` → `bash -l "…"` for pod script phases (persistent);
  the app-project RN-bundle phase's backtick rewritten to a quoted `$(…)` in
  `ios/MapMapNav.xcodeproj/project.pbxproj` (persistent); and quoting
  `basename "$PROJECT_DIR"` in `node_modules/expo/node_modules/expo-constants/
  scripts/get-app-config-ios.sh` (**non-persistent** — lost on `npm install`;
  needs a `patch-package` patch or, better, moving the repo to a space-free
  path).
- **Recommendation**: keep the working tree at a path without spaces; it's a
  known, unfixable-upstream RN/Expo limitation.

*(living document — updated as the build proceeds)*
