# `presentation` may name renderers, not reachers; `ActivityPubPort` inverts into three `domain` ports ADR-0006 and ADR-0007 both closed with the same known follow-up, in the same words: "Plan D inverts `ActivityPubPort` so `presentation` drops its one remaining adapter dependency, `activitypub`." That framing understated the problem in one direction and overstated it in another. Understated, because `activitypub` was not the only adapter crate `presentation` depended on — `crates/presentation/Cargo.toml` carried **eleven** adapter-crate entries at `051d9ce`: ten unconditional (`auth`, `metadata`, `poster-fetcher`, `object-storage`, `template-askama`, `rss`, `export`, `importer`, `jellyfin`, `plex`) plus `activitypub` as an optional dependency behind the `federation` feature. Overstated, because the reason the inversion kept getting deferred — that moving the trait into `domain` would drag `k_ap::RemoteActor` and `k_ap::BlockedDomain` in with it, giving the domain layer a dependency on a third-party federation library — turned out to be an artifact of treating a 17-method trait as one indivisible unit. Seven of the eleven were simply dead. They were found by commenting each dependency line out and running `cargo check -p presentation --all-features`, then repeating with `--all-targets`: `auth`, `metadata`, `poster-fetcher`, `object-storage`, `rss`, `export`, `importer`. **Both grep-based methods that would have been the obvious way to audit this gave wrong answers**, and the reason is worth recording because it also determines the shape of this ADR's guard: `use ::` misses the fully-qualified call sites, and plain `::` collides with same-named *modules* inside `application` and `presentation`. `auth::` matches `application::auth::logout`. `rss::` matches `handlers::rss::get_user_feed`. Both crates therefore looked live to a source grep while being entirely unreferenced as crates. The one function in `presentation` that genuinely named an adapter type — `mappers/social.rs::remote_actor_to_dto`, taking `activitypub::RemoteActor` — had zero callers anywhere in the workspace and was deleted with the file. `ActivityPubPort`'s 17 methods split across three **disjoint** consumer sets, which is what dissolves the `k_ap`-in-`domain` blocker: | Methods | Only consumer | | --- | --- | | `follow`, `unfollow`, `accept_follower`, `reject_follower`, `remove_follower`, `block_actor`, `unblock_actor`, `get_blocked_actors` | `CompositeSocialAdapter`, **inside the `activitypub` crate itself** | | `actor_json`, `followers_collection_json`, `following_collection_json`, `get_blocked_domains`, `add_blocked_domain`, `remove_blocked_domain` | `presentation` | | `get_following`, `import_remote_outbox`, `run_backfill_for_follower` | `worker::FollowBackfillHandler` | Eight of the seventeen have exactly one call site today, and it lives in the same crate as the trait, so they did not need a trait. That is a claim about call sites, not reachability: `ActivityPubWire` also hands `crates/server` the same `Arc` that `CompositeSocialAdapter` wraps (the `service` field, below), so all eight are `pub` and callable from outside the crate today — nothing does, but nothing stops it either. See the deferred item below on closing that gap. Exactly two of the seventeen return `k_ap::RemoteActor` — `get_blocked_actors`, which is one of those eight and therefore lost its trait, and `get_following`, which is worker-only. Presentation reached neither. The only `k_ap` type in presentation's six signatures is `BlockedDomain` in `get_blocked_domains`'s return, and both call sites consumed it structurally — reading `.domain`, `.reason`, `.blocked_at` into an `api_types::BlockedDomainResponse` or a `template_askama::BlockedDomainEntry` — without ever naming the type. `BlockedDomain` is `{ domain: String, reason: Option, blocked_at: String }`; `blocked_at` is `String` at every layer including both destinations, so a three-field domain-owned record reproduces it exactly. The worker's `get_following` does return `Vec`, but the worker reads exactly `.url` and `.outbox_url`. Two minimal domain models therefore replace both `k_ap` types at the boundary, and `crates/domain/Cargo.toml` gained no dependency other than a new `[dev-dependencies]` entry for `tokio`, to run the noop tests. ## Decision **`presentation` may name things that render output; it may not name things that reach external systems or storage.** `template-askama` is the sole permitted adapter crate, because an HTML template engine is a renderer: it turns data the handler already holds into bytes. Everything else in `crates/adapters/` reaches — over HTTP, to a database, to object storage, to a message queue — and a handler that names one of those has, by construction, a path around the application layer that ADR-0006 and ADR-0007 spent two plans closing. `crates/presentation/Cargo.toml`'s `[dependencies]` now lists exactly one entry from `crates/adapters/`: `template-askama`. Verified by iterating all 26 directory names under `crates/adapters/` against the section and finding exactly one hit. **`ActivityPubPort` is deleted and replaced by three consumer-shaped traits in `domain::ports::federation`.** `ApDocumentPort` (3 methods) serves ActivityPub documents for content negotiation and is called only from presentation. `InstanceBlocklistPort` (3 methods) is instance-wide domain blocklist administration, also presentation-only. `ApBackfillPort` (3 methods) pulls remote content in and pushes local content out after a follow is established, and is worker-side only — no HTTP handler calls it. The eight adapter-internal methods lost their trait entirely; `CompositeSocialAdapter` now takes a concrete `Arc` and calls them as inherent methods. All nine surviving methods return `Result<_, DomainError>` rather than `anyhow::Result<_>`, so the error type crosses the boundary as a domain type. `grep -rn "ActivityPubPort\|NoopActivityPubService" crates` returns nothing. **The adapter carries the three impls on a local wrapper type, `ApServiceAdapter`, not on `k_ap::ActivityPubService` directly — this is forced, not stylistic.** The plan originally specified `impl domain::ports::ApDocumentPort for k_ap::ActivityPubService`. That does not compile: E0117, the orphan rule. From inside `crates/adapters/activitypub`, both the trait (owned by `domain`) and the type (owned by the external `k-ap` crate) are foreign. The deleted `port.rs` compiled only because `ActivityPubPort` was *local* to that crate — the very property being given up by moving the trait to `domain`. `ApServiceAdapter { service: Arc }` in `crates/adapters/activitypub/src/federation_ports.rs` holds all three impls, matching two existing precedents in the same crate: `DomainUserRepoAdapter` (a local wrapper across a foreign boundary) and `CompositeSocialAdapter` (one type serving three `domain` traits, handed out as three `Arc`s). A side effect worth naming, since the plan feared the opposite: because `ApServiceAdapter` has no inherent `actor_json`, `self.service.actor_json(..)` is unambiguous and no fully-qualified call or recursion guard is needed. `ActivityPubWire` gained `document`, `blocklist`, and `backfill` fields, three `Arc` casts of one `ApServiceAdapter`. **`actor_json` keeps its `Err(_) => 404`; the two collection handlers now return a logged 500.** This is the plan's one intentional behavior change and lands in its own commit (`8bfeae5`). The asymmetry is a measured property of `k_ap`, not a preference: `followers_collection_json` and `following_collection_json` never look the user up. They derive a URL from the UUID and count rows, so a nonexistent user yields `total = 0` and a valid empty `OrderedCollection` — `Ok`, not `Err`. Every `Err` those two can produce is therefore a genuine infrastructure or serialization failure, and the 404 they used to emit misreported it as "no such user", hiding real faults from operators. `actor_json` calls `get_local_actor`, which genuinely fails for a missing user, and a federation peer probing an actor URL should get 404 — so it is untouched, deliberately. Two tests in `crates/presentation/src/tests/api_handlers.rs` pin the new behavior; both were observed failing with `left: 404, right: 500` before the change, and both send `Accept: application/activity+json`, without which the handlers redirect and the tests would have passed against the old code too. **Jellyfin and Plex parser construction moved to the composition root.** `presentation`'s two webhook handlers used to name `jellyfin::JellyfinParser` / `plex::PlexParser` inline, passing each unit struct by reference straight into `run_ingest`. The parsers are now built once in `composition::build_deps` and held on `application::Deps`'s `IntegrationsGroup`; the handlers read `state.app_ctx.deps.integrations.{jellyfin,plex}_parser`. `jellyfin` and `plex` moved from `presentation`'s `[dependencies]` to `composition`'s, unconditionally. `ingest::execute`'s signature and its test-suite `FakeParser` injection point are untouched. **A fourth Makefile guard, `check-presentation-adapters`, encodes the rule.** `make check` now runs `fmt-check clippy test check-appcontext check-handler-deps check-handler-repos check-presentation-adapters`, and both CI workflows (`.github/workflows/ci.yml:47` and `.gitea/workflows/ci.yml:47`, which must stay in sync) invoke it. **It reads `Cargo.toml`, not `src/`, deliberately** — for exactly the reason the `auth`/`rss` audit above failed: a source-level `::` grep cannot distinguish an adapter crate from a same-named module inside `application` or `presentation`, so it is structurally incapable of deciding this question. The guard `awk`s the `[dependencies]` section, `sed`s out the key of each `key = ...` line, and rejects any that matches a directory name under `crates/adapters/` other than `template-askama`. All 26 adapter directory names equal their package `name` today (verified by reading each `Cargo.toml`), which is what makes directory names a safe source of truth. Both behaviors were observed: `jellyfin` added under `[dependencies]` produced `❌ presentation depends on adapter crate(s): jellyfin` and exit 1; the same line under `[dev-dependencies]` passed, which is what proves the `awk` range actually stops at the next section header rather than scanning the whole file. **`cargo build -p worker --no-default-features --features sqlite` compiles again, closing a follow-up ADR-0005 opened and ADR-0006/0007 carried forward.** `crates/worker/src/follow_backfill_handler.rs` named `activitypub::ActivityPubPort` unconditionally, with no `#[cfg(feature = "federation")]` gate, so the federation-off worker build failed with E0433. `FollowBackfillHandler` now holds `Arc`, a trait from a crate the worker depends on unconditionally, so the gate is unnecessary rather than merely added. Three `map_err` closures that converted `anyhow` to `DomainError` at the call site collapsed to bare `?`, because the port already returns `DomainError` — verified to produce the same `DomainError` and the same log output as the closures did. ### Four things this ADR does not claim These were measured or observed during the work and are recorded rather than fixed. This project documents its guards as useful-not-airtight (see ADR-0007 on `check-handler-repos`); the same honesty applies here. **`check-presentation-adapters` has four known blind spots.** Each needs an atypical or deliberately obfuscated manifest form, and none is present today: 1. `[dependencies.jellyfin]` dotted-table form — `awk`'s `/^\[/` closes the range, and the crate name lives in the section header rather than on a `name = ` line. Invisible to the guard. 2. `[target.'cfg(...)'.dependencies]` — never scanned at all; the range opens only on an exact `^\[dependencies\]`. This is the most plausible *accidental* blind spot, though unlikely in a web backend crate. 3. `alias = { package = "jellyfin", workspace = true }` — the `sed` extracts `alias`, which matches no adapter directory. Requires deliberate evasion. 4. Directory name equal to package name is *assumed*, not enforced. True for all 26 adapters today; a future adapter whose directory and package names diverge would be unguarded. **Two HTML handlers now log one extra line on the error path.** The plan's contract for Tasks 1-7 was "identical status codes, identical response bodies, identical log lines", and the third of those is not literally true. `ap_err` in the adapter logs `ActivityPub error: {:?}` before returning — the same message, at the same level, that presentation's deleted `ap_to_domain` used to emit — so the two blocklist HTML handlers (`handlers/social.rs` `post_blocked_domain`, `post_remove_blocked_domain`), which log their own line on error, now emit two lines where they emitted one. The two collection handlers (`get_followers_collection`, `get_following_collection` in `handlers/social.rs:707` and `:742`) previously logged nothing on the error path and now log two lines, not one: the adapter's `ap_err` line and their own (`followers_collection_json error: {:?}` / `following_collection_json error: {:?}`), so their error path went from 0 lines to 2. The two lines are complementary, not duplicative — the adapter's carries the cause, the full `anyhow` chain via `{:?}`; the handler's identifies which operation failed. Strictly more detail, same level, no client-visible change; unavoidable once the conversion point moved into the adapter, short of reintroducing a shim in presentation purely to suppress it. **"The worker sqlite-only build compiles" is not "it is clippy-clean."** `cargo clippy -p worker --no-default-features --features sqlite -- -D warnings` reports three errors: unused `app_config`, unused `remote_goal`, and `FollowBackfillHandler is never constructed`. The first two predate this work and are unrelated to it. No gate runs this combination — `make clippy` is `cargo clippy -- -D warnings` and CI adds `--all-targets`, both at default features, where the worker's defaults are `["sqlite", "sqlite-federation"]` and the struct *is* constructed. Gating `mod follow_backfill_handler;` behind a `federation` cfg would silence one of the three and leave the combination dirty regardless, so it was measured and deliberately not done. **`cargo check -p presentation --no-default-features --all-targets` fails, and failed identically before this work.** Two errors, both in the test target: E0433 `cannot find social in handlers` (`handlers::social` is federation-gated; something in the test tree names it unconditionally) and E0277 `Panic: RemoteWatchlistRepository` not satisfied. Measured on the worktree and on master at `051d9ce` with the same command and the same two errors, so it is pre-existing, not a regression from the port swap. The library itself compiles federation-off both before and after — `cargo check -p presentation --no-default-features` finishes clean on both — so the `#[cfg(feature = "federation")]` gates around the new `ap_document` / `ap_blocklist` fields do hold. It remains a known-broken feature combination in the repo — not the only one; see the `server`/`worker` `federation` meta-feature gap recorded under Known follow-ups below, which is also pre-existing. ## Considered Options - **Moving `ActivityPubPort` into `domain` whole, accepting a `k_ap` dependency in the domain layer** — the option the previous two ADRs implicitly assumed and rejected, which is why the work kept being deferred. Rejected here too, but the point is that it was never the only option: splitting by consumer means the two `k_ap` types that forced the dependency (`RemoteActor`, `BlockedDomain`) land respectively in the eight methods that need no trait at all and in one method whose three scalar fields a domain-owned record reproduces exactly. - **One `domain` trait with all nine cross-boundary methods, instead of three** — simpler to wire: one `Arc` on `AppContext` instead of two, and one trait to name instead of three. Rejected because the three consumer sets are disjoint: presentation would hold a handle exposing `run_backfill_for_follower`, the worker one exposing `actor_json`, and the ability to call a method is what the previous two ADRs were about removing. Three traits make "no HTTP handler runs a backfill" a compile-time fact rather than a convention. - **Grepping `src/` for adapter crate names instead of reading `Cargo.toml`** — the intuitive form of this guard. Rejected because it was tried as the audit method and demonstrably produced wrong answers in both directions: `auth` and `rss` looked live because `application::auth` and `handlers::rss` exist, while fully-qualified call sites are invisible to a `use ::` pattern. A guard cannot be more reliable than the measurement technique it encodes. - **Deleting `port.rs` in the same task that added the new ports** — the plan's original shape, which would have left `presentation`, `server`, and `worker` uncompilable for three tasks and required skipping `make check` in between, contradicting the plan's own green-at-every-commit constraint. Split into additive-first (new ports alongside the old trait) and delete-last (after every consumer moved), which is why the branch has an extra commit and no known-red window. ## Known follow-ups, out of scope here - **Collapsing the worker's three backfill calls into one adapter-side operation.** `FollowBackfillHandler` calls `get_following`, filters for the actor, then `import_remote_outbox`. That filtering could live behind one port method, but moving it would move logic across the boundary during a refactor whose contract was behavior preservation. Not started; a real question, deliberately left open. The duplication is also a naming collision: `ApBackfillPort::get_following` is now the third `get_following` in the `domain::ports` glob namespace, alongside `FollowGraphQuery::get_following` (`crates/domain/src/ports/social.rs:47`) and `FollowQuery::get_following` (`crates/domain/src/ports/follow.rs:53`). All three compile and answer different questions, but a reader seeing `deps.something.get_following()` now has three candidates. - **Splitting `actor_json`'s error so a missing actor and an infrastructure failure become distinguishable.** Today both produce `DomainError::InfrastructureError` and the handler returns 404 for either. Doing it properly needs `k_ap` to expose a typed error; doing it by inspecting the `anyhow` message for a substring would be fragile against a third-party crate's wording. Rejected as currently formulated, not deferred for lack of time. - **`service`'s reachability gap, noted above.** `CompositeSocialAdapter::new` is only called from `crates/server/src/main.rs:127`, outside this crate, using `ActivityPubWire::service`. Moving that construction into `wire()` itself and exposing `Arc` / `FollowGraphQuery` / `BlockQuery` on `ActivityPubWire` instead would let `service` become crate-private, and would make "the eight adapter-internal methods are unreachable from outside the crate" true of reachability, not just of call sites, closing the gap recorded above. Not started here — `wire()` doesn't have the `UserRepository`, `FollowCommand`, and `FollowQuery` handles `CompositeSocialAdapter::new` takes, and threading them through is real wiring work, not a rename. - **The `PRESENTATION_ALLOWED_ADAPTERS` guard trusts `template-askama` by name, not by property.** `PRESENTATION_ALLOWED_ADAPTERS := template-askama` permits that crate because it is a renderer today, but nothing stops it from later acquiring a reaching dependency and smuggling it through the guard transitively — the Makefile checks the manifest's direct `[dependencies]` entries, not what `template-askama` itself depends on. It is clean today: its own `[dependencies]` are `askama`, `chrono`, `uuid`, `domain`, `api-types`, none of which reach. The "renderer, not reacher" premise genuinely holds, but it is trusted rather than checked. - **A fourth known-broken feature combination, pre-existing and untouched by this branch:** `cargo check -p server --no-default-features --features sqlite,federation` fails with E0433 on `activitypub`. `server`'s `federation` feature (`crates/server/Cargo.toml:14`) is a bare meta-feature that enables `application/federation` and `presentation/federation` but does not imply `dep:activitypub` — only `sqlite-federation` and `postgres-federation` do that. The same gap exists in `crates/worker/Cargo.toml`. Neither manifest was modified by this branch (both last touched at `4d8f836`, before this branch's merge-base `051d9ce`), so this is recorded here rather than attributed to this plan. - The other deferred items recorded in ADR-0005/0006/0007 are unaffected by this plan: the isolated `cargo test -p composition` `worker_deps_wiring` failure, the admin-extractor body assertions, the SPA zod schemas, and `check-handler-repos`'s substring fragility. The one item this plan does close is the worker sqlite-only build, as recorded above. - `crates/presentation/Cargo.toml`'s `[dev-dependencies]` still lists `composition`, which ADR-0007 established as legitimate — test helpers build a real `Repositories` and call `composition::build_deps`. `check-presentation-adapters` intentionally does not scan that section, and it does not need to: `composition` is not an adapter crate.