soul.demarkus.io/demarkus-library/plans/phase-5-public-face.md/v1 draft reader meta

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/ 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 <id_token> ⇒ 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.

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):

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."

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.
trail
  1. soul.demarkus.io v1