Public user-profile pages, gated by the existing visibility predicate
Esta página aún no está disponible en tu idioma.
Amendment (2026-08-13, #1023 / PR #1068) — §3 has a SECOND consumer, and the ladder was copied THREE more times
Section titled “Amendment (2026-08-13, #1023 / PR #1068) — §3 has a SECOND consumer, and the ladder was copied THREE more times”#557 (below) gave the rule one home in users.ResolveDisplayName. It did not find the copies
living in SQL. #1023 found three, character-for-character identical, each a two-rung ladder
(COALESCE(NULLIF(up.display_name,''), u.username, '')) that consulted neither §3’s anonymous
fullname skip nor ADR 0024’s hide_from_anonymous opt-out:
app/internal/posts/handler.goapp/internal/collections/resources_page.goapp/internal/visibility/fields.go
⭐ The reachable leak was through the first two, not the third, and this is the part worth
carrying: an anonymous caller is floored to sensitivity='public' by the row predicate, so they
never meet a restricted asset through browse or search — they meet one through a PUBLIC
COLLECTION or POST that contains it. A fix aimed only at the visibility package would have
patched the copy an attacker cannot reach. (The planning agent’s brief did exactly that; the
implementing agent found the live path.)
Now: one SQL builder (visibility.OwnerDisplayNameSQL) called by all three sites, one Go
form (users.PlaceholderOwnerName), both driven by a single shared ladder and held together by
TestOwnerDisplayNameSQL_MatchesGo — the third instance of ADR 0063’s twin discipline, after
ContentReadableSQL and FieldsReadableSQL.
- Authenticated:
display_name → fullname → username - Anonymous:
NULLif opted out, elsedisplay_name → username—fullnameskipped, which is §3. - Rung 4 (
user {ref}) is deliberately not transcribed;withheldAssetomitsowner_user_ref.
⚠️ A widening rode along and is safe only because of the guard: authenticated callers now get
the fullname rung on placeholders, matching post author headers, where the SQL ladder previously
had no such rung. Adding it without the anonymous skip would have been precisely the #557 leak,
one layer down.
Severity, for the record: the anonymous branch is only reachable on a public install — a
private install 401s anonymous callers outright.
Amendment (2026-08-11, #557) — §3’s anonymous rule has ONE home now, and its published description was stale enough to leak
Section titled “Amendment (2026-08-11, #557) — §3’s anonymous rule has ONE home now, and its published description was stale enough to leak”§3 decided that an anonymous viewer never sees a user’s real name — “not directly, and not
smuggled through the display_name fallback.” #478 implemented it by skipping the fullname rung
of the display-name ladder for anonymous callers. That exception was never written into the
rule’s own documentation, and #557 nearly shipped a leak because of it.
What was wrong. Both the UserPublic schema description in openapi.yaml and the doc comment
on users.rowToAPI stated the precedence as three rungs — profile.display_name → user.fullname → user.username — with no mention of the anonymous case. The correct code sat four lines below
that comment. A brief written from the published description told a coding agent to “reuse that
resolution” for the new feed card’s author header. Seeded users all carry a real name in
fullname and no display_name, so following it would have rendered “Akira Tanaka” on a
public feed card where the rule requires akira.tanaka.
What changed. The precedence is now one exported expression — users.ResolveDisplayName —
taking anonymous as an argument, called by both the profile path and the new post-author path.
The schema description and doc comment now state the anonymous exception. Removing the skip fails
tests in both packages, which is the proof the expression is genuinely shared rather than
copied.
The transferable lesson, recorded because it is not really about display names. A rule with a
security exception is the dangerous kind to document by summary: the happy path matches the
summary, so the divergence only appears on the path nobody demos, and the summary is what everyone
downstream quotes. When this ADR’s decisions are implemented, the implementing expression should
say so and name this ADR — and anyone briefing work against it should state the rule from the
function body, citing file:line, rather than from a description.
⚠️ Known remaining gap, tracked as #1023. The opt-out is honoured by the profile route (404)
and now by the post author (author: null), but not by owner_display_name on a
restricted-member placeholder, which still names an opted-out user to anonymous callers. It cannot
leak a real name (that path never consults fullname), so this is an opt-out gap rather than a
PII leak — but it is the same question answered inconsistently across three call sites, and the
answer should live in one expression.
Context
Section titled “Context”The route/link-integrity net from ADR 0068 (added in #477) surfaced three pre-existing dead internal links (#478) — components that link to routes that never existed:
/users/by-username/[username]— rendered byUserMenu,MobileNavDrawer, and post-author links./users/by-ref/[ref]— rendered by notifications./posts/by-asset/[id]— rendered bySimilarAssetsPanel.
They are parked in a KNOWN_GAPS allowlist so the net still catches
new dead links; each entry must be deleted the moment its route
lands. Clicking a user’s name or a similar-asset link dead-ends
today.
Two features are implied: (1) a user-profile page resolvable by both username and ref, and (2) a post-by-asset lookup route. The open product question was whether these pages are public (anonymous-visible) or authenticated-only — the “what does the visitor see” call that ADR 0063’s public-mode work deferred for per-surface decisions.
Nothing in the ADRs decided it. ADR 0024 (privacy and consent) supplies the frame — anonymous exposure is default-off with per-user opt-out — but does not itself decide that profile pages exist or what they show. This ADR makes that call.
Decision
Section titled “Decision”Build public user-profile pages, gated by the existing visibility predicate — not a new access model.
-
The pages exist, resolvable two ways:
/users/by-username/[username]— the human-facing permalink./users/by-ref/[ref]— the stable-identifier path used by notifications and cross-references. Both render the same profile. This mirrors ADR 0067’s standalone-permalink treatment for assets.
-
A profile shows a display name and avatar, plus exactly the assets, posts, and collections the viewer is already allowed to see — the profile’s content lists run through the same
visibilitypredicate (ADR 0063) and content plane (ADR 0064) that govern every other listing. There is no new enforcement plane: a profile is a filtered view keyed onowner, nothing more. An anonymous viewer sees the owner’s public work; an authenticated teammate additionally sees team-tier work they’d see anywhere else. The plane never confirms restricted content (ADR 0064) — a profile is not a side channel around it. -
Anonymous visibility follows public mode (ADR 0063). With public mode off, profiles require authentication like the rest of the surface. With it on, a profile is anonymous-visible showing only public content — and the owner can opt out of anonymous exposure per the ADR 0024 default-off/opt-out model. Personal data beyond display name + avatar (email, real name) is never on the public profile.
-
post-by-assetresolves to the posts that feature an asset. An asset can be a member of more than one post, so the route is a “posts featuring this asset” resolution, not a 1:1 redirect (it may redirect when there is exactly one, list when there are several) — and the list is visibility-filtered like any other. -
Each landed route deletes its
KNOWN_GAPSentry in the same PR, returning the link-integrity net (ADR 0068) to zero-tolerance for that shape.
The two features are independent slices: the profile page (rows 1–2 of #478) and the post-by-asset route (row 3).
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- The artist-portfolio surface an “artist alley” platform is named for — a public page per creator showing their work — exists, and costs almost nothing to enforce because it rides the visibility predicate rather than inventing a profile-specific access rule.
- Attribution and discovery links (author names, similar-asset →
post) stop dead-ending; the three
KNOWN_GAPSentries clear. - Privacy posture is inherited, not reinvented: public mode gates anonymous access, ADR 0024 gives owners the opt-out, and no personal data beyond display-name/avatar is exposed.
- No new authorization code to get wrong — the risky part (who sees what) is the predicate that already has a contract test.
Negative
Section titled “Negative”- A profile aggregates a user’s public work in one place, which is a discovery surface some users may not expect. The ADR 0024 opt-out is the mitigation; it must be wired before profiles go anonymous, not after.
post-by-assetreturning a list (not a single post) is a small UX branch to design (which post to land on when several feature the asset).- Display-name/avatar become a lightly public surface; abuse/ moderation of those fields is now in scope (defer to the existing moderation track, not this ADR).
Alternatives considered
Section titled “Alternatives considered”- Authenticated-only profiles. Same page, never anonymous. Simpler privacy story, but discards the public-portfolio value that fits the platform’s thesis, and still needs the same visibility gating for the authenticated case. Rejected as the default; public mode + opt-out already bounds the exposure.
- Defer — hide the dead links. Remove the author-name and
similar-asset links so nothing 404s, revisit post-v1. Lowest
effort, but removes attribution and discovery, and leaves
KNOWN_GAPScarrying entries indefinitely. Rejected. - A profile-specific access model. A dedicated “profile visibility” setting separate from the predicate. Rejected: it would be a second expression of the visibility rule (the exact drift ADR 0063 exists to prevent).
References
Section titled “References”- ADR 0024 — Privacy and consent. Default-off anonymous exposure + per-user opt-out; the frame for a public profile’s personal data.
- ADR 0041 — Identity-provider registry. Profiles are for platform users regardless of how they authenticate.
- ADR 0060 — Public read-only demo. Profiles are part of the anonymous browsing surface the demo exercises.
- ADR 0063 — Content-visibility predicate. The single enforcement point a profile’s content lists reuse.
- ADR 0064 — Content-visibility plane. A profile never confirms restricted content.
- ADR 0067 — Asset permalink route. The standalone-permalink pattern the profile route mirrors.