Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ type EntryResult struct {
AutoActionable bool `json:"auto_actionable"`
Conflicts []cairn.DedupFinding `json:"conflicts"`
Body string `json:"body"`
// RedirectedFrom is the originally-requested id when it was redirected
// to an entry that explicitly Corrects it (crn-evw98.3); empty when no
// redirect occurred.
RedirectedFrom string `json:"redirected_from,omitempty"`
}

var getCmd = &cobra.Command{
Expand All @@ -155,6 +159,20 @@ var getCmd = &cobra.Command{
if err != nil {
return emitError(cmd, err)
}

// A redirect is resolved before any telemetry/freshness/conflict
// work below, so every downstream signal (obslog outcome, payload
// tokens, reuse count, conflicts) reflects what was actually served
// -- the corrector, when one exists -- not the superseded entry
// that was originally requested (crn-evw98.3).
var redirectedFrom string
if corrector, cerr := cairn.FindCorrection(cmd.Context(), storePath(), e.ID); cerr != nil {
return emitError(cmd, cerr)
} else if corrector != nil {
redirectedFrom = e.ID
e = corrector
}

st, detail := cairn.Check(cmd.Context(), e)

outcome := "hit"
Expand Down Expand Up @@ -225,6 +243,7 @@ var getCmd = &cobra.Command{
AutoActionable: e.AutoActionable,
Conflicts: nonNil(conflicts),
Body: e.Body,
RedirectedFrom: redirectedFrom,
})
}

