Spec 05 · City guide

← back to index

# City Guide v-next — multi-tenant + interest-wired + invite + people (Fable 5 spec)

*stop=end_turn · out_tokens=10085*

---

# CITY GEN‑3 SPEC — "It Already Knows *You*"
**Scope: multi-tenant, interest-reshaped guides, route layer, invites, tenant-scoped People. Target: buildable by Opus 4.8 this week against city-app/ as it exists.**

---

## 0. Ruthless read before the spec

- **Substance:** Interest-driven *regeneration* of the guide is the real product. It's the difference between a demo and a tool. The lens architecture below makes it cheap (shared per city+interest, not per tenant).
- **Theater:** The phone-book area-code People signal. It's already shipped, it's cute (+44 → Neeraj in London), and it's a dead end. Country-code granularity, number portability, and expats-keeping-home-numbers cap its ceiling permanently. **Do not spend another hour strengthening it.** Verdict in §5.
- **Risk:** index.html at ~2048 lines is one feature away from unmaintainable, but a rewrite this week would kill the ship. Add three sections in place; refactor is staged.
- **Cost trap to avoid:** naive multi-tenancy makes every tenant's guide a fresh 3-model fan-out. The spec below caches at (city) and (city × interest), never (city × tenant). Personalization is **assembly-time composition** — which is exactly the Mentor pattern, so we get to reuse the isolation model and its test gate for free.

---

## 1. Tenancy & profile model (Mentor-aligned)

**One tenancy model, not two.** Same rule as Mentor: **isolation is enforced in context assembly, not in the model.** Every API handler resolves a tenant from a token, loads *only* that tenant's blobs, and composes them with *shared* city-level caches. No tenant data ever enters a shared cache. No prompt ever contains two tenants' data.

### Identity: capability token, no passwords
- Token: 128-bit random, base64url, minted at invite acceptance. Client stores in `localStorage.cityToken` and sends as `Authorization: Bearer`.
- Server stores **SHA-256 of the token** as the blob key. Raw token never persisted server-side.
- Byron = tenant zero: `OWNER_TOKEN_HASH` env var (harness owner-auth pattern, verbatim). First load with legacy localStorage profile + owner token migrates the profile to `tenants/{ownerHash}/profile.json`.

### Blob layout (the whole data model)
```
city/{slug}/base-v4.json                  shared · 14d TTL (existing guide, schemaVersion 4)
city/{slug}/lens/{canonical}-v4.json      shared · 14d TTL (NEW — interest lens)
city/{slug}/tonight/{yyyy-mm-dd}.json     shared · 1d  (existing)
interests/canon.json                      shared · append-only keyword→canonical map
tenants/{tokenHash}/profile.json          PRIVATE
tenants/{tokenHash}/people.json           PRIVATE
invites/{inviteTokenHash}.json            one-shot
snapshots/{snapId}.json                   shared, PII-stripped share artifact
```

### Profile shape
```json
{
  "v": 1,
  "name": "Priya",
  "createdAt": "2025-06-01T09:00:00Z",
  "invitedBy": "owner",
  "interests": [
    {"raw": "Audi",        "canonical": "automotive",  "weight": 1.0},
    {"raw": "A5",          "canonical": "automotive",  "weight": 0.5},
    {"raw": "photography", "canonical": "photography", "weight": 1.0},
    {"raw": "Nikon",       "canonical": "photography", "weight": 0.5},
    {"raw": "running",     "canonical": "running",     "weight": 1.0}
  ],
  "home": {"city": "Bengaluru", "cc": "IN"},
  "limits": {"generationsToday": 0, "maxLensesPerGuide": 4}
}
```

### Isolation gate (falsifiable, same as Mentor's A-vs-B)
`tests/isolation.test.js`, run in CI before deploy:
1. Mint tenant A with canary interest `"zorbing-canary-a7f3"` and a people entry `"CANARY PERSON A7F3, Reykjavik"`.
2. Mint tenant B with interests `"jazz"`.
3. Call `guide`, `people`, `foryou` as B for the same city.
4. **Assert:** zero occurrences of either canary string in any B response body, AND zero occurrences in any *shared* blob written during the run (`city/*`, `snapshots/*`).
5. Call `guide` as A; assert canary interest *does* shape A's guide (a `zorbing` lens attempt appears), proving the test has teeth in both directions.

