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 ("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/ Lookupdispatch to the world with an empty bearer (dispatcher.Fetch(world, path, "")). The world'stokens.tomlgrants noreadop (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: noAuthorization: Bearer <id_token>⇒ 401. The org gate is a single HTTP-edge check, not per-read authz. readableWorldsreturns every configured world (reads gated by the broker SSO/allowDomainsorg gate alone, never per-worldAllow— that's the writer allowlist). Its doc comment explicitly reserves Phase 2 read-restriction filtering for this seam.ipRateLimitalready exists for unauthenticated routes (/auth/login).
Two consequences that frame everything below:
- Anonymous read is cheap to enable and catastrophic to get wrong. Since
the world read is already tokenless, a public route is "skip
requireAuthfor this request" — and a careless one exposes the entire org universe. So the design is dominated by fail-closed discipline, not by plumbing. - 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, alongsideAllow). 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:publictag (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):
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 behindipRateLimit(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: trueper 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)
WorldConfig.Collections[].Public bool(+ laterPublicListing bool) + config validation; reuse the Phase 2 rule struct if it lands first.- One read-authz evaluator:
(principal, world, path) → allow|deny, principal ∈ {anonymous, authed-claims}; anonymous matches onlypublic: truerules; default-deny for anonymous, default-open-then-Phase2 for authed. - Dedicated anonymous read entry (D4): no
requireAuth,ipRateLimiton, read-only op set, evaluator-gated, fail-closed. - Optional
visibility:publicauthor-axis check (D2) wired into the evaluator. - 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)
- Outbound: an anonymous read verb on the gateway (no bearer) hitting the broker's public path; returns not-found-as-absent on deny.
- Session-less public route + template (OG meta, sanitized render, "sign in" CTA); public-link rewriting (public↔public stays public; →private ⇒ login).
- Public render cache keyed on (world, path, version) — identity-free.
- 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;
publicListingis 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
- Settle D1 (★ tier yes/no) and D2 (operator-only vs operator∧author) with Fritz.
- 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.
- Broker: rule field → evaluator → dedicated anonymous path → leak-matrix tests.
- Library: anonymous gateway verb → public route + OG + sanitize → cache → front-of-house.
- 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.iointo 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.