Summary
ctx chain resolves an outbound reference (resolved: true) but never
signals that the resolved target has been superseded. The root view surfaces
superseded_by as a first-class branch, yet a fact reached as someone's
outbound comes back with its supersession lineage stripped. Since ctx list
folds superseded facts out by default, the outbound edge is the one place that
quietly hands back a stale id — the reader only discovers it's stale by
spending the next hop.
Repro
# 001 referenced by 002, then 001 superseded by 003
ctx record --fact "phase-2 chain returns a one-hop neighborhood..." # -> 001
ctx record --fact "001's chain advances one hop; deeper is re-running chain" # -> 002 (mentions 001)
ctx supersede 001 --fact "phase-2 chain ... no recursion" # -> 003
ctx chain 001 # OK: shows `superseded by 003` + inbound 002
ctx chain 002 # outbound -> 001, resolved:true, but NO hint 001 is dead
ctx chain 002 --format json
chain 002 JSON, outbound entry:
{ "id": "ctx-...-001", "resolved": true, "fact": { ... } }
No superseded_by. The same fact, viewed as a root, would carry it.
Expected vs actual
- Expected: an outbound entry whose target is superseded says so — at
minimum the successor id(s) — consistent with ctx list folding superseded
facts and with the root view's superseded_by branch.
- Actual: outbound reports only
{ id, fact, resolved }. Supersession is
invisible until the reader runs chain on the surfaced id themselves.
Root cause
CtxUseCases.chain() builds outbound as a bare id+fact pair:
// src/passages/ctx/application/CtxUseCases.ts (~L224)
for (const refId of extractReferences(root.fact).ctxIds) {
if (refId === rootId) continue;
outbound.push({ id: refId, fact: byId.get(refId) ?? null });
}
supersededBy is computed for the root only:
const supersededBy = all.filter((c) => c.supersedes === rootId).sort(byNewestFirst);
all is already in memory, so the successor of any outbound ref is reachable
without a second scan — it just isn't wired into the outbound branch. The
CtxChainRef type ({ id, fact }) has no slot for it either.
Proposed fix (keeps one-hop)
Surface the successor id(s) on the outbound ref — not the successor's fact —
so the rule stays exactly one hop: chain tells you "this id is stale, its
successor is X"; following X is still the reader's next chain.
CtxChainRef: add readonly supersededBy: readonly string[] (ids only).
chain(): build a supersedes -> [successors] index once from all, then
populate supersededBy per outbound ref (empty when live / unresolved).
chain.ts JSON: emit superseded_by on each outbound entry.
chain.ts text: mark a stale outbound row, e.g.
… (superseded by ctx-...-003) after factRow.
Inbound arguably deserves the same treatment, but outbound is where the
"go read this" instruction lives, so it's the sharp edge — fixing outbound
alone closes the friction.
Notes
Surfaced via dogfood (ctx record of the friction itself). Consistent with
records-outlive-writers (the stale record stays, immutable) and
advisory-not-directive (chain advises "this is stale + here's the successor",
doesn't auto-follow).
Labels: bug · passage:ctx · good-first-issue?
Summary
ctx chainresolves an outbound reference (resolved: true) but neversignals that the resolved target has been superseded. The root view surfaces
superseded_byas a first-class branch, yet a fact reached as someone'soutbound comes back with its supersession lineage stripped. Since
ctx listfolds superseded facts out by default, the outbound edge is the one place that
quietly hands back a stale id — the reader only discovers it's stale by
spending the next hop.
Repro
chain 002JSON, outbound entry:{ "id": "ctx-...-001", "resolved": true, "fact": { ... } }No
superseded_by. The same fact, viewed as a root, would carry it.Expected vs actual
minimum the successor id(s) — consistent with
ctx listfolding supersededfacts and with the root view's
superseded_bybranch.{ id, fact, resolved }. Supersession isinvisible until the reader runs
chainon the surfaced id themselves.Root cause
CtxUseCases.chain()builds outbound as a bare id+fact pair:supersededByis computed for the root only:allis already in memory, so the successor of any outbound ref is reachablewithout a second scan — it just isn't wired into the outbound branch. The
CtxChainReftype ({ id, fact }) has no slot for it either.Proposed fix (keeps one-hop)
Surface the successor id(s) on the outbound ref — not the successor's fact —
so the rule stays exactly one hop: chain tells you "this id is stale, its
successor is X"; following X is still the reader's next
chain.CtxChainRef: addreadonly supersededBy: readonly string[](ids only).chain(): build asupersedes -> [successors]index once fromall, thenpopulate
supersededByper outbound ref (empty when live / unresolved).chain.tsJSON: emitsuperseded_byon each outbound entry.chain.tstext: mark a stale outbound row, e.g.… (superseded by ctx-...-003)afterfactRow.Inbound arguably deserves the same treatment, but outbound is where the
"go read this" instruction lives, so it's the sharp edge — fixing outbound
alone closes the friction.
Notes
Surfaced via dogfood (
ctx recordof the friction itself). Consistent withrecords-outlive-writers(the stale record stays, immutable) andadvisory-not-directive(chain advises "this is stale + here's the successor",doesn't auto-follow).
Labels:
bug·passage:ctx·good-first-issue?