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

@ -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