Merge pull request #478 from trails-cool/docs/architecture-vision-capture
docs: capture remaining architecture vision; draft route-federation change
This commit is contained in:
commit
10a9ea523a
11 changed files with 580 additions and 50 deletions
|
|
@ -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 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
|
||||
|
|
|
|||
46
docs/ideas/activity-participants.md
Normal file
46
docs/ideas/activity-participants.md
Normal 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.
|
||||
49
docs/ideas/instance-administration.md
Normal file
49
docs/ideas/instance-administration.md
Normal 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).
|
||||
45
docs/ideas/multi-day-collections.md
Normal file
45
docs/ideas/multi-day-collections.md
Normal 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.
|
||||
47
docs/ideas/social-interactions.md
Normal file
47
docs/ideas/social-interactions.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
2
openspec/changes/route-federation/.openspec.yaml
Normal file
2
openspec/changes/route-federation/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-06-07
|
||||
109
openspec/changes/route-federation/design.md
Normal file
109
openspec/changes/route-federation/design.md
Normal 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).
|
||||
87
openspec/changes/route-federation/proposal.md
Normal file
87
openspec/changes/route-federation/proposal.md
Normal 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).
|
||||
|
|
@ -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
|
||||
39
openspec/changes/route-federation/tasks.md
Normal file
39
openspec/changes/route-federation/tasks.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue