Anchored addresses
Status: Shipped (v0.34.0) — implemented in
src/ctx/anchors.py, covered bytests/test_anchors.py, measured inevals/anchor-drift-2026-08-20.md. New to the vocabulary? Read How it works and Concepts first.
The promise that had a hole in it
Section titled “The promise that had a hole in it”straitjacket’s second house rule is omission keeps an address. For frozen
artifacts — run:, blob:, snapshot: — a handle resolves the exact stored
bytes while that artifact store is available.
repo: needs a different promise. It names a live file that someone may be
editing, so its content can move or disappear between the turn that records an
address and the turn that follows it.
Before this mechanism:
$ ctx get repo:m.py --lines 4:5[ctx get repo:m.py (snapshot:cf3db50116cd)]selector: --lines 4:5 of 8L4: def beta():L5: return 2
# ... two imports get added at the top of the file ...
$ ctx get repo:m.py --lines 4:5[ctx get repo:m.py (snapshot:a4536d5d3f22)]selector: --lines 4:5 of 10L4: return 1L5:Same address. Different code. Exit 0, and nothing in the output says so. The
snapshot id changed, but nobody reads a snapshot id for a diff they were not
told to expect. A line number is a position, not an identity, and every
navigation verb that hands back repo:<path> L<a>:<b> — ctx def, ctx refs,
ctx diag, ctx map, ctx get — was minting addresses with that built in.
The exposure is not theoretical. Replaying ordinary edit shapes over this repository’s own source, 99.9% of re-resolved unanchored addresses returned different content, silently (receipt). An address is only worth something between the turn that mints it and the turn that uses it, and that interval is exactly when an agent edits files.
Where this problem comes from
Section titled “Where this problem comes from”This is the read-side twin of a failure the field has already spent a lot of effort on from the other end. String-replacement edit tools fail when the model cannot reproduce a region byte-for-byte; patch formats fail when they apply against lines that have moved; at least one vendor shipped a separate 70B model whose whole job is reconciling edits that did not apply cleanly.
Those look like edit-tool problems. They are all the same missing primitive:
a stable, verifiable identifier for a region of a file, that does not cost the whole file in context.
Without it, a model has exactly two ways to name code — reproduce it (expensive, and it must be perfect) or point at a line number (cheap, and it goes stale) — and both fail in the same situation: the file changed since it was read.
straitjacket’s entire architecture is built on the claim that the third option is better than either: an address. So the gap here was not a missing feature at the edge of the system. It was the central mechanism not holding on the one surface where it is hardest to hold.
The grammar
Section titled “The grammar”An anchor is a short content digest that rides inside the selector the address already carries, so no emission site grows a second field and no parser learns a second address shape:
ctx get repo:m.py --lines 4:5@07407f1c └──┬──┘└──┬───┘ where it what was there was (content, never position)Everything that accepted --lines A:B still accepts it. The anchor is optional
everywhere, on the CLI and on the MCP surface alike.
The three outcomes
Section titled “The three outcomes”Re-resolving an anchored address does one of three things, and which one happened is always visible.
Verified — the content is still there
Section titled “Verified — the content is still there”The read is byte-identical to the unanchored one. No extra note, no reflow. Declaring omissions is house style; narrating successes is window tax, and a confirmation line would also break digest determinism for callers that anchor.
Relocated — the content moved
Section titled “Relocated — the content moved”$ ctx get repo:m.py --lines 4:5@07407f1c[ctx get repo:m.py (snapshot:a4536d5d3f22)]anchor: @07407f1c moved L4:5 → L6:7 (content unchanged)selector: --lines 6:7@07407f1c of 10L6: def beta():L7: return 2The caller asked for content, and the content is what comes back — along with the move, and a corrected address to carry into the next turn. This is the half that makes an anchor a working identifier rather than a tripwire: in the receipt, following moved content accounts for 1,452 of 1,454 correct answers. An address that merely detected staleness would have been right once.
Lost — the content is gone
Section titled “Lost — the content is gone”$ ctx get repo:m.py --lines 4:5@07407f1cctx get: anchor @07407f1c not found in repo:m.py: the content that was atlines 4:5 is no longer in this file (it was edited or deleted). Re-navigate —`ctx def`/`ctx refs`/`ctx search` mint a fresh anchored address — or read thecurrent lines with `ctx get repo:m.py --lines 4:5` if the coordinates are whatyou meant.$ echo $?2It refuses. The alternative is to return whatever now occupies those coordinates — which is what an unanchored address already does, and is the entire defect. A read that cannot keep its promise fails loudly; it does not quietly answer a different question. The refusal still keeps omission reversible: it names both ways forward.
Line tags
Section titled “Line tags”The same idea at line granularity, for naming individual lines rather than a span — two characters each, rendered only on request:
$ ctx get repo:m.py --lines 6:7 --hashlinesL6:78| def beta():L7:56| return 2Off by default, because every existing digest, receipt and test depends on the
untagged L6: text shape being byte-identical. Both renderings are produced by
one function so they cannot drift apart.
What it costs, and where it is spent
Section titled “What it costs, and where it is spent”An anchor is nine characters — about 20% on top of a bare repo: line
address, and a rounding error against the span it addresses.
It is still not minted everywhere, and the choice is deliberate:
| Surface | Anchored? | Why |
|---|---|---|
ctx get repo:… --lines | yes | the address most likely to be replayed after an edit |
ctx get continuations (next:) | yes | a chain that sheds its anchor at the first budget cut silently stops being verifiable |
ctx def | yes | the verb that runs immediately before an edit — the navigate-to-edit handoff |
ctx refs, ctx diag | no | one anchor per row, on addresses that mostly get navigated rather than edited |
run:, blob:, snapshot: | no | stored bytes cannot move while retained; the ref kind already carries content identity |
ctx def now emits two addresses, labelled, because they answer different
questions and confusing them was its own quiet bug:
span: b34a0fcd32 (region L4:5) · as captured: ctx get repo:m.py --span b34a0fcd32live: ctx get repo:m.py --lines 4:5@07407f1cThe span resolves against the snapshot that call froze, so while retained it answers “what did I read”. The anchored range resolves against the worktree, so it answers “what is there now” and follows the definition if an edit moves it. The span alone used to be offered for both, which returned the pre-edit body to a reader asking about current code.
Design constraints
Section titled “Design constraints”ctx.anchors is pure and total — no I/O, no store access, no shell-out, and
no imports from the retrieval package, so an emission site can mint an address
without pulling retrieval onto the hook’s hot import path.
- Position-free. Anchors hash content and nothing else. An anchor minted for lines 4:5 equals one minted for the same two lines at 6:7 — that equality is what makes relocation possible at all. Mixing a line number into the digest would degrade the mechanism to a tripwire.
- Bounded. Relocation searches outward from the address’s stated position and stops at a fixed candidate cap, so a lost anchor costs a predictable scan rather than one proportional to file size.
- Versioned. Both digests are domain-separated with a scheme version, so a future change to the derivation is a mismatch — caught, and routed through the same refusal path — rather than a silent reinterpretation.
Known limits
Section titled “Known limits”- Duplicate content relocates to the nearest copy. An anchor names content, so a span that is byte-identical to another region can resolve to that region. The bytes returned are the bytes addressed, which is the promise; the identity is not recovered. Longer spans make this vanishingly rare, and the receipt shows it happening only where a file genuinely repeats itself.
- Anchoring opts out of the seek fast path. Verifying needs only the window, but relocating needs the whole file to search, and the fast path cannot know which outcome it is in before computing it. An anchored read therefore costs a full file read. Uniform verification was worth more than a seek on the one selector that asked to be checked.
- Rewrite-in-place is unrecoverable by design. If the addressed lines were themselves edited, no mechanism can find them; anchors convert that from a wrong answer into a refusal, which is the whole available win.
--symboland an anchor are mutually exclusive.--symbollooks the range up for you, discarding the one the caller supplied — so an anchor alongside it describes a range that is about to be thrown away. That is refused rather than ignored: silently dropping it would leave an address that looks verified and is not, which is this mechanism’s own failure mode arriving through a selector combination instead of an edit.- Listing verbs stay bare.
ctx refsandctx diagrows are unanchored. If measurement shows those addresses being replayed post-edit as often asctx def’s, the two-character line tag is the cheaper rung to reach for before the nine-character anchor.