Topic core — data model & anchoring — Design
Draft

Topic core — data model & anchoring — Design

2026-05-11Danielwiki-browsersub-project #2

Problem

Sub-project #2 of the collaborative-annotations initiative fills the gap left by sub-project #1: it defines what goes inside topics.anchor (the JSON column #1 left empty), settles the full topic_messages.kind enum, and specifies the semantic-anchor mechanism — how a Topic stays bound to a meaningful region of its Document as the Source changes.

This is the technically hardest piece of the initiative because "stays bound across rewrites" sounds like an algorithm problem but is really a contract problem: the Agent owns Source writes, so the right design hands the Agent enough information and convention to keep anchors valid by construction. The cross-cutting decisions that produced this design live in the decisions log; this spec is the implementable contract that downstream sub-projects (#3 Agent runtime, #4 Incorporation, #8 UI integration) build against.

Goals

Non-goals

Approach

Three moves work together:

  1. Anchors are inline markers the Agent stamps directly into the Source for open, non-global Topics — HTML elements carrying data-orcha-anchor="<topic-id>". Because the Agent is the sole writer of Source (per #1's invariants), it can place markers wherever the idea now lives during every rewrite. The set of "what's anchorable" collapses to "anything the Agent can wrap": text span, image, heading, figure, code block, mermaid fence.
  2. The renderer exposes Source byte positions on every block-level element of the rendered HTML (data-source-start / data-source-end) and returns a server-side rendered-text-to-Source map for those blocks. Selection capture maps a rendered selection back to exact Source offsets through that map; the diff viewer (#4) and the inline annotator (#8) reuse the same substrate.
  3. Three anchor kinds tracked in topics.anchor: pre-marker (just created, awaiting the next Source rewrite while still open), marker (Agent has stamped one), global (deliberately document-level). Three states, two transitions, no orphan state.
pre-marker selection captured offsets in Source marker data-orcha-anchor in current Source global no specific anchor on first Incorporation re-anchored every subsequent Incorporation created from selection ↑     created without selection →
Anchor lifecycle. pre-marker exists briefly until the first Incorporation; global never transitions.

Design

Anchor JSON — three kinds

All anchors live in topics.anchor (TEXT, JSON). The kind is a discriminator; extra fields depend on the kind. The column is non-null for any Topic; an "empty" anchor is represented explicitly.

json// kind: "pre-marker"
{
  "kind":        "pre-marker",
  "source_sha":  "<40-char git blob sha>",
  "start":       1234,                // byte offset, inclusive
  "end":         1289,                // byte offset, exclusive
  "quote":       "the exact selected rendered text"
}

// kind: "marker"
{ "kind": "marker" }

// kind: "global"
{ "kind": "global" }

Field meanings:

Why three kinds and not two

A user can open a Topic two ways: with a text selection ("comment on this") or without ("comment on the doc"). These are distinct intents that the model should preserve. Collapsing them would force one of two unattractive options: lose the "global" intent (the Agent eventually has to put the Topic somewhere in the Source, but there's no reason to do so for genuinely document-level comments) or pretend every Topic has a region (the Agent has to invent one, surfacing as a poorly-placed marker).

Decision — no orphan state

There is no kind for "anchor lost." An open Topic with marker always has a marker the Agent placed (by invariant — every Incorporation re-anchors every other open non-global Topic). A Topic with global deliberately has none. A Topic with pre-marker is in a brief, well-defined window before the next Source rewrite while it remains open. If a resolver ever fails to locate a marker for an open marker Topic, that's a bug in the Agent's re-anchor work — log it and surface it as a system error, not a designed state.

Marker conventions in the Source

The Agent stamps anchors into Source files (both .md and .html) as HTML elements carrying data-orcha-anchor="<topic-id>". Two placement forms:

Both forms are CommonMark-legal (inline HTML is passed through) and don't break Markdown read in any other tool. The Agent picks the form that best represents the idea's scope.

Marker ID = Topic ID

The data-orcha-anchor attribute carries the Topic's UUID directly. No separate marker ID space, no allocation table, no mapping bugs. UUIDs in the raw Source are visually noisy but bounded — one per open non-global Topic on the file. Incorporated and discarded Topics do not keep live markers in Source.

The Agent's anchoring obligation

Every Incorporation involves exactly one Topic's resolution, but the Agent's rewrite touches the whole Source and must preserve anchors for every other open Topic on the same Source as well. The contract from #2 to #4 is:

  1. Before invoking the Agent, the harness loads the Topic being incorporated plus every other open Topic on this Source path.
  2. The Agent's prompt includes the full list of open Topics, their current anchors, and their resolved content (the text or region each is currently pinned to).
  3. In the proposed Source, the Agent must emit a data-orcha-anchor for each other open non-global Topic — placed naturally if the idea fits a region of the rewrite, or in an "Other ideas (potentially to discard)" section appended to the document if the idea no longer maps cleanly.
  4. The Agent never drops markers silently. The parking-lot section serves as visible evidence of unmappable ideas, suitable for a human's eventual discard decision.
  5. On commit, every re-anchored other open Topic transitions to {kind: "marker"} as part of the same SQLite transaction that records the commit SHA on the Topic being incorporated. The incorporated Topic becomes closed; its marker is not retained in the committed Source.
  6. global Topics are untouched — neither markers placed nor anchor JSON changed.

The wording of the Agent's prompt (how to phrase the parking-lot rule, how to describe the open Topics, how to format the "Other ideas" section) is owned by sub-project #4.

Why pre-marker → marker eagerly, not lazily

One earlier instinct was to leave pre-marker anchors as-is and only upgrade them when the Topic itself is incorporated. We chose eager for Topics that remain open: every Incorporation upgrades every other open non-global Topic. The reason is the invariant — pre-marker's offsets are valid only while source_sha matches. After any Incorporation on the same path, those offsets are stale. Eager upgrade keeps the steady state clean: open Topics are always either marker or global; pre-marker exists only between Topic creation and the next Source rewrite while the Topic remains open.

Renderer changes — Source-position attributes

Every block-level element in the rendered HTML carries:

html<p data-source-start="234" data-source-end="389">…</p>
<h2 data-source-start="412" data-source-end="428">…</h2>
<pre data-source-start="450" data-source-end="612">…</pre>

Offsets reference UTF-8 byte positions in the Source file. The half-open range [start, end) covers the bytes that produced this block.

Markdown (internal/render/markdown.go): a new goldmark NodeRenderer wraps the default html.NewRenderer. For each block-level ast.Node, the wrapper writes data-source-start and data-source-end attributes derived from the node's Lines() segments and contributes entries to the render map (defined below). goldmark already tracks segment positions on every text node; the renderer records those segments verbatim instead of trying to reverse-engineer Markdown from DOM text later.

HTML (internal/render/html.go): a tokenizer pass over the Source HTML, using golang.org/x/net/html. As tokens are emitted to the output, block-level opening tags receive data-source-start and data-source-end attributes recording the byte range of the original token stream. Text tokens inside those blocks contribute render-map entries using decoded text for rendered offsets and token byte ranges for Source offsets; entity references collapse into a single entry (e.g., rendered & ↔ five Source bytes &amp;). Self-closing and inline tags are passed through unmodified.

Block-level scope for both formats is the standard set: p, h1h6, ul, ol, li, blockquote, pre, table, tr, figure, div, and the wiki-browser-specific pre.mermaid. Inline elements (span, a, strong, em, code) do not carry DOM attributes; selection capture interpolates within a block via the render map.

The server content path changes for .html files as part of #2: handleContent must render HTML files through internal/render and the resolver pipeline, not stream raw bytes directly. Raw Source remains available through ?raw=1.

Render map structure

The render map is a sibling output of the renderer, returned alongside the rendered HTML in the *render.Document struct. It is a per-block list of linear segments, where each segment translates between rendered-text offsets and Source byte offsets. The map is the contract between selection capture, the resolver, and the diff viewer (#4).

gotype RenderMap struct {
    Blocks []BlockMap // indexed lookup by source range
}

type BlockMap struct {
    SourceStart, SourceEnd int      // matches the block's data-source-* attrs
    Segments               []Segment
}

// Segment is a maximal linear stretch where rendered-text offsets
// map proportionally to Source byte offsets (1:1 per character when
// the renderer copies bytes through; many-to-one for entities and
// Markdown escapes). Renderer-injected content (syntax-highlighting
// tokens, Mermaid SVG labels, list-marker glyphs, "View more" etc.)
// is represented by a Segment with Source == empty, marked NonSource.
type Segment struct {
    RenderedStart, RenderedEnd int
    SourceStart,   SourceEnd   int
    NonSource                  bool
}

Translation rules used by selection capture and the resolver:

Cross-block selections are out of scope for v1: a selection that crosses block boundaries — two paragraphs, two list items, two table cells — is rejected client-side (the composer asks the user to narrow to one block). The render map carries one block per BlockMap, and the API shape carries one block range per request. Multi-block anchors can be added later by extending the API to accept an array of BlockMap references; the storage shape (single start/end) would need a follow-up decision before that lands.

Selection capture

Every rendered page carries the Source's git blob SHA in a <meta name="wb-source-sha" content="…"> tag inside the iframe document. The client reads it once on load and includes it in the Topic-creation request so the server can detect a stale iframe before translating offsets.

The flow when a user selects text and creates a Topic:

  1. Client JS listens for selectionchange in the iframe. On a non-empty selection, a floating composer (described under Topic creation chrome) appears anchored to the selection. If the selection spans more than one block element (different data-source-start ancestors), the composer disables Save and explains "please select inside a single block."
  2. On Save, the client computes: the selected quote (DOM toString()), the rendered-text start/end offsets of the selection within its block (measured by walking text nodes), and that block's data-source-start / data-source-end values.
  3. It POSTs {source_path, source_sha, first_message_body, selection: {quote, block_source_start, block_source_end, rendered_start, rendered_end}} to /api/topics.
  4. Staleness check. The server computes the Source's current source_sha (via the collab.SourceSHA helper from #1) and compares to the request's source_sha. If they differ, return 409 Conflict with code stale_source — the iframe was rendered against an older Source, and the captured offsets are no longer trustworthy. The client reloads the iframe and asks the user to reselect.
  5. Translation. The server loads the render map for the named path (re-rendering if not cached) and locates the block whose SourceStart/SourceEnd match the request. It applies the render-map translation rules above to convert [rendered_start, rendered_end) into a Source byte range. Selections that overlap a NonSource segment return 409 Conflict with code non_source_selection.
  6. Write. The server writes {kind: "pre-marker", source_sha, start, end, quote} into the new Topic row, alongside the first message. Translation and write happen inside the single SQLite write funnel from #1, so a concurrent Incorporation cannot interleave; if the Incorporation lands first, the funnel still serialises this write after it — but the staleness check in step 4 will have already rejected the request because the request's source_sha referenced the pre-Incorporation Source.
Why match on the server, not just trust client offsets

The client could in principle compute byte offsets itself by walking the DOM and accumulating text lengths. We don't trust that: rendered text and Source bytes differ (Markdown formatting characters, entity references, normalised whitespace), and any client-side mapping would be a brittle reimplementation of the renderer. The server-owned render map is the contract: the browser identifies a rendered range; the renderer translates it to Source bytes.

The resolver

When the harness renders a Document with active Topics, a resolver pass overlays anchor highlights onto the rendered HTML. The resolver runs server-side after the renderer produces its *render.Document (HTML body + render map) and before it's served. Conceptually:

gofunc ResolveAnchors(doc *render.Document, topics []OpenTopic) *render.Document {
    // doc.HTML and doc.RenderMap are produced together by the renderer.
    // 1. Walk the topics; for each non-global anchor, compute its
    //    target range in the Source bytes, then project it through
    //    doc.RenderMap into rendered-text intervals (Source → Rendered
    //    direction documented under "Render map structure").
    // 2. Walk the rendered HTML once with a tokenizer.
    //    - On any block opening tag: read data-source-start / -end and
    //      look up the matching BlockMap.
    //    - When inside a block whose rendered intervals overlap one
    //      or more target intervals, split text nodes at interval
    //      boundaries and wrap each segment in <mark class="wb-anchor"
    //      data-topic-id="..." [data-topic-ids="..."]>.
    //    - On a span/element with data-orcha-anchor=<topic-id> that
    //      matches a marker-kind Topic, wrap its contents the same way.
    // 3. Return the rewritten Document (HTML updated; map unchanged).
}

Behaviour by kind:

Overlapping anchors (two Topics whose target ranges intersect) are handled by interval splitting, not by assuming simple containment. For every text segment, the resolver computes the set of covering Topic IDs. Segments with one Topic emit <mark class="wb-anchor" data-topic-id="...">; segments with multiple Topics emit one <mark class="wb-anchor wb-anchor-overlap" data-topic-ids="id-a id-b">. Styling distinguishes overlap visually (#8's call); the resolver only needs to sort Topic IDs deterministically before emitting attributes.

Topic creation chrome (minimal UI)

Two affordances are introduced in #2:

  1. Selection composer. Click-and-drag text in the iframe → a small floating popover appears near the selection: textarea + "Save" + "Cancel". Save creates the Topic with a pre-marker anchor.
  2. Topics sidebar. A panel adjacent to the iframe lists open Topics on the current Document, grouped: Anchored (those with pre-marker or marker anchors) and Global. Each entry shows the originator, the quote preview (for anchored Topics), and the first message snippet. Clicking an entry opens the thread view — message list plus a textarea to post a new 'human' message. A "New global Topic" button in the sidebar header creates a Topic with {kind: "global"}.

Clicking a wb-anchor highlight in the iframe focuses the sidebar on the corresponding Topic (via data-topic-id). No keyboard shortcuts, no rich formatting, no notifications, no presence indicators, no Resolve button. Visual polish — sidebar position and width, highlight colour, popover styling — uses the existing wiki-browser design tokens and is intentionally minimal; #8 owns the production-quality treatment.

HTTP API

All endpoints are JSON in/out. Authentication is not in scope for v1; every request attributes to the bootstrap operator from wiki-browser.yaml (see #1).

Method & pathBody / paramsEffect
POST /api/topics {source_path, selection?, global?, first_message_body} Create a Topic with first message. If selection present → pre-marker anchor; if global: trueglobal anchor; exactly one must be present.
GET /api/topics?source_path=… List open Topics on this Source. Returns Topics with their current anchor JSON, first message preview, and message count.
GET /api/topics/{id}/messages Return the full ordered message thread for a Topic.
POST /api/topics/{id}/messages {body} Append a kind='human' message to the Topic.

Topic resolution endpoints (Discard, Incorporate) are owned by #4, which adds them alongside the Resolve flow. Existing internal/collab mutators (InsertTopic, InsertMessage) underpin the write paths; this sub-project adds an internal/collab reader API for sidebar queries and an HTTP layer in internal/server.

Message kinds

For v1 the topic_messages.kind column carries one of two values:

Topic resolution (incorporated / discarded) is fully derivable from topics outcome columns; the UI can synthesise "Topic discarded by Alice on 2026-05-11" without persisting it as a message. Additional kinds (system events, rework requests) can be added when a concrete UI need surfaces — the column accepts any string at the schema level, so adding a kind is a non-migration change.

Validation rules (server-side)

Re-anchor contract (handoff to #4)

Sub-project #4 implements the actual re-anchor step as part of Incorporation. This spec defines its input/output shape so #4 doesn't have to renegotiate with #2:

re-anchor task — invoked once per IncorporationInput  (from harness, prepared by #4):
  - source_path
  - current Source bytes (pre-rewrite)
  - the Topic being incorporated:
      { id, anchor, messages[] }
  - all other open non-global Topics on this source_path:
      [{ id, anchor, messages[] }, ...]   // excludes 'global' Topics

Output (from the Agent, consumed by #4):
  - proposed Source bytes (post-rewrite)
  - INVARIANT: for every "other open" input Topic id,
    proposed Source bytes contain a data-orcha-anchor="<id>"
    element exactly once.
  - INVARIANT: any Topic whose idea did not map naturally is
    represented by a marker inside an "Other ideas (potentially
    to discard)" section appended to the proposed Source.

Side effects on commit (in the same SQLite transaction as the
commit-sha write owned by #1):
  - For every "other open" Topic id in the input list, set
      topics.anchor = '{"kind":"marker"}'

Two role-based distinctions to keep straight:

Storage — no schema change

Sub-project #1 already shipped topics.anchor TEXT as nullable. This sub-project tightens the contract: every row created via the API has a non-null anchor JSON, and the JSON validates against one of the three kinds defined above. The NOT NULL constraint is enforced in Go (in InsertTopic), not as a schema-level constraint — keeping the schema unchanged from #1 means no second migration.

topic_messages.kind remains an unconstrained TEXT column at the schema level. The Go layer constrains it to {'human', 'agent-proposal'} on insert.

The existing index topics_by_source_path covers the sidebar's primary query ("list open Topics for this source_path"). No new indexes for v1.

Code organisation

New files and the packages they live in:

PathRole
internal/collab/anchor.goAnchor JSON types (PreMarker, Marker, Global), validation, marshal/unmarshal helpers.
internal/collab/anchor_test.goRound-trip + validation tests.
internal/collab/reader.goRead-side query helpers: ListOpenTopicsForSource, GetTopicWithMessages, ListMessages.
internal/collab/reader_test.go
internal/render/sourcepos.gogoldmark NodeRenderer wrapper that emits data-source-start/-end on block-level elements and builds the RenderMap (per-block linear segments) returned alongside the rendered HTML in *render.Document. Equivalent helper for the HTML render path.
internal/render/sourcepos_test.goGolden-file tests for emitted attributes and the structure of the RenderMap on canonical Markdown and HTML inputs, including entity references, Markdown escapes, syntax-highlighting tokens, list markers, and Mermaid output (verifying which segments are flagged NonSource).
internal/render/rendermap.goRenderMap, BlockMap, Segment types plus the bidirectional translation helpers (RenderedToSource, SourceToRendered) used by selection capture and the resolver.
internal/render/rendermap_test.goTable tests for the translation helpers, especially the entity-expansion and NonSource-rejection cases.
internal/render/resolver.goAnchor resolver pass that consumes *render.Document, projects Source ranges to rendered intervals via the map, and wraps target ranges in <mark class="wb-anchor"> elements.
internal/render/resolver_test.go
internal/server/topics.goHTTP handlers for the four endpoints under /api/topics/….
internal/server/topics_test.goRequest/response round-trips using httptest against an in-memory store.
internal/server/templates/Existing — extended with the sidebar partial and selection composer.
internal/server/static/wb-topics.jsNew — selection capture, sidebar interactions. Vanilla JS, no framework.
internal/server/static/wb-topics.cssNew — minimal styling for sidebar, composer, highlight.

The Topic core sits between internal/collab (storage primitives owned by #1) and internal/server (the HTTP+UI surface). Re-anchoring logic that the Agent will eventually drive lives in internal/collab/anchor.go as the type definitions plus pure helpers; #4 will add the runtime that invokes the Agent against these types.

Testing

Open questions

Small items, none blocking implementation. Resolve during writing-plans or first PR.

References