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.