Skip to content

ctx chain outbound silently points at superseded facts #448

Description

@eris-ths

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.

  1. CtxChainRef: add readonly supersededBy: readonly string[] (ids only).
  2. chain(): build a supersedes -> [successors] index once from all, then
    populate supersededBy per outbound ref (empty when live / unresolved).
  3. chain.ts JSON: emit superseded_by on each outbound entry.
  4. 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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions