# `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 <crate>::` misses the
fully-qualified call sites, and plain `<crate>::` 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<ActivityPubService>` 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<String>, 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<RemoteActor>`, 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<k_ap::ActivityPubService>` 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<ActivityPubService> }` 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
`<crate>::` 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<dyn domain::ports::ApBackfillPort>`, 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 <crate>::` 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<dyn SocialCommand>` / `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.
