# trails.cool - Architecture Plan

## Vision

A federated, self-hostable platform for collaborative route planning and social
activity sharing for outdoor enthusiasts. Two independent but integrated products:

1. **Planner** - Stateless collaborative route editor (like Etherpad for routes)
2. **Journal** - Federated social platform for routes and activities (like Mastodon for trails)

## Project Philosophy

These principles inform key architectural and product decisions:

- **Privacy by design**: The Planner collects no personal data and is minimal
  in storage. This is out of respect for BRouter and bikerouter.de which
  inspire this project. The Journal is equally mindful — all data collection
  is documented in a user-visible, always-up-to-date privacy manifest.
- **Data ownership**: Users own their data. The Journal provides easy export
  of all tracks and activities, migration to self-hosted instances, and clear
  documentation of the data format for each entity so users can build custom
  visualizations and tools around their data.
- **Open source (MIT)**: The code is MIT licensed — both because of the
  commitment to the open web, and out of respect for the open-source projects
  that inspire trails.cool (BRouter, bikerouter.de, brouter-web).
- **AI-assisted development**: AI (Claude Code) and spec-driven development
  (OpenSpec) are core to how this project is built. Human contributions are
  very welcome. This is also an experiment in how far AI-assisted development
  can bring such a project.
- **Internationalization from day one**: Use react-i18next for all user-facing
  strings. Start with English + German, community can contribute more.

### Instance Administration

The Journal supports instance administration:
- Open/close user registration
- User management (suspend/ban users)
- Federation management (block instances)
- Instance-level settings (name, description, rules)
- Moderation tools for reported content

Domain: trails.cool

## Product Separation

### Planner (planner.trails.cool)

- **Stateless**: No user accounts, no persistent user data storage
- **Privacy-first**: Planner never collects or stores user data — reflects the
  privacy philosophy of the BRouter ecosystem. No tracking, no analytics on
  user routes. Sessions are anonymous by default.
- **Collaborative**: Real-time editing via Yjs CRDTs
- **Session-based**: Shareable links, guests can join without accounts
- **BRouter-powered**: Server-side routing with OSM data
- **Ephemeral**: Sessions expire after configurable period (default 7 days)
- **Invocable**: Journal passes GPX + callback URL to start a session

Data model (Yjs CRDT document):
```typescript
{
  waypoints: Y.Array<{lat, lon, name, isDayBreak?: boolean}>,
  profile: Y.Map<string, any>,
  noGoAreas: Y.Array<Polygon>,
  notes: Y.Text,
  settings: Y.Map<string, any>
}
// Day breaks: waypoints marked with isDayBreak=true split the route into
// days/stages. Reordering waypoints doesn't break day assignments —
// days are derived from the sequence of day-break markers.
// The entire route is planned as one continuous route. Day breaks are
// just visual/logical split points for multi-day display.
```

Tech stack:
- Frontend: TypeScript + React + Tailwind + React Router 7 + Leaflet + OSM
- CRDT sync: Yjs + y-websocket
- Routing: BRouter (Java) wrapped as HTTP API
- Map data: RD5 segments (~750 MB for Germany, ~3 GB for Europe)

### Journal (trails.cool / self-hostable)

- **Stateful**: User accounts, persistent storage, media
- **Federated**: ActivityPub via Fedify (fedify.dev)
- **Self-hostable**: Docker Compose deployment
- **Mastodon-compatible**: Activities visible in Mastodon, likes/comments federate back

Features:
- Route CRUD (create, read, update, delete)
- Activity import (from Garmin, Strava, Wahoo via GPX/FIT upload)
- Activity export (to Garmin, Strava, Wahoo — later phase)
- Social: Following, likes, comments
- Photo sharing (as part of activities only, not standalone)
- Multi-day routes (bikepacking trips)
- Route versioning (sequential, with Yjs-based conflict resolution)
- GPX import/export
- Routes as blueprints: one route can have many linked activities

