soul.demarkus.io/demarkus-library/plans/reading-room.md/v10 draft reader meta

The Reading Room — design notes (draft)

Reading-first frontend for the universe library. Desktop only. Markdown-native, no hosted media, agent-maintained content, navigation by tag and link rather than search. Status: draft — under discussion 2026-06-12 (Fritz + Fable). Will be locked as ADR 0005 once the open decisions below settle.

Thesis

Most knowledge management feels exhausting because every click is a context amputation. Back/forward is a one-dimensional history: a single cursor on a line. Reading knowledge is not linear — it branches. You are on RFC A, it references B and C, and you want to return to A's argument with B still in view. A linear history cannot hold a branch, so the reader rebuilds context in their head on every pop. That reconstruction is the tax.

The Reading Room makes context spatial instead of temporal. The trail is laid out on screen; the reader never reconstructs where they were — they look left.

Governing principle: never make the reader hold in their head what the layout could hold for them. Panes hold the path. The margin holds the document's trust signals and the author's asides. The body holds only the argument.

Influences

  • Andy Matuschak's working notes — stacked panes; a link opens a column to the right rather than navigating away.
  • Tufte CSS — ~60ch reading column, wide working margin, sidenotes not footnotes, off-white/off-black, booktabs tables.
  • Gwern "Sidenotes In Web Design" — margin-note rationale + implementation survey.
  • adoc-studio Tufte layout — CSS-variable two-part layout (~650px body, ~250px margin).
  • matt.roam.garden — hover link previews, backlinks, sliding layered notes.
  • Maggie Appleton's garden — legible note maturity; status as first-class signal.

Pane mechanics

The invariant: the stack is always a single path, root → focus. Not a browsing history — the reasoning chain currently in view. Every rule defends this.

Link resolution (core rule). Click a link in pane N:

  1. Truncate the stack to N (discard everything right of it).
  2. Append the target as pane N+1.
  3. Exception: target already in the stack → don't duplicate; scroll it into view and focus it (handles circular references for free).

Truncation is the counterintuitive half: at A→B→C, returning to B to follow a different link abandons C — it's off the path, so it doesn't survive. Keeping it would turn the stack back into temporal history. C is one click away in B.

Active-link highlighting. Each pane marks which outbound link is open to its right. The stack is self-describing — the highlighted links ARE the breadcrumb, spatial and in-context.

Overflow. Panes are ~950px (60ch + margin); 1.5–2 full panes per screen. Overflow is the normal state, not an edge case. Never evict:

  • Left panes collapse to a thin vertical spine (title + status badge).
  • Focused pane + immediate parent render full; older panes are spines.
  • Clicking a spine expands + focuses it — navigation, not a new branch; no truncation.
  • New panes open at the right; canvas auto-scrolls to them.
  • Only the focused pane shows its full margin; parents render body-only. The margin is an attribute of attention. (This is also the width math: 950 focused + ~700 body-only parent = 1650 fits 1920.)

Locked decisions:

  • Canvas scrolls to reveal panes; panes do NOT resize to fit (resize produces unreadable columns past three hops). Shapes the layout engine.
  • External (non-demarkus) links leave the graph — browser navigation, never a pane. The stack stays pure to the knowledge graph.
  • overscroll-behavior-x: contain on the canvas — macOS two-finger swipe at the scroll edge must not trigger history-back mid-trail.

Trail sharing: the URL encodes the whole stack (ordered doc ids + focus), so sharing a link shares the reasoning chain, reconstructed exactly.

The margin

Two content kinds with opposite behaviors — don't mix:

Position-anchored (flows with text): sidenotes — author/agent written, anchored to a body point, vertically aligned to the referent. The Tufte core.

Document-level (pinned, position: sticky at margin top):

  • Status — the trust signal. Unmissable badge; draft vs accepted look different at a glance (possibly muted body for drafts). The thing enterprise KM never does.
  • Tags — clickable, the lateral-nav exit.
  • Provenance — last touched, by which agent, when. Answers "can I trust this is current" for agent-written content.
  • Backlinks ("referenced by") — the link graph made visible, each with a hover preview card (title + opening lines). The same preview component serves outbound body links — build once, use both directions.

Discipline: an empty margin is correct. Status, tags, provenance, backlinks, and the sidenotes the author actually wrote. Nothing else earns the space. A stuffed margin is a sidebar in costume.

What NOT to build

  • No search box. Navigation is tags, links, backlinks. (Capability survives: a tag click is mark_lookup with a tag filter rendered as a tag page — same backend, right affordance.)
  • No persistent sidebar file tree.
  • No comments/reactions/collab cruft. Reading room, not workspace.
  • No rich-text editing. Markdown in, rendered out (Phase 3 cataloging desk is markdown-source editing, not WYSIWYG).

Visual baseline

Body ~650px / 60ch · margin ~250px · off-white/off-black, color reserved for signal (status) · booktabs tables (three horizontal rules, no verticals) · mermaid + KaTeX (already shipped, v0.1.2 islands).

Feasibility (htmx-hard, ADR 0003) — assessed 2026-06-12, VIABLE

The unlock: the trail is server state encoded in the URL (/t/...), so ADR 0003 rule 3 (no client state) holds. The server renders every link in pane N already carrying its post-click trail URL (prefix-to-N + target) — truncation, dedup, spine-expand, and active-link highlight all cost zero client logic; the link IS the state transition. Browser back/forward becomes temporal history OVER spatial states for free.

  • Panes/spines/focus/pinned margin: server-rendered + CSS only.
  • Hover previews: pure htmx (hx-trigger="mouseenter delay:300ms once" → server fragment).
  • Sidenotes: standard footnote syntax in markdown; goldmark AST transform inlines each note at its reference as a margin span; Tufte CSS floats it. Degrades to footnotes. Agent-writable.
  • Only new JS in the entire design: ~5 lines of scroll-focused-pane-into-view after htmx settle (htmx show: is vertical-biased). Rides islands.js.

Hard requirement discovered: rendered-HTML cache. Naive full-canvas re-render = N world fetches per click; a 6-pane trail against the broker's 30/min budget = ~2 clicks/min then 429s. The design therefore REQUIRES a server-side rendered-HTML cache keyed (world, path, version) — a trail click fetches only the NEW document; the rest re-renders from cache (active-link highlight is a cheap post-process). demarkus versioning gives invalidation. With it, a click costs one read — same as today.

Status/maturity channel decision: body frontmatter is what we STRIP; demarkus carries metadata out of band. Maturity rides metadata tags as an axis (status:draft, status:accepted) — matches knowledge-system policy axes, write-gate enforceable. Provenance comes from response metadata (modified/version/agent) we already receive.

Backlinks dependency: the one margin item without a data source — library gateway has no backlinks verb; broker graph store is ephemeral (per-pod, crawl-populated). Phase behind the rest.

Phasing

  • R1 — the Tufte pane (single doc): column + margin, pinned metadata block (status badge, tags, provenance), sidenote transform, booktabs styling, remove search box, add tag pages. Ships alone — the look + trust model.
  • R2 — the trail engine: stack URL scheme, canvas, truncate/dedup/focus, spines, active-link highlight, rendered-HTML cache. Layout-shaping decisions lock here.
  • R3 — previews + backlinks: hover cards both directions; backlinks when the data source lands.
  • Universe view (mark_worlds floor) = the natural trail root: pane zero.

Open decisions (to settle before ADR 0005)

  1. Confirm killing the nav search box in R1 (capability survives as tag pages).
  2. Status-axis tag vocabulary (status:draft|wip|accepted|archived?) and whether the write gate should require the axis.
  3. Trail URL shape: path segments vs query param; stack depth cap (~10?).
  4. R1-before-R2 phasing vs straight to the trail engine.
  5. Backlinks data source: wait for broker graph persistence, or library-side crawl of the home world?

One-line synthesis

A Tufte-styled reading column with a working margin (sidenotes, backlinks, provenance), stacked panes for trail preservation, status as trust signal, lateral movement by tag. No search, no tree, no media. Everything in service of reading and trusting, nothing in service of the CMS. Most KM is the inverse.

Decisions settled 2026-06-12 (→ ADR 0005)

