diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml
index 7b87971..ed59b70 100644
--- a/.github/workflows/cd-staging.yml
+++ b/.github/workflows/cd-staging.yml
@@ -195,6 +195,13 @@ jobs:
# via cd-infra) are live. Idempotent.
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
+ # Reclaim superseded image layers. cd-apps prunes after its own
+ # deploys, but a day of staging/preview deploys while cd-apps is
+ # red can fill the disk on its own (2026-06-07: 100% full,
+ # postgres down). The 1h filter avoids racing layers another
+ # in-flight deploy just pulled.
+ docker image prune -af --filter "until=1h" || true
+
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent ps
# ── PR preview deploy ────────────────────────────────────────────────
@@ -356,6 +363,10 @@ jobs:
# Reload Caddy to pick up the per-PR snippet (writes/replaces it from the SCP step)
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile
+ # Same disk-hygiene prune as the persistent staging deploy —
+ # preview pushes are the highest-volume image source.
+ docker image prune -af --filter "until=1h" || true
+
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" ps
# Find any prior preview comment so we can update it in place rather
diff --git a/.github/workflows/disk-maintenance.yml b/.github/workflows/disk-maintenance.yml
new file mode 100644
index 0000000..5113a0c
--- /dev/null
+++ b/.github/workflows/disk-maintenance.yml
@@ -0,0 +1,52 @@
+name: Disk Maintenance
+
+# Daily safety net for flagship disk usage. The deploy workflows prune
+# superseded image layers after their own runs, but that protection
+# disappears exactly when it's needed most: a deploy that fails early
+# never reaches its prune step, while other workflows keep pulling
+# fresh images (2026-06-07 incident: cd-apps red all day, ~10 staging
+# deploys, disk 100% full, postgres down on a Saturday morning).
+#
+# Also doubles as a redundant alert channel: the run FAILS when the
+# disk is still above the threshold after pruning, so a scheduled-run
+# failure email lands even if the Grafana disk alert drowns in other
+# noise (which is what happened during the incident).
+
+on:
+ schedule:
+ # Daily at 04:30 UTC (offset from staging-cleanup's Monday 04:00)
+ - cron: "30 4 * * *"
+ workflow_dispatch: {}
+
+concurrency:
+ group: disk-maintenance
+ cancel-in-progress: false
+
+jobs:
+ prune-flagship:
+ name: Prune unused images (flagship)
+ runs-on: ubuntu-latest
+ environment: production
+ steps:
+ - name: Prune and check disk headroom
+ uses: appleboy/ssh-action@v1
+ with:
+ host: ${{ secrets.DEPLOY_HOST }}
+ username: root
+ key: ${{ secrets.DEPLOY_SSH_KEY }}
+ script: |
+ set -euo pipefail
+ echo "before: $(df -h / | tail -1)"
+ # 12h filter: never touch layers a same-day deploy may still
+ # be assembling; running containers' images are never pruned.
+ docker image prune -af --filter "until=12h"
+ echo "after: $(df -h / | tail -1)"
+
+ PCT=$(df --output=pcent / | tail -1 | tr -dc '0-9')
+ if [ "$PCT" -ge 85 ]; then
+ echo "Disk still at ${PCT}% after pruning — needs a human."
+ echo "Largest docker consumers:"
+ docker system df
+ exit 1
+ fi
+ echo "Disk at ${PCT}% — healthy."
diff --git a/apps/journal/app/components/CollectionPage.tsx b/apps/journal/app/components/CollectionPage.tsx
index 08c8bab..2632b44 100644
--- a/apps/journal/app/components/CollectionPage.tsx
+++ b/apps/journal/app/components/CollectionPage.tsx
@@ -4,6 +4,9 @@ interface Entry {
username: string;
displayName: string | null;
domain: string;
+ /** Local path (`/users/x`) or, for federated entries, the remote profile URL. */
+ profileUrl: string;
+ remote: boolean;
}
interface Props {
@@ -42,9 +45,10 @@ export function CollectionPage({ kind, user, entries, page, total }: Props) {
) : (
{entries.map((entry) => (
- -
+
-
diff --git a/apps/journal/app/lib/federation-inbox.integration.test.ts b/apps/journal/app/lib/federation-inbox.integration.test.ts
index 313c0b4..9adcbee 100644
--- a/apps/journal/app/lib/federation-inbox.integration.test.ts
+++ b/apps/journal/app/lib/federation-inbox.integration.test.ts
@@ -64,6 +64,23 @@ describe.runIf(runIntegration)("federation inbox (integration)", () => {
expect(rows[0]!.acceptedAt).not.toBeNull();
});
+ it("remote followers appear in the followers list (count/list consistency)", async () => {
+ const username = `fed-list-${Date.now()}`;
+ const userId = await makeUser({ username });
+ await recordRemoteFollow(REMOTE_ACTOR, username);
+
+ const { listFollowers, countFollowers } = await import("./follow.server.ts");
+ const [entries, total] = await Promise.all([listFollowers(userId), countFollowers(userId)]);
+ expect(total).toBe(1);
+ expect(entries).toHaveLength(1);
+ const entry = entries[0]!;
+ expect(entry.remote).toBe(true);
+ expect(entry.profileUrl).toBe(REMOTE_ACTOR);
+ // No remote_actors cache row in this test → identity parsed from the IRI.
+ expect(entry.username).toBe("alice");
+ expect(entry.domain).toBe("other-trails.example");
+ });
+
it("inbound Follow is idempotent — replays don't double-insert", async () => {
const username = `fed-replay-${Date.now()}`;
const userId = await makeUser({ username });
diff --git a/apps/journal/app/lib/federation.server.test.ts b/apps/journal/app/lib/federation.server.test.ts
index f00aa39..294ab08 100644
--- a/apps/journal/app/lib/federation.server.test.ts
+++ b/apps/journal/app/lib/federation.server.test.ts
@@ -95,12 +95,18 @@ describe("actor object", () => {
expect(actor.preferredUsername).toBe("bruno");
expect(actor.name).toBe("Bruno");
expect(actor.summary).toBe("Riding bikes");
- // Profile-metadata field Mastodon renders ("this is a trails profile")
- const attachments = Array.isArray(actor.attachment) ? actor.attachment : [actor.attachment];
- const field = attachments.find((a: { type: string }) => a?.type === "PropertyValue");
- expect(field?.name).toBe("🥾 trails.cool");
- expect(field?.value).toContain('href="http://localhost:3000/users/bruno"');
- expect(field?.value).toContain('rel="me"');
+ // Profile-metadata fields Mastodon renders ("this is a trails
+ // profile"). MUST serialize as a JSON array — Mastodon's parser
+ // silently ignores a bare attachment object, which is what Fedify
+ // compacts single-element arrays into (hence two fields).
+ expect(Array.isArray(actor.attachment)).toBe(true);
+ const fields = actor.attachment.filter((a: { type: string }) => a?.type === "PropertyValue");
+ expect(fields.length).toBeGreaterThanOrEqual(2);
+ const trails = fields.find((f: { name: string }) => f.name === "🥾 trails.cool");
+ expect(trails?.value).toContain('href="http://localhost:3000/users/bruno"');
+ expect(trails?.value).toContain('rel="me"');
+ const instance = fields.find((f: { name: string }) => f.name === "Instance");
+ expect(instance?.value).toContain("localhost:3000");
});
it("404s the actor for a private user", async () => {
diff --git a/apps/journal/app/lib/federation.server.ts b/apps/journal/app/lib/federation.server.ts
index 6cb971e..8622653 100644
--- a/apps/journal/app/lib/federation.server.ts
+++ b/apps/journal/app/lib/federation.server.ts
@@ -180,11 +180,19 @@ function buildFederation(): Federation {
// metadata table — a human-visible "this is a trails profile"
// marker. (The machine-readable marker is NodeInfo; this is
// flair.) Mastodon strips most HTML in values but keeps links.
+ // NOTE: Mastodon's parser requires `attachment` to be a JSON
+ // *array* and silently ignores a bare object — and Fedify
+ // compacts single-element arrays to bare objects. Keep at least
+ // two fields here so the array survives serialization.
attachments: [
new PropertyValue({
name: "🥾 trails.cool",
value: `${getOrigin().replace(/^https?:\/\//, "")}/users/${identifier}`,
}),
+ new PropertyValue({
+ name: "Instance",
+ value: `${getOrigin().replace(/^https?:\/\//, "")}`,
+ }),
],
});
})
diff --git a/apps/journal/app/lib/follow.server.ts b/apps/journal/app/lib/follow.server.ts
index f1254ae..0105e5d 100644
--- a/apps/journal/app/lib/follow.server.ts
+++ b/apps/journal/app/lib/follow.server.ts
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { eq, and, count, desc, isNull, isNotNull } from "drizzle-orm";
import { getDb } from "./db.ts";
-import { users, follows } from "@trails-cool/db/schema/journal";
+import { users, follows, remoteActors } from "@trails-cool/db/schema/journal";
import { localActorIri } from "./actor-iri.ts";
import { createNotification } from "./notifications.server.ts";
import { logger } from "./logger.server.ts";
@@ -283,35 +283,89 @@ export interface CollectionEntry {
username: string;
displayName: string | null;
domain: string;
+ /** Where the entry's profile lives: local path or remote actor URL. */
+ profileUrl: string;
+ remote: boolean;
}
const COLLECTION_PAGE_SIZE = 50;
+/**
+ * Best-effort identity for a remote actor we may or may not have
+ * cached: prefer the remote_actors row, fall back to parsing the IRI
+ * (canonical fediverse shape `https://host/users/name`).
+ */
+function remoteEntry(
+ actorIri: string,
+ cached: { username: string | null; displayName: string | null; domain: string | null },
+): CollectionEntry {
+ let host = "";
+ let lastSegment: string;
+ try {
+ const url = new URL(actorIri);
+ host = url.host;
+ lastSegment = url.pathname.split("/").filter(Boolean).pop() ?? "";
+ } catch {
+ // Malformed IRI in the DB — display it raw rather than hide the row.
+ lastSegment = actorIri;
+ }
+ return {
+ username: cached.username ?? lastSegment,
+ displayName: cached.displayName,
+ domain: cached.domain ?? host,
+ profileUrl: actorIri,
+ remote: true,
+ };
+}
+
/**
* Paginated list of accepted followers of `userId`. Newest acceptance first.
- * Pending requests are excluded — they live in the Requests tab on
- * /notifications.
+ * Includes remote (federated) followers — `follower_actor_iri` rows — so the
+ * list matches countFollowers; display data comes from the remote_actors
+ * cache when present, IRI parsing otherwise. Pending requests are excluded —
+ * they live in the Requests tab on /notifications.
*/
export async function listFollowers(userId: string, page: number = 1): Promise {
const db = getDb();
const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE;
const rows = await db
.select({
- username: users.username,
- displayName: users.displayName,
- domain: users.domain,
+ localUsername: users.username,
+ localDisplayName: users.displayName,
+ localDomain: users.domain,
+ followerActorIri: follows.followerActorIri,
+ remoteUsername: remoteActors.username,
+ remoteDisplayName: remoteActors.displayName,
+ remoteDomain: remoteActors.domain,
})
.from(follows)
- .innerJoin(users, eq(follows.followerId, users.id))
+ .leftJoin(users, eq(follows.followerId, users.id))
+ .leftJoin(remoteActors, eq(follows.followerActorIri, remoteActors.actorIri))
.where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt)))
.orderBy(desc(follows.acceptedAt))
.limit(COLLECTION_PAGE_SIZE)
.offset(offset);
- return rows;
+ return rows.map((r) =>
+ r.localUsername !== null
+ ? {
+ username: r.localUsername,
+ displayName: r.localDisplayName,
+ domain: r.localDomain ?? "",
+ profileUrl: `/users/${r.localUsername}`,
+ remote: false,
+ }
+ : remoteEntry(r.followerActorIri ?? "", {
+ username: r.remoteUsername,
+ displayName: r.remoteDisplayName,
+ domain: r.remoteDomain,
+ }),
+ );
}
/**
* Paginated list of accepted follows from `userId`. Newest acceptance first.
+ * Includes remote (trails-to-trails) targets — rows whose followed side is
+ * only an actor IRI — for the same count/list consistency as listFollowers.
* Pending outgoing follows (against private/locked targets) are excluded.
*/
export async function listFollowing(userId: string, page: number = 1): Promise {
@@ -319,15 +373,34 @@ export async function listFollowing(userId: string, page: number = 1): Promise
+ r.localUsername !== null
+ ? {
+ username: r.localUsername,
+ displayName: r.localDisplayName,
+ domain: r.localDomain ?? "",
+ profileUrl: `/users/${r.localUsername}`,
+ remote: false,
+ }
+ : remoteEntry(r.followedActorIri, {
+ username: r.remoteUsername,
+ displayName: r.remoteDisplayName,
+ domain: r.remoteDomain,
+ }),
+ );
}
diff --git a/docs/architecture.md b/docs/architecture.md
index 699c40b..c264615 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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
diff --git a/docs/ideas/activity-participants.md b/docs/ideas/activity-participants.md
new file mode 100644
index 0000000..f7e2c4f
--- /dev/null
+++ b/docs/ideas/activity-participants.md
@@ -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.
diff --git a/docs/ideas/instance-administration.md b/docs/ideas/instance-administration.md
new file mode 100644
index 0000000..e28422e
--- /dev/null
+++ b/docs/ideas/instance-administration.md
@@ -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).
diff --git a/docs/ideas/multi-day-collections.md b/docs/ideas/multi-day-collections.md
new file mode 100644
index 0000000..d64434a
--- /dev/null
+++ b/docs/ideas/multi-day-collections.md
@@ -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.
diff --git a/docs/ideas/social-interactions.md b/docs/ideas/social-interactions.md
new file mode 100644
index 0000000..29daa1f
--- /dev/null
+++ b/docs/ideas/social-interactions.md
@@ -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.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index d87a6ca..6ee701e 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -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)
diff --git a/openspec/changes/route-federation/.openspec.yaml b/openspec/changes/route-federation/.openspec.yaml
new file mode 100644
index 0000000..11967fc
--- /dev/null
+++ b/openspec/changes/route-federation/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-06-07
diff --git a/openspec/changes/route-federation/design.md b/openspec/changes/route-federation/design.md
new file mode 100644
index 0000000..ca5c672
--- /dev/null
+++ b/openspec/changes/route-federation/design.md
@@ -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).
diff --git a/openspec/changes/route-federation/proposal.md b/openspec/changes/route-federation/proposal.md
new file mode 100644
index 0000000..798af71
--- /dev/null
+++ b/openspec/changes/route-federation/proposal.md
@@ -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).
diff --git a/openspec/changes/route-federation/specs/route-federation/spec.md b/openspec/changes/route-federation/specs/route-federation/spec.md
new file mode 100644
index 0000000..a2162c8
--- /dev/null
+++ b/openspec/changes/route-federation/specs/route-federation/spec.md
@@ -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
diff --git a/openspec/changes/route-federation/tasks.md b/openspec/changes/route-federation/tasks.md
new file mode 100644
index 0000000..d079fa2
--- /dev/null
+++ b/openspec/changes/route-federation/tasks.md
@@ -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
diff --git a/packages/db/migrations/0002_connected_services.sql b/packages/db/migrations/0002_connected_services.sql
index 5a02645..20ec004 100644
--- a/packages/db/migrations/0002_connected_services.sql
+++ b/packages/db/migrations/0002_connected_services.sql
@@ -65,11 +65,15 @@ BEGIN
ALTER COLUMN status SET NOT NULL;
-- 5. CHECK constraints. Drop-then-add for idempotency.
+ -- 'public' = credential-less public-profile connection (Komoot public
+ -- mode, api.sync.komoot.verify.ts). It was missing from the original
+ -- list, which made this migration fail against production data on
+ -- 2026-06-07 (a komoot public connection existed) and block cd-apps.
ALTER TABLE journal.connected_services
DROP CONSTRAINT IF EXISTS connected_services_credential_kind_check;
ALTER TABLE journal.connected_services
ADD CONSTRAINT connected_services_credential_kind_check
- CHECK (credential_kind IN ('oauth', 'web-login', 'device'));
+ CHECK (credential_kind IN ('oauth', 'web-login', 'device', 'public'));
ALTER TABLE journal.connected_services
DROP CONSTRAINT IF EXISTS connected_services_status_check;
diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts
index 546de41..3ed696e 100644
--- a/packages/db/src/schema/journal.ts
+++ b/packages/db/src/schema/journal.ts
@@ -224,6 +224,9 @@ export const oauthTokens = journalSchema.table("oauth_tokens", {
// - 'oauth': { access_token, refresh_token, expires_at }
// - 'web-login': { email, encrypted_password, session_jar } (Komoot, future)
// - 'device': {} (Apple Health, future)
+// - 'public': {} credential-less public-profile connection (Komoot public mode)
+// Keep this list in sync with the CHECK constraint in
+// packages/db/migrations/0002_connected_services.sql.
// See docs/adr/0001 and CONTEXT.md (Connected Services).
export const connectedServices = journalSchema.table(
"connected_services",