First step of the public-viewing feature (design: docs/design/tree-visibility.md). No non-member behavior change yet — this only widens the vocabulary and UI. - TreeVisibility gains `site_members` (any authenticated user of the instance), giving the four-level model: public / site_members / unlisted / private. - Alembic migration adds the enum value via an autocommit block (ALTER TYPE ADD VALUE can't run in a transaction on older Postgres); downgrade is a no-op since PG can't drop an enum value. - Regenerated openapi.json + frontend TS client. - Trees-list dropdown now offers Private / Public – Members / Unlisted / Public with an explanatory tooltip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
7.5 KiB
Design note: tree visibility & the public viewing surface
Status: proposed (design only — no code yet). Owner: Justin. Created 2026-06-09.
This is a privacy-critical change (it creates the first anonymous read surface in Provenance). Per CLAUDE.md, design before code. Implementation should land in small, individually-reviewable PRs, with tests on the privacy engine and the public read path before any anonymous endpoint is exposed.
1. The model
Visibility flattens two axes — who may read and how discoverable — into one ordered enum for the UI:
| Level | Anonymous (no login) | Any logged-in user | Tree members | In-app directory | Search-indexed |
|---|---|---|---|---|---|
public — anyone on the web |
✅ view¹ | ✅ view¹ | ✅ full | ✅ listed to everyone | ✅ sitemap + indexable |
site_members — Public, Site Members |
❌ | ✅ view¹ | ✅ full | ✅ listed to logged-in users | ❌ (noindex) |
unlisted — anyone with the link |
✅ via direct link¹ | ✅ via link¹ | ✅ full | ❌ never listed | ❌ (noindex) |
private |
❌ | ❌ | ✅ full | ❌ | ❌ |
¹ Every non-member view passes through the privacy engine. Living people are
redacted, and per-person private hides / public reveals, exactly as
person_visibility() already does (backend/app/services/privacy.py:100-110).
This is the single enforcement point — no public code path may issue a raw query.
Decisions captured (2026-06-09):
- Unlisted = anyone with the link, no account required. The link must be
unguessable (the tree UUID is already non-enumerable; do not add a public
integer id). Unlisted trees are excluded from the directory and sitemap and
served
noindex. - Public discovery for v1 includes an in-app public browse/search, not just search-engine indexing.
- Public – Site Members = any registered account on this instance (not an
invite list — that is already tree membership /
private).
2. Data model
TreeVisibility enum (backend/app/models/enums.py) gains a value:
public # anyone on the web
site_members # any authenticated user of this instance <-- NEW
unlisted # anyone with the link
private # members only (default)
- Alembic migration to
ALTER TYPE tree_visibility ADD VALUE 'site_members'(Postgres enum add-value cannot run inside a transaction with other DDL — useop.executewith autocommit, separate migration). - Default stays
private. Existing rows unchanged. TreeRead/TreeUpdate/TreeCreateschemas already carry the enum; they pick up the new value automatically. The OpenAPI client regen (gen:api) exposes it to the frontend.
3. Privacy engine
can_view_tree() today treats public and unlisted identically and ignores
whether the viewer is anonymous vs authenticated (privacy.py:44-49). Replace the
final line with explicit branching on viewer auth state:
if membership: return True # members always
match tree.visibility:
public, unlisted: return True # anonymous OK (unlisted gated only by knowing the link)
site_members: return user_id is not None # any logged-in account
private: return False
person_visibility() is unchanged — it already redacts living/private people for
non-members. Add focused unit tests: anonymous + each visibility; living person
redacted on public/unlisted; site_members denies anonymous but allows a
logged-in non-member; private denies both.
4. The anonymous read path (the careful part)
Recommendation: a dedicated read-only public API namespace, not optional-auth
on the existing endpoints. Rationale: it is far easier to audit a small,
purpose-built surface that always funnels through person_visibility than to
weaken the membership checks on the authenticated endpoints and hope every branch
is covered.
- New router
app/api/v1/public.py, mounted at/api/v1/public, with an optional-auth dependencyCurrentUserOrNone(returnsUser | None; never 401s). Contrast withCurrentUser(deps.py:30-36) which hard-401s. - Endpoints (read-only; no create/update/delete):
GET /public/trees— directory: listspublicto everyone; additionally listssite_memberswhen the caller is authenticated. Paginated, search via existingpg_trgm. Never listsunlisted/private.GET /public/trees/{id}— tree metadata ifcan_view_tree(user_or_none).GET /public/trees/{id}/persons,/persons/{pid},/relationships,/events,/media, … — each filtered throughperson_visibility, returning redacted projections (aPublicPersonReadthat omits PII for redacted people: no exact dates, no living-person names beyond "Living", etc.).
- A redacted response schema, distinct from the member
PersonRead, so the serializer physically cannot emit fields a non-member shouldn't see. Redaction happens in the service, not the route. - Rate limiting on the public namespace (per-IP) to blunt scraping/enumeration.
- Audit: count public reads; do not log PII.
5. Frontend public pages
- New server-rendered routes outside the authed app shell, e.g.
/p/[treeId](tree),/p/[treeId]/[personId](person),/explore(directory). Server components fetch the/api/v1/public/*endpoints; no login redirect. robots: allow + sitemap forpublic;noindex, nofollowmeta forunlistedandsite_members. Sitemap lists onlypublictrees/persons.- The directory
/exploreis anonymous forpublic; showssite_memberstrees only to logged-in users. - Reuse the tree/person view components where possible, fed by the redacted schema.
6. UI control
Update the visibility dropdown (frontend/app/trees/page.tsx, shipped in PR #41)
from 3 to 4 options with helper text:
Private — only you and people you invite
Public – Members — any signed-in user on this site
Unlisted — anyone with the link (not listed or indexed)
Public — anyone on the web; listed and search-indexable
A short confirmation when switching to public ("This makes visible to
anyone on the web. Living people stay hidden.") is worthwhile given the stakes.
7. Guardrails / invariants
- One enforcement point: every public response is built from
person_visibilityoutput. No raw repository reads in the public router. - Living-person protection holds regardless of tree visibility.
- Unlisted relies on UUID unguessability; never expose a sequential public id.
noindexeverything exceptpublic; sitemap ispublic-only.- Tests gate the merge: privacy-engine matrix + an integration test that hits the public endpoints anonymously and asserts no living-person PII leaks.
8. Suggested phasing (small PRs)
- Enum value + migration + regen client (+ dropdown → 4 options). No behavior change yet for non-members.
- Privacy-engine branching + unit tests.
- Public read API namespace (optional-auth, redacted schema, rate limit) + tests.
- Public frontend pages (
/p/...) + robots/sitemap. - In-app
/exploredirectory + search.
Steps 2–3 are the privacy-critical core and should be reviewed hardest.
9. Open questions
- Caching: public pages are cacheable for SEO, but cache keys must not blur the redacted-vs-member rendering. Likely: cache only the anonymous projection at the edge; never cache member responses.
- Do
site_memberstrees appear in the sitemap for logged-in crawling? (Default: no —noindex.) - Per-tree opt-out of the directory even when
public? (Probably unnecessary;unlistedalready covers "reachable but not listed.")