All five open decisions resolved; ADR 0005 locks the invariants. Key syntheses from the hash-out:

  • Catalog-as-pane (decision 1): no global search box, but the card catalog joins the trail as a pane — tag clicks open it, and a filter input INSIDE the catalog pane keeps lookup easy without a bar to stare at. Search results live on the path.
  • Frontmatter rendered friendly (decision 2): parse the stripped fence and render keys into the margin's document-properties block. Metadata channel stays authoritative; status badge reads metadata tag axis first, frontmatter status: fallback, absent ⇒ draft.
  • Graph-as-pane (decision 5, Fritz's "add into context, not break out"): click/hotkey in the margin opens the document's graph neighborhood AS A PANE; clicking a node continues the trail. Walking the graph IS building a trail — one model. The universe view is the same component at maximum zoom: the whole-universe graph as pane zero / the floor.
  • Universe view needs research before building — what a special universe-scale graph view looks like (rendering approach, zoom levels, what aggregates at scale). Likely exercises ADR 0003's pre-agreed canvas island. Run research alongside R1/R2.

Agent symmetry (added 2026-06-12, ADR 0005 amendment — decisions 11–15)

Fritz: "we can't forget about the agent aspect." The room is human-facing; the universe is agent-maintained. Principles now locked in ADR 0005:

  • Projection, never source (11): no HTML-only information, ever. Every affordance ⇄ an MCP-readable/writable channel.
  • Escape to protocol (12): canonical mark:// + raw source in every pane's margin.
  • Authoring contract published in-universe (13): style doc agents fetch; defines footnote→sidenote, status axis, alerts/mermaid/KaTeX, tag conventions. Ships with R1.
  • Trail format documented + constructible (14): agents mint trails (hand humans their reasoning chain as a URL that reconstructs on screen) and parse them (load a human's trail as context). The trail is the serialized reading-context object both species share. Docs ship with R2.
  • Librarian consumes the trail (15): Phase 4 librarian joins the canvas as a pane with the current stack as its context — the URL is the context spec.

The sleeper feature is 14: agent→human reasoning handoff as a trail URL.

R2 build decisions (2026-06-12, locked before building — Fable)

R1 shipped (PR #13, merged). These settle the two layout-shaping designs ADR 0005 leaves to R2.

Trail URL format (decision 1 + 14 concretized)

/t/ + one pane chunk per pane, separated by a literal ~ segment; focus as a query param:

/t/<world>/d/<path>~<world>/tags/<tag>~<world>/d/<path>?focus=1
   └────── pane 0 ──────┘ └──── pane 1 ───┘ └── pane 2 ──┘
  • A pane chunk is exactly the tail of the pane's standalone /w/ route (world + d/<path> | tags/<tag>) — one address language everywhere; agents mint trails by concatenating route tails they already know.
  • ~ is a reserved path segment (never a world, never a path segment).
  • Raw / inside chunks — no %2F anywhere (ingress-proof). Echo route: /t/* with library-side chunk splitting.
  • ?focus=<0-based index>, omitted ⇒ last pane. Out of range ⇒ clamped.
  • Depth cap 10: an append that would exceed drops pane 0 (truncate-left, documented, deterministic).
  • Click semantics (server-rendered into every link): link in pane N → truncate to panes 0..N, append target, focus it; target already on the path → same stack, focus jumps to it (dedup); spine/header click → same stack, focus=i. External links untouched (leave the graph via browser).
  • /w/ routes stay as-is: the single-pane permalink + fragment renderer (and the projection escape's stable address). / renders the default doc as a one-pane trail.

Cache policy (decision 9 concretized): focused-live, parents-cached

Key insight: the freshness that matters is where attention is. Every trail render fetches the focused pane live (and refreshes its cache entry); all other panes are read-through from the rendered-document cache (kind-prefixed key d:/tags: + world + path). Consequences:

  • Every click costs exactly one world read — append (new doc), spine expand, dedup focus, back/forward: all 1 read. Broker budget (~30/min) supports ~30 clicks/min/reader regardless of trail depth.
  • Unfocused panes can be stale only in the sense that they show what the reader was already looking at — re-focusing refreshes them.
  • Cold shared trail (agent-minted link): N reads once, then 1/click.
  • Cache stores the rendered domain.Document (markdown→HTML, margin data), NOT trail-ized links: per-request post-process rewrites pane hrefs to their post-click trail URLs + marks the active link (the cheap pass ADR 0005 anticipated). LRU, in-memory, per-pod (sessions are already per-pod; chart stays replicaCount 1).

Universe-view research (2026-06-13, Fable — recorded before building)

The universe view = the graph pane at maximum zoom = pane zero / the floor (ADR 0005 decision 4). Research findings; design to validate with Fritz before R3/universe build.

Data sources (decision 11 audit: every layer is MCP-derivable)

layer source cost availability
worlds mark_worlds (to build — broker tool over authorizedWorlds, same data /me/install already serves on REST) 1 call broker only; quic-mode universe IS the home world (decision 16 degradation)
nodes per world mark_lookup scope /, high limit — path, title, importance, tags 1 call/world, cacheable both transports
tree shape mark_list (dirs as clusters) 1 call/dir both
edges broker graph store (mark_graph crawl → mark_backlinks/mark_graph_export) — ephemeral, per-pod, crawl-populated by design (broker is a wire-shape adapter; persistent bucket store parked) crawl cost, store may be empty broker; quic worlds have no store — library-side crawl or none

Key insight: the catalog is the cheap universe. Importance gives node weight, tags give clustering, path gives hierarchy — one lookup per world, no link crawl. Edges are an enrichment, not the skeleton: the floor degrades gracefully to an importance-weighted cluster map when the graph store is cold. This resolves the "backlinks/graph must not assume the broker store" clause of decision 16.

Rendering approach — SSR SVG first, canvas island only if scale demands

ADR 0003 pre-agrees a canvas island as a concession, but research says we may not need to spend it:

  • Scale reality: a knowledge system is a handful of worlds × O(10²–10³) catalogued docs. After aggregation (below) the floor renders tens of nodes, a world view low hundreds. That is comfortably server-rendered SVG territory — zero new JS, cacheable in the DocumentCache like any pane, degrades to markup, and stays projection-pure (the same data an agent gets from mark_worlds + mark_lookup).
  • Layout must be deterministic server-side (no client force simulation): radial/orbit layout — worlds as anchors, docs orbiting their world clustered by top-level directory (or tag), radius ∝ rank, node size ∝ importance, status as stroke color (the trust signal again). Deterministic layout also means stable across renders → diffable, cacheable, shareable.
  • Hover = title tooltip (SVG , free); click = the node's pane appended to the trail (plain wrapping SVG node — htmx-hard holds).
  • The canvas island stays the documented escalation path: trigger = a world whose post-aggregation node count makes SVG DOM weight a real problem (~thousands of visible nodes), or a future force/physics want. Until a deployment hits that, SVG ships.

Zoom/aggregation levels (one component, zoom = which level renders)

  1. Floor (pane zero): worlds as large nodes (size = catalog count, label + world badge); each world shows its top-importance docs (importance ≥ 0.8 — hubs/ADRs per the tagging convention) as satellite nodes. Everything else aggregates into per-world "+N docs" rings.
  2. World view: one world's catalog — clusters by top-level dir, labeled nodes for the top ~50 by importance, aggregate bubbles ("plans/ +12") that click into the listing pane (the stacks — already a pane kind!). Edges drawn only if the graph store has them.
  3. Neighborhood (R3 graph pane): one doc + direct links/backlinks — the store-backed (or on-demand mark_graph depth-1) view.

Zoom transitions are clicks: aggregate bubble → listing pane or deeper graph pane — every transition continues the trail (decision 4), no client zoom state (ADR 0003).

Pane address

New pane kind in the trail vocabulary: <world>/g/<path> (neighborhood, R3) and the floor as g at universe scope — exact chunk shape to lock when building (must stay agent-constructible; trails doc gets updated, decision 14). Floor in quic mode = world view of the home world.

Build order proposed

  1. mark_worlds broker tool (demarkus repo) — small, unblocks the floor.
  2. Floor + world view (SSR SVG over mark_worlds + lookup) as a pane — could ship before R3's neighborhood pane since it doesn't need edges.
  3. R3 neighborhood pane + backlinks margin block when the edge-source decision lands (broker store vs library-side crawl).

Addendum: the non-brokered universe (Fritz + Fable, 2026-06-13)

The two postures define "universe" differently, and the floor should honor that rather than fake symmetry:

  • Brokered = intensional. Config is the authoritative world list; mark_worlds enumerates it. Worlds appear on the floor whether or not anything links to them.
  • Non-brokered = extensional. No registry exists; the universe is the home world plus whatever it links to. Discovery is traversal: external mark:// links are the only map. (mark_graph already records external links without following them — cross-host edges are exactly this data.)

Quic-mode floor design: home-world cluster at the center (catalog-based, identical to the brokered per-world view), externally-linked demarkus hosts as small portal nodes at the rim — rendered only when edge data exists, absent otherwise. No links observed ⇒ the floor is just the world; that degradation is honest, not a gap. No mark_worlds analogue needed — nothing to enumerate.

Cheap edge source for portals (no crawl): the library parses every link in every document it renders (rewriteLinks). Accumulating observed cross-world targets as a side effect of normal reading yields an observed-links map for free — per-pod and ephemeral, same posture as the broker's graph store. Candidate for the floor build.

Addendum 2: hubs are the universe map (Fritz, 2026-06-13)

Fritz: the world could be built from demarkus hubs — and /universe.md confirms it: a hub is an indexing server whose content IS the topology, published as ordinary versioned documents (mark_index hash indexes, mark_graph_publish graph exports). The knowledge-system deployment already runs a federation agent crawling worlds into the fixed-name hub.

This supersedes the earlier edge-source candidates as the PRIMARY floor source:

  • Decision 11 made literal: the floor renders a published document's data; agents fetch the same map. Projection and source coincide.
  • Durable + versioned (vs the broker's per-pod store) — and the DocumentCache's (world, path, version) invalidation applies unchanged.
  • Transport-symmetric: quic deployments can point at (or be) a hub; brokered systems already have one. Same mechanism both postures.

Revised layering for the floor:

  1. Enumeration baseline + permission mask: mark_worlds (broker) / home world (quic). Hub docs are NOT permission-filtered — broker-mode floor must intersect hub topology with mark_worlds so unauthorized worlds never render.
  2. Topology/edges: hub index + graph docs (primary, both transports).
  3. Fallbacks: render-time observed-links map; broker ephemeral store.
  4. Node detail per world: mark_lookup catalog (importance, tags).

Open before building: hub discovery for the library — env (DEMARKUS_HUB=<world>), convention path, or the hub self-declaring via its agent manifest (mark_discover). Lean manifest (agent-symmetric) with env override; decide with Fritz.

R3 build decisions (2026-06-13, locked before building)

R1 (Tufte pane), R2 (trail engine), and the floor (pane zero) are live. R3 is the last planned phase: hover previews, the backlinks margin block, and the graph neighborhood pane. These settle what ADR 0005 left to R3.

THE load-bearing decision: edge source = render-time observed-links map

Backlinks and the graph pane both need link edges. Three candidates were on the table (open decision 5 + the universe-view research table):

  1. Broker graph store (mark_graph crawl → mark_backlinks) — ephemeral, per-pod, crawl-populated. Rejected as primary: fails ADR 0005 decision 16 — QUIC single-world mode has no broker, so a broker-only edge source is "incomplete, not shipped."
  2. Library-side crawl of the home world — needs a background crawler loop, spends read budget, owns its own invalidation. Over-built for R3.
  3. Render-time observed-links map (addendum 1) — CHOSEN. rewriteLinks already resolves every <a> in every rendered document to a (world, path) target. Capturing those edges as a side-effect of normal reading yields a forward+reverse edge map for free, in pure library memory.

Why (3) wins:

  • Transport-symmetric by construction (decision 16): it is library-internal state, identical under QUIC and broker. No broker dependency, no new infra.
  • Nearly free: the resolution work is already done in rewriteLinks; we only return the edges it computes and record them.
  • Same posture as the broker store it replaces: per-pod, in-memory, ephemeral. Honest cold-start degradation — an unread doc has no observed backlinks yet ("none observed" is the correct empty state, decision 8). The graph fills in as the room is read.

Limitation (recorded honestly): the map only knows edges among documents rendered during this pod's lifetime. A backlink from a never-visited doc is invisible until something renders it. A cold agent-minted graph/backlinks view may be sparse. This is the ephemeral posture, accepted for R3.

Upgrade path (not built now): addendum 2's hub-sourced topology (mark_index / mark_graph_publish docs) is the durable, versioned, transport-symmetric PRIMARY when hub discovery is settled with Fritz. It layers in front of the observed map (hub edges ∪ observed edges); the observed map stays the always-available baseline. The broker store stays a broker-only enrichment. Today's seam (the service's edge methods) makes that swap local.

Architecture (hexagonal, ADR 0002)

  • domain.Ref{World, Path} — a knowledge-graph coordinate (comparable; used as a map key). domain.Neighborhood{Center, Out, In []Ref} — the graph pane's data.
  • Edge store = an internal service struct (service.linkGraph: forward + reverse map[Ref]set[Ref] under a mutex), mirroring the existing floorCache precedent rather than adding a port+adapter+wiring. Folds the future hub/broker sources behind the service's public methods. (When hub-sourced lands, this becomes a strategy behind a port.LinkGraph — noted, not built.)
  • Inbound port additions (port.ReadingService):
    • RecordLinks(world, path string, targets []domain.Ref) — the web adapter calls this after rewriteLinks (which now returns the resolved in-universe doc edges). Resolution stays in web (it owns the URL scheme, per links.go); the write flows through the inbound port; the core owns the store. No-op for non-document sources (listings, tag pages, catalog).
    • Backlinks(world, path string) []domain.Ref — store query, best-effort, empty when unknown (no error: a margin enhancement never fails a render).
    • Neighborhood(world, path string) domain.Neighborhood — center + observed out/in edges, store-only (zero reads, works cold in both transports).
  • rewriteLinks now returns (html, []domain.Ref); rewriteHref returns (route, domain.Ref, isDoc). Edges collected only for in-universe document targets — external/anchor/listing(dir)/self targets are skipped.

The three pieces

  1. Hover preview cards — htmx-pure (ADR 0003, no JS): each previewable anchor is wrapped <span class="preview-host"><a hx-get hx-trigger= "mouseenter delay:300ms once" hx-target="next .preview-card"><span class="preview-card"></span></span>; CSS shows the card on host-hover once non-empty. hx-trigger=mouseenter overrides the anchor's default click trigger, so click still navigates via hx-boost. Route GET /w/:world/preview/* → "preview" fragment (title, status badge, first- paragraph snippet, mark:// + open). Served from ReadCached (budget-safe). One component serves both body links and backlink entries (ADR 0005 §margin: build once, both directions).
  2. Backlinks margin block — "Referenced by" in doc-meta (shared by the trail pane and the /w/ permalink view). Fed by Backlinks. Each entry is a link that continues the trail (trail URL in the canvas; /w/ permalink in the single-doc view) and carries the same hover preview. Empty ⇒ omitted.
  3. Graph neighborhood pane — new pane kind <world>/g/<path> added to the trail codec via paneAddrFromParts (the single source of truth — shared by both parsers, not duplicated). Route /w/:world/g/* (permalink + chunk-tail source + projection escape). Rendered as SSR SVG (same approach as the floor; ADR 0003's canvas island stays unspent): center node + observed out/in nodes, each a plain <a> whose href continues the trail (decision 4). Opened from the focused doc's margin (a "graph" affordance, decision 5). docs/trail-format.md + the in-universe mark://soul.demarkus.io/.well-known/library/trails.md both updated to document the g kind (decision 14, kept in sync).

Acceptance (decision 16 — BOTH transports)

  • QUIC against soul.demarkus.io (/t/u, walk into a doc, hover a link, open its graph). Observed map fills as you read; backlinks appear after the linking doc is rendered.
  • Broker mode (dev.env). Identical behavior — the edge source is library- internal, so there is no broker-only path to diverge.

Floor enrichment build decisions (2026-06-13, locked before building — Fritz + Opus)

R1→R3 + the floor are live (library 0.1.7). Next phase = floor enrichment (roadmap Next #1). Settles addendum 2's open hub question.

THE decision: topology source = D (hybrid layered), library stays reader-only

A "hub" is not special server-side — it is just a demarkus world holding topology documents. So the abstraction is topology source, not "hub": the floor reads topology from somewhere; the publisher varies by deployment. Brokered → the root hub (federation agent publishes). Single-world → the world publishes its own map to itself (Fritz's framing). Same consumer code.

Chosen shape D — hybrid: durable topology doc(s) ∪ the R3 observed-links map; nodes from hub-or-mark_worlds ∩ permissions; detail from lookup. The library never writes — producing the map is an agent/operator job (federation agent in brokered; an agent or mark_graph_publish run in single-world). Preserves ADR 0005 decision 11 (projection never source; the room is a reader). Options B (library self-publishes) rejected on that ground; A/C are subsumed as D's configured-vs-fallback cases.

Hub discovery: DEMARKUS_HUB env, default self

DEMARKUS_HUB=<world> names the topology-source world. Unset ⇒ self (the home/default world). Cluster sets DEMARKUS_HUB=root. Manifest self-declaration via mark_discover (agent-symmetric) is deferred — env is deterministic and the cluster hub is already fixed-name root; revisit if a deployment needs zero-config discovery.

Read contract (formats confirmed live, 2026-06-13)

  • Node enumeration — the hash index (mark_index): a # Content Index doc with a | Hash | Server | Path | table. Server = world (a mark://host:port), Path = doc. Cross-world, durable, versioned; carries no importance/tags/status. The agent publishes one per crawled host at hub /index/<host>.md (perServer: true), beside the curated /index.md. Library reads the hub /index/ listing → fetches each → unions nodes.
  • Edges — the graph export (mark_graph_export): Nodes: (status/title/ link-count) + Edges: (A -> B mark:// pairs). Durable when a producer publishes it to a well-known path (e.g. hub /graph.md). The cluster agent does NOT publish this yet → edges fall back to the observed map.
  • Node detail — importance/tags/status from mark_lookup per world (the current floor path, unchanged).

Layering (addendum 2 made concrete)

  1. Enumeration + permission mask: hub hash-index nodes ∩ mark_worlds (broker) / home world (quic). Hub docs are not permission-filtered, so the intersection keeps unauthorized worlds off the broker-mode floor.
  2. Edges: published graph doc (if present) ∪ R3 observed-links map ∪ broker store. Union, deduped.
  3. Detail: lookup (importance → size, tags → cluster, status → stroke).

Degradation (decision 16 — both transports, all honest)

  • No DEMARKUS_HUB, no hub reachable → today's mark_worlds/home floor, unchanged. Nothing regresses.
  • Hub present, no graph doc → durable node map, edges from the observed map (fills as the room is read).
  • Single-world QUIC, DEMARKUS_HUB=self → reads the home world's own published index/graph if an agent put one there; else just the world + observed cross-world edges (portal nodes).

Render

  • Hub enumeration gives a fuller, durable node inventory than the top-N lookup the floor uses now.
  • Portal nodes: cross-world edges (observed or graph-doc) render externally-linked hosts as rim nodes — the extensional universe made visible in quic mode.
  • Edges drawn between nodes when known; SSR SVG still (canvas island unspent).

Scope this phase

Hub-sourced topology (node map + permission intersection + DEMARKUS_HUB config) + edge layering + portal nodes. World-view zoom level (one world's catalog as dir clusters + aggregate bubbles) is a separable follow-up, deferred.

Architecture seam (hexagonal)

  • Topology-doc parsing is a parse-of-markdown concern like service/floor.go's catalog-table parse — keep it in the core service (or a small outbound reader behind the gateway). Reuse the linkGraph seam for the edge union.
  • Extend service.Floor assembly: enumerate from the topology source, mask with mark_worlds, detail with lookup, union edges. domain.Floor grows edges + portal worlds.
  • Library stays read-only — no new write path, no new credential.

Build-time finding: host↔name mismatch reorders the slices (2026-06-13)

The hash index Server column is a crawl host (mark://<name>.<name>.svc. cluster.local:6309); the library addresses broker worlds by name (federated.go routes by shape: host→direct QUIC, name→broker). So hub enumeration ∩ mark_worlds needs a host↔name map the library lacks — messy in broker mode, and ~no visible gain at 2 worlds. Quic enumeration is clean (hosts are addressable) but has no live multi-world target yet.

Edges, by contrast, need no mapping — they are mark:// URLs the web layer already resolves (R3). So reorder: lead with the edge layer + portal nodes (visible payoff, mapping-free, reuses the observed map, lights up durable edges as soon as the agent graph-publishes). Hub enumeration is deferred until a host↔name map exists or a multi-world quic deployment needs it. Pending Fritz's nod on the reorder.

trail
  1. soul.demarkus.io v10