Ullrich's notes from the first real federation exchange (2026-06-06/07
staging ↔ Mastodon soak), expanded with the constraints the soak
surfaced: route map images in Notes, fediverse kudos (inbound
Like/Announce), comments from the fediverse, planned routes as
Events (Mobilizon), Wanderer interop, federated explore.
The fourth note item — remote follower counted but not listed — was a
bug, fixed separately in #475.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The 2026-06-07 disk-full outage (flagship 100%, postgres crash-looping
on its pidfile, prod + staging down): cd-apps DOES prune images after
deploys, but it had been failing early all day (migration bug), so its
prune never ran — while ~10 staging/preview deploys kept pulling fresh
images with no prune of their own.
- cd-staging: prune superseded layers (until=1h guard against racing
in-flight pulls) after persistent staging and preview deploys.
- disk-maintenance.yml: NEW daily scheduled prune (04:30 UTC) that
also FAILS when the disk is still ≥85% after pruning — a redundant
alert channel for exactly the case where the Grafana disk alert
drowns in other noise, as it did during the incident.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
countFollowers counts every accepted follow row, but listFollowers
inner-joined users on follower_id — so a federated follower (NULL
follower_id, follower_actor_iri set) bumped the count while never
appearing in the list. Observed live: profile said '1 follower', list
below was empty. listFollowing had the same latent bug for outbound
trails-to-trails follows.
Both lists now left-join users + remote_actors: local entries link to
the local profile as before; remote entries display cached actor data
(IRI parsing as fallback) and link out to the remote profile.
Integration test pins count/list consistency for a remote follower.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 0002 migration's allowed list predates the Komoot public-profile
connection mode (api.sync.komoot.verify.ts writes credentialKind
'public'). Against production data — which has such a connection —
the drop-then-add CHECK failed at ATRewriteTable and blocked every
cd-apps deploy since 2026-06-07 06:25 UTC.
Also documents 'public' in the schema comment with a pointer to keep
the two lists in sync.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mastodon's PropertyValue parser requires the actor's attachment to be
a JSON *array* and silently ignores a bare object — and Fedify
compacts single-element arrays to bare objects, so the 🥾 trails.cool
field shipped in #464 never rendered (verified: Mastodon stored
fields = {} after a forced actor refresh on the soak instance).
Ship two fields so the array survives serialization: the 🥾 profile
link (rel=me) plus an Instance link — the latter is genuinely useful
for self-hosted instances anyway. Test now asserts the array shape
explicitly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Caddy 2.9 deprecated the nested `servers { metrics }` form; every
deploy logs a deprecation warning. The global `metrics` option is the
replacement and emits the same Prometheus metrics on the admin
endpoint, so the existing scrape config is unaffected.
Validated with `caddy validate` against the caddy:2 image — config
valid, no deprecation warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hardening from two incidents on 2026-06-06/07:
Schema drift (morning): drizzle-kit push exits 0 even when it aborts
on an interactive prompt it can't render in CI, so cd-staging's
set -euo pipefail never fired and a month of staging schema drift
accumulated silently until new code hit missing columns. All three
drizzle push call sites now tee output and fail the deploy on any
'Error:' line.
Production outage (overnight, ~9h): cd-infra and cd-apps had no
failure handling at all. A network-option change stopped postgres for
a network recreation that then deadlocked on containers from other
compose projects holding trails-shared; the script carried on, the
stack stayed down. Both scripts now run set -euo pipefail and gate on
container health at the end (postgres+journal for cd-infra,
journal+planner for cd-apps) so a deploy that leaves the stack down
is a red X, not a shrug.
docs/deployment.md gains the cross-project manual procedure for
network-changing deploys — the CD workflows only manage their own
compose project and cannot apply those safely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In production there are two copies of boss.server.ts in the process:
server.ts imports the TypeScript source directly (node
--experimental-strip-types) while route handlers live in the bundled
build/server/index.js with its own module instance. setBoss() from
server.ts wrote a module-local variable the bundle never saw, so EVERY
request-path enqueue failed with 'pg-boss not initialized' and was
swallowed by enqueueOptional's best-effort catch:
- notifications fan-out on activity publish: silently dropped
- komoot bulk-import kick-off: silently dropped
- federation activity push delivery: silently dropped (how we found
it — flipping an activity public on staging delivered nothing)
Dev never reproduces this: vite serves one module graph. Caught live
during the social-federation staging soak.
Fix: store the instance on globalThis under a Symbol.for() key so both
module copies resolve the same boss.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to #466: staging and preview services attach only to the
externally-named trails-shared network (created by the production
compose project) — they have no per-project default network, so the
enable_ipv6 added to the staging file was dead config. Move the flag
to trails-shared where it takes effect.
Rollout requires recreating trails-shared on the flagship: detach
staging, previews, and production postgres, remove the network, re-up.
Production journal/planner are unaffected (they use the default
network).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Notes we emit (outbox + push delivery) use /activities/{id} as
their id, but that URL only served HTML — so Mastodon's search-fetch
of an activity URL failed, and strict instances that re-fetch pushed
objects to verify them would drop our deliveries.
- Fedify object dispatcher for Note at /activities/{id}: serves the
same activityToNote mapping used everywhere else; 404 unless the
activity is public AND the owner's profile is public (private owners
don't federate, mirroring the actor).
- Content-negotiation middleware on the activity detail route, same
pattern as the actor route: AP Accept headers short-circuit to
Fedify, browsers get HTML.
Found during the staging soak (Mastodon showed the outbox post count
but couldn't fetch the posts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The journal container could not reach v6-only fediverse instances
(e.g. social.ullrich.is) because the compose project network was
IPv4-only. The flagship host has working IPv6, and Docker >= 27
auto-allocates a ULA subnet with NAT66 masquerading when a network
sets enable_ipv6, so the fix is a one-line opt-in per network.
Applying it to an existing deployment requires recreating the
network (docker compose down && up -d) — a brief full-stack
restart on the flagship and the persistent staging project.
PR-preview networks are created fresh per PR and need nothing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mastodon gives inbox deliveries a 10s read timeout; without a queue,
Fedify runs inbox listeners AND outbound deliveries (the Accept we
push back after a Follow) inside the inbound request. A slow or
unreachable remote then blows the budget, the sender times out and
its circuit breaker (Stoplight on Mastodon) cuts us off — observed
live during the staging soak.
InProcessMessageQueue + manuallyStartQueue: the queue consumer starts
explicitly on first federation use (skipped under vitest so tests
don't leak timers). In-process queueing is acceptable for the
single-process journal: queued work lost on restart is recoverable
(remotes retry Follows; activity push delivery is owned by our own
pg-boss jobs). Revisit with a Postgres-backed MessageQueue if that
changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mastodon renders PropertyValue attachments as the profile metadata
table — adds a '🥾 trails.cool' field linking to the user's canonical
profile, so it's human-visible that an account is a trails profile.
(The machine-readable signal stays NodeInfo; this is flair.) The link
carries rel=me so Mastodon can verify it once our HTML profile links
back.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inbound Mastodon Follows are being rejected 401 (signature
verification) on staging and Fedify's diagnostics were invisible:
it logs through LogTape, which is silent until configured.
- Configure LogTape with a console sink for the 'fedify' category when
the federation instance is built; level via FEDERATION_LOG_LEVEL
(default info).
- Staging deploy sets FEDERATION_LOG_LEVEL=debug for the soak —
signature-verification detail per request; dial down once proven.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
social-federation rollout step 12.2 — inbound soak. Persistent staging
gets FEDERATION_ENABLED=true; PR previews keep the flag empty so
preview journals never emit federation traffic.
- docker-compose.staging.yml: journal accepts FEDERATION_ENABLED +
FEDERATION_KEY_ENCRYPTION_KEY (both default empty).
- cd-staging.yml: persistent staging env sets FEDERATION_ENABLED=true;
the encryption key arrives via the existing SOPS decryption.
- secrets.app.env: new FEDERATION_KEY_ENCRYPTION_KEY (SOPS-encrypted,
generated with openssl rand -hex 32).
Production is unaffected: the flagship compose doesn't pass these vars
and the flag defaults off in code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
social-federation tasks 5.1–5.6. Completes the inbound-federation
story: a Mastodon follower now receives a trails user's new public
activities in their home timeline.
Outbox (5.1/5.2):
- /users/:username/outbox — paginated OrderedCollection of public
activities as Create(Note), newest first; unlisted/private never
federate. Private-user 404 enforced at the route layer because
Fedify builds collection-level responses from counter/cursors
without consulting the page dispatcher.
- Note shape: HTML content (escaped name/description/stats + link to
the activity page) with structured PropertyValue attachments
(distance-m, elevation-gain-m, duration-s) — Mastodon renders the
text, trails consumers read the structured fields. Resolves the
design open question toward Create(Note).
- Authorized Fetch: signed and unsigned outbox fetches deliberately
see the same (public-only) content until locked accounts exist.
Push delivery (5.3–5.6):
- createActivity / updateActivityVisibility(→public) enqueue one
deliver-activity job per accepted remote follower; flips away from
public and hard deletes enqueue Delete(Tombstone) retractions
(enqueued before the row disappears).
- deliver-activity job: re-reads the row at delivery time (skips if
gone or no longer public), resolves the recipient inbox via the
remote_actors cache with actor-document fetch fallback (priming the
cache), HTTP-signs via the owner's key, and POSTs. retryLimit 8 +
exponential backoff at enqueue time; outbound paced at 1 req/s per
remote host.
- Actor objects now advertise the outbox IRI.
- @js-temporal/polyfill added (same range Fedify uses) for published
timestamps; Fedify's types want the global esnext.temporal namespace,
bridged with a documented cast.
Tests: 9 unit tests for the AS mapping (escaping, stats, attachments,
published fallback, stable ids, tombstones), 4 outbox integration
tests (collection count, page shape/visibility filtering, private-404,
delivery audience query).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
social-federation tasks 3.1–3.4, 4.1–4.8. With this, a Mastodon user
can follow a public trails user: WebFinger → actor fetch → signed
Follow → recorded + Accept(Follow) pushed back.
Identity surface (section 3):
- Actor objects now carry the user's public key (publicKey +
assertionMethods via Fedify key pairs dispatcher; keys generated
lazily as a fallback to the backfill) and an inbox IRI; url uses
localActorIri (3.1).
- Software discovery shipped as standard NodeInfo
(/.well-known/nodeinfo + /nodeinfo/2.1, software.name trails-cool)
instead of the originally-sketched custom AS actor field — Fedify's
typed vocab can't emit arbitrary actor props and NodeInfo is what
the fediverse reads. Artifacts updated accordingly (3.4).
Inbox (section 4):
- /users/:username/inbox resource route; HTTP Signatures verified by
Fedify before any listener runs (4.1). Rate-limited 60 req/5 min per
source instance (host from Signature keyId) BEFORE verification so
hostile instances can't burn CPU on key fetches (4.8).
- Listeners: Follow → auto-accept for public profiles + Accept pushed
back (4.2); Undo(Follow) → row removed (4.3); Accept(Follow) →
Pending settled + first outbox poll enqueued (4.4); Reject(Follow) →
Pending dropped (4.5; UI notice deferred to 6.6). Unhandled types
are acknowledged + dropped by Fedify (4.6).
- Replay protection via Fedify's KvStore, now Postgres-backed
(journal.federation_kv + daily sweep job) so dedupe survives
restarts (4.7).
Schema (discovered requirement):
- follows.follower_id relaxed to nullable + follows.follower_actor_iri
for inbound remote followers — the proposal's 'follows is already
federation-ready' only held for outbound. Check constraint enforces
exactly one follower identity; partial unique index dedupes remote
follows. Notification fan-out + approve flow now filter local
followers explicitly. Design/proposal updated.
Tests: 7 inbox integration tests (accept/refuse/idempotence/undo/
settle/reject/check-constraint), 6 KvStore integration tests, 2 unit
tests for source-host extraction. All against real Postgres, gated on
FEDERATION_INTEGRATION=1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
social-federation tasks 2.1–2.5:
- users.public_key / users.private_key_encrypted (TEXT NULL): RSA 2048
keypairs as JWK JSON; private key AES-256-GCM encrypted at rest with
FEDERATION_KEY_ENCRYPTION_KEY.
- remote_actors table: cache of remote AP actors (display fields,
inbox/outbox URLs, public key, software discovery field, poll cursor).
- activities.remote_origin_iri (UNIQUE) / remote_actor_iri / audience:
provenance + audience tagging for rows ingested from remote outboxes.
Replay-safe ingestion keys off the unique origin IRI.
- crypto.server.ts: generalized into createAesCipher(envVar, salt)
factory; existing INTEGRATION_SECRET surface unchanged.
- federation-keys.server.ts: generate/ensure/load keypairs.
RSASSA-PKCS1-v1_5 because that's what Mastodon interops on.
- backfill-user-keypairs pg-boss job: enqueued once per server startup
when FEDERATION_ENABLED=true; only touches users with NULL keys, so
re-runs are no-ops. New users get keys at registration (both passkey
and magic-link paths), best-effort with the backfill as safety net.
- design.md: noted open question — activities.owner_id is NOT NULL but
remote-ingested rows have no local owner; decide in task 7.2.
Schema is additive; zero behavior change while FEDERATION_ENABLED is
off. db:push verified clean against local Postgres.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
social-federation tasks 1.1–1.3:
- Add @fedify/fedify pinned to exactly 2.1.16: every 2.2.x release
depends on @fedify/webfinger@2.2.x which was never published to npm,
so 2.1.16 is the newest installable version.
- app/lib/federation.server.ts: Federation instance with an actor
dispatcher serving Person objects for public users; private and
unknown users 404 (no existence leak). MemoryKvStore for now.
- /.well-known/webfinger resource route delegating to federation.fetch.
- ActivityPub content negotiation on /users/:username via route
middleware (future.v8_middleware — no loader uses the context arg,
so the flag is a no-op for existing code).
- FEDERATION_ENABLED env flag (default off) gating every federation
surface.
- Unit tests exercise the Fedify dispatcher as a remote AP client:
WebFinger resolution, actor fetch with Mastodon's Accept header,
private-user 404s, flag-off 404s.
Spike verdict: Fedify fits — URL dispatch, JRD/AP serialization, and
visibility gating all work through framework routes without a custom
server layer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The roadmap claimed social-federation depends on route-sharing for
public routes, but that prerequisite shipped independently via
public-content-visibility (2026-04-24) and social-feed (2026-04-25):
visibility columns, public profiles, and IRI-keyed follows are all in
place. Reorder Phase 1 to reflect that federation can start now.
Also flag route-sharing's proposal as needing re-scoping: tasks
1.1-1.3 and 3.1 duplicate already-shipped visibility work, and the
proposed enum contradicts the shipped text-column design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>