# Federation is optional at the dependency level, not just in code paths The `federation` feature has existed since ADR-0002 and has been treated by every ADR since as the switch that decides whether this instance speaks ActivityPub. It did not do that. It gated code paths, HTTP routes and struct fields; it removed nothing from the build. Measured at this branch's merge-base `f6ff7dd` with `cargo tree -p server -e normal --prefix none`, deduplicated by `sed 's/ (\*)$//' | awk '{print $1" "$2}' | sort -u | wc -l`: | Feature set at `f6ff7dd` | unique crates | `activitypub_federation` | | --- | --- | --- | | `sqlite` | 357 | present | | `sqlite,sqlite-federation` | 357 | present | | `postgres` | 368 | present | | `postgres,postgres-federation` | 368 | present | Identical, to the crate. The cause was one unconditional manifest edge per backend: `crates/adapters/sqlite/Cargo.toml:16` read `sqlite-federation = { workspace = true }`, and `crates/adapters/postgres/Cargo.toml:16` the same for `postgres-federation`. Those crates depend on `k-ap`, which depends on `activitypub_federation`. Anything that wanted a database therefore got the whole ActivityPub stack, and `--no-default-features --features sqlite` compiled, linked and shipped it. The feature was a runtime posture, not a build-time decision. Two consequences followed from that, and only the first was known. The first is size: an operator who does not want federation still pays for it in compile time, binary size and dependency surface. The second needs more care than an earlier draft of this document gave it: it is a read-path bug, not the write-path bug it first looked like. Because the whole social stack lived on one struct inside the federation crate, a federation-off `crates/server` had nothing to wire `SocialCommand`/`FollowGraphQuery`/`BlockQuery` to, so it wired `NoopSocialCommand`/`NoopSocialQuery` — `follow` returned `Ok(())` and wrote nothing, every query returned `0` or `vec![]`. It is tempting to read that as "following a user on your own instance silently did nothing," directly contradicting ADR-0003. **That reading is wrong: nothing federation-off could ever reach `NoopSocialCommand::follow` in the first place.** Every social write route — `/social/follow`, `/social/unfollow`, and everything else `SocialCommand` exposes — lives in `federation_api_routes()` (`crates/presentation/src/routes.rs:485`) and `federation_html_routes()` (`routes.rs:194`), both `#[cfg(feature = "federation")]`, merged into the router only under that gate (`routes.rs:459`, `:188`). `crates/server/Cargo.toml` takes `presentation` with `default-features = false`, so on a federation-off build `POST /api/v1/social/follow` is a 404 — the handler was never compiled in. Nobody could follow anybody, successfully or silently, because there was no route to call. `NoopSocialCommand::follow`'s silent `Ok(())` was a latent trap, genuinely worth removing, but it was never a shipped bug: correct-looking code sitting behind a door that no federation-off request could open. `NoopSocialQuery` sat behind no such door. Three consumers call `FollowGraphQuery` through routes that carry **no federation gate at all**: `get_local_profile` (follower/following counts on `/users/{id}`, which is an ungated route at `routes.rs:76`), `get_page_viewer` (the pending-follower badge), and `get_activity_feed` (the followed-user set behind the activity feed's `filter_following`). All three read `deps.social_query`, which `crates/composition` wires from `repos.follow_graph` — before this branch, `NoopSocialQuery`, federation-off. **A federation-off instance did not fail to follow; it lied about its own follow graph on every profile view and every activity-feed request** — reporting zero followers, zero following, no pending requests, no followed users, regardless of what `ap_followers`/`ap_following` actually held. That is the live bug this branch fixes, and it is the read half, not the write half: the write half was unreachable over HTTP, so its noop cost nothing; the read half was reachable everywhere, so its noop cost the truth. ## Decision **A `-social` crate speaks only `domain::ports`; a `-federation` crate speaks ActivityPub; the former must never depend on the latter.** `crates/adapters/sqlite-social` and `crates/adapters/postgres-social` are new and hold the implementations of the **seven** *domain* ports that the federation crates used to carry, across three types. `SqliteSocialRepository` / `PostgresSocialRepository` carry five — `FollowCommand`, `FollowQuery`, `FederatedProfileQuery`, `RemoteWatchlistRepository`, `FederationAdminQuery` — and two more sit on their own single-purpose types: `SqliteApContentQuery` implements `LocalApContentQuery` (`sqlite-social/src/ap_content.rs:9`), and `SqliteRemoteGoalRepository` implements `RemoteGoalRepository` (`sqlite-social/src/remote_goals.rs:18`). This is not cosmetic: those last two are exactly what the base `sqlite` crate re-exported from the federation crate before this split (`crates/adapters/sqlite/src/lib.rs:35`, `:121`) — they are the answer to "why did `sqlite` depend on `sqlite-federation` at all." `sqlite-federation` and `postgres-federation` keep the twelve `k_ap` trait impls — `ActivityRepository`, `KeypairRepository`, `RemoteActorCache`, `AnnounceRepository`, `RemoteReviewRepository`, `DomainBlocklist`, `ActorBlocklist`, and the five `follow/` traits. The base backend crates now depend on `-social` unconditionally and on `-federation` not at all; the federation crates enter the graph only through the `sqlite-federation` / `postgres-federation` features. That inversion is the whole mechanism: the feature is real because the edge it gates is the only edge. Measured on this branch with the same command and the same dedup pipeline: | Feature set | unique crates | `activitypub_federation` | | --- | --- | --- | | `sqlite` | **282** | **absent** | | `sqlite,sqlite-federation` | 358 | present | | `postgres` | **293** | **absent** | | `postgres,postgres-federation` | 369 | present | Both backends drop exactly **76** crates when federation is off, and `grep -c activitypub_federation` over the federation-off tree returns `0` for each. The on-counts are 358 and 369 rather than the old 357 and 368 because `sqlite-social` and `postgres-social` are themselves new crates in the graph; against the old build, a federation-off deployment resolves 75 fewer crates. **The 76 crates are visible in the shipped image, not only in `cargo tree`.** Both container images were built and compared: ``` docker build --build-arg FEATURES=sqlite . -> 171MB docker build --build-arg FEATURES=sqlite,sqlite-federation . -> 190MB ``` **19MB smaller.** This is the measurement that matters to an operator: a crate count is an argument about the build graph, whereas the image size is the consequence someone actually deploys. It is also the evidence that the decoupling reaches all the way through — a change that only moved `impl` blocks around would leave both images the same size. Note the second invocation is what plain `docker build .` does: **the Dockerfile's default is `ARG FEATURES=sqlite,sqlite-federation`, i.e. federation-on.** The federation-off image — the entire point of this ADR — is only built when asked for explicitly, so `docker build .` on its own does not exercise it. Both commands above are the reproduction, and both should be run before a release that claims the federation-off deployment works. **The orphan rule forced the struct split, and this is the third consecutive plan in which it has dictated a design.** `SqliteFederationRepository` was a two-field struct — `pool` and `instance` — carrying **seventeen** trait impls: five owned by `domain`, twelve owned by `k_ap`. Moving five impls to a new crate is not a matter of moving five `impl` blocks, because from inside `sqlite-social` both `domain`'s traits and a type defined in `sqlite-federation` are foreign, which is E0117. The five impls needed a type defined where they live, so `SqliteSocialRepository { pool, instance }` exists, and `PostgresSocialRepository` likewise. ADR-0008 records the first two occasions (`ApServiceAdapter`, `DomainUserRepoAdapter`); the pattern is now settled enough to state as a rule of this codebase: **"move these impls to another crate" always implies "and define a type there to carry them."** Plan for the type, not just the impls. The two structs are deliberately *not* symmetric. `SqliteFederationRepository` lost its `instance` field entirely — after the five domain impls left, nothing read it, and the alternative on the table was an `#[allow(dead_code)]`, which was reversed during review in favour of deleting the field and updating its seven crate-internal call sites. `SqliteSocialRepository` keeps `instance`, because `follow_repository.rs` reads it in eight places to build the actor URLs ADR-0003 specifies. The asymmetry was measured before the postgres half was written, so `postgres-social` was built to the corrected shape rather than copied from its sibling and then fixed. **A non-recursive glob misclassified an entire subdirectory, and the correction is worth recording because it nearly moved the wrong code.** The plan's first pass classified which files were ActivityPub-free using a `src/*.rs` glob. That glob never descends into `src/follow/`, so `follow/` was scored as clean and slated to move into the social crate. It is the opposite of clean: all five of its traits — `FollowerWriter`, `FollowerReader`, `FollowingWriter`, `FollowingReader`, `FollowMigration` — are `k_ap` traits, and its files import `k_ap::{Follower, FollowerStatus, RemoteActor, ...}` directly. A recursive rescan before any code moved corrected the classification and moved the split from a claimed 1333/359 line division to an actual 923/769. `follow/` stayed put. The near-miss is easy to make because the *local* follow path really is ActivityPub-free — it is just a different file. `crates/adapters/sqlite-social/src/follow_repository.rs` imports only `domain`, `sqlx`, `chrono`, `async_trait` and `adapter_common`, names no `k_ap` type, and carries the `FollowCommand`/`FollowQuery` impls. Two similarly-named things sit side by side: the AP follower protocol in `follow/`, and ADR-0003's direct-SQL local follow in `follow_repository.rs`. That the file layout already separated them cleanly is ADR-0003's design showing through — the split was legible because that decision had been made years earlier. The lesson stands regardless: **scan recursively**. This was the second wrong classification produced by a single grep pattern in the same session. **The wiring is correct now; the read path is what a federation-off deployment actually gets today.** `application::social::LocalSocialService` implements `SocialCommand`, `FollowGraphQuery` and `BlockQuery` against the domain ports only, and `crates/server/src/main.rs` wires one instance of it across all three when `federation` is off, replacing three separate noops. The bodies were ported from `CompositeSocialAdapter`'s local branches rather than rewritten, and were compared token-by-token against the originals during review — the two copies coexisted and agreed for three commits, which is what made that comparison possible. `CompositeSocialAdapter` then delegates its local branches to `Arc` and shrank from 299 to 207 lines; every surviving `SocialCommand` line in it is either a delegation or genuinely ActivityPub-specific. What this buys a federation-off build, concretely, is the read half described above: `get_local_profile`, `get_page_viewer` and `get_activity_feed` now read real `ap_followers`/`ap_following` rows through `LocalSocialService` instead of `NoopSocialQuery`'s always-empty answer. **An instance rebuilt federation-off stops lying about its own follow graph** — that is the user-visible improvement this branch ships, and the ADR previously did not name it. The write half — `SocialCommand::follow`/`unfollow`/`accept_follow`/`reject_follow` — is now wired correctly and errors honestly for a remote target (below), but it has **no HTTP surface** federation-off: the social routes that would call it are federation-gated in `presentation`, as established above, and this branch does not touch that gating. The wiring exists so it is correct on the day those routes are ungated, not because anything reaches it today. Ungating the local-only social routes — splitting `federation_api_routes`/`federation_html_routes` into a local-only half (follow, unfollow, accept/reject, the counts and lists) and an AP-only half (blocks, remote admin queries, the AP follower/following collections) — was raised and considered during review. **Decision: do not ungate them here.** It is a deliberate non-goal of this branch and a candidate follow-up, not a gap in it: the split is a presentation design task nobody has done or reviewed, since the two halves are currently interleaved one function each, and getting it right means deciding case-by-case which of `get_followers`/`get_following`/`get_relationship`/etc. are actually AP-agnostic versus AP-flavored in their response shape. Recorded in the follow-ups below. **A remote target on a federation-off build now errors, and the error names the actor:** `cannot reach remote actor {actor}: this instance was built without the federation feature` (`crates/application/src/social/local_service.rs:74`). This is the branch's one intentional behaviour change and lands in its own commit (`b8f74eb`). The verb is "reach", not "follow", because the same constructor serves five call sites including unfollow and unblock, and "cannot follow" would misdescribe four of them. At the four non-follow sites `{actor}` is whatever `actor_url_of` returns, which for a persisted row is an AP URL rather than an `@user@host` handle; the message says "remote actor", not "handle", so both read correctly. **The wiring fix is guarded by a Makefile grep rather than a test, and that is a limitation, not a preference.** The decision lives in `wire_dependencies()` — a private `async fn` in `crates/server/src/main.rs`, a *binary* crate. Binary crates expose no library API, so nothing under `crates/server/tests/` can call it; `server/tests/api_test.rs` builds `AppState` and `AppContext` by hand with its own fakes and never exercises the real wiring either. Making it testable means splitting `server` into lib+bin, a structural change well outside this work. `LocalSocialService`'s *behaviour* has five unit tests from earlier in the branch; what has no test is the *wiring*, and this repo already expresses that class of rule as a Makefile grep guard — four existed, and ADR-0006/0007 record the convention. `check-federation-off-social` is therefore the idiomatic instrument here rather than a consolation prize. It was observed red (three hits at `main.rs:161,164,167`) before the fix and green after, and is wired into `make check` (`Makefile:4`), `.PHONY`, and line 47 of both `.github/workflows/ci.yml` and `.gitea/workflows/ci.yml`. `make check` now runs **six** guards: the five above, plus `check-social-crates-are-ap-free`, added during this review to enforce the invariant the whole federation-optional property rests on — a `-social` crate must never depend on `k-ap`, `activitypub`, or anything matching `*-federation`. A single manifest line (`k-ap = { version = "0.5.0", registry = "gitea" }` in `sqlite-social/Cargo.toml`) would silently restore all 76 crates and 19MB while `make check`, `cargo test` and CI stayed green without it. `check-federation-off-social` was also strengthened in the same review: it previously only asserted the noops were *absent* from `main.rs`, which passes even if `LocalSocialService` were deleted from the same file and nothing were wired at all. It now also asserts `LocalSocialService` is *present*. Its scope is a single file, which is narrower than its siblings and is now documented in the recipe: `crates/server/src/main.rs` is the sole wiring site, and `worker` and `tui` do not name `SocialCommand`, `FollowGraphQuery` or `BlockQuery` at all — verified by recursive grep over both crates' `src/`, zero hits. Relocating the wiring out of `main.rs` would move it out of the guard's scope; the comment says to widen the guard to `crates/server/src` if that ever happens. **Both halves share the `ap_followers` and `ap_following` tables, and neither owns the migrations.** Those tables are created by `crates/adapters/sqlite/migrations/0003_activitypub.sql` and `crates/adapters/postgres/migrations/0001_initial.sql` — the *base* backend crates, which both halves already depend on. This is ADR-0003's arrangement unchanged: a local follow writes the same rows in the same format as a remote one, which is what makes local relationships visible to the fediverse through the AP collection endpoints without any duplication. The consequence for maintenance is that **a schema change to either table touches both crates**, and neither crate's directory is where you would look for the migration. This is a real coupling, accepted deliberately, because the alternative — a separate `local_follows` table — was rejected in ADR-0003 as two sources of truth for one concept. **A latent bug in `worker` was exposed, not caused, by cutting the dependency edge.** `crates/worker/src/main.rs` calls `tokio::signal::ctrl_c()` with no feature gate, but the workspace `tokio` (`Cargo.toml:44`) declares only `macros, net, rt, rt-multi-thread, sync, time` — no `signal`. It had been arriving transitively, through `sqlite → sqlite-federation → activitypub_federation`. Removing that edge broke the worker build, which looked like collateral damage and was not: the worker had a hard requirement satisfied by a dependency four hops away that it did not declare and had no reason to expect. It would have broken the moment anything else in the graph shifted. Diagnosed by stashing back to the pristine tree to confirm the transitive source, and fixed at the place the requirement actually lives — `crates/worker/Cargo.toml:31` now reads `tokio = { workspace = true, features = ["signal"] }`. This is the kind of thing a decoupling finds that no amount of reading finds. ### What this ADR does not claim **Blocks remain federation-only, and `LocalSocialService::block`/`unblock` always error.** There is no domain port for block storage — blocking is `k_ap::ActorBlocklist` and `k_ap::DomainBlocklist`, both `k_ap` traits on the federation struct — so making blocks work federation-off is a new port and new tables, not a wiring change. It was not attempted. **`LocalSocialService::get_blocked` returns `Ok(vec![])` federation-off — the same stale-row shape this section already discusses at length for `FederationAdminQuery`, and worth naming rather than leaving implicit.** `block`/`unblock` always error, so no block can be *recorded* through this path either way, but an instance that accumulated blocks while federation-ON and was then rebuilt federation-OFF would report none. Operationally harmless: block enforcement itself lives in `k_ap`, absent federation-off, and the route that exposes this list (`/social/blocked`) is federation-gated, so nothing acts on the stale answer. But it is the same class of claim as the paragraph below makes for `FederationAdminQuery`, and this section should say so plainly rather than naming `block`/`unblock` and skipping the query. **`FederationAdminQuery` is still noop'd federation-off, returning an empty list, and the empty list is an invariant rather than an assumption.** The weaker argument — "you cannot follow a remote actor federation-off, so empty is honest" — is only plausible. The stronger one is provable from the wiring this change installs: `LocalSocialService::follow_resolved` hard-errors on any non-`Local` target (`local_service.rs:253-256`) and `block`/`unblock` always error, so a federation-off build **cannot persist a remote follow through this path at all**. The empty list is enforced, not estimated. A comment at the wiring site now records this so a future reader does not re-derive it. There is one exception, and it is a display artifact rather than a functional regression: **an instance that ran federation-ON, accumulated remote follow rows, and was then rebuilt federation-OFF would report zero remote follows while the rows still exist.** Those follows cannot be acted on without federation anyway, so nothing is lost operationally, but the admin view understates reality. Wiring `FederationAdminQuery` for real is viable whenever that is judged worth surfacing — both `-social` crates implement it, and both are present federation-off. It was left noop'd because doing otherwise would surface rows the build cannot act on. **"The federation-off builds compile" is not "they are clippy-clean."** `cargo clippy -p worker --no-default-features --features sqlite -- -D warnings` still fails, with two errors: unused `remote_goal` and `FollowBackfillHandler is never constructed`. ADR-0008 recorded *three* here; the third, unused `app_config`, is incidentally gone because this branch hoists `let instance` above `build_database_adapters` in both binaries and derives it from `app_config` unconditionally. The remaining two predate this work and no gate runs this combination. Likewise `cargo build -p server --no-default-features --features sqlite` emits one pre-existing warning, unused `ap_content_repo` at `main.rs:65` (introduced at `ac7edd6`, long before this branch), and the single-backend postgres builds emit unreachable-pattern warnings on `DbPool` matches that are inherent to compiling a two-variant enum with one variant enabled. All warnings, none errors, none new. **The known-broken feature combinations ADR-0008 recorded are unchanged.** `--features sqlite,federation` on `server` still fails — `federation` is a bare meta-feature that does not imply `dep:activitypub` — and `cargo check -p presentation --no-default-features --all-targets` still fails with the same two pre-existing errors. This branch neither fixed nor worsened either. ## Considered Options - **Gate the existing `sqlite-federation` dependency behind the feature instead of splitting the crate** — the obvious one-line fix, and the reason it does not work is the whole point. The five domain-port impls the rest of the system needs unconditionally (`FollowCommand`, `FollowQuery`, `FederatedProfileQuery`, `RemoteWatchlistRepository`, `FederationAdminQuery`) lived on the same struct in the same crate as the twelve `k_ap` impls. Gating the edge would have removed those five too, and the `federation`-off build would have had no follow storage at all — which is precisely the state that produced the silent-noop bug. The dependency could not be made conditional until the code behind it was separated by what it speaks. - **`#[cfg(feature = "federation")]` inside the existing crates rather than two new crates** — cheaper in file moves, and rejected because a cfg inside a crate does not remove the crate's manifest dependency on `k-ap`. `cargo tree` would have been unchanged, `activitypub_federation` would still resolve, and the headline measurement would still read 357/357. Conditional compilation cannot subtract a dependency edge; only the manifest can. - **Keep the noops and document that federation-off means no social features** — internally consistent, and rejected because it contradicts ADR-0003 on the merits, not merely on the docs. That ADR's entire argument is that a local follow is two SQL writes and does not need ActivityPub. A build that omitted ActivityPub and therefore omitted local-follow *storage and querying* would prove ADR-0003 wrong; the split proves it right at the storage and service level — the HTTP surface for the write path remains gated regardless of which option was chosen here, see the follow-up below. - **Put `follow_resolved` on `SocialCommand` instead of on a new sibling trait** — the smaller-looking option, rejected by enumerating implementors rather than by taste. Only `LocalSocialService` and one domain test stub satisfy all four original `LocalSocial` bounds; `CompositeSocialAdapter` and `InMemorySocialRepository` are real `SocialCommand` implementors that are *not* `LocalSocial` and could not meaningfully implement `follow_resolved`. Extending `SocialCommand` would have stranded two types. `LocalSocial` is now a marker supertrait over five traits with an unconstrained blanket impl, matching `k_ap::FollowRepository`'s precedent over its own five. - **Resolve the follow target twice on the local path** — what the plan originally prescribed, and what the first implementation did: the composite called `resolve_target` to choose a branch, then handed the raw `FollowTarget` to `local.follow`, which resolved it again. Cost was a redundant `find_by_username` per handle-based local follow plus a race in which a user deleted between the two resolutions makes a *federation-ON* deployment emit "this instance was built without the federation feature", which is nonsense in that configuration. Replaced by `ResolvedFollow::follow_resolved`, so the composite passes the identity it already computed. The five `LocalSocialService` tests passed byte-unmodified across that change, which is the evidence that plumbing moved and behaviour did not. ## Known follow-ups, out of scope here - **Ungating the local-only social routes** (`/social/follow`, `/social/unfollow`, accept/reject, the counts and lists in `federation_api_routes`/`federation_html_routes`) so a federation-off build gets an HTTP surface for the write path described above. Considered and deliberately deferred during review, not because it is undesirable but because it is unreviewed design work: it needs splitting those two functions into a local-only half and an AP-only half, deciding case-by-case which handlers are AP-agnostic versus AP-flavored, and nobody has done or reviewed that split. Until it happens, `LocalSocialService`'s write path is correctly wired and unreachable over HTTP federation-off. - **The Dockerfile hand-enumerates every workspace member's `Cargo.toml`, and this branch broke it.** `docker build .` failed at `RUN cargo fetch` with `failed to read /build/crates/adapters/sqlite-social/Cargo.toml`, because the dependency-cache layer copies manifests one `COPY` line at a time and the two new crates had no lines. Fixed here by adding them and by verifying the list now matches `find crates -name Cargo.toml` exactly (37 against 37), with a comment saying that adding a crate means adding a line. **This is a standing trap, not a one-off, and it is still unguarded.** Adding any workspace crate silently breaks the container image, and the breakage is invisible to `make check`, to `cargo build` in every feature combination, and to CI as currently configured — only a full `docker build` finds it. This plan tripped it, and the project's history already records a Docker build broken across three merges for the same reason: nobody ran it. **Recommendation, not implemented here** (it is a separate decision): a `make` target that diffs the Dockerfile's `COPY` list against `find crates -name Cargo.toml` and fails on any difference would close it in a few lines, and is exactly the check that was run by hand to confirm this fix. A seventh guard is cheap; a fourth broken release is not. - **The error message names the actor and the build-time cause, but not which mutation was attempted.** A user who unfollows and sees "cannot reach remote actor X: built without the federation feature" learns why but not what. Closing this needs either per-site verbs — which reintroduces five message strings to keep in sync — or route-level logging. Left open deliberately. - **`FederationAdminQuery`'s stale-row case**, described above. Both `-social` crates already implement the port federation-off, so this is wiring, not new code, if the staleness is judged worth surfacing. - **`crates/infra-wiring` (87 lines across two files) still exists to break a real cycle** — `DbPool`, `EventBusBackend` and `AppConfig` need to be visible to both `composition` and the binaries without `composition` depending on `application::config`. It looks like a candidate for dissolution and is not one; the cycle is genuine. Recorded so the next reader does not re-investigate. - **`build_database_adapters`' two backend arms are near-duplicates** (`crates/composition/src/factory.rs:49`). Each now constructs a `*SocialRepository` alongside everything else, so the duplication grew slightly in this branch. Collapsing them needs a trait over the two backends' `wire()` outputs — real design work, not a tidy-up, and not attempted during a refactor whose contract was behaviour preservation. - **`tests/local_service.rs`'s `service()` helper takes `user_repo` by value and `follow_store` by reference.** Pure polish, explicitly left alone. - **`crates/adapters/` now holds 28 directories, up from the 26 ADR-0008 counted.** `check-presentation-adapters` iterates those directory names and still passes, so neither new crate leaked into `presentation`. ADR-0008's caveat carries over unchanged: the guard assumes directory name equals package name, true for all 28 today but not enforced. - The deferred items in ADR-0005/0006/0007/0008 are otherwise unaffected by this plan.