A wrong place is worse than no place

A broken anchor does not fail. It lands somewhere, reports `ok`, and paints a highlight that looks exactly like a correct one. How REX keeps a comment on its passage while the document is edited, the three times it lied anyway, and the rule that came out of it: orphan before you guess.

Most of REX fails where you can see it. A page that does not render is blank. A tool call the gate refuses is recorded as denied in the thread. The anchor — the thing that keeps a comment attached to the sentence it was written about — does neither. When it breaks, it resolves to somewhere, reports ok, and paints a highlight that looks exactly like a correct one. Nobody finds out until a person reads the highlight and notices it is on the wrong paragraph.

The first REX spec introduces anchoring as “the core of the product. Specified in full because it is the only part that fails silently.” Milestone 0 was not the app. It was a standalone script, roughly 150 lines, no Electron, no database, no UI: create ten anchors on a document, apply three edits — insert a paragraph near the top, reword one sentence, delete a section — reload, and re-resolve all ten. The gate was that the reworded one must come back moved or orphaned, “never silently resolved to the wrong place”, and under it: if this fails, stop and revisit §6 before building anything else.

That script is still in the repository as test/anchor.spec.ts, and it still refuses to assert that resolution succeeded. It prints what each anchor landed on and fails when a hit does not return the text it was created from. Every marker carries a why:

{
  id: "docx/reworded",
  quote: "Margin expansion came from three identifiable sources.",
  expect: "moved-or-orphaned",
  why: "THE REWORDED ONE — must never resolve silently to another passage",
},

The verdict column has three values: pass, FAIL, and WRONG PLACE. The third one is the reason the file exists.

What you can point at

A place is a run of text, a table, a row, a cell, a region dragged out of a figure, a section, the whole document, a node or an edge in a Mermaid diagram, or whatever a hand-drawn circle encloses — a block is inside the circle when its centre is, and the outermost match wins, so a table inside a circle is one thing and not fifteen cells. One comment can hold many places, across many documents.

The one thing you cannot point at is a page. Spec 06 was written in answer to a request for pages in the Markdown and Word viewers, and it delivered sections and the pen instead. A PDF has pages because its author paginated it. A Markdown file has none, and a page invented by slicing rendered HTML at a chosen height “is stable only while that height never changes; a different window, font size or zoom renumbers every page in the file. An anchor to ‘page 3’ would then point somewhere else, silently.”

Four layers, and a map back to the DOM

The model is the W3C Web Annotation Data Model, the approach Hypothes.is uses. Each anchor records four things at creation time so that resolution has something to fall back to:

Layer Field Resolves by Survives
1 quote text search — the exact text plus 32 characters of prefix and suffix reflow, restyling, most edits
2 position character offsets disambiguates a repeated quote
3 element id, then a CSS path images, SVG, tables — anything with no text
4 region fractions of the element’s box a specific spot inside a diagram

The prefix and suffix are what let Q2 2026, which occurs three times in the test document, resolve to the right one.

Underneath is a text index: the document’s visible text with every run of whitespace collapsed to one space, and a two-way map from each character back to the DOM text node it came from. Highlights are painted with the CSS Custom Highlight API and never with a <mark>, because wrapping a range in an element mutates the document under review and shifts the offsets every other anchor depends on.

Resolution ends in one of three states. ok is the quote found on an unchanged document. moved is found, but not where or not how it was — a badge, not a hidden comment. orphaned is nothing found, and an orphaned comment is never deleted and never hidden. REX’s own edit feature creates orphans as a matter of course, so orphaning is normal operation, and it has to be cheap enough that the resolver prefers it to a guess.

Where the code refused the spec

The spec said layers 2 to 4 are fallbacks, tried in order. Two of the tries did not survive milestone 0.

The element layer is not a fallback for text. When a quoted passage is deleted outright, its stored CSS path — section:nth-of-type(3) > p:nth-of-type(2) — still matches something, because a positional path names a slot, and something else now occupies the slot. The resolver says so in place:

// Layer 3 is deliberately NOT a fallback for a text anchor. Measured on the
// milestone 0 documents: when a quoted passage is deleted outright, its
// element ref — a positional CSS path — still matches *something*, so the
// comment lands on an unrelated paragraph and reports `moved`.

The path is still recorded, and it is still the only layer an image or a chart ever had. But a text anchor that cannot find its text is orphaned, not relocated. The same run showed that deleting one <section> renumbered section:nth-of-type(3), and an anchor on a diagram resolved, confidently, to a different diagram — so generateCssPath now prefers an id, an aria-label, a data-testid, a name or a title at every level of the walk, and falls back to nth-of-type only when nothing identifies the element.

Bitap caps a pattern at 32 characters. The spec’s fuzzy step called match_main on the whole quote. diff-match-patch’s Bitap search works on a machine word and refuses a pattern longer than Match_MaxBits, which is 32. So the code probes with the first 32 characters and then verifies the whole quote at the spot it found, rejecting a candidate whose Levenshtein distance exceeds a quarter of its length. Without the second step “a shared opening phrase would resolve confidently into the wrong paragraph.”

Three times it said ok

Each of these passed every check REX had at the time.

Four thousand characters of CSS in the index. Measured on 2026-08-21. The text index skips <script> and <style> by tag name, and tagName is upper-cased for HTML elements but left as written for SVG ones. Mermaid’s SVG carries about four thousand characters of its own CSS in an inline <style>, spelled style, and every one of them entered the index — shifting every anchor offset below the diagram and making the CSS quotable as if it were the author’s prose. The fix is one toUpperCase() before the set lookup, which is the kind of fix that makes you re-read every other tagName comparison in the tree.

A wash that stopped mid-word. An anchor on a whole block records the block’s opening text as its key, truncated at 320 characters so that a long table does not store a copy of itself with every comment. Until 2026-08-26 that key was resolved to a range and the range was painted, so a comment on an eight-paragraph card wore a violet wash over its first 320 characters and stopped mid-word. Nothing was in the wrong place. The claim was still false — it read as “the comment is about this much” — and the same truncation reached the agent as 320 characters cut mid-word with nothing saying more existed. A block pick is now outlined and never filled, and a quote at the cap is labelled as an opening.

The heading that was a list item. Spec 16 added a place between two blocks — a gap — and a gap is the anchor kind most able to fail silently, because a gap looks the same everywhere. Each side resolves through its quote and is then checked to still be that block. On 2026-09-04, against the sample document, the gap below the last roadmap item resolved its lower neighbour — the deleted <h2 id="faq">FAQ</h2> — onto the table-of-contents entry <li>FAQ</li>, two hundred lines higher up, and reported ok. The block check was satisfied, correctly: an <li> reading “FAQ” is a block whose entire text is “FAQ”. The quote cannot tell a heading from the entry that points at it, and the CSS path could not either, because a stable id had reduced it to #faq. The tag can, so the element ref now carries one. The same shape had already happened once, on 2026-08-26, with a heading resolving onto a bullet three hundred lines away.

A diagram is its source

Pointing at a node in a Mermaid diagram used to be pointing at an SVG element. On 2026-09-01 I measured what pick mode offered for the node B{Has comment?} in a seven-line flowchart drawn by Mermaid 11.17.0: eight scopes, six of them the plumbing Mermaid draws a label with — a <p> in a <div> in a <foreignObject> in two <g>s. Pointing at the subgraph produced a quote of “The engineyesnoReviewerHas comment?Ask agentRead onrex.db”, every label in the diagram run together.

The one scope that read durable was the <g> with an id, and it was the least durable thing on the page. A sequence diagram’s lifelines are actor0 and actor1 on the first render of a source and actor2 and actor3 on the second render of the same source: two calls to mermaid.render with identical text produced markup that differed at byte 895, and only there. REX redraws every diagram when the paper switches between light and dark, so an anchor on that id would be orphaned by a theme change.

The rule that came out is that a Mermaid diagram is its source. A comment on a part names the part in the source — a node’s id, an edge’s two ends and its ordinal, a subgraph’s id, or a run of lines — with the lines it is stated on, their text, and a fingerprint of the whole fence. It never records an SVG id. The drawing is a lookup rebuilt after every draw, and a part the lookup cannot find is still a part: it resolves, it reaches the agent, and it is outlined as the whole diagram rather than as a wrong node. The parts come from a 150-line scanner that asks each source line what it declares, not from Mermaid’s parser, which has no line numbers; only flowcharts get named parts, because the corpus I measured was 14 flowcharts and nothing else.

The rejected-ideas table has one row that is this post in a sentence. Resolving a run of lines by line number when their text is gone was refused because “a line number always resolves to something. That is the silent wrong-place failure the whole anchor design exists to prevent, and orphaning costs nothing.”

One lost place

Being willing to orphan has a cost, and on 2026-09-02 I hit it from the other side. Thread 75804a2a was a comment with four places across three files. One heading was renamed. The row read anchor lost, the card read “the text it was written on is gone”, and the comment left the open filter for the gone lane — with three of its four places on screen, painted, and one click away the whole time.

The cause was a function called worstState. It ranked orphaned above moved above ok and handed the loudest one to the whole comment. It was written in spec 04, when a comment had one anchor and the worst of one is that one; spec 05 gave a comment many places and nothing had re-read the rule since. “The cost grows with the number of places, which is backwards. A comment about six passages is the one most likely to have a place die, and the one least likely to be lost by it.”

The fix is not to hide the loss. It is to stop pretending one word can describe four places: the row now reads 1 of 4 lost, the comment stays in open, and the count is the verdict. In the SQL that feeds the file tree, MAX(rank) became MIN(rank) — “some place is gone” became “every place is gone” — and worstState was deleted rather than deprecated. The denominator is deliberately the number of places anyone has looked at, never the number the comment has. In the live run that checked the build, a four-place comment read 1 of 3 lost until the file holding its fourth place was opened, then 1 of 4 lost, because a claim about a file nobody has read is not a claim REX is willing to make.

My own first proposal was to drop the comment-level state entirely and keep it per place. That was rejected, and correctly: a comment whose every place is gone has no highlight and no margin bar, cannot be reached by pointing at the paper, and the gone lane is the only way back to it. The lane answers a different question — can I still find this on the page — and the rewrite is that question written down.

The rule

Prefer lost to wrong. An orphan costs a badge and a lane; a wrong place costs a reviewer’s trust in every highlight on the page, and it costs it silently. So the resolver refuses a positional path for text, verifies a fuzzy hit over its whole length, checks a neighbour’s tag as well as its text, names a diagram part in the source rather than in the drawing, and never resolves a line by its number. Each of those is a place where a plausible answer was available and declined.

This generalises to anything that attaches meaning to a document someone else is editing — annotations, citations, test fixtures keyed to line numbers. The question is never “can I find something?” A CSS path always finds something. A line number always finds something. The question is whether the thing you found is still the thing, and the only honest answer to “I cannot tell” is to say so.

Next: a comment is a conversation that waits for you — what happens to a thread when you close REX and come back a week later.