**Acceptance:** test red if a handler ever writes tenant data into a shared path. This is the gate; multi-tenant features don't merge without it.

---

## 2. Interests → generator directives (the Lens architecture)

Interests must **reshape**, not filter. Mechanism: each canonical interest compiles to a **lens** — a generation directive that produces net-new sections — and lenses are cached **per (city, canonical)**, shared across all tenants. A car person in Lisbon and another car person in Lisbon hit the same `city/lisbon/lens/automotive-v4.json`. This is what keeps multi-tenant affordable.

### 2a. Keyword → canonical resolver — `api/interests.js`
- `POST {keywords:["Audi","A5","AI","photography","Nikon","running"]}`
- Checks `interests/canon.json`; unknown keywords go to one cheap model call:
```json
{"keyword":"Nikon","canonical":"photography","categories":["places","routes","gear"],
 "directives":["camera shops & rental houses","classic photo vantage points ranked by golden-hour quality","photo walks as routes"]}
```
- Result appended to global canon (keyword-level, no tenant data — safe to share). Brand keywords fold into their parent canonical with weight 0.5; the *raw* keyword still travels into the lens prompt as flavor ("enthusiast leans Audi/A5 — mention Audi dealerships and A5-friendly drives if genuinely notable").
- **Duplicate-fold rule:** ≥2 keywords → same canonical = one lens, weight summed. Cap **4 lenses per guide** (top by weight); rest listed as "add later" chips in UI.

### 2b. Lens generation — inside `api/guide.js` (schemaVersion → 4)
Request: `GET /api/guide?lat&lng` + Bearer token.
Assembly:
1. Resolve tenant → profile → top-4 canonicals.
2. Fetch/refresh `base-v4.json` (existing 3-call fan-out, unchanged semantics, >14d refresh).
3. For each canonical, fetch `lens/{canonical}-v4.json`; on miss, one grounded model call per lens (parallel), with the canon directives + city as prompt. **Citations rule applies to lenses identically** — every item carries citation indices; uncited items are dropped by the same validator base sections use.
4. Compose response: base sections, then lens sections ordered by weight, then `tonight` teaser. Personal weights and raw keywords influence *ordering and prompt flavor* only for cache-miss generation of a lens the tenant is first to request — acceptable, since raw keywords are interests, not identity. Names/contacts never enter lens prompts (isolation gate covers this).

### Lens shape
```json
{
  "schemaVersion": 4, "canonical": "automotive", "city": "lisbon",
  "generatedAt": "...", "citations": [{"title":"...","url":"..."}],
  "sections": [
    {"type": "places", "title": "For the car obsessed",
     "items": [{"name":"Museu do Caramulo","note":"...","lat":40.57,"lng":-8.17,"citations":[0]}]},
    {"type": "routes", "title": "Drives worth the rental",
     "items": [{"name":"N247 coast road","start":{"lat":38.78,"lng":-9.49},"distance_km":32,"kind":"drive","loop":false,"notes":"...","citations":[1]}]}
  ]
}
```

**Acceptance:** two tenants, same city, disjoint interests → guides share base sections byte-for-byte (cache hit) and differ in lens sections; a "running" tenant sees a routes section that simply does not exist for the "jazz" tenant. That's reshape, demonstrated.

---

## 3. Route layer + distance-from-me sort

**Honesty first: don't fake geometry.** Grounded LLM calls cannot reliably emit correct polylines; hallucinated linework on a map is worse than none. So:

- **v-next (this week):** routes are `{name, start:{lat,lng}, distance_km, kind: run|drive|walk|photowalk, loop, surface?, notes, citations}` — a *point + length*, which is enough for the actual user need: "nearest good run, how long."
- **Staged:** true geometry via OSM/Overpass (`route=foot/running` relations near start point) rendered as encoded polylines. Only then does "distance to nearest point on route" replace "distance to start."

**Client:** one generic renderer, `renderRoutesSection(items, here)` in index.html:
- `distMeters = haversine(here, item.start)` → sort ascending → render `"400 m away · 8.2 km loop · trail"`.
- Recompute on the existing geolocation watch; re-sort in place.
- **Generic by construction:** any lens emitting `type:"routes"` (running routes, scenic drives, photo walks) flows through the same renderer. No running-specific code path anywhere.

**Byron's profile:** seed `running` weight 1.0 in migrated tenant-zero profile → running lens appears automatically. No special-casing the owner.

**Acceptance:** standing at a known lat/lng in a cached city, routes section is sorted strictly ascending by haversine-to-start; moving the mock position re-sorts without reload.

---

## 4. Invite / send-to-anyone

Two artifacts, one endpoint family. **This is sharing, not social** — no accounts of the sender visible, no feed, no reciprocity.

### Artifact A — Guide Snapshot ("here's Lisbon")
- `POST /api/invite {mode:"snapshot", city:"lisbon"}` (Bearer) → writes `snapshots/{snapId}.json`: base sections + sender's *lens sections* + citations. **Stripper is mandatory and tested:** no People data, no `foryou`, no tenant name, no interests list (lens content is fine — it's shared-cache material by definition). Returns `https://city.arnao.ai/s/{snapId}`.
- Recipient hits `/s/{snapId}`: read-only render in the same PWA (route on `location.pathname` in index.html), citations intact, plus one CTA: **"Build your own for any city →"** which mints Artifact B.

### Artifact B — Build-Your-Own Invite
- `POST /api/invite {mode:"invite"}` → `invites/{inviteHash}.json = {createdAt, invitedBy, uses:1}` → `https://city.arnao.ai/i/{inviteToken}`.
- Recipient onboarding (3 screens, in the PWA):
  1. **Name** (display only, no email, no password).
  2. **Interests** — free-text keywords, chips as they type; `api/interests` resolves live and shows what each keyword *will do* ("running → routes near you"). This preview is the onboarding's whole personality.
  3. **Location** — geolocate (the one-tap arrival moment) or type a city.
- Accept → `POST /api/tenant {inviteToken, name, keywords}` → burns invite, mints tenant token, returns it once; client stores in localStorage → straight into their first generated guide. Warm-cache city: <3s to lensed guide. Cold city: existing skeleton loading states.
- **Lost token = lost tenant.** Recovery = Byron sends a new invite. Documented, accepted; see adjudication.

**Guardrails:** invites single-use; tenant cap via env `MAX_TENANTS` (start 25); per-tenant `generationsToday` cap 10 (checked in guide.js before any cold generation).

**Acceptance:** end-to-end on a phone that has never seen the app: tap invite link → 3 screens → personalized lensed guide, with zero data from Byron's tenant present (isolation test extended to cover snapshot stripping).

---

## 5. People-in-city — adjudication + tenant model

**Honest adjudication of Byron's re-proposal:** the area-code roster is *already built* (63 contacts, exact|city|country confidence). It was worth building once; it is not worth strengthening. Its errors are structural, not tunable: country codes rarely resolve to cities, +1 resolves to a continent, and mobile portability plus expat number-keeping mean the mapping decays with exactly the people most likely to be interesting abroad. Byron said he's "not sure I have a great idea" — correct instinct. **Call: freeze the phone-book signal as the fallback tier. Redirect all People effort to (i) manual override + freeform import this week, (ii) the already-planned Gmail/LinkedIn/WhatsApp fusion staged.** Fusion is strictly higher-signal; a Gmail "let's meet when you're in London" beats any prefix heuristic forever. Also be honest about LinkedIn: the official connections export **does not include location** — LinkedIn feeds `rel`/`ctx`, not `city`. Don't promise otherwise.

### This week, buildable
1. **Manual override (highest ROI in the whole People area):** tap a person in the roster → set city → `{confidence:"exact", source:"manual"}`. Ten minutes of Byron tapping beats any inference pass.
2. **Freeform import for any tenant** — `POST /api/people {action:"import", text:"Neeraj — London, ex-colleague\nAna, Lisbon, uni friend..."}`. One LLM parse call → per-person:
```json
{"name":"Neeraj Kumar","city":"London","cc":"GB","confidence":"exact",
 "rel":"ex-colleague","ctx":"AWS 2019-22","lastContact":null,"whyNow":null,
 "source":"manual-import"}
```
   Written to `tenants/{hash}/people.json`. Same shape the planned fusion pass emits — fusion later *upgrades* records (fills `lastContact`, `whyNow`), it doesn't replace the schema.
3. **`api/people.js` goes tenant-scoped:** Bearer required; loads only the caller's `people.json`; Byron's 63-contact roster migrates into tenant-zero's file. The city-match query stays as-is.

### Privacy boundary (non-negotiable, tested)
- People data lives only in `tenants/{hash}/people.json`. Never in `city/*`, never in `interests/canon.json`, never in `snapshots/*`, never in any lens prompt. The isolation canary person (§1) is the enforcement.
- The parse LLM call receives one tenant's text only — assembly-time isolation, same rule, no exceptions for "it's just a parse."
- Import screen carries explicit consent copy: "These names are stored privately for you and are never shared or used to generate anyone else's guide."

### Staged (in priority order)
1. Gmail mining for tenant-zero (owner-consented, richest signal).
2. LLM fusion pass producing `whyNow`/`lastContact` (the planned Gen-2 item — unchanged).
3. WhatsApp via Zia agent.
4. LinkedIn CSV as rel/ctx enrichment only.

---

## 6. Non-goals reconciliation

| Gen-2 non-goal | Gen-3 status |
|---|---|
| User accounts | **Partially flips.** Becomes "tenant tokens" — capability URLs, hashed at rest, no passwords/email/OAuth/recovery. That is the minimal honest version of accounts for a solo builder, and it's the harness owner-auth pattern already trusted in Mentor. |
| Social features | **Holds.** Snapshot share is one-way, static, anonymous-sender. No follows, comments, presence, or reciprocity. |
| Cities CMS | **Holds, reinforced.** Lenses are generated + Blob-cached like everything else. The canon map is a cache, not a CMS. |

---

## 7. Endpoint summary
| Endpoint | Method | Auth | Change |
|---|---|---|---|
| `api/guide.js` | GET | Bearer | v4: tenant resolve + lens compose |
| `api/interests.js` | POST | Bearer | NEW: keyword→canonical resolver |
| `api/tenant.js` | POST/GET | invite token / Bearer | NEW: mint + profile read/update |
| `api/invite.js` | POST | Bearer (owner or tenant) | NEW: snapshot + invite mint |
| `api/people.js` | GET/POST | Bearer | tenant-scoped + import action |
| `api/tonight.js` `api/foryou.js` `api/souvenir.js` | — | Bearer added | foryou reads tenant profile; tonight cache stays shared |

---

## (a) THE TENSION — accounts now, or tokens?

**Definitive call: tokens. No accounts, no auth system, this generation.** Shareable per-tenant capability tokens (hashed at rest, localStorage on client), reusing the harness owner-auth pattern verbatim — because Mentor already forces you to prove tenancy safety at the *context-assembly* layer with a falsifiable A/B gate, and that is where City's real risk lives too. Passwords would add recovery flows, email infrastructure, and PII liability while doing **nothing** for the actual threat model (cross-tenant leakage), which the isolation gate covers better. The honest costs — lost token = re-invite, link forwarding = shared profile — are acceptable at ≤25 invited tenants and are documented, not hidden. Revisit only if City acquires strangers rather than invitees; the migration path (attach OAuth to an existing tokenHash) is clean and loses nothing by waiting.

## (b) BUILD ORDER for 4.8
1. **v-next** — `api/tenant.js`: token mint/hash/resolve; `OWNER_TOKEN_HASH` env; Bearer middleware shared by all handlers.
2. **v-next** — Tenant-zero migration: legacy localStorage profile + 63-contact roster → `tenants/{ownerHash}/`.
3. **v-next** — `tests/isolation.test.js` with canary tenants (§1). **Wire into deploy gate before building features 4–9.**
4. **v-next** — `api/interests.js` + `interests/canon.json` (resolver, dedupe-fold, weights).
5. **v-next** — `api/guide.js` v4: lens fetch/generate/compose, citation validation on lenses, 4-lens cap, per-tenant generation cap.
6. **v-next** — Client: `renderRoutesSection` with haversine sort + re-sort on position change; render lens sections generically by `type`.
7. **v-next** — `api/invite.js` + `/i/:token` onboarding (3 screens) + `/s/:snapId` read-only snapshot with tested PII stripper; extend isolation test to snapshots.
8. **v-next** — `api/people.js`: tenant scoping + manual override + freeform import parse.
9. **v-next** — Byron's own run: seed `running` interest, verify sorted routes in a real city. This is the acceptance demo.
10. **Staged** — Overpass/OSM polyline geometry for routes; distance-to-nearest-point sort.
11. **Staged** — Gmail mining (owner) → fusion pass emitting `whyNow`/`lastContact` → WhatsApp/Zia → LinkedIn rel/ctx enrichment.
12. **Staged** — index.html decomposition (it survives this week; it won't survive Gen-4).