docs: capture remaining architecture vision; draft route-federation change

Gap analysis of docs/architecture.md against openspec/ and docs/ideas/
found the envisioned-but-uncaptured remainder. This commit captures it:

New OpenSpec change (validated):
- route-federation — the collaboration half of the federation vision:
  routes as dereferenceable trails:Route objects, Create/Update
  fan-out, Invite/Accept collaboration mirroring (arch decision #2),
  cross-instance Planner edits via HTTP-Signature-requested scoped
  tokens (decisions #3/#12), mirror sync healing (#16). Depends on
  social-federation §6 + route-sharing; carries the 2026-06-07 soak
  lessons as design constraints.

New docs/ideas/ explorations:
- instance-administration — registration toggle, suspend/ban,
  federation blocklists, reports (moderation now gates the federated
  comments idea)
- social-interactions — local likes + comments (don't exist even
  locally; the foundation federated kudos/comments attach to)
- activity-participants — group tagging with confirm/decline +
  federated mentions (the participants jsonb column is an untyped stub)
- multi-day-collections — architecture open question #1, directions
  evaluated

architecture.md cleanup:
- Mastodon-compat section annotated with shipped/captured state + the
  live-soak interop lessons (attachment arrays, tombstones, 10s
  timeout, no backfill)
- api.trails.cool removed (contradicted resolved decision #18)
- Phase 1 ticked (shipped); Phase 2/3 items annotated with where each
  is tracked; specs/ → openspec/, activity.* → journal.*, cx21 → cx23
- brouter-web open question marked resolved-in-practice; new section
  pointing to where the unshipped vision is tracked

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-07 10:31:50 +02:00
parent a26d59c804
commit bf9787e56a
11 changed files with 580 additions and 50 deletions

View file

@ -190,19 +190,34 @@ Tech stack:
### Mastodon Compatibility
Completed activities appear as posts with:
- Text description
- Map preview image (auto-generated)
- Link to full view on trails.cool (or the self hosted instance)
- Photo attachments
- GPX as attachment
- 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
- Like activities (federates back)
- Comment on activities (federates back)
- 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.
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
@ -409,14 +424,18 @@ shared host are ingested.
```
planner.trails.cool -> Planner frontend + Yjs sync + BRouter
trails.cool -> Journal frontend + API
api.trails.cool -> ActivityPub endpoints
cdn.trails.cool -> Media + map tiles (optional)
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 CX21: 5 EUR/month
- Hetzner cx23: ~6 EUR/month
- Storage: 3.20 EUR/month
- Domain: ~10 EUR/year
- Backups: ~2 EUR/month
@ -458,7 +477,7 @@ github.com/trails-cool/trails
gpx/ - GPX parsing, generation, validation
i18n/ - Shared i18n config + translation strings (react-i18next)
infrastructure/ - Terraform + Docker Compose
specs/ - OpenSpec specifications
openspec/ - OpenSpec specifications + changes
docker/
brouter/ - BRouter Docker image + segment management
docs/ - Documentation
@ -466,55 +485,65 @@ github.com/trails-cool/trails
Tooling: **Turborepo** for monorepo management, **pnpm** workspaces.
OpenSpec specs live in `specs/` directory, feeding into both apps.
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 `specs/` directory
[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)
### Phase 1: Foundation (Weeks 1-8) — ✅ shipped
**Planner MVP**:
- [ ] Collaborative waypoint editing (Yjs)
- [ ] BRouter integration (route computation)
- [ ] Map display (Leaflet + OSM overlays)
- [ ] Session sharing (shareable link)
- [ ] Profile selection (bike/hike)
- [ ] Elevation profile display
- [ ] GPX export
- [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**:
- [ ] User accounts (local, no federation)
- [ ] Route CRUD
- [ ] Start Planner session from route (callback integration)
- [ ] GPX import/export
- [ ] Basic profile page
- [ ] Activity feed (own activities)
- [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)
### Phase 2: Social & Federation (Months 3-6) — in progress
- [ ] ActivityPub federation
- [ ] Following/followers
- [ ] Likes and comments
- [ ] Activity import (Strava/Garmin GPX/FIT upload)
- [ ] Photo attachments on activities
- [ ] Mastodon compatibility
- [ ] Route sharing permissions
- [ ] Route versioning
- [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 (Capacitor or native)
- [ ] Offline route editing (WASM + cached segments)
- [ ] Multi-day route planning
- [ ] Route recommendations
- [ ] Clubs/groups
- [ ] CDN for map segments (mobile offline)
- [ ] 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
@ -713,7 +742,7 @@ 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 (`activity.*`). On the trails.cool flagship,
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.
@ -833,7 +862,22 @@ 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
2. **brouter-web dependencies**: Review https://github.com/nrenner/brouter-web
for proven library choices (map rendering, elevation charts, etc.)
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 34; `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

View file

@ -0,0 +1,46 @@
# Activity participants (group tagging)
Pre-spec exploration. From `docs/architecture.md` §Activity Sharing &
Participants: when people ride/hike together, the activity creator tags
the others; tagged users confirm or decline; confirmed participants see
the activity on their own profile and can attach their own GPS trace and
photos. "Like photo tagging on social media, but for rides."
The `activities.participants` jsonb column exists as an untyped stub —
no flow, UI, or spec behind it.
## Scope sketch
**v1 (local)**
- Tag local users on your activity (search by username, like the
share dialog planned in `route-sharing`)
- Notification `participant_tagged` → confirm / decline (Requests-tab
pattern from follow requests)
- Confirmed participation renders the activity on the participant's
profile (clearly attributed: "with @alice")
- Participant may attach their own trace + photos to the shared
activity — or link their own existing activity as "same outing"
(the second option composes better with imports: Bob's Garmin
recording is already its own activity)
- Schema: promote from jsonb stub to a real
`journal.activity_participants` table
(`activity_id, user_id | participant_actor_iri, status, own_activity_id?`)
— the exactly-one-of local/remote pattern from `follows` again
**v2 (federated)**
- Tagging across trails instances; Mastodon sees mentions
(`"Rode with @bob@bob.trails.xyz"`) per the architecture
- Confirm/decline crosses instances → needs a small custom activity
vocabulary or reuse of `Invite`/`Accept` (the same pair
route-federation needs — design together)
## Constraints & notes
- Privacy: being tagged must never reveal more than the tagger could
already see; declined/pending tags are visible only to tagger +
taggee. Tagging requires the activity to be visible to the taggee.
- The "link own activity as same outing" model doubles as the natural
seed for multi-day collections' "shared trip" case — see
`multi-day-collections.md`.
- Mention-style federation means participant handles end up in public
Note content — privacy manifest update when v2 lands.

View file

@ -0,0 +1,49 @@
# Instance administration & moderation
Pre-spec exploration. Envisioned in `docs/architecture.md` (§Instance
Administration) since day one, never spec'd. Now that federation is live
on staging — a public inbox accepting traffic from arbitrary instances —
the moderation half has moved from "eventually" to "before the federated
surface grows."
## Scope sketch
**Instance settings**
- Instance name, description, rules page (rendered at `/about`)
- Open/close registration (env var today at best; should be a runtime
admin setting)
- Instance-level contact / operator info (some of this exists for the
legal pages — consolidate)
**User management**
- Admin role (first user? env-designated? explicit grant)
- Suspend / unsuspend users (suspended: no login, content hidden,
federation stops — actor 404s like a private profile)
- Delete user + content (GDPR path; account-management spec covers
self-deletion, not admin-initiated)
**Federation management**
- Per-instance blocklist: refuse inbox traffic, drop follows, stop
deliveries to blocked domains (checked in the inbox rate-limit layer
we already have — `federationSourceHost` is the natural hook)
- Per-instance silence (content hidden from shared surfaces but
follows still work) — maybe later; block first
- Visibility into federation peers: which instances follow us /
we deliver to, volumes, error rates (remote_actors table + Fedify
logs already hold most of this)
**Reports & moderation queue**
- Report content/users (local reports first; ActivityPub `Flag`
federation later)
- Simple queue: open → resolved/dismissed, with action taken
## Constraints & notes
- Mastodon's admin model is the obvious reference; ours can be a tiny
subset (single-admin instances are the norm for self-hosters).
- The federated-comments idea (`fediverse-enhancements.md`) explicitly
depends on at least instance blocks + report handling existing.
- Suspension must compose with federation the same way profile privacy
does today: suspended ⇒ actor 404, push delivery suppressed (the
`social-federation` spec's 9.3 mechanics generalize).
- Admin surfaces are user-facing strings → i18n from the start (en+de).

View file

@ -0,0 +1,45 @@
# Multi-day activity collections
Pre-spec exploration. This is `docs/architecture.md`'s **explicit open
question #1** (§Multi-Day Route Support: "Exact data model for
collections TBD — needs more design work"). Multi-day *routes* shipped
(day-break waypoints, per-day stats, track segments per day); the
activity side — recording a 5-day bikepacking trip as one entity with
five day-recordings — is captured nowhere.
## The shape from the architecture
- A multi-day activity is a **collection linking individual
day-activities**
- Each day-activity keeps its own GPS trace, photos, description
(possibly imported from different devices/apps per day)
- The collection references the planned multi-day route as a whole
- Day-activities can be imported one at a time (Garmin/Komoot/Wahoo per
day) and attached as the trip progresses
## Data-model directions to evaluate
1. **Collection row + membership table**
`journal.trips (id, owner, name, description, route_id?)` +
`journal.trip_activities (trip_id, activity_id, day_index)`.
Activities stay fully independent (visibility, federation, photos);
the trip is a presentation/grouping layer. Cheapest; probably right.
2. **Self-referencing activities** (`parent_activity_id` + a `kind`
column). Fewer tables but overloads the activity model and makes
feed/outbox queries carry exclusion rules forever.
3. Whatever we pick must answer: trip-level visibility vs per-day
visibility (suggest: trip visibility caps day visibility), trip-level
stats (sum of days), and how a trip federates (one Note per day as
today, plus a trip page link? A trip-level Note at completion?).
## Notes
- Pairs naturally with `activity-participants.md` (group trips) and the
"Tour mode" sketch in `fediverse-enhancements.md` (day N/M check-in
posts are trivial once a trip entity exists).
- The route side already encodes day breaks in GPX track segments —
importing a multi-day route's recording per segment could pre-seed
day-activities.
- Keep GPX exportability: a trip should export as one GPX with track
segments per day (mirror of the route format), preserving the
data-ownership principle.

View file

@ -0,0 +1,47 @@
# Local likes & comments
Pre-spec exploration. The architecture's Journal feature list has
"Social: Following, likes, comments" — follows shipped
(`social-follows`), likes and comments don't exist **even between local
users**. The federated halves are sketched separately in
`fediverse-enhancements.md` (kudos = inbound Like/Announce, comments =
inbound replies); this idea is the local foundation those would attach
to.
## Scope sketch
**Likes (kudos)**
- One per (user, activity); toggleable; count on activity cards + detail
- Notification to the activity owner (`notifications` spec has the
pattern; new type `activity_liked`)
- Storage: `journal.likes (user_id, activity_id, created_at)` or a
generalized reactions table if we ever want more than ❤️ — start
with likes only (simplicity principle)
- Federation hook-in later: inbound Mastodon `Like` increments the same
counter with `remote_actor_iri` instead of `user_id` (same
exactly-one-of pattern as `follows.follower_id`/`follower_actor_iri`)
**Comments**
- Flat list on activities first (no threading — Mastodon-reply
interop pushes toward flat anyway); plain text → maybe limited
markdown later
- Owner can delete comments on their activities; commenter can delete
their own
- Notification type `activity_commented`
- Federation hook-in later: inbound replies (`inReplyTo`) land in the
same table with remote attribution; outbound, local comments on a
federated activity would need to federate back as replies
## Constraints & notes
- Visibility: liking/commenting requires the activity to be visible to
you — public activities only for non-owners today; revisit when
followers-only content lands (§7/§8 of social-federation).
- The `activities.owner_id` NOT NULL open question (design.md of
social-federation) applies to remote comment authors the same way —
decide once.
- Decide whether routes also get likes/comments or activities only.
The architecture says activities; start there.
- Moderation precedes federated comments (see
`instance-administration.md`) but NOT local comments — local users
are accountable accounts on your own instance.

View file

@ -73,6 +73,7 @@ These changes are scoped and designed but not blocking launch:
| [`route-discovery`](../openspec/changes/route-discovery/) | Spatial map-based route exploration. The text `/explore` page covers the gap at launch. |
| [`activity-photos`](../openspec/changes/activity-photos/) | Photo uploads for activities. Enriches content but not a day-one requirement. |
| [`mobile-app`](../openspec/changes/mobile-app/) | React Native unified app. Substantial scope; explicitly post-launch. |
| [`route-federation`](../openspec/changes/route-federation/) | Routes as federated objects: Invite/Accept mirroring, cross-instance Planner edits. The collaboration half of the federation vision; depends on `social-federation` §6 + `route-sharing`. |
---
@ -83,3 +84,7 @@ See `docs/ideas/` for pre-spec explorations:
- `mobile-activity-recording/` — GPS tracking and activity logging from a native app
- `mobile-nearby-sync/` — BLE-based proximity discovery
- `self-host-overpass/` — Self-hosted Overpass API for POI overlays
- `instance-administration.md` — admin role, registration toggle, suspend/ban, federation blocklists, reports
- `social-interactions.md` — local likes + comments (the foundation the federated kudos/comments attach to)
- `activity-participants.md` — tag co-riders on shared activities, confirm/decline, federated mentions
- `multi-day-collections.md` — activity collections for multi-day trips (architecture open question #1)

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-07

View file

@ -0,0 +1,109 @@
## Context
Transcribes and refines architecture.md Resolved Decisions #2 (route
mirroring), #3 (cross-instance edits), #12 (cross-instance auth) and
#16 (delivery retry/sync healing) into a buildable change, on top of
what `social-federation` shipped: Fedify mounted in the journal,
per-user keys, the narrow inbox, NodeInfo-based trails-instance
discovery, push delivery via pg-boss, and the hard-won serialization
lessons (attachment arrays, tombstones, async queue, dereferenceable
objects).
Status: drafted ahead of implementation (2026-06-07) to capture the
architecture vision as a concrete change; revisit against reality once
social-federation §6/§7 and route-sharing have landed.
## Goals / Non-Goals
**Goals:**
- A public route is a dereferenceable AP object at its canonical URL.
- Alice (instance A) shares a route with Bob (instance B): Invite →
Accept → B holds a mirror that stays current via Update fan-out.
- Bob edits Alice's route in a Planner session; the save lands on A as
a new version crediting Bob — no drafts on B (canonical-owner model).
- Mirrors self-heal after extended downtime via periodic sync check.
**Non-Goals:**
- Mastodon rendering of routes (trails-to-trails only).
- Federated forking, federated route discovery/search.
- Moving Planner sessions between instances (a session lives on one
Planner; only the *save destination* is remote).
## Decisions
### Decision: Canonical owner, mirrors as read caches (arch #2)
The owner's instance is the single source of truth. Collaborator
instances store mirrors — metadata + latest GPX — updated by `Update`
fan-out, used for display and as the seed when opening an edit session
while A is reachable; stale mirrors are explicitly labeled in the UI
when the sync check can't reach the origin.
### Decision: Edits go straight to the owner (arch #3, #12)
Bob's instance never stores a draft. The edit flow:
1. B requests a scoped edit token from A
(`POST /api/federation/routes/:id/edit-token`, HTTP-Signature
signed by Bob's actor key; A verifies Bob is a collaborator).
2. A issues the same shape of scoped JWT the local edit-in-planner
flow uses (route id, permissions, expiry, jti) — single-use
enforcement via the existing `consumed_jwt_jti` table.
3. B opens the Planner with A's callback URL + token; the existing
callback endpoint on A accepts the save and records Bob's actor IRI
as contributor (contributors become IRIs, matching the route
metadata envelope in arch #8).
### Decision: Vocabulary — custom `trails:Route` object, standard activity wrappers
`Create`/`Update`/`Invite`/`Accept` are standard AS2 activities; the
object is a custom type under the trails JSON-LD context (namespace
served from the instance, e.g. `https://trails.cool/ns#Route`) carrying
the metadata envelope (distance, elevation, day breaks, routing
profile, contributors) plus a GPX `Document` attachment. Soak lesson
applied: every shape verified against a real second instance before
merge, arrays for one-element collections, objects dereferenceable
from day one.
### Decision: Trails-to-trails gating reuses the §6 check
Same NodeInfo `software.name` allowlist as outbound follows; route
activities are neither delivered to nor accepted from non-trails
instances. (The Wanderer-interop idea would widen this allowlist —
explicitly out of scope here.)
### Decision: Sync check is a pg-boss cron, owner-driven re-fetch
Daily job on the *mirror-holding* instance: for each mirror whose
`last_update_at` predates its origin's advertised `updated` (HEAD/GET
on the canonical object), re-fetch and reconcile. Covers the
"instance down > 72h, Update lost" case in arch #16.
## Risks / Trade-offs
- **Custom vocabulary is an interop commitment** — version the
context document; additive evolution only.
- **Token issuance endpoint is a new authenticated surface**
HTTP-Signature + collaborator check + rate limit + audit log;
security-review gate before enabling (same staged-flag pattern as
FEDERATION_ENABLED, possibly its own ROUTE_FEDERATION_ENABLED).
- **Tombstone semantics apply to routes too** (2026-06-07 soak
lesson): unsharing/unpublishing federates a retraction only on an
actual public/shared→not transition, and re-publishing after a
Delete won't resurrect mirrors that processed it.
- **Mirror divergence** if a save lands while fan-out is mid-flight —
versions are sequential on the owner; mirrors always converge to the
owner's latest (last-write-wins on the mirror, never on the origin).
## Open Questions
- Mirror storage: dedicated `route_mirrors` table vs provenance
columns on `routes` (the activities precedent says columns +
`remote_origin_iri UNIQUE`; routes carry more state — versions?
Probably mirror latest-only, no version history on mirrors).
- Does `Invite` target the user or the instance? (User — but the
Accept must be signed by the invited actor; what happens when the
user moves instances?)
- Contributor identity display for remote contributors (handle?
cached display name? same remote_actors cache?).
- Whether the planner needs to know anything at all (ideally zero
changes — callback URL + token are opaque to it already).

View file

@ -0,0 +1,87 @@
## Why
`social-federation` delivers the *social* half of the federation pitch:
activities federate as `Create(Note)`, Mastodon users can follow trails
users, trails users will follow each other across instances (§6/§7).
The *collaboration* half of the architecture vision — routes as
first-class federated objects — is still aspirational: `Create/Update
Route` activities, cross-instance route sharing with Invite/Accept
mirroring, and cross-instance Planner edits saved back to the owner's
instance (architecture.md Resolved Decisions #2, #3, #12, #16 and Data
Flow Scenarios 3 & 4).
This is the differentiator. Activity federation makes trails.cool a
Mastodon-compatible publisher; route federation makes it the thing the
architecture promises: collaborative route planning across self-hosted
instances, with the owner's instance as the single source of truth.
## What Changes
- **Routes federate as objects**: a public route is dereferenceable at
its canonical URL as an ActivityPub object (custom `trails:Route`
type carrying the JSON-LD metadata envelope + GPX attachment;
Mastodon-facing fallback rendering deferred — routes federate
trails-to-trails only). `Create`/`Update` activities fan out to
followers and collaborators on publish and on new versions.
- **Cross-instance sharing via Invite/Accept**: sharing a route with a
remote trails user sends an `Invite`; their `Accept` registers them
as collaborator on the owner's instance and creates a **mirror** (a
read cache: metadata + latest GPX) on theirs. Route `Update`s push
new versions to all collaborator mirrors.
- **Cross-instance edits**: a collaborator's instance requests a
**scoped edit token** from the owner's instance (HTTP-Signature
authenticated instance-to-instance request). The Planner session
opens with the owner's callback URL + that token; saves create new
versions directly on the owner's instance, crediting the
collaborator as contributor (the existing JWT callback machinery,
issued across instances).
- **Mirror healing**: a periodic sync check detects mirrors that
missed `Update`s (instance down > Fedify's retry budget) and
re-fetches the canonical object.
- **Trails-to-trails only**, enforced with the same NodeInfo
`software.name` check as outbound follows. Mastodon never sees
`trails:Route` objects (they're not Notes and aren't delivered to
non-trails inboxes).
## Capabilities
### New Capabilities
- `route-federation`: Route objects over ActivityPub — dereferenceable
routes, Create/Update fan-out, Invite/Accept collaboration
mirroring, cross-instance scoped edit tokens, mirror sync healing.
### Modified Capabilities
- `social-federation`: outbox/delivery extended beyond `Create(Note)`
with the route activity vocabulary; inbox accepts
`Invite`/`Accept(Invite)`/`Update(Route)` from trails instances.
- `route-management`: routes gain remote provenance (mirrors) and
remote collaborators; version creation accepts cross-instance
contributors.
- `route-sharing`: the share dialog can target remote trails handles
(`@bob@bob.trails.xyz`), entering the Invite lifecycle instead of a
local share row.
- `planner-callback`: callback tokens verifiable when issued by THIS
instance to a session initiated from a REMOTE instance, and the
edit-in-planner flow can carry a remote owner's callback.
## Impact
- **Dependencies**: requires `social-federation` §6 (outbound
trails-to-trails follows + NodeInfo check) and `route-sharing`
(local share model the Invite extends). Build after both.
- **Schema**: `route_mirrors` (or `routes.remote_origin_iri` +
provenance columns mirroring the activities approach),
`route_collaborators` extension for remote actor IRIs (the
exactly-one-of local/remote pattern from `follows`), token-issuance
audit table.
- **Federation surface**: new inbound activity types (gated to
verified trails instances), instance-to-instance token endpoint —
security-review before exposure; privacy manifest update (what a
collaborator instance learns and stores).
- **Out of scope**: rendering trails Route objects on Mastodon;
federated forking (fork-from-mirror can be a follow-up); real-time
*federated* presence in Planner sessions (sessions remain on one
Planner instance — federation is about where the route lives, not
where the session runs).

View file

@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: Public routes are dereferenceable ActivityPub objects
The Journal SHALL serve a public route at its canonical URL as a
`trails:Route` object (JSON-LD metadata envelope + GPX attachment) when
requested with an ActivityPub Accept header, and SHALL announce new
public routes and new versions to followers and collaborators as
`Create`/`Update` activities. Non-public routes SHALL NOT be
dereferenceable or announced.
#### Scenario: Route object resolves
- **WHEN** a trails instance GETs a public route's canonical URL with `Accept: application/activity+json`
- **THEN** the response is a `trails:Route` object containing the metadata envelope (distance, elevation, day breaks, routing profile, contributors) and a GPX `Document` attachment
#### Scenario: New version fans out
- **WHEN** a new version of a shared public route is created
- **THEN** an `Update` activity carrying the new version is delivered to every collaborator instance's inbox
### Requirement: Cross-instance sharing via Invite/Accept mirroring
Sharing a route with a user on another trails instance SHALL send an
ActivityPub `Invite`; the remote user's `Accept` SHALL register them as
collaborator on the owner's instance and establish a mirror (metadata +
latest GPX, no version history) on theirs, kept current by `Update`
fan-out. The owner's instance SHALL remain the canonical source.
#### Scenario: Invite accepted creates a mirror
- **WHEN** Alice (instance A) shares a route with `@bob@b.example` and Bob accepts
- **THEN** A records Bob as collaborator, sends the current route, and B stores a mirror visible in Bob's collection attributed to A
#### Scenario: Invite to a non-trails instance is refused
- **WHEN** a share targets a handle whose instance does not pass the trails-instance check
- **THEN** the share is refused at the API layer with a clear "route federation is trails-to-trails only" error
### Requirement: Cross-instance edits store to the owner via scoped tokens
A collaborator's instance SHALL obtain a scoped, single-use edit token
from the owner's instance via an HTTP-Signature-authenticated request,
SHALL be refused when the requester is not an accepted collaborator,
and the resulting Planner session SHALL save new versions directly to
the owner's instance with the collaborator credited as contributor by
actor IRI.
#### Scenario: Collaborator edits across instances
- **WHEN** Bob starts an edit session on his mirror of Alice's route
- **THEN** B obtains a token from A, the Planner opens with A's callback, and Bob's save creates the next sequential version on A crediting Bob's actor IRI
#### Scenario: Non-collaborator cannot obtain a token
- **WHEN** an instance requests an edit token for an actor who is not an accepted collaborator
- **THEN** the owner's instance refuses with 403 and records the attempt
### Requirement: Mirror sync healing
Each instance holding mirrors SHALL periodically verify them against
the canonical object and re-fetch when stale, and SHALL visibly mark a
mirror whose origin has been unreachable past the verification window.
#### Scenario: Missed update is healed
- **WHEN** instance B was offline long enough to miss an `Update` and its retry window
- **THEN** B's next sync check detects the version gap and re-fetches the canonical route from A

View file

@ -0,0 +1,39 @@
## 0. Preconditions
- [ ] 0.1 `social-federation` §6 (outbound trails-to-trails follows + NodeInfo instance check) shipped
- [ ] 0.2 `route-sharing` shipped (local share model the Invite extends)
- [ ] 0.3 Two-instance integration environment exists (social-federation task 11.4's docker-compose setup — shared prerequisite)
## 1. Vocabulary + object surface
- [ ] 1.1 Define the trails JSON-LD context document (versioned, served at a stable URL); `trails:Route` type with metadata envelope fields
- [ ] 1.2 Fedify object dispatcher for routes at the canonical route URL (public-only; owner-private ⇒ 404), content-negotiation middleware on the route detail page (same pattern as activities)
- [ ] 1.3 GPX `Document` attachment on the object; verify shapes against a second live instance before merging (soak lesson: arrays, dereferenceability, tombstones)
## 2. Create/Update fan-out
- [ ] 2.1 Publish-on-public + version-created hooks enqueue route activity deliveries (reuse deliver-activity job patterns; transition-aware retraction rules apply to routes too)
- [ ] 2.2 Inbox listeners accept `Update(trails:Route)` from verified trails instances holding a mirror relationship; reject otherwise
## 3. Invite/Accept collaboration
- [ ] 3.1 Schema: route collaborators support remote actor IRIs (exactly-one-of local/remote pattern); mirror provenance on routes (`remote_origin_iri` et al.)
- [ ] 3.2 Share dialog accepts remote trails handles; outbound `Invite` delivery with Pending state
- [ ] 3.3 Inbox: `Accept(Invite)` registers the collaborator + sends current route; `Reject(Invite)`/`Undo` lifecycle
- [ ] 3.4 Mirror storage + rendering (read-only route page attributed to origin, "mirrored from" affordance, stale badge)
## 4. Cross-instance edit tokens
- [ ] 4.1 Token issuance endpoint (HTTP-Signature verified, collaborator-checked, single-use jti, rate-limited, audited)
- [ ] 4.2 Edit-in-planner flow from a mirror: request token from origin, open Planner with origin callback
- [ ] 4.3 Callback path records remote contributors by actor IRI; contributor display via remote_actors cache
## 5. Sync healing
- [ ] 5.1 pg-boss cron: verify mirrors against canonical objects, re-fetch on version gap, mark unreachable origins
## 6. Hardening + rollout
- [ ] 6.1 Feature flag (own flag or FEDERATION_ENABLED tier), staged soak with a second instance, security review of the token endpoint
- [ ] 6.2 Privacy manifest: what collaborator instances learn/store
- [ ] 6.3 Two-instance integration tests: invite→accept→mirror, update fan-out, cross-instance edit roundtrip, sync healing after simulated downtime