Tech stack:
- Frontend: TypeScript + React + Tailwind + React Router 7 + Leaflet + OSM
- Backend: React Router 7 (Remix stack) + Fedify for ActivityPub
- Database: PostgreSQL + PostGIS (spatial queries for route discovery)
- Media: S3-compatible (Garage for self-hosters — https://garagehq.deuxfleurs.fr/)
- Auth: Federated identity (@user@instance.com)

## Architecture Diagram

```
+-----------------------------------------------------+
| trails.cool (Flagship Instance)                      |
|                                                      |
|  +------------------+      +-------------------+     |
|  |  Planner         |      |  Journal     |     |
|  |  (Stateless)     |<---->|  (Stateful)       |     |
|  |                  |      |                    |     |
|  |  - Yjs sync      |      |  - PostgreSQL     |     |
|  |  - BRouter API   |      |  - S3 media       |     |
|  |  - Ephemeral     |      |  - ActivityPub    |     |
|  +------------------+      +-------------------+     |
|         ^                           ^                |
|         |                           |                |
|         +----------+----------------+                |
|                    | Auth/Identity                    |
|                    | (for initiated sessions)         |
+--------------------+---------------------------------+
                     |
                     | ActivityPub Federation
                     |
+--------------------v---------------------------------+
| bob.trails.xyz (Self-hosted)                         |
|                                                      |
|  +------------------+      +-------------------+     |
|  |  (No Planner)    |      |  Journal     |     |
|  |  Uses            |<---->|  (Same codebase)  |     |
|  |  trails.cool     |      |                    |     |
|  |  planner         |      |  - PostgreSQL +   |     |
|  +------------------+      |    PostGIS         |     |
|                            |  - Garage (S3)     |     |
|                            +-------------------+     |
+------------------------------------------------------+
```

## Data Flow: Collaborative Route Editing

### Scenario 1: Alice creates a new route

1. Alice opens Journal, clicks "New Route"
2. Journal creates route record, opens Planner with callback URL
3. Planner creates Yjs session, Alice edits waypoints
4. BRouter computes route from waypoints
5. Alice clicks "Save" -> Planner sends GPX to callback URL
6. Journal stores GPX as route v1

### Scenario 2: Alice invites Bob to collaborate

1. Alice shares Planner session link with Bob
2. Bob joins session (no account needed for planning)
3. Both edit waypoints in real-time via Yjs
4. One user acts as "routing host" (talks to BRouter API)
5. Route updates are broadcast to all session participants
6. When done, GPX is saved back to Alice's Journal instance

### Scenario 3: Bob (self-hosted) edits Alice's shared route

1. Bob's Journal receives Alice's shared route via ActivityPub
2. Bob clicks "Edit" -> Bob's instance opens trails.cool Planner
3. Planner loads latest GPX from Alice's instance
4. Bob edits, saves -> New version stored on Alice's instance
5. Update federates via ActivityPub

### Scenario 4: Cross-instance collaboration

1. Alice (trails.cool) starts planning session from her route
2. Federation notifies Bob (bob.trails.xyz) that a shared session is open
3. Bob joins Planner session via link
4. Both edit in real-time
5. Save stores GPX back to Alice's instance (route owner)
6. Bob's instance caches updated route via ActivityPub

## ActivityPub Integration

### Federated Activities

- `Create` Route - Publishing a new route
- `Update` Route - Updating an existing route
- `Create` Activity - Completed ride/hike with GPS trace
- `Like` Activity - Liking someone's activity
- `Create` Note (on Activity) - Commenting
- `Follow` / `Accept` - Following other users

### Mastodon Compatibility

Completed activities appear as posts with:
- Text description ✅ shipped (`social-federation`: HTML content + stats
  + PropertyValue metadata, dereferenceable Note objects)
- Map preview image (auto-generated) — captured in
  `docs/ideas/fediverse-enhancements.md`
- Link to full view on trails.cool (or the self hosted instance) ✅ shipped
- Photo attachments — with `activity-photos`
- GPX as attachment — needs a public GPX endpoint for activities first
  (see `fediverse-enhancements.md`)

Mastodon users can:
- See activities in their timeline ✅ shipped (verified live 2026-06-07)
- Like activities (federates back) — captured as "fediverse kudos" in
  `fediverse-enhancements.md`; v1's narrow inbox drops Like/Announce
  by design
- Comment on activities (federates back) — captured in
  `fediverse-enhancements.md`; gated on moderation tooling
  (`docs/ideas/instance-administration.md`)

Route-specific federation (collaboration invites, version updates) uses
custom ActivityPub extensions not visible in Mastodon — spec'd as
`openspec/changes/route-federation/`.

Interop lessons from the first live soak (2026-06-06/07, recorded in
`openspec/changes/social-federation/design.md`): Mastodon requires
`attachment` arrays (bare objects are silently ignored), records
received `Delete`s as permanent tombstones, gives inbox deliveries a
10-second timeout, and never backfills outbox history — every new
outgoing shape must be verified against a real instance, not the spec.

## Route Sharing & Permissions

### Visibility Levels

- **Private**: Only owner can see the route
- **Public**: Anyone can view the route (readonly)
- **Shared**: Specific invited users can view the route

### Permission Matrix

| Action | Owner | Shared (view) | Shared (edit) | Public | Guest (via link) |
|--------|-------|---------------|---------------|--------|-----------------|
| View route | yes | yes | yes | yes | no |
| Export GPX | yes | yes | yes | yes | no |
| Start edit session | yes | no | yes | no | no |
| Join edit session | yes | no | yes | no | yes* |
| Delete route | yes | no | no | no | no |
| Change permissions | yes | no | no | no | no |
| Fork route (copy) | yes | yes | yes | yes | no |

Notes:
- "Shared (edit)" users are explicitly granted edit rights by the owner
- There is no "public editable" — edit access requires explicit invitation
- *Owners can always share a direct link to an active edit session that
  anyone (including guests) can join — this bypasses the permission matrix
  for joining that specific session only
- Guests (no account) can join Planner sessions via link, but cannot
  save routes to a Journal instance
- Forking creates an independent copy on the user's own instance

## Activity Sharing & Participants

When multiple people do the same activity together (e.g., a group ride),
they can tag each other as participants:

- Activity creator can **tag other users** as participants
- Tagged users receive a notification and can confirm/decline
- Confirmed participants see the activity linked in their own profile
- Each participant can attach their own GPS trace and photos to the
  shared activity (their recording may differ slightly)
- ActivityPub: Participant tagging federates across instances
  (Alice on trails.cool tags Bob on bob.trails.xyz)
- Mastodon: Appears as mentions (`"Rode with @bob@bob.trails.xyz"`)

This is similar to how photos work on social media — you can be tagged
in someone else's activity, and it shows on your profile too.

## Multi-Day Route Support

### Route Planning (Planner)

Routes are planned as **one continuous route**. Day splits are marked by
flagging specific waypoints as day-break points:

- The entire route is planned end-to-end in a single Planner session
- Specific waypoints are marked as `isDayBreak: true` (e.g., overnight stops)
- Days are derived from the sequence of day-break markers
- Reordering waypoints automatically recalculates day assignments
- Each day/stage gets its own distance and elevation stats
- GPX export uses track segments per day

### Activities (Journal)

Multi-day trips are modeled as an **Activity Collection**:

- A multi-day activity is a collection linking individual day-activities
- Each day-activity has its own GPS trace, photos, and description
- The collection references the planned route as a whole
- This allows tracking a 5-day bikepacking trip as one entity while
  recording each day separately (possibly from different devices/apps)
- Individual day-activities can be imported from Garmin/Strava per day

Note: Exact data model for collections TBD — needs more design work.

## Import/Export

### Import Sources
- GPX files (manual upload)
- FIT files (Garmin devices)
- External tools (bikerouter.de, Komoot, etc.)
- Activity platforms (Strava, Garmin Connect, Wahoo) - OAuth or file upload

### Export Formats
- GPX (primary)
- GeoJSON
- KML

## Self-Hosting

### Minimal Docker Compose (Journal only)

```yaml
services:
  journal:
    image: ghcr.io/trails-cool/journal:latest
    ports: ["3000:3000"]
    environment:
      DOMAIN: bob.trails.xyz
      PLANNER_URL: https://planner.trails.cool
      DATABASE_URL: postgres://trails:trails@postgres/trails
      S3_ENDPOINT: http://garage:3900
      S3_BUCKET: trails-media
    depends_on: [postgres, garage]

  postgres:
    image: postgis/postgis:16-3.4
    volumes: ["pgdata:/var/lib/postgresql/data"]

  garage:
    image: dxflrs/garage:v1.0
    volumes: ["media:/var/lib/garage"]

  caddy:
    image: caddy:2
    ports: ["80:80", "443:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
    depends_on: [journal]

volumes:
  pgdata:
  media:
  caddy_data:
```

Caddy is the reverse proxy for all instances. It provides automatic HTTPS
via Let's Encrypt with zero configuration beyond the Caddyfile:

```
# Caddyfile (self-hosted example)
bob.trails.xyz {
    reverse_proxy journal:3000
}
```

### With Self-Hosted Planner (Advanced)

```yaml
services:
  # ... above services plus:

  planner:
    image: ghcr.io/trails-cool/planner:latest
    ports: ["3001:3001"]
    environment:
      BROUTER_URL: http://brouter:17777
    depends_on: [brouter]

  brouter:
    image: ghcr.io/trails-cool/brouter:latest
    volumes:
      - segments:/data/segments
    # Segments can be pulled from:
    # - https://brouter.de/brouter/segments4/ (official, weekly updates)
    # - A trails.cool CDN mirror (later)
    ports: ["17777:17777"]

volumes:
  segments: # Download RD5 files here (~750 MB Germany, ~3 GB Europe)
```

## Infrastructure (trails.cool flagship)

### Hosting: Hetzner (Cloud + Robot)

trails.cool runs on two hosts in the same Falkenstein datacenter,
bridged via a Hetzner vSwitch (VLAN 4000) to a private network:

- **Flagship** — Hetzner Cloud cx23 (2 vCPU, 4 GB RAM, 40 GB SSD).
  Runs Journal, Planner, Postgres+PostGIS, Caddy, Prometheus, Loki,
  Grafana, and exporters. vSwitch IP `10.0.0.2`.
- **BRouter host** — Hetzner Dedicated (operator-owned shared box,
  currently `ullrich.is`; 3 TB RAID, 32 GB RAM). Runs only BRouter +
  a Caddy auth sidecar + scoped cAdvisor/Promtail sidecars in a
  `~trails/brouter/` compose project under a non-root `trails` user.
  vSwitch IP `10.0.1.10`. BRouter covers the full planet
  (~10 GB RD5 tiles) with an 8 GB JVM heap.

Planner → BRouter traffic crosses the vSwitch; a shared-secret
`X-BRouter-Auth` header enforced by the Caddy sidecar prevents any
other process on the dedicated host from reaching BRouter even if
they share the private network.

BRouter container metrics and logs are scraped/shipped from the
dedicated host to the flagship's Prometheus and Loki over the same
vSwitch. Filtering (cAdvisor `--whitelisted_container_labels`, Promtail
relabel-drop) keeps trails.cool observability scoped to trails
containers only — none of the operator's other workloads on the
shared host are ingested.

- Storage Box: 1 TB for backups - ~3.20 EUR/month
- Infrastructure as Code: Terraform (Hetzner Cloud provider); Hetzner
  Robot side (dedicated server) is operator-managed
- Docker Compose for runtime orchestration on both hosts
- CI/CD: GitHub Actions — three workflows (`cd-apps`, `cd-infra`,
  `cd-brouter`) with different SSH targets and deploy users
- Monitoring: Grafana + Prometheus + Loki on flagship, scraping both
  hosts
- Error tracking: Sentry

### Services

```
planner.trails.cool    -> Planner frontend + Yjs sync + BRouter
trails.cool            -> Journal frontend + API + ActivityPub endpoints
cdn.trails.cool        -> Media + map tiles (optional, future)
```

ActivityPub lives on the main domain per Resolved Decision #18 (single
domain per instance) — there is no `api.trails.cool`. Staging and PR
previews additionally live under `*.staging.trails.cool` (see
CLAUDE.md "Staging & Previews").

