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:
- Truncate the stack to N (discard everything right of it).
- Append the target as pane N+1.
- 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: containon 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_lookupwith 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)
- Confirm killing the nav search box in R1 (capability survives as tag pages).
- Status-axis tag vocabulary (
status:draft|wip|accepted|archived?) and whether the write gate should require the axis. - Trail URL shape: path segments vs query param; stack depth cap (~10?).
- R1-before-R2 phasing vs straight to the trail engine.
- 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)
- 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.
- 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.
- 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
mark_worldsbroker tool (demarkus repo) — small, unblocks the floor.- 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.
- 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_worldsenumerates 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_graphalready 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:
- 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.
- Topology/edges: hub index + graph docs (primary, both transports).
- Fallbacks: render-time observed-links map; broker ephemeral store.
- 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):
- Broker graph store (
mark_graphcrawl →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." - Library-side crawl of the home world — needs a background crawler loop, spends read budget, owns its own invalidation. Over-built for R3.
- Render-time observed-links map (addendum 1) — CHOSEN.
rewriteLinksalready 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 + reversemap[Ref]set[Ref]under a mutex), mirroring the existingfloorCacheprecedent 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 aport.LinkGraph— noted, not built.) - Inbound port additions (
port.ReadingService):RecordLinks(world, path string, targets []domain.Ref)— the web adapter calls this afterrewriteLinks(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).
rewriteLinksnow returns(html, []domain.Ref);rewriteHrefreturns(route, domain.Ref, isDoc). Edges collected only for in-universe document targets — external/anchor/listing(dir)/self targets are skipped.
The three pieces
- 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=mouseenteroverrides the anchor's default click trigger, so click still navigates via hx-boost. RouteGET /w/:world/preview/*→ "preview" fragment (title, status badge, first- paragraph snippet, mark:// + open). Served fromReadCached(budget-safe). One component serves both body links and backlink entries (ADR 0005 §margin: build once, both directions). - Backlinks margin block — "Referenced by" in
doc-meta(shared by the trail pane and the /w/ permalink view). Fed byBacklinks. 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. - Graph neighborhood pane — new pane kind
<world>/g/<path>added to the trail codec viapaneAddrFromParts(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-universemark://soul.demarkus.io/.well-known/library/trails.mdboth updated to document thegkind (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 Indexdoc with a| Hash | Server | Path |table.Server= world (amark://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 -> Bmark:// 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_lookupper world (the current floor path, unchanged).
Layering (addendum 2 made concrete)
- 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. - Edges: published graph doc (if present) ∪ R3 observed-links map ∪ broker store. Union, deduped.
- Detail: lookup (importance → size, tags → cluster, status → stroke).
Degradation (decision 16 — both transports, all honest)
- No
DEMARKUS_HUB, no hub reachable → today'smark_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.Floorassembly: enumerate from the topology source, mask with mark_worlds, detail with lookup, union edges.domain.Floorgrows 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.
CORRECTION (2026-06-13): enumeration is NOT deferred — unified hub-topology reader
The prior "defer enumeration" note is superseded. Fritz: hub-as-map IS the KS requirement (decision 11 — the floor must render the canonical published universe, not a bespoke mark_worlds+lookup view that drifts from what agents read). The host↔name wrinkle is a join detail, not a reason to skip it.
Unified design — one hub-topology reader, mark_worlds-masked, name-joined:
- Names + permission =
mark_worlds(authority, unchanged): the authorized world names + each world'sURL(mark://host). Build a host→name map from those URLs — this is the join key (the hash index / graph export key endpoints by host; mark_worlds gives host↔name). - Topology = hub docs: hash index (full node inventory per world) + graph export (edges). Joined to worlds by name via the host→name map.
- Detail = lookup (importance/tags/status), unchanged.
- Floor = hub map ∩ mark_worlds; edges = hub graph ∪ R3 observed map; endpoints whose host has no authorized name → portal nodes (external).
- Degradation (no regress): no
DEMARKUS_HUB/ hub unreachable → today's mark_worlds+lookup floor. mark_worlds URL absent → edges can't attach to authorized nodes, render as portals only. Hub present, host-keyed (pre agent-change) → join misses, falls back; name-keyed → full join.
Coupled agent change (demarkus repo / agent chart): the federation agent
should publish (a) the index keyed by world name (or include the name) and
(b) a graph export (mark_graph_publish) to the hub, so the library's join
lights up fully. Library degrades until it ships — build order is library-led,
both land together.
Build (library, reader-only): DEMARKUS_HUB config (default self/home in
quic, explicit in broker — cluster root); service hub reader (fetch + parse
index/graph like floor.go's catalog parse); host→name map from mark_worlds;
domain.Floor grows edges + portal worlds; floorSVG draws edges + rim portals.
Tests via fixtures. World-view zoom still a later phase.
Floor enrichment — PR1 BUILT (2026-06-13, branch feature/floor-enrichment)
Library hub-topology reader, reader-only, degrades cleanly. Gate green
(./pre-commit.sh). Not committed (Fritz commits).
- Topology source = the hub graph export (
mark_graph_exportformat), one doc at<hub>/graph.md— a strict superset of the hash index (nodes WITH title/status + edges), so one fetch + one parser covers nodes and edges.service/hub.go:parseGraphExport(Nodes| URL | Title | Status | Links |- Edges
| From | To |, mark:// only),parseMarkRef,worldEdges(host→name join + intra-world skip + dedup + portal detection).
- Edges
- Config:
DEMARKUS_HUB(default = home host in quic; explicit in broker — cluster will setroot).service.WithHub()(off the constructor so all existing callers/tests are untouched). - Floor assembly (
service/floor.go): authorized worlds from mark_worlds (mask + names + host→name from eachWorldInfo.URL); edges = hub graph ∪ R3 observed map (linkGraph.allEdges), aggregated to world-level, masked; endpoints with no authorized name → portal worlds.domain.FloorgrewEdges;FloorWorldgrewPortal. - Render (
web/floor.go): systems row + edges (lines between cluster centers) + a portal band below (dashed rim nodes linking to the host root). - Degradation verified live (quic): no
<hub>/graph.md⇒ empty topology ⇒ baseline floor, no edges/portals, no error. Host↔name join uses mark_worlds URLs; observed-map edges (already library-world-id keyed) pass through.
Coupled agent change (demarkus repo — REQUIRED to light up durable edges):
the federation agent (client/cmd/demarkus-agent, client/internal/fedcrawl)
currently publishes hash indexes only (PublishToHubs). It must also build
the link graph (client/graph/crawl.go already crawls links) and publish a
graph export to each hub at /graph.md (add a --publish-graph mode +
fedcrawl step). Until then the library floor shows observed-map edges only
(sparse) and no durable cross-world edges. This is a demarkus feature, not just
deploy config.
Deferred: hub node-inventory join for fuller per-world doc sets (graph export nodes are parsed but only edges/portals are rendered today); world-view zoom level.
Floor edges — live demo + CONFIRMED host↔name join gap (2026-06-14)
End-to-end pipeline verified in the cluster: appended cross-world + external
links to world-a/index.md (via the knowledge MCP) → agent re-crawled
(servers:3, followed the links) → published root/graph.md v3 with edges
world-a…svc:6309/index.md → mark://root/index.md and → mark:// soul.demarkus.io/index.md. The library reads it and renders edges + portals.
The producer→hub→reader→floor loop WORKS.
Confirmed root cause of the join gap: mark_worlds returns an EMPTY url
column for every world (| root | |, | world-a | |). The library builds its
host→name map from those URLs (floor.go host2name ← WorldInfo.URL), so it
is empty. The agent keys graph nodes by crawl host
(world-a.world-a.svc.cluster.local:6309), which then can't map to the
world-a cluster → it renders as a portal (crawl-host) wired to the root
cluster, instead of a clean world-a ↔ root line. External soul.demarkus.io
portal renders correctly (it's genuinely unauthorized).
Correct fix (broker, demarkus repo): mark_worlds must populate each
world's url with the address the agent crawls it by (the cluster-internal
mark://<name>.<name>.svc.cluster.local:6309), so the library's host→name map
resolves graph hosts to authorized names. Then cross-world edges between
crawled worlds join cluster-to-cluster. Until then the floor shows crawl-host
portals for inter-world links — honest, but not the intended cluster lines.
Library-side suffix-stripping is the fragile fallback; the broker URL is the
clean fix. (The library's worldEdges already does the join the moment URLs
arrive — no library change needed if the broker populates them.)
World-view zoom level — build decisions (2026-06-14, locked before building — Fritz + Opus)
Floor enrichment is live (library 0.1.10). Next phase = the deferred zoom level 2 (plan §"Universe-view research": one world's catalog as dir clusters + aggregate bubbles that click into listing panes). Fritz picked it as the next phase.
THE decision: world map = the floor kind u with a World set
One component, two zoom levels (the plan already frames it: "Floor in quic mode =
world view of the home world"; ADR 0005 decision 4 — universe view is the graph at
max zoom, the world view is one zoom in). So no new pane kind — reuse paneFloor
("u"):
- bare
uchunk → the universe floor (unchanged). <world>/u/chunk → that world's map. Agent-constructible, parallels the<world>/d/stacks and<world>/g/<path>graph conventions (trailing slash, empty value).paneAddrFromPartsgains aucase (value ignored);paneChunkemitsuwhen World=="",<world>/u/otherwise. Single source of truth holds — both decoders share it.
Reached by: the floor's world node zooms in (was: opened the stacks)
The floor's world cluster node now links to <world>/u/ (the world map) instead of
<world>/d/ (the stacks). Coherent zoom hierarchy: floor → world map → (dir
listing | doc | graph). No capability lost — the stacks stay one click away via the
world map's per-dir aggregate bubbles. Floor satellites (top docs) keep linking
straight to docs (a shortcut).
Data: ReadingService.WorldMap(ctx, world)
Full catalog via Lookup(world, "/", "*", "") (the floor's call, uncapped),
grouped by top-level path segment (dir). Per cluster: top-N by importance as
labeled doc nodes; the remainder collapses into a "+N more" aggregate that links to
the <world>/d/<dir>/ listing pane (the stacks — already a pane kind). Per-world
cache mirroring floorCache; focused-live policy (WorldMap live, WorldMapCached
for unfocused/parent panes). New domain types WorldMap{World, Clusters} +
WorldCluster{Dir, Docs, More, ListPath}. Added to the inbound port.
Render: worldMapSVG (SSR SVG, canvas island still unspent)
Dir clusters laid out in a row/grid; each cluster = a dir label + its doc nodes on
a small orbit (status stroke + importance size, reused from the floor) + a "+N"
aggregate bubble when truncated. Doc node → <world>/d/<path> trail click; bubble →
<world>/d/<dir>/ listing trail click; both continue the trail (decision 4).
Permalink GET /w/:world/u → standalone world map (nodes link to /w/ permalinks).
Edges: intra-world, among rendered nodes, when present
The floor drops intra-world edges (worldEdges skips same-world) — the world map is
where they belong. Drawn from s.graph.allEdges() ∪ hub graph filtered to this
world, but only between two labeled (rendered) doc nodes; edges to aggregated docs
are skipped (honest — can't point at a node that isn't drawn). Degrades to no edges
when none observed, like the graph pane.
Docs (decision 14)
docs/trail-format.md + in-universe mark://soul.demarkus.io/.well-known/library/ trails.md both gain the world-scoped u chunk, kept in sync.
Scope / deferred
This phase ships the world map (dir clusters + bubbles + doc nodes + intra-world edges). Out: a dedicated per-dir sub-zoom (the listing pane already covers it); tag-based clustering (dir is the load-bearing hierarchy); force layout (SSR deterministic stays).