A public profile (`profile_visibility = 'public'`) with zero public routes or activities now returns 200 and renders the empty profile shell instead of 404. The earlier change kept the legacy "AND has public content" gate to preserve a no-existence-leak guarantee, but that conflated the implicit behavior with the new explicit setting. With profile_visibility now a deliberate toggle, the cleaner contract is: visibility = public means the profile renders, period. Followability already only depends on profile_visibility, so this brings the public-page contract in line with that. The `private` toggle remains the way to hide a profile from visitors. Updates social-feed change spec + design to match (drops the "AND has at least one public" predicate and the matching 404 scenario; adds an explicit "empty public profile renders an empty shell" scenario). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 KiB
Context
trails.cool has the primitives for a social layer in place but nothing wired up:
- Users have public profiles at
/users/:username(postpublic-content-visibility). - Activities and routes have
public | unlisted | privatevisibility. - There is no
followsrelation today, no follower/following lists, and no aggregated feed. - No ActivityPub yet. The Journal does not currently speak ActivityPub. There are no actor objects, no inbox, no outbox, no HTTP-signing layer, no Fedify dependency. Federation is genuinely Phase 2 (per
CLAUDE.md) and is being scoped separately as thesocial-federationchange.
This change is local-only social: follows between users on the same Journal instance, and the /feed aggregation. The schema is shaped so federation can layer on without a migration when it arrives.
Goals / Non-Goals
Goals:
- A logged-in user can follow another local user with a public profile and see their public activities in a
/feedview. - Profile visibility is explicit (
users.profile_visibility), gates followability deterministically, and pre-pays the model for federation and locked accounts. - Follower and following counts + lists are public per local instance.
- Unfollow fully cleans up.
- Schema is forward-compatible:
followskeyed byactor_iri TEXTso when remote follows land insocial-federation, they slot in with no migration.
Non-Goals:
- All ActivityPub federation primitives — Fedify integration, actor objects, WebFinger, signed inbox/outbox, remote actor caching, audience-aware ingestion. Deferred to
social-federation. Thefollowsschema is shaped to accept remote IRIs but no remote IRIs will land in this change. - Locked local accounts (a manual approval flow for our own users). Out of scope; the
'public' | 'private'enum onprofile_visibilityis intentionally minimal so alockedvalue orusers.lockedflag can extend it cleanly. Tracked as follow-uplocked-local-accounts. - Blocking, muting, or report flows.
- Direct messages or any non-activity ActivityPub object.
- Real-time WebSocket feed updates. The feed is loader-driven and refreshes on page load.
- A "people you may know" / suggestions surface.
Decisions
Decision: Make profile visibility explicit on the users row
Today the Journal has no profile-level visibility setting. A profile is "public" implicitly: /users/:username returns 200 iff the user has at least one public route or activity, otherwise 404 (and the 404 doesn't distinguish "no such user" from "private content only" so existence isn't leaked).
Add users.profile_visibility: 'public' | 'private' (NOT NULL, default 'public').
The new rules:
/users/:usernamereturns 200 iffprofile_visibility = 'public'. A public profile with zeropublicitems renders an empty shell (header + empty-state copy in the routes/activities sections), matching Mastodon-style "discoverable account, no posts yet" behavior. The explicitprofile_visibilitytoggle is the contract for hiding the profile; existence of a public-but-empty account is observable, and that's accepted.- A user is followable iff
profile_visibility = 'public', regardless of whether they have content yet. Following someone before they post is reasonable; the follower's feed just stays empty for that follow until the user posts.
When social-federation lands, the local user's ActivityPub actor object will gate on the same profile_visibility = 'public' check — private profiles will return 404 to federation lookups too.
Default 'public': matches fediverse convention (Mastodon defaults to discoverable; "lock" is opt-in), aligns with the existing implicit behavior where any user could be public, and keeps onboarding smooth (post a public route → it's listed on your profile, no extra toggle). Activity-level privacy still defaults to 'private', so content stays private by default; being findable on the network is the part we default open.
Migration: backfill all existing users to 'public'. Operators can flip themselves to 'private' post-migration if desired.
Why explicit, why now: with follows landing, the question "can someone follow you?" needs a deterministic answer. Deriving it from "do you have any public content?" is fragile (toggling content visibility silently flips followability). The toggle also pre-pays for locked-local-accounts, which will extend this enum or add a users.locked flag.
Alternative considered: add a 'public' | 'unlisted' | 'private' triple to mirror activity visibility. Rejected for now — 'unlisted' for profiles ("actor object resolvable but profile page 404s") is a real fediverse pattern but a confusing UX surface to ship before users ask for it. Add as a third state if needed later.
Decision: Pull-based feed, not fan-out-on-write
The feed query is:
SELECT a.* FROM journal.activities a
JOIN journal.follows f ON f.followed_user_id = a.owner_id
WHERE f.follower_id = :currentUser
AND f.accepted_at IS NOT NULL
AND a.visibility = 'public'
ORDER BY a.created_at DESC LIMIT 50;
We pull at read time rather than precomputing per-follower timelines at write time.
Why: our scale is tiny (hundreds of users, not millions), and pull keeps us free of the consistency+cleanup headaches that fan-out-on-write brings (deletes, visibility changes, unfollows, backfill on new follow). A (follower_id, created_at DESC) index on follows + the existing (created_at) index on activities makes the join O(k log n) for k follows, which is fine to well past the scale trails.cool cares about for years. The same query naturally extends to remote activities once social-federation adds the remote-content cache.
Alternative considered: write-side fan-out into a per-user timeline table. Rejected for the complexity vs. the scale we'll actually hit.
Decision: Store follows keyed by actor IRI even though all rows are local today
Schema:
follows(
id UUID PRIMARY KEY,
follower_id TEXT NOT NULL REFERENCES users(id), -- always a local user
followed_actor_iri TEXT NOT NULL, -- e.g. https://trails.cool/users/bruno; future: remote IRIs
followed_user_id TEXT REFERENCES users(id), -- non-null for local follows; FK for fast joins
accepted_at TIMESTAMPTZ, -- always set in this change (auto-accept for local public)
created_at TIMESTAMPTZ DEFAULT now()
)
UNIQUE (follower_id, followed_actor_iri)
INDEX (follower_id, created_at DESC)
INDEX (followed_actor_iri)
The follower is always a local user. The followed side is an actor IRI. In this change all IRIs are local (built as https://{DOMAIN}/users/{username}). followed_user_id is always populated, so queries that need the full user row do an indexed FK join.
Why keyed by IRI now: ActivityPub is IRI-first, and the social-federation change will need this column to point at remote actors. Adding it now (instead of just a followed_user_id FK) costs one extra column and a tiny IRI-builder helper today, and saves a real schema migration + backfill when federation lands. The denorm followed_user_id keeps query performance identical to a pure-FK design for local-only operation.
Why accepted_at even though it's always set in this change: the column models the eventual lifecycle (Pending → Accepted → Rejected/Undone) that arrives with federation. Marking the column nullable now means no migration later when remote follows can sit in Pending.
Decision: Feed lives at /feed (new route), not on /
The signed-in home already is the personal dashboard. Overloading it with tabs now adds product complexity without much payoff. A dedicated /feed is discoverable via the nav and mirrors how Mastodon separates home and local timelines.
Alternative: tabs on / (Personal / Following / Public). Rejected for this change to keep scope small; easy to move later.
Risks / Trade-offs
- Privacy of follow lists: follower/following lists are public on the instance. → Explicit, documented in the privacy manifest; matches Mastodon norms; users who don't want to be discoverable as a follower should set
profile_visibility = 'private', which 404s their profile and (when federation arrives) their actor object. - Empty feed on small instances: a typical local-only instance will have few people to follow. → Acknowledged; the empty-state copy points users to
/users/:usernameprofiles to find folks. Federation will widen the pool, but isn't part of this change. - Schema churn at federation time: even with the forward-compatible IRI column,
social-federationwill likely addremote_actors+ audience columns + per-user keypair storage. → Accepted; the federation change can iterate on its own schema. The local-only follows + feed survive untouched. - Feed query growth: at 50k activities × 1k follows, the join could degrade. → The
(follower_id, created_at DESC)index keeps this fine up to ~10k follows per user; above that we'd revisit fan-out-on-write. Punt. - Marketing copy currently overstates federation: the Journal home blurb says "Federated by design — your Journal instance talks to other trails.cool servers via ActivityPub. Follow friends anywhere." → Out of scope for this PR; soften copy as a separate one-liner change before public launch, or leave as forward-looking until
social-federationlands.
Migration Plan
- Land schema via
drizzle-kit push --force— addsfollowstable andusers.profile_visibilitycolumn. No row backfill needed (column default fills existing users with'public'). - Ship API + UI behind no flag — the feature is additive and a user with zero follows just sees an empty
/feed. - Document the new relation + setting in the privacy manifest before user-visible release.
- Rollback: revert the PR. The
followstable can be dropped without affecting other data; theprofile_visibilitycolumn can be dropped, returning the implicit "public iff has public content" behavior (loaders already check that).
Open Questions
- Should the initial nav entry be labeled "Feed" or "Following"? "Feed" is shorter and matches the route; "Following" is clearer about what's in it. Tasks phase can pick — lean "Feed" for now.
- Do we surface the instance's local public feed anywhere for signed-in users post-change? Today they see it only when signed out. A "Local" tab alongside the Following feed could be a later follow-up; not in this change.