# Phase 5 — Public Face: the anonymous-read decision (plan) Status: **draft — recorded 2026-06-18 (Fritz + Opus).** Settles the standing open question from [/plans/universe-library.md](/plans/universe-library.md) ("whether the broker exposes any anonymous-readable surface at all"), which gates Phase 5's "shareable public doc URLs." This is the detailed plan behind the roadmap's "Phase 5 public face" bullet. **The broker leads**, the library projects — same shape as Phase 2. ## The question The identity model resolved 2026-06-10 was firm: **org-scoped, no anonymous public tier** — a library card (org OIDC) is the Phase 1 turnstile. But Phase 5 wants *shareable public doc URLs with OpenGraph cards* so "a shared link explains demarkus on open." A link that only renders after org login can't do that. So: **does the broker expose any anonymous-readable surface, and if so, how — without becoming a hole that leaks the org's private universe?** ## What the code actually says (verified 2026-06-18) Grounded in `demarkus-broker/internal/broker/{ratelimit,mcp_tools_read,authz}.go`: - **The read layer is ALREADY identity-blind.** `handleMarkFetch/List/Versions/ Lookup` dispatch to the world with an **empty bearer** (`dispatcher.Fetch(world, path, "")`). The world's `tokens.toml` grants no `read` op (write-only), so reads need no token. `dispatchWithAuth(_ *Claims, …)` literally ignores claims on the read path. - **The ONLY thing making reads org-private is `requireAuth`** (ratelimit.go:164), the middleware in front of the MCP surface: no `Authorization: Bearer ` ⇒ 401. The org gate is a single HTTP-edge check, not per-read authz. - `readableWorlds` returns **every** configured world (reads gated by the broker SSO/`allowDomains` org gate alone, never per-world `Allow` — that's the *writer* allowlist). Its doc comment explicitly reserves Phase 2 read-restriction filtering for this seam. - `ipRateLimit` already exists for unauthenticated routes (`/auth/login`). **Two consequences that frame everything below:** 1. **Anonymous read is cheap to *enable* and catastrophic to *get wrong*.** Since the world read is already tokenless, a public route is "skip `requireAuth` for this request" — and a careless one exposes the *entire* org universe. So the design is dominated by **fail-closed** discipline, not by plumbing. 2. **Anonymous-public and Phase 2 restricted-collections are the two ends of one axis.** Phase 2 threads identity into the read path to *subtract* (restrict); public *adds* (a principal below "any org login" — nobody). They MUST share one read-authz evaluation, or a public world with a restricted subtree will serve the secret subtree to the internet. **Build them on one model.** ## The decision (recommended, settle the ★ before building) **Yes — expose a narrow, default-closed, explicitly-opt-in anonymous read surface. Do NOT relax the org-scoped default.** Public is a per-resource *carve-out*, not a tier flip. This refines (does not discard) the 2026-06-10 "no anonymous tier" resolution: the default stays org-only; "public" is an exception an operator and an author must both choose. ### D1 ★ — Is there an anonymous tier at all? RECOMMEND: yes, narrow + default-closed The adoption payoff (a link that explains demarkus to a stranger) is real and is the whole point of Phase 5. Reaffirm org-scoped as the **default**; add `public` as an opt-in carve-out evaluated per resource. If Fritz wants zero anonymous surface, the fallback is **D1-No** below — Phase 5 becomes login-gated links + OG-only-anonymous, and the rest of this plan is void. This is the one genuine product-values call. ### D2 — Who decides a doc is public? RECOMMEND: operator-eligible ∧ author-marked (both) Default-closed defense in depth — a doc is *effective-public* only if BOTH hold: - **Operator** opts the world/subtree in via broker `WorldConfig` (deploy-time, authoritative, alongside `Allow`). No world has any public surface unless the operator says so. This is the master switch and the security boundary. - **Author** marks the doc with a `visibility:public` tag (metadata axis, out of band — never a body fence). Content-level intent, so enabling a public path can't expose a doc whose author never meant it. `effective_public(doc) = operator_eligible(path) ∧ author_marked(doc)`. Either alone fails closed. (Settle: accept operator-only as a lighter v1 if the author axis is too much ceremony — but the AND is the safe default and cheap.) ### D3 — Unify with Phase 2 on one read-authz model. RECOMMEND Model public as a rule on the **same per-path collection list Phase 2 introduces**, with an explicit anonymous flag rather than overloading `AllowConfig` (anonymous has no email/domain/groups): ```yaml worlds: - name: handbook allow: { domains: [latebit.io] } # world entry (writers); reads still org-gated collections: - paths: ["/public/**"] public: true # NEW — anonymous may read (Phase 5) listing: locked # non-public siblings show as locked (Phase 2 D3) - paths: ["/hr/**"] allow: { groups: [hr] } # Phase 2 restrict (orthogonal) ``` One evaluation function, evaluated for the request's principal: - **anonymous request** (no/!valid bearer) → a path matches only if its rule has `public: true`; otherwise **deny** (default-deny for anonymous). - **org-authed request** → today's open-read + Phase 2 restrictions (unchanged). This makes "public" and "restricted" the same mechanism, so they compose correctly and can't contradict. If Phase 2 ships first, public is one more rule field; if Phase 5 ships first, Phase 2 slots into the same evaluator. ### D4 — Enforcement seam. RECOMMEND: a dedicated anonymous read path, fail-closed Do **not** route anonymous traffic through the existing authed MCP gateway with "claims == nil." Stand up a **separate, read-only** broker entry (e.g. a `/public/*` HTTP surface or an explicitly public MCP read variant) that: - is **not** behind `requireAuth` (that's the point), but **is** behind `ipRateLimit` (reuse the existing unauthed limiter); - hard-restricts the op set to read verbs (Fetch + the public-listing subset of D5) — structurally cannot reach write/version-mutation/graph-crawl; - evaluates D3 with the anonymous principal and **refuses to dispatch** any resource that isn't effective-public — fail closed on any doubt (unknown world, unmarked doc, missing operator rule, parse error → 404-as-if-absent, never a body). The world read itself is unchanged (already tokenless). The broker is the entire authz boundary. A dedicated path keeps the blast radius of a bug contained to "public resources only" instead of "the whole gateway." ### D5 — What can anonymous DISCOVER? RECOMMEND: follow-a-link, not browse-the-org v1 anonymous surface = **direct fetch of an effective-public doc + navigation among effective-public neighbors only.** NO anonymous `mark_lookup` / `mark_list` / floor / `mark_worlds` / backlinks-to-private — those leak structure and names. - A shared link renders; its in-body links to other public docs work; links to non-public docs become "sign in to read." - The Phase 5 **front-of-house** (featured / recent / most-cited) is a **curated operator allowlist or an explicitly public index doc**, NOT the live catalog. - Anonymous enumeration (a public *listing* of a public subtree) is a separate, later knob (`publicListing: true` per collection) — off in v1. Even a fully `/**`-public world does not get anonymous catalog browse until that's switched on. This bounds the leak surface to "exactly the docs someone shared." ### D6 — Library projection. RECOMMEND - A **session-less public render path** (`/p/:world/*` or similar) that calls the broker's anonymous read, renders with the existing goldmark + bluemonday pipeline (XSS sanitation is non-negotiable on an internet-facing surface), and emits **OpenGraph/Twitter meta** (title, first-paragraph description, optional SSR card image later). No cookie, no OAuth, tokens never involved. - A "sign in for the full library" affordance; public→public links stay in the public surface, public→private links route to login. - Public renders are **identity-invariant ⇒ safely cacheable** — this is the natural home for Phase 5's "catalog caching," and a strong CDN story. - Projection purity (ADR 0005 d11): the library never invents public-ness; it renders exactly what the broker advertises as public. No HTML-only authz. ### D7 — Abuse / ops. RECOMMEND IP rate-limit the public path (reuse `ipRateLimit`); cache public renders; public surface lives on the cloud-reachable broker (`broker.knowledge.demarkus.io`) so **residential worlds (e.g. soul.demarkus.io) stay behind the broker** — no direct world exposure. Audit-log anonymous fetches (path + hit/deny) for abuse visibility. ## Broker work (leads) 1. `WorldConfig.Collections[].Public bool` (+ later `PublicListing bool`) + config validation; reuse the Phase 2 rule struct if it lands first. 2. One read-authz evaluator: `(principal, world, path) → allow|deny`, principal ∈ {anonymous, authed-claims}; anonymous matches only `public: true` rules; default-deny for anonymous, default-open-then-Phase2 for authed. 3. Dedicated anonymous read entry (D4): no `requireAuth`, `ipRateLimit` on, read-only op set, evaluator-gated, fail-closed. 4. Optional `visibility:public` author-axis check (D2) wired into the evaluator. 5. **Leak-matrix tests (not optional):** anonymous CAN fetch effective-public; anonymous CANNOT fetch a path-adjacent non-public doc; CANNOT lookup/list/ enumerate/worlds/graph; a doc that loses its public mark (or a world whose operator rule is removed) stops serving **immediately**; historical-version fetch of a once-public doc is denied anonymously in v1; a public doc linking into a private world does not leak the target. ## Library work (projects) 1. Outbound: an anonymous read verb on the gateway (no bearer) hitting the broker's public path; returns not-found-as-absent on deny. 2. Session-less public route + template (OG meta, sanitized render, "sign in" CTA); public-link rewriting (public↔public stays public; →private ⇒ login). 3. Public render cache keyed on (world, path, version) — identity-free. 4. Front-of-house from the curated public index (D5), not the live catalog. ## Acceptance (both transports — ADR 0005 d16) - Broker mode: a stranger (no cookie) opens a shared link to an effective-public doc → it renders, with an OG card; a sibling private doc URL → "sign in" / 404, never a body; no anonymous catalog/list/worlds; org login still unlocks the full room; un-marking the doc closes the public link on next request. - QUIC single-world mode: no broker ⇒ **no anonymous surface** (the public tier is a broker concept). The library serves no public route. Honest absence, not a gap. ## Scope / non-goals - Read-only. No anonymous write, ever (writes stay org+`Allow`+token gated). - No anonymous catalog browse / search / world-enumeration in v1 (D5) — direct public links only; `publicListing` is a later, separate decision. - Not a CMS/public-blog engine; the universe stays markdown-native, broker-fronted. - Public-ness is operator+author opt-in, default-closed — not a global switch. ## Build order 1. Settle D1 (★ tier yes/no) and D2 (operator-only vs operator∧author) with Fritz. 2. Prefer **Phase 2 first** (or jointly): it builds the per-path rule model + identity-into-read-path that public reuses. If Phase 5 is wanted first, build the evaluator public-first and let Phase 2 extend it. 3. Broker: rule field → evaluator → dedicated anonymous path → leak-matrix tests. 4. Library: anonymous gateway verb → public route + OG + sanitize → cache → front-of-house. 5. Deploy: broker bump first (enforcement), then library (projection); they converge — library serves no public surface until the broker advertises one. ## Dependencies / risks - **Highest-stakes surface in the project:** the read layer is already tokenless, so the broker edge is the only thing between the internet and the org's universe. Fail-closed + dedicated path + the leak matrix are the deliverable, not extras. - **Couples with Phase 2** — one read-authz model or they will contradict (public world × restricted subtree). Don't build two authz systems. - **Internet exposure** — a public surface turns `broker.knowledge.demarkus.io` into a genuine public endpoint; revisit rate limits, caching, abuse logging, and DDoS posture before flipping any world's first public rule. - Needs the broker repo (`~/latebit/demarkus/tools/demarkus-broker`), not just the library — cross-repo, like Phase 2 / floor-enrichment.