Expand All @@ -236,6 +255,9 @@ var getCmd = &cobra.Command{
if len(e.Scope) > 0 {
scope = strings.Join(e.Scope, " ")
}
if redirectedFrom != "" {
fmt.Printf("NOTE: %s redirected to %s\n", redirectedFrom, e.ID)
}
fmt.Printf("%s: %s\n", e.ID, e.Title)
fmt.Printf("topic: %s scope: %s\n", topic, scope)
fmt.Printf("freshness: %s — %s\n", st, detail)
Expand Down
33 changes: 33 additions & 0 deletions cmd/commands_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,39 @@ func TestGetJSONReportsConflicts(t *testing.T) {
assert.Contains(t, result.Conflicts[0].EntryIDs, "rig/hook")
}

// TestGetJSONReportsRedirectedFrom is crn-evw98.3's JSON-mode counterpart
// to TestGetRedirectsToCorrectionWhenOriginalIsRequested: get --json on an
// entry that another entry Corrects must return the corrector's own
// EntryResult, with RedirectedFrom naming the originally-requested id.
func TestGetJSONReportsRedirectedFrom(t *testing.T) {
dir := t.TempDir()
seedEntry(t, dir, "global/orig.md",
"+++\nid = \"orig\"\ntitle = \"Old fact\"\ntopic_key = \"topic-old\"\nscope = []\n+++\nthe old, wrong claim\n")
seedEntry(t, dir, "global/fix.md",
"+++\nid = \"fix\"\ntitle = \"Corrected fact\"\ntopic_key = \"topic-new\"\ncorrects = \"orig\"\nscope = []\n+++\nthe corrected claim\n")

out, err := execRootJSON(t, "get", "orig", "--json", "--store", dir)
require.NoError(t, err)

var result EntryResult
require.NoError(t, json.Unmarshal([]byte(out), &result))
assert.Equal(t, "fix", result.ID, "the corrector's own id must be reported, not the superseded entry's")
assert.Equal(t, "Corrected fact", result.Title)
assert.Equal(t, "orig", result.RedirectedFrom, "RedirectedFrom must name the originally-requested id")
}

// TestGetJSONOmitsRedirectedFromWhenNoCorrectionExists is the JSON-mode
// negative case: RedirectedFrom must be absent (omitempty) when no
// redirect occurred, not present-but-empty.
func TestGetJSONOmitsRedirectedFromWhenNoCorrectionExists(t *testing.T) {
dir := t.TempDir()
seedEntry(t, dir, "global/a.md", "+++\nid = \"g/a\"\ntitle = \"A\"\nscope = []\n+++\nbody\n")

out, err := execRootJSON(t, "get", "g/a", "--json", "--store", dir)
require.NoError(t, err)
assert.NotContains(t, out, "redirected_from")
}

func TestGetJSONRejectsBadIdentityTag(t *testing.T) {
dir := t.TempDir()
seedEntry(t, dir, "global/a.md", "+++\nid = \"g/a\"\ntitle = \"A\"\nscope = []\n+++\nbody\n")
Expand Down
38 changes: 38 additions & 0 deletions cmd/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,44 @@ func TestGetReportsContentConflict(t *testing.T) {
assert.Contains(t, out, "g/hook-b")
}

// TestGetRedirectsToCorrectionWhenOriginalIsRequested is crn-evw98.3: a
// `get` on an entry that another entry explicitly Corrects must serve the
// corrector's own content -- not silently return the superseded claim --
// and note the redirect so a caller can see it happened.
func TestGetRedirectsToCorrectionWhenOriginalIsRequested(t *testing.T) {
require.NoError(t, resetIdentityFlag())
t.Cleanup(func() { _ = resetIdentityFlag() })

dir := t.TempDir()
seedEntry(t, dir, "global/orig.md",
"+++\nid = \"orig\"\ntitle = \"Old fact\"\ntopic_key = \"topic-old\"\nscope = []\n+++\nthe old, wrong claim\n")
seedEntry(t, dir, "global/fix.md",
"+++\nid = \"fix\"\ntitle = \"Corrected fact\"\ntopic_key = \"topic-new\"\ncorrects = \"orig\"\nscope = []\n+++\nthe corrected claim\n")

out, err := execRoot("get", "orig", "--store", dir)
require.NoError(t, err)
firstLine := strings.SplitN(out, "\n", 2)[0]
assert.Equal(t, "NOTE: orig redirected to fix", firstLine, "plain text must lead with a NOTE line naming both the requested and serving ids")
assert.Contains(t, out, "Corrected fact", "the corrector's title must be served, not the superseded entry's")
assert.Contains(t, out, "the corrected claim", "the corrector's body must be served, not the superseded entry's")
assert.NotContains(t, out, "the old, wrong claim", "the superseded entry's own body must not be served")
}

// TestGetDoesNotRedirectWhenNoCorrectionExists is the redirect's negative
// case: an entry nobody corrects must be served as-is, with no leading
// NOTE line.
func TestGetDoesNotRedirectWhenNoCorrectionExists(t *testing.T) {
require.NoError(t, resetIdentityFlag())
t.Cleanup(func() { _ = resetIdentityFlag() })

dir := t.TempDir()
seedEntry(t, dir, "global/a.md", "+++\nid = \"g/a\"\ntitle = \"A\"\nscope = []\n+++\nbody\n")

out, err := execRoot("get", "g/a", "--store", dir)
require.NoError(t, err)
assert.NotContains(t, out, "NOTE:")
}

// resetRunIDFlag clears rootCmd's --run-id flag around a test, mirroring
// resetIdentityFlag's rationale: rootCmd is a package-level singleton, so a
// prior test's --run-id value and Changed bit otherwise leak into whichever
Expand Down
19 changes: 19 additions & 0 deletions cmd/remember.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ func init() {
"immediately compute and persist the anchor's fingerprint (soft-fails to stderr if it doesn't resolve)")
rememberCmd.Flags().Bool("force", false,
"create a new entry even if a recurrence match is found, recording which entry it overrides")
rememberCmd.Flags().String("corrects", "",
"id of another entry this one explicitly corrects/supersedes; `cairn get` on that id will redirect here regardless of topic")
rememberCmd.Flags().String("batch-file", "",
"path to a JSONL manifest of entries to remember in one call (see docs/knowledge-lifecycle.md); "+
"--topic/--scope/--title/--summary/--type/--anchor-repo/--anchor-path/--force become per-line manifest "+
Expand Down Expand Up @@ -122,6 +124,20 @@ var rememberCmd = &cobra.Command{
nudgeIfUnanchored(cmd, e)
nudgeIfDerivedMetadata(cmd, e)

corrects, _ := cmd.Flags().GetString("corrects")
var correctsLine string
if corrects != "" {
if _, err := cairn.Find(cmd.Context(), storePath(), corrects); err != nil {
if errors.Is(err, cairn.ErrNotFound) {
return emitError(cmd, classifiedErr(CategoryInvalidInput, corrects,
fmt.Errorf("--corrects target %q not found: %w", corrects, err)))
}
return emitError(cmd, err)
}
e.Corrects = corrects
correctsLine = fmt.Sprintf("corrects: %s", corrects)
}

matched, isRecurrence, err := recurrenceMatch(cmd, e)
if err != nil {
return emitError(cmd, err)
Expand Down Expand Up @@ -155,6 +171,9 @@ var rememberCmd = &cobra.Command{
if overrideLine != "" {
fmt.Printf("%s\n", overrideLine)
}
if correctsLine != "" {
fmt.Printf("%s\n", correctsLine)
}
}

if cairn.IsPrivateScope(e.Scope) {
Expand Down
64 changes: 61 additions & 3 deletions cmd/remember_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ func withStdin(t *testing.T, body string, fn func()) {

func resetRememberFlags(t *testing.T) {
t.Helper()
for _, name := range []string{"topic", "scope", "reviewer", "file", "title", "summary", "type", "anchor-repo"} {
for _, name := range []string{"topic", "scope", "reviewer", "file", "title", "summary", "type", "anchor-repo", "corrects"} {
f := rememberCmd.Flags().Lookup(name)
require.NotNil(t, f)
require.NoError(t, f.Value.Set(""))
Expand Down Expand Up @@ -367,14 +367,19 @@ func withKnowledgeType(args []string) []string {
}

// assertNoFilesWritten requires that a rejected remember call wrote nothing
// under store, ignoring the .git directory that gitInit itself creates.
// under store, ignoring the .git directory that gitInit itself creates and
// the "index" directory (cairn.IndexPath): a rejection that validates
// against an existing entry (e.g. --corrects) reads through cairn.Find,
// which lazily builds the store's rebuildable SQLite index cache as a
// side effect of any read -- gitignored infrastructure, not entry content,
// the same category .git already gets exempted for.
func assertNoFilesWritten(t *testing.T, store string) {
t.Helper()
entries, err := os.ReadDir(store)
require.NoError(t, err)
var written []string
for _, e := range entries {
if e.Name() != ".git" {
if e.Name() != ".git" && e.Name() != "index" {
written = append(written, e.Name())
}
}
Expand Down Expand Up @@ -2006,3 +2011,56 @@ func TestRememberForceWithNoMatchBehavesLikeOrdinaryCreate(t *testing.T) {
e := requireSingleEntry(t, filepath.Join(store, "agent", "test"))
assert.Empty(t, e.OverriddenDuplicateOf, "--force with no actual duplicate match must behave like an ordinary create -- nothing to override")
}

// TestRememberCorrectsSetsFieldAndPrintsLine is crn-evw98.3: --corrects
// names another entry this one explicitly corrects/supersedes. Unlike
// --force's OverriddenDuplicateOf (an inferred same-topic_key match),
// --corrects is an explicit author declaration and works across unrelated
// topics.
func TestRememberCorrectsSetsFieldAndPrintsLine(t *testing.T) {
store := t.TempDir()
gitInit(t, store)
t.Setenv("CAIRN_IDENTITY", "agent:test")

captureStdout(t, func() {
err := runRememberAgainstStore(t, store, "--topic", "topic-old", "--scope", "agent:test", "the old, now-wrong claim")
require.NoError(t, err)
})
first := requireSingleEntry(t, filepath.Join(store, "agent", "test"))

secondOut := captureStdout(t, func() {
err := runRememberAgainstStore(t, store, "--topic", "topic-new", "--scope", "agent:test", "--corrects", first.ID, "the corrected claim")
require.NoError(t, err)
})

entries, err := os.ReadDir(filepath.Join(store, "agent", "test"))
require.NoError(t, err)
require.Len(t, entries, 2, "--corrects must create a new entry alongside the one it corrects")

secondLines := strings.Split(strings.TrimSpace(secondOut), "\n")
require.Len(t, secondLines, 3, "a --corrects create prints the id, the corrects line, then the commit SHA")
assert.Equal(t, "corrects: "+first.ID, secondLines[1])

var second *cairn.Entry
for _, ent := range entries {
parsed, err := cairn.ParseEntry(filepath.Join(store, "agent", "test", ent.Name()))
require.NoError(t, err)
if parsed.ID != first.ID {
second = parsed
}
}
require.NotNil(t, second)
assert.Equal(t, first.ID, second.Corrects, "the new entry must record which entry it corrects")
}

// TestRememberCorrectsRefusesUnknownTargetBeforeAnyStoreWrite is
// crn-evw98.3: --corrects naming an id that does not exist must be
// rejected before any store write, the same "fail before writing"
// contract TestRememberRefusesPolicyBeforeAnyStoreWrite documents for
// policy entries.
func TestRememberCorrectsRefusesUnknownTargetBeforeAnyStoreWrite(t *testing.T) {
store, err := runRemember(t, "--topic", "valid-topic", "--scope", "agent:test", "--corrects", "does-not-exist", "a body")
require.Error(t, err)
assert.Contains(t, err.Error(), "does-not-exist")
assertNoFilesWritten(t, store)
}
36 changes: 36 additions & 0 deletions internal/cairn/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ type Entry struct {
// OverriddenDuplicateOf is set to the matched entry's ID when --force
// creates a new entry past a detected duplicate (crn-lzn4.1.1).
OverriddenDuplicateOf string `toml:"overridden_duplicate_of,omitempty"`
// Corrects is an explicit, author-declared id of another entry this one
// corrects/supersedes (crn-evw98.3). Unlike OverriddenDuplicateOf (an
// inferred same-topic_key match), it is set directly via --corrects and
// followed across topic_key boundaries by FindCorrection at read time.
Corrects string `toml:"corrects,omitempty"`

BodyPath string `toml:"-"`
Body string `toml:"-"`
Expand Down Expand Up @@ -700,6 +705,37 @@ func resolveBodyPath(store, bodyPath string) string {
return filepath.Join(store, bodyPath)
}

// FindCorrection returns the entry that explicitly Corrects id, or nil if
// none does (crn-evw98.3). Unlike OverriddenDuplicateOf's same-topic_key
// recurrence match, Corrects is an explicit author declaration followed
// regardless of topic_key. It is single-hop by design: if b corrects a and
// c corrects b, FindCorrection(a) returns b, not c -- chasing a chain
// transitively is out of this bead's scope. When more than one entry
// claims to correct the same id, the most recently created one wins.
func FindCorrection(ctx context.Context, store, id string) (*Entry, error) {
if err := ensureFresh(ctx, store); err != nil {
return nil, err
}
db, err := openDB(store)
if err != nil {
return nil, err
}
defer func() { _ = db.Close() }()

var correctorID string
err = db.QueryRowContext(ctx,
`SELECT id FROM entries WHERE corrects = ? ORDER BY created_at DESC LIMIT 1`, id,
).Scan(&correctorID)
if errors.Is(err, sql.ErrNoRows) {
//nolint:nilnil // (nil,nil) = "no correction"; callers check corrector != nil before use
return nil, nil
}
if err != nil {
return nil, err
}
return Find(ctx, store, correctorID)
}

// Visible returns entries an identity may see: every scope-tag on the entry
// must be satisfied by the identity (a subset match). Global (untagged)
// entries are visible to all. When multiple visible entries share a
Expand Down
77 changes: 77 additions & 0 deletions internal/cairn/entry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,83 @@ func TestFindAfterSequentialCreateOnNonGitStore(t *testing.T) {
require.NoError(t, err, "second Find must succeed even though the store's index predates this entry")
}

// TestEntryCorrectsRoundTripsThroughTOML is crn-evw98.3. Corrects is an
// explicit, author-declared cross-topic link ("this entry corrects that
// one"), distinct from OverriddenDuplicateOf's inferred same-topic_key
// match. It must round-trip through the TOML frontmatter the same way
// OverriddenDuplicateOf already does.
func TestEntryCorrectsRoundTripsThroughTOML(t *testing.T) {
dir := t.TempDir()
e, err := NewEntry(NewEntryParams{Type: EntryTypeKnowledge, TopicKey: "topic-a", Body: "corrected body", CreatedBy: "tester"})
require.NoError(t, err)
e.Corrects = "orig-id"
require.NoError(t, e.Create(dir))

reparsed, err := ParseEntry(e.BodyPath)
require.NoError(t, err)
assert.Equal(t, "orig-id", reparsed.Corrects, "Corrects must round-trip through the TOML frontmatter like OverriddenDuplicateOf does")
}

// TestFindCorrectionReturnsNilWhenUncorrected is crn-evw98.3: an entry with
// no corrector must report no correction rather than erroring.
func TestFindCorrectionReturnsNilWhenUncorrected(t *testing.T) {
ctx := t.Context()
store := t.TempDir()
writeFile(t, store, "global/a.md", "+++\nid = \"a\"\ntitle = \"A\"\n+++\nbody\n")

corrector, err := FindCorrection(ctx, store, "a")
require.NoError(t, err)
assert.Nil(t, corrector, "an entry nobody corrects must report no correction")
}

// TestFindCorrectionFollowsExplicitCorrectsLinkRegardlessOfTopicKey is
// crn-evw98.3's core contract: unlike OverriddenDuplicateOf (same
// topic_key only), Corrects must be followed across topic_key boundaries
// since it is an explicit author declaration, not an inferred match.
func TestFindCorrectionFollowsExplicitCorrectsLinkRegardlessOfTopicKey(t *testing.T) {
ctx := t.Context()
store := t.TempDir()
writeFile(t, store, "global/orig.md", "+++\nid = \"orig\"\ntitle = \"Old fact\"\ntopic_key = \"topic-old\"\n+++\nthe old, wrong claim\n")
writeFile(t, store, "global/fix.md", "+++\nid = \"fix\"\ntitle = \"Corrected fact\"\ntopic_key = \"topic-new\"\ncorrects = \"orig\"\n+++\nthe corrected claim\n")

corrector, err := FindCorrection(ctx, store, "orig")
require.NoError(t, err)
require.NotNil(t, corrector, "orig is named by fix's Corrects field -- FindCorrection must surface it even though the two entries do not share a topic_key")
assert.Equal(t, "fix", corrector.ID)
}

// TestFindCorrectionPicksMostRecentWhenMultipleEntriesCorrectTheSameID is
// crn-evw98.3: if more than one entry claims to correct the same id, the
// most recently created correction must win.
func TestFindCorrectionPicksMostRecentWhenMultipleEntriesCorrectTheSameID(t *testing.T) {
ctx := t.Context()
store := t.TempDir()
writeFile(t, store, "global/orig.md", "+++\nid = \"orig\"\ntitle = \"Old fact\"\n+++\nold\n")
writeFile(t, store, "global/fix1.md", "+++\nid = \"fix1\"\ntitle = \"First fix\"\ncorrects = \"orig\"\ncreated_at = \"2026-01-01T00:00:00Z\"\n+++\nfirst correction\n")
writeFile(t, store, "global/fix2.md", "+++\nid = \"fix2\"\ntitle = \"Second fix\"\ncorrects = \"orig\"\ncreated_at = \"2026-06-01T00:00:00Z\"\n+++\nsecond, more current correction\n")

corrector, err := FindCorrection(ctx, store, "orig")
require.NoError(t, err)
require.NotNil(t, corrector)
assert.Equal(t, "fix2", corrector.ID, "the most recently created correction must win when more than one entry claims to correct the same id")
}

// TestFindCorrectionDoesNotFollowChains is crn-evw98.3's documented scope
// boundary: FindCorrection is single-hop only and must not chase a
// corrects-of-a-corrects chain transitively.
func TestFindCorrectionDoesNotFollowChains(t *testing.T) {
ctx := t.Context()
store := t.TempDir()
writeFile(t, store, "global/a.md", "+++\nid = \"a\"\ntitle = \"A\"\n+++\na\n")
writeFile(t, store, "global/b.md", "+++\nid = \"b\"\ntitle = \"B\"\ncorrects = \"a\"\n+++\nb\n")
writeFile(t, store, "global/c.md", "+++\nid = \"c\"\ntitle = \"C\"\ncorrects = \"b\"\n+++\nc\n")

corrector, err := FindCorrection(ctx, store, "a")
require.NoError(t, err)
require.NotNil(t, corrector)
assert.Equal(t, "b", corrector.ID, "FindCorrection is single-hop by design (crn-evw98.3 scope) -- it must not chase corrects chains transitively")
}

func TestVisibleNeverReadsBodiesAfterIndexBuilt(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "global/g.md", globalEntry)
Expand Down
Loading