8.9 KiB
InstanceIdentity owns URL derivation; social queries decompose
ADR-0002 and ADR-0003 unified local and federated social operations, but left base_url
travelling as a bare &str: a parameter on FollowQuery methods, a field on
CompositeSocialAdapter, and an argument to SocialIdentity::from_actor_url,
format_local_handle and host_from_base_url, scattered across many call sites. With no
owner, each layer re-derived URLs its own way — and handlers/social.rs derived
local:{uuid}, which from_actor_url could not parse, so every POST echoing a local actor's
url back resolved to Remote and was routed to ActivityPub. Accepting a local follow request
through the SPA was broken.
Decision
InstanceIdentity(crates/domain/src/value_objects/instance.rs) is a domain value object owningactor_url_for,handle_for,image_url_for,actor_url_of,identifyandhost.SocialIdentity's static URL helpers (from_actor_url,format_local_handle,host_from_base_url) are deleted so the duplication cannot return.- AP object URLs — review, goal and watchlist-entry URLs — deliberately stay out of
InstanceIdentityand live in the ActivityPub adapter'surls.rs(crates/adapters/activitypub/src/urls.rs), which takes&InstanceIdentityand builds oninstance.base_url()/instance.actor_url_for()rather than re-deriving. A review, goal or watchlist entry isn't an identity of this instance the way an actor URL or handle is — it's a content object's location — so folding them into the value object would turn it into a general URL grab-bag instead of an identity abstraction.actor_urlis the one exception that delegates straight toinstance.actor_url_for(...), so there is exactly one construction of an actor URL in the codebase. The five ActivityPub adapter structs (ActivityPubEventHandler,GoalObjectHandler,ReviewObjectHandler,DomainUserRepoAdapter,WatchlistObjectHandler) andhandlers/users.rs's image-serving endpoints now hold/useInstanceIdentityinstead of a barebase_url: String, closing the gap the whole-branch review found: this ADR's scope claim now holds for the entire codebase, not just the follow/social/profile-identity paths that landed first. handle_fortakes&str, not&Username. Database rows carry unvalidatedOption<String>usernames; requiring aUsernamewould force revalidation of data that is already stored and already trusted.- Local actors are identified on the wire by their canonical AP actor URL,
{base_url}/users/{uuid}— what the database already stores.identify(actor_url_of(id)) == idis a tested domain property (instance_actor_url_round_trips_both_variantsincrates/domain/src/tests/value_objects.rs). - Adapters hold an
InstanceIdentityrather than receivingbase_urlper call; it is the same value on every call.CompositeSocialAdapterandGetProfileDepsboth carry one. SocialQuerysplits intoFollowGraphQueryandBlockQuery, along the seamCompositeSocialAdapteralready showed:get_blockedis the only method that reaches k_ap (ap_service.get_blocked_actors); everyFollowGraphQuerymethod — including the newget_relation— goes through the localfollow_querySQL port instead.is_following -> boolbecomesget_relation -> FollowRelation, carrying both directions (following/followed_by) and distinguishing pending from accepted.get_relationpropagates sqlx decode errors asDomainError::InfrastructureErrorrather than folding them into the sameNonethat means "no follow edge" — the original snippet used.ok().flatten(), which would have reported a broken query as a stranger.- Social queries become one file per use case (
get_followers.rs,get_following.rs,get_blocked.rs,get_pending_followers.rsundercrates/application/src/social/), matchingdiary/andusers/and making ADR-0002's one-file-per-use-case claim actually true — it described a pattern the query side never had until now.SocialQryandexecute_queryare deleted outright, along with thequeries.rsfile that held nothing else. - The
SocialCmdcommand dispatcher (crates/application/src/social/execute.rs) stays as a singleexecute_commandmatching on an enum. Every branch is genuinely "call port, return event" andexecute_commandpublishes the event — a real, uniform abstraction, not an accident of history the way the query enum was. get_relation's application-layer wrapper is deferred, not built. The port method exists and is tested (againstInMemorySocialRepository; neither SQL adapter's query is exercised yet), but nothing calls it until a follow-up plan adds a/social/relationshipendpoint — building an unused public function now would be overbuilding.- The dead
SocialCommandDeps.social_queryfield was removed.execute_commandnever read it; every construction site was cloning anArcnothing consumed. get_profile(crates/application/src/users/get_profile.rs) now assembles the wholeProfileIdentity— username, display name, bio, handle, actor URL, avatar/banner URLs — from theUserentity andInstanceIdentity, rather than leaving the handler to fill inNonefor fields it didn't have.display_nameandbioare populated for the first time; the API had always returnedNonefor both despite the entity exposing them.get_profile's missing-local-row case was deliberately tolerant, notNotFound.build_federated_profile_responsebuilt its ownGetProfileDepsand calledexecute()a second time with a federateduser_idthat has no row in the localuserstable — there are threeexecutecall sites inhandlers/users.rs, not one, and the file aliases the import asget_user_profile_uc::execute, which hides two of them from a naive grep. ReturningNotFoundhere would have broken federated profile viewing outright. The tolerant branch was covered by a permanent regression test,tolerates_a_user_id_with_no_local_row_for_federated_style_calls.- Known wart, closed: the tolerant branch filled
usernameandhandlewith empty-string sentinels, butactor_urlwas still a well-formed-looking{base_url}/users/{uuid}pointing at a user that does not exist locally — one field obviously wrong, one plausibly wrong. It was inert only because the federated handler never readprofile.identity(it built its own handle/actor_url from the resolvedfedactor). A follow-up refactor (see the C1 bypasses-and-server-split plan, task 1) made that "moment any caller starts consumingprofile.identityon the federated path" arrive:get_profilesplit intousers::get_local_profile—Err(DomainError::NotFound(_))for a missing local row,identityalways fully populated — andusers::get_federated_profile_stats, whose return type (FederatedProfileStats) carries noidentityfield at all, because the federated handler never read one. The old regression test now lives asget_federated_profile_stats::tests::succeeds_for_a_user_id_with_no_local_row, and a new one,get_local_profile::tests::get_local_profile_is_not_found_for_unknown_user, pins the behavior change directly. No type in the codebase can represent the old sentinel anymore. ADR-0006, covering the rest of the bypasses-and-server-split plan, cross-references this closure.
- Known wart, closed: the tolerant branch filled
Considered Options
- Teach
from_actor_urlto parselocal:{uuid}— rejected because it keeps two encodings for one concept and lets the API's on-the-wire form diverge from the stored and federated form. - Pass
base_url: Stringin deps structs and keep the static helpers — rejected because it fixes the symptom while leaving the primitive threaded through every layer and the duplicatedformat!calls in place. - A port for instance identity — rejected as needless indirection over a static config value; a value object is testable without mocking.
- Decompose commands as well as queries — rejected because the command enum's uniform
"call port, return event" shape is a real abstraction, and splitting it would produce seven
near-identical files plus a dissolved event-publishing seam. The query enum was the one worth
breaking up because it forced every query into a single
Vec<SocialActor>return type, which a count or a relationship could not fit.
Footnote, unrelated to the design decisions above: writing this ADR discovered that .gitignore's
docs/ + !docs/adr/ pattern never actually re-included anything under docs/adr/ for new
files — excluding a directory outright stops git from descending into it, so the negation was
dead on arrival. It silently swallowed docs/adr/0001-general-review-editing.md, which existed
on disk with zero git history until this ADR's landing fixed the pattern to docs/* +
!docs/adr/ and recovered it in the same commit.