decision
ADR-0033: External-only citations in the member UI
ADR-0033 (**Proposed** — supersedes ADR-0029 (Citation content access and entitlement)., 2026-07-07): External-only citations in the member UI.
Status: Proposed — supersedes ADR-0029 (Citation content access and entitlement).
Reverses the display half of #245/#252; the measurement half (ADR-0030/0032) stands.
Date: 2026-07-07
Deciders: Seth Shoultes (Lead Architect), Blair Williams (CEO)
Author: Seth Shoultes
Context
ADR-0029 (#245/#252) let the member UI surface cited brain sources: the Sources panel
rendered playbook/brain citations with a ↗ pop-out that fetched the full entry body via
GET /api/v1/brain/sources/{id}, and answers carried inline [n] citation pills.
The 2026-07-07 Blair × Seth Q2 review reversed the user-surface policy (caseproof/innovations#231,
#235): the brain is the secret sauce, and personalization is the product. Playbook/brain
material is delivered by weaving it into the answer, never by exposing the source. Surfacing
internal playbook titles/summaries/bodies invites distillation of the corpus and erodes the moat.
External, publicly available sources (e.g. hive_docs linking to memberpress.com) may still
be cited and linked.
The forces: (1) protect the corpus from distillation; (2) keep the analytics that ADR-0030
(entry-performance) and ADR-0032 (retrieval-event log) depend on — those unnest the FULL
messages.sources payload to count playbook citations; (3) don’t regress answer quality.
Decision
Filter to external-only at the client-serving boundary; keep the stored payload full.
- New
retrieval.citations.external_sources_only(sources)keeps only sources whose allowlisted
metadata carries a non-empty stringurl. It runs at the two points that serve sources to
the browser: the non-streaming chat response (api/chat/router.py) and history reload
(api/conversations/router.py) — the latter is how the streaming client hydrates sources. format_citationsand the persistedmessages.sourcescolumn are unchanged: the stored
row still contains every cited entry (internal playbooks included), so the entry-performance
rollup and retrieval-event log keep working. Measurement stays; display goes.- Member UI is prose-only.
renderMarkdownstrips inline[n]markers from displayed
answers (stored content keeps them, so ADR-0032 cited-detection is unaffected).SourcesPanel
lists only external (url-bearing) sources. TheCitationModalcomponent and the↗affordance
are deleted. - The member endpoint
GET /api/v1/brain/sources/{id}(added for the modal) is deleted — no
member surface fetches internal brain bodies. Admin brain endpoints (CF-Access-gated) are
untouched; operators keep full visibility.
Consequences
Positive:
- The chat surface (answers, the Sources panel, history reload) no longer exposes internal brain
content — closes the primary distillation surface and matches the #231 secret-sauce boundary.
The serve-boundary filter also re-applies the #261 metadata allowlist, so even legacy
pre-allowlist rows can’t leak internal keys (author/path/content_hash) on reload. - Measurement is fully preserved:
messages.sources,retrieval_events,entry_performance
and the citation-grounding eval are byte-for-byte unchanged, because the model still emits and
we still store[n]markers + the full sources list. - Answer quality is unaffected — the model’s generation is identical; only presentation changes.
Known open surface (NOT closed by this ADR):
POST /api/v1/searchis member-authenticated (get_current_user, not admin-gated) and returns
raw braincontent+ numberedcontext+ full metadata. The member app does not call it, but
any authenticated member could — the same distillation surface the chat path just closed. Its
fate (delete / admin-gate / external-only filter) is a product/scope decision tracked as a
follow-up (caseproof/innovations#247). Until then, “the brain is
invisible” holds for the chat UI but NOT for a member hitting/api/v1/searchdirectly.
Negative / costs:
- Users lose the ability to open the full text of a cited playbook. This is intentional.
- Inline citation anchoring is gone for external sources too (prose-only). External links remain
available in the Sources panel, just not number-anchored in the prose. - Filtering must stay at every client-serving read path; a new path that serves
messages.sources
must callexternal_sources_only. A unit + router test lock this.
Mitigations:
external_sources_onlyis a single shared helper with unit tests; the two call sites carry
comments pointing here. The history-endpoint test asserts the stored row stays full while the
served payload is external-only.- The done-when gate requires an eval/regression-replay confirming answer quality holds with
citations hidden (caseproof/innovations#235) — tied to the eval-harness work (#230).
Alternatives considered
- Filter inside
format_citations(strip internal before storing) — rejected: breaks the
entry-performance rollup and retrieval-event log, which unnestmessages.sourcesto count
playbook citations. ADR-0029’s non-goal was explicit: measurement must not change. - Frontend-only filter (SourcesPanel hides non-url sources) — rejected: internal source
metadata (titles, summaries) would still travel to the browser in the API JSON, leaving the
distillation surface open to anyone inspecting network traffic. - Renumber and keep inline anchors for external sources — rejected for V1: requires rewriting
citation markers in the answer text (fragile) for marginal UX gain over a plain external-links
list. Prose-only is the lowest-risk match to “the brain is invisible.” - Admin-gate rather than delete the sources endpoint — rejected: no member surface needs it,
and the admin SPA already has its own CF-Access-gated brain endpoints. Deleting avoids a dormant
member-reachable route (no-legacy).