### Estimated Costs (100 users)

- Hetzner cx23: ~6 EUR/month
- Storage: 3.20 EUR/month
- Domain: ~10 EUR/year
- Backups: ~2 EUR/month
- Total: ~12 EUR/month

## GitHub Repository Structure

GitHub organization: **github.com/trails-cool**

### Monorepo vs Multi-Repo

**Recommendation: Monorepo** (single repo for planner + Journal)

Pros:
- **Shared components**: React component library, TypeScript types, and utilities
  shared between Planner and Journal without publishing npm packages
- **Atomic changes**: A change to the Route interface updates both apps in one PR
- **Simpler CI/CD**: One pipeline builds and deploys both apps
- **Claude Code / OpenSpec**: Specs and AI-assisted development work best when
  the full context is in one repo
- **Tooling**: Turborepo/Nx handles monorepo builds, caching, and dependency graph

Cons:
- Larger repo size (mitigated by sparse checkouts)
- Self-hosters who only want Journal need to build from monorepo
  (mitigated by publishing Docker images)

### Proposed Structure

```
github.com/trails-cool/trails
  apps/
    planner/            - Planner app (React Router 7)
    journal/            - Journal app (React Router 7 + Fedify)
  packages/
    ui/                 - Shared React components (map, buttons, layout)
    types/              - Shared TypeScript types (Route, Activity, etc.)
    map/                - Map rendering utilities (Leaflet wrappers, tile layers)
    gpx/                - GPX parsing, generation, validation
    i18n/               - Shared i18n config + translation strings (react-i18next)
  infrastructure/       - Terraform + Docker Compose
  openspec/             - OpenSpec specifications + changes
  docker/
    brouter/            - BRouter Docker image + segment management
  docs/                 - Documentation
```

Tooling: **Turborepo** for monorepo management, **pnpm** workspaces.

OpenSpec specs live in the `openspec/` directory, feeding into both apps.
This keeps specifications close to implementation and allows Claude Code
to reference specs when working on either app.

## MVP Phasing

Note: Detailed specifications for each phase will be created using
[OpenSpec](https://openspec.dev/) and stored in the `openspec/` directory
of the monorepo. This architecture plan feeds into OpenSpec as the
high-level context for generating implementation specs.

### Phase 1: Foundation (Weeks 1-8) — ✅ shipped

**Planner MVP**:
- [x] Collaborative waypoint editing (Yjs)
- [x] BRouter integration (route computation)
- [x] Map display (Leaflet + OSM overlays)
- [x] Session sharing (shareable link)
- [x] Profile selection (bike/hike)
- [x] Elevation profile display
- [x] GPX export

**Journal MVP**:
- [x] User accounts (local, no federation)
- [x] Route CRUD
- [x] Start Planner session from route (callback integration)
- [x] GPX import/export
- [x] Basic profile page
- [x] Activity feed (own activities)

### Phase 2: Social & Federation (Months 3-6) — in progress

- [x] ActivityPub federation — `social-federation` (inbound follows +
      activity push delivery live on staging since 2026-06-07;
      trails-to-trails outbound §6/§7 remaining)
- [x] Following/followers (`social-feed`)
- [ ] Likes and comments — `docs/ideas/social-interactions.md` (local)
      + `fediverse-enhancements.md` (federated)
- [x] Activity import (GPX upload, Komoot, Wahoo; Strava/Garmin OAuth
      and FIT *import* still open)
- [ ] Photo attachments on activities — `activity-photos` change drafted
- [x] Mastodon compatibility (follow + timeline posts verified live)
- [ ] Route sharing permissions — `route-sharing` change drafted
- [x] Route versioning
- [ ] Route federation (mirroring, cross-instance edits) —
      `route-federation` change drafted

### Phase 3: Scale & Mobile (Months 6-12)

- [ ] Mobile app — `mobile-app` change in progress
- [ ] Offline route editing (WASM + cached segments) — not yet captured
      beyond this line
- [ ] Multi-day route planning — routes ✅ shipped; activity collections:
      `docs/ideas/multi-day-collections.md`
- [ ] Route recommendations — distinct from `route-discovery` (spatial
      explore); not yet captured beyond this line
- [ ] Clubs/groups — not yet captured beyond this line
- [ ] CDN for map segments (mobile offline) — not yet captured beyond
      this line

## Resolved Decisions

### 1. Route Versioning: Sequential + Yjs Conflict Resolution

Sequential version numbers (v1, v2, v3). When two users edit the same route
concurrently via separate Planner sessions, conflicts are resolved using Yjs
CRDT merge semantics:

- The Planner session stores the full Yjs document state (waypoints, settings)
- When saving back to the Journal, the GPX + Yjs state vector are stored
- If a second session started from an older version, the Yjs state vectors
  can be merged automatically (CRDTs are conflict-free by design)
- The merged result becomes the next sequential version
- Edge case: If edits are made via raw GPX upload (not through Planner),
  no Yjs state exists — last-write-wins with the previous version archived

### 2. Route Mirroring: Accept-Based Collaboration Mirroring

Collaborator instances mirror routes they've been invited to:

1. Alice shares route with Bob → ActivityPub `Invite` activity
2. Bob's instance receives invite → shows in Bob's UI
3. Bob accepts → ActivityPub `Accept` activity
4. Alice's instance adds Bob as collaborator → sends current GPX + metadata
5. Bob's instance stores a mirror copy (read cache)
6. On route `Update`, Alice's instance sends updated GPX to all collaborators
7. If Alice's instance is down, Bob still has his cached copy

Canonical source is always the owner's instance. Mirrors are read caches
that stay in sync via ActivityPub `Update` activities.

### 3. Cross-Instance Edits: Edit on Planner, Store to Owner via Callback

When Bob edits Alice's route:

1. Bob's Journal opens Planner with Alice's GPX
2. Planner session callback points to Alice's instance API
3. On save, Planner POSTs new GPX to Alice's instance (with auth token)
4. Alice's instance creates new version, credits Bob as contributor
5. Update federates to all collaborators (including Bob's mirror)

This keeps the owner's instance as single source of truth. Bob never stores
a "draft" on his own instance — edits go directly to the canonical source.

### 4. Planner Session Lifetime

- Sessions are created on demand (from Journal or direct link)
- Session lifetime is **configurable** (default: 7 days, max: 30 days)
- Session state is saved server-side (Yjs document in PostgreSQL)
- Manual save triggers callback to Journal (stores GPX as new version)
- Session owner can **manually close** the session (notifies all participants)
- Abandoned sessions are garbage-collected after expiry
- Browser localStorage keeps a backup of unsaved Yjs state for crash recovery
- Resource usage to be observed and session defaults tuned accordingly

### 5. Frontend Framework: React + Tailwind + React Router 7

- **React** with **Tailwind CSS** for styling
- **React Router 7** (Remix stack) for both Planner and Journal
- Shared component library between Planner and Journal
- Leaflet for map rendering with OSM tiles

### 6. Journal Backend: React Router 7 + Fedify

- **React Router 7** (Remix stack) — full-stack TypeScript framework
  - Server-side rendering for SEO and initial load
  - API routes for ActivityPub endpoints
  - Loader/action pattern for data fetching
- **Fedify** (fedify.dev) for ActivityPub protocol support
  - Handles WebFinger, HTTP Signatures, inbox/outbox
  - TypeScript-native, integrates well with the stack
  - Avoids reimplementing ActivityPub from scratch

### 7. Database: PostgreSQL (with PostGIS)

PostgreSQL for all deployments (flagship and self-hosted). PostGIS is valuable
for trails.cool because:

- **Spatial queries**: "Find routes near me" or "routes in this bounding box"
- **Route geometry storage**: Store route linestrings as PostGIS geometries
- **Distance calculations**: "Routes within 50km of Berlin"
- **Spatial indexing**: Fast lookups for map-based route browsing
- **Overlap detection**: "Routes similar to this one"

For self-hosters, PostGIS is included in the standard `postgis/postgis` Docker
image — no extra setup needed.

Note: SQLite option dropped for simplicity. PostGIS is too valuable, and
PostgreSQL via Docker is easy enough for self-hosters.

### 8. Route Format: GPX + Metadata Envelope

GPX alone is insufficient. Routes are stored as a **metadata envelope** wrapping
the GPX:

```typescript
interface Route {
  id: string;
  name: string;
  description: string;
  gpx: string;                    // Canonical GPX (track + waypoints)
  geometry: PostGIS.LineString;    // Extracted for spatial queries
  metadata: {
    created: Date;
    updated: Date;
    owner: string;                // ActivityPub actor URI
    contributors: string[];       // ActivityPub actor URIs
    routingProfile: string;       // 'trekking', 'mtb', 'car', etc.
    dayBreaks: number[];           // Waypoint indices that are day-break points
    distance: number;             // meters (computed from GPX)
    elevation: {
      gain: number;               // meters
      loss: number;               // meters
    };
    tags: string[];               // user-defined tags
  };
  plannerState?: Uint8Array;      // Yjs document state for conflict resolution
  versions: RouteVersion[];
}

interface RouteVersion {
  version: number;
  gpx: string;
  createdAt: Date;
  createdBy: string;              // ActivityPub actor URI
  changeDescription?: string;
}
```

**Why GPX is not enough**:
- No routing preferences (bike vs car, avoid highways)
- No contributor metadata
- No version history
- No multi-day structure (GPX tracks can have segments, but no day labels)
- No spatial indexing (need PostGIS geometry extracted from GPX)

**GPX remains the interchange format**: Import/export always uses GPX.
The metadata envelope is internal storage only.

When federating via ActivityPub, routes are sent as:
- GPX attachment (for interoperability)
- JSON-LD metadata (for rich display in trails.cool instances)

### 9. Route-to-Activity Relationship: One Route, Many Activities

A Route is a **blueprint**. An Activity is a **completed instance**.

```
Route: "Sunday Black Forest Loop" (60km, 800m elevation)
  ├── Activity: Alice rode it on March 15 (with GPS trace, photos)
  ├── Activity: Alice rode it on March 22 (different weather, faster time)
  └── Activity: Bob rode it on March 20 (imported from Strava)
```

Database model:
```
routes     1 ──── N  activities
  id                   id
  name                 route_id (nullable — activities can exist without a route)
  gpx                  actual_gpx (recorded GPS trace, differs from planned route)
  ...                  started_at
                       duration
                       photos[]
                       description
```

Activities can also exist **without** a route (e.g., imported from Strava with
no pre-planned route). Users can retroactively link an activity to a route,
or create a new route from an activity's GPS trace.

### 10. Routing Host: Session Initiator + Automatic Failover

The "routing host" is the client responsible for sending waypoint changes to
BRouter and broadcasting the computed route to other participants.

- **Initial host**: The user who created/started the Planner session
- **Failover**: If host disconnects, the Yjs awareness protocol detects it
  and the longest-connected remaining client becomes the new host
- **Implementation**: Yjs awareness state includes a `role` field
  ```typescript
  awareness.setLocalStateField('role', 'host' | 'participant');
  // On host disconnect, participants compare join timestamps
  // Lowest timestamp becomes new host
  ```
- **Why not everyone**: Sending every waypoint change from every client to
  BRouter would cause redundant API calls and race conditions
- **Route broadcast**: Host computes route, stores result in a Y.Map field
  that syncs to all participants automatically

### 11. Yjs Persistence: PostgreSQL

Store Yjs documents in PostgreSQL (not LevelDB). This keeps the Planner's
session state in the same database infrastructure, simplifies backups, and
allows querying session metadata (last activity, participant count) for
garbage collection.

The Planner service uses its own PostgreSQL schema (`planner.*`) separate
from the Journal schema (`journal.*`). On the trails.cool flagship,
both schemas live in the same PostgreSQL instance. Self-hosters who don't
run a Planner don't need the planner schema.

### 12. Cross-Instance Auth: Scoped JWT Tokens

When Alice's Journal opens a Planner session for a route:

1. Alice's instance generates a **scoped JWT token** containing:
   - `iss`: Alice's instance URL (`https://trails.cool`)
   - `route_id`: The route being edited
   - `permissions`: `["read", "write"]`
   - `exp`: Token expiry (matches session lifetime)
2. This token is passed to the Planner as part of the callback URL
3. When the Planner saves, it includes the JWT in the callback POST
4. Alice's instance validates the JWT signature and stores the new version

For cross-instance edits (Bob editing Alice's route):
- Bob's instance requests a scoped token from Alice's instance
- Alice's instance verifies Bob has edit rights, issues a token
- This uses **HTTP Signatures** (already part of ActivityPub) for
  instance-to-instance trust, plus JWT for the Planner callback

### 13. Rate Limiting

For the public trails.cool Planner instance:

- **Session creation**: Max 10 sessions per IP per hour
- **BRouter API calls**: Max 60 route computations per session per hour
  (debouncing on the client should keep this well under limit)
- **Concurrent sessions**: Max 50 active sessions per IP
- **Abuse detection**: Flag sessions with unusual patterns
  (automated bulk routing, scraping)
- **Implementation**: Rate limiting middleware in React Router 7,
  backed by Redis or in-memory store (for single-server setup)

Self-hosted Planner instances can configure their own limits.

### 14. Map Tiles & Overlays

Base layers:
- **OpenStreetMap** (default)
- **OpenTopoMap** (topographic — great for hiking)
- **CyclOSM** (cycling-focused — great for bike routes)

Overlays (toggleable):
- **OpenCampingMap** (campsites, shelters — essential for bikepacking)
- **POI overlay**: Water points, shelters, bike repair stations
  (sourced from OSM Overpass API or pre-cached)
- **Waymarked Trails** (hiking/cycling trail networks)

Implementation: Leaflet layer switcher with tile URLs. No API keys needed
for OSM-based tiles (but respect usage policies and consider setting up
a tile cache/proxy for the flagship instance).

### 15. Monitoring & Observability

For trails.cool flagship instance only (not required for self-hosters):

- **Metrics**: Prometheus (scrape Node.js and PostgreSQL exporters)
- **Dashboards**: Grafana (route computation latency, active sessions,
  federation delivery, PostgreSQL performance)
- **Logging**: Loki (structured JSON logs from all services)
- **Alerting**: Grafana Alertmanager (disk space, memory, error rate)
- **Error tracking**: Sentry (frontend + backend exceptions)

Stack: Grafana + Prometheus + Loki (the "GPL stack"), self-hosted on
the same Hetzner server or a separate small instance.

PostgreSQL monitoring is especially important:
- Query performance (slow queries, index usage)
- PostGIS spatial query latency
- Connection pool utilization
- Replication lag (if we scale later)

### 16. Federation Delivery & Retry

When Alice sends an update and Bob's instance is down:

- ActivityPub uses **HTTP POST** to deliver activities to inboxes
- If delivery fails (timeout, 5xx), Fedify implements **automatic retry**
  with exponential backoff:
  - Retry after 1 min, 5 min, 30 min, 2 hours, 12 hours, 24 hours
  - Give up after 72 hours of failures
- This is standard ActivityPub behavior (same as Mastodon)
- Bob's instance receives the update when it comes back online
- If the update is lost (instance down > 72 hours), Bob's mirror becomes
  stale — a periodic "sync check" can detect and heal this

### 17. Planner Session Management (Updated)

- Session lifetime: **Configurable** (default: 7 days, max: 30 days)
  - Start conservative — observe resource usage and tune
  - Yjs document size in PostgreSQL is small (~10-100 KB per session)
  - Main cost is WebSocket connections for active sessions
- **Manual close**: Session owner (initiator) can close the session
  - Closing notifies all connected participants
  - Triggers auto-save callback if Journal session
  - Closed sessions become read-only briefly, then deleted
- **Garbage collection**: Cron job removes expired sessions

### 18. Domain: Single Domain per Instance

Each instance uses a single domain. No split between web app URL and
ActivityPub handle domain. Self-hosters set `DOMAIN=bob.trails.xyz` and
both the web UI and user handles (`@user@bob.trails.xyz`) use that domain.

Simpler to set up, simpler to reason about, avoids WebFinger complexity.

### 19. Route Permissions: View + Edit (Simple)

Two permission levels are sufficient:
- **View**: Can see the route and export GPX
- **Edit**: Can start/join edit sessions and create new versions

No finer-grained permissions (e.g., "edit waypoints but not profile").
Keep it simple. Can revisit if users request it.

## Remaining Open Questions

1. **Multi-day activity collections**: Exact data model for linking
   day-activities into a multi-day trip collection — exploration started
   in `docs/ideas/multi-day-collections.md`
2. ~~**brouter-web dependencies**: Review https://github.com/nrenner/brouter-web
   for proven library choices~~ — resolved in practice: the Planner
   shipped on Leaflet + its own elevation/coloring implementations
   (see `road-type-coloring`, `elevation-map-interaction` specs)

## Where the rest of this vision is tracked

Everything in this document that isn't shipped lives in one of:

- `openspec/changes/` — drafted, buildable changes (`route-federation`
  for Decisions #2/#3/#12/#16 and Scenarios 3–4; `route-sharing`,
  `activity-photos`, `route-discovery`, `mobile-app`, …)
- `docs/ideas/` — pre-spec explorations (`instance-administration`,
  `social-interactions`, `activity-participants`,
  `multi-day-collections`, `fediverse-enhancements`, …)
- `docs/roadmap.md` — what ships when, and why
