From b565913629b12a368c6c67a76ef438b0f431c0bb Mon Sep 17 00:00:00 2001 From: cairn-reviewer-scratch Date: Wed, 19 Aug 2026 19:18:15 -0700 Subject: [PATCH 1/5] =?UTF-8?q?test(feat):=20red=20=E2=80=94=20Cross-topic?= =?UTF-8?q?-key=20Corrects/Supersedes=20link,=20consulted=20at=20read=20ti?= =?UTF-8?q?me=20(refs=20crn-evw98.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/commands_json_test.go | 33 ++++++++++++++++ cmd/commands_test.go | 37 +++++++++++++++++ cmd/remember_test.go | 53 +++++++++++++++++++++++++ internal/cairn/entry_test.go | 77 ++++++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+) diff --git a/cmd/commands_json_test.go b/cmd/commands_json_test.go index 35f1180a..22dccd6b 100644 --- a/cmd/commands_json_test.go +++ b/cmd/commands_json_test.go @@ -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") diff --git a/cmd/commands_test.go b/cmd/commands_test.go index ca696ee1..4fd3eec3 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -323,6 +323,43 @@ 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) + assert.Contains(t, out, "redirected_from: orig", "get must note that the requested id was redirected") + 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 +// redirected_from 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, "redirected_from") +} + // 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 diff --git a/cmd/remember_test.go b/cmd/remember_test.go index e43a4310..65d31bbd 100644 --- a/cmd/remember_test.go +++ b/cmd/remember_test.go @@ -2006,3 +2006,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) +} diff --git a/internal/cairn/entry_test.go b/internal/cairn/entry_test.go index 8c343ff8..96e4bc00 100644 --- a/internal/cairn/entry_test.go +++ b/internal/cairn/entry_test.go @@ -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) From c5e9e7943b7d49cc1061f011f487cb645d5bd8c7 Mon Sep 17 00:00:00 2001 From: cairn-reviewer-scratch Date: Wed, 19 Aug 2026 19:23:24 -0700 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20green=20=E2=80=94=20Cross-topic-key?= =?UTF-8?q?=20Corrects/Supersedes=20link,=20consulted=20at=20read=20time?= =?UTF-8?q?=20(refs=20crn-evw98.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/commands.go | 22 ++++++++++++++++++++++ cmd/remember.go | 19 +++++++++++++++++++ cmd/remember_test.go | 11 ++++++++--- internal/cairn/entry.go | 35 +++++++++++++++++++++++++++++++++++ internal/cairn/index.go | 22 +++++++++++++--------- 5 files changed, 97 insertions(+), 12 deletions(-) diff --git a/cmd/commands.go b/cmd/commands.go index 7e402b20..8f918d9c 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -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{ @@ -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" @@ -225,6 +243,7 @@ var getCmd = &cobra.Command{ AutoActionable: e.AutoActionable, Conflicts: nonNil(conflicts), Body: e.Body, + RedirectedFrom: redirectedFrom, }) } @@ -237,6 +256,9 @@ var getCmd = &cobra.Command{ scope = strings.Join(e.Scope, " ") } fmt.Printf("%s: %s\n", e.ID, e.Title) + if redirectedFrom != "" { + fmt.Printf("redirected_from: %s\n", redirectedFrom) + } fmt.Printf("topic: %s scope: %s\n", topic, scope) fmt.Printf("freshness: %s — %s\n", st, detail) fmt.Printf("type: %s kind: %s auto_actionable: %t\n", e.Type, kind, e.AutoActionable) diff --git a/cmd/remember.go b/cmd/remember.go index db296a8d..838c7678 100644 --- a/cmd/remember.go +++ b/cmd/remember.go @@ -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 "+ @@ -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) @@ -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) { diff --git a/cmd/remember_test.go b/cmd/remember_test.go index 65d31bbd..776889f4 100644 --- a/cmd/remember_test.go +++ b/cmd/remember_test.go @@ -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("")) @@ -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()) } } diff --git a/internal/cairn/entry.go b/internal/cairn/entry.go index ac59207e..a60a317b 100644 --- a/internal/cairn/entry.go +++ b/internal/cairn/entry.go @@ -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:"-"` @@ -700,6 +705,36 @@ 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) { + 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 diff --git a/internal/cairn/index.go b/internal/cairn/index.go index f2153e21..245e7525 100644 --- a/internal/cairn/index.go +++ b/internal/cairn/index.go @@ -48,7 +48,8 @@ CREATE TABLE IF NOT EXISTS entries ( recurrence_count INTEGER DEFAULT 0, promoted_bead_id TEXT, last_recalled_at TEXT, - overridden_duplicate_of TEXT + overridden_duplicate_of TEXT, + corrects TEXT ); CREATE INDEX IF NOT EXISTS idx_entries_topic ON entries(topic_key); CREATE TABLE IF NOT EXISTS index_meta ( @@ -83,6 +84,7 @@ var entriesMigrationCols = []struct{ name, def string }{ {"overridden_duplicate_of", "TEXT"}, {"title_source", "TEXT"}, {"summary_source", "TEXT"}, + {"corrects", "TEXT"}, } // indexSchemaVersion is the shape of the index this binary expects. BUMP IT @@ -103,7 +105,7 @@ var entriesMigrationCols = []struct{ name, def string }{ // test caught it because tests build their index from scratch with the // current binary -- the one case that cannot reproduce it. The regression // test below builds an index at the old shape on purpose. -const indexSchemaVersion = 2 +const indexSchemaVersion = 3 // indexMetaMigrationCols forward-migrate an index_meta built by an older // binary, mirroring entriesMigrationCols. @@ -376,8 +378,8 @@ func reindexUpsertChunkTx(ctx context.Context, db *sql.DB, batch []*Entry) error anchor_type, anchor_repo, anchor_paths, anchor_spec, anchor_fingerprint, verified_at, created_by, created_at, hit_count, kind, auto_actionable, recurrence_count, promoted_bead_id, last_recalled_at, - overridden_duplicate_of - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + overridden_duplicate_of, corrects + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET title=excluded.title, title_source=excluded.title_source, summary=excluded.summary, summary_source=excluded.summary_source, type=excluded.type, @@ -387,20 +389,22 @@ func reindexUpsertChunkTx(ctx context.Context, db *sql.DB, batch []*Entry) error anchor_fingerprint=excluded.anchor_fingerprint, verified_at=excluded.verified_at, created_by=excluded.created_by, created_at=excluded.created_at, - overridden_duplicate_of=excluded.overridden_duplicate_of`, + overridden_duplicate_of=excluded.overridden_duplicate_of, + corrects=excluded.corrects`, // hit_count, kind, auto_actionable, recurrence_count, promoted_bead_id, // and last_recalled_at are deliberately not in the UPDATE SET: like // hit_count (crn-6az.6.1.1), they're index-only state a future call // site writes directly via SQL (crn-28ge.1.1), so a reindex must not // stamp a surviving row back to the body's stale seed value. - // overridden_duplicate_of is body-sourced (like created_at), not - // index-only, so unlike those it IS in the UPDATE SET -- a --force - // correction edited into the body must overwrite a stale indexed copy. + // overridden_duplicate_of and corrects are body-sourced (like + // created_at), not index-only, so unlike those they ARE in the + // UPDATE SET -- an edit to either in the body must overwrite a + // stale indexed copy. e.ID, e.Title, e.TitleSource, e.Summary, e.SummarySource, e.Type, e.TopicKey, e.BodyPath, e.Anchor.Type, e.Anchor.Repo, string(anchorPaths), e.Anchor.Spec, e.Anchor.Fingerprint, e.VerifiedAt, e.CreatedBy, e.CreatedAt, e.HitCount, e.Kind, autoActionable, e.RecurrenceCount, e.PromotedBeadID, e.LastRecalledAt, - e.OverriddenDuplicateOf, + e.OverriddenDuplicateOf, e.Corrects, ); err != nil { _ = tx.Rollback() return err From e576acf4e526e6543ab699de3f073dd7a6666379 Mon Sep 17 00:00:00 2001 From: cairn-reviewer-scratch Date: Wed, 19 Aug 2026 19:28:36 -0700 Subject: [PATCH 3/5] cairn: silence nilnil lint on FindCorrection's no-match return golangci-lint flags return nil, nil as ambiguous; the codebase already has a precedented idiom for this exact "no match" contract (recurrenceMatch in cmd/remember.go), so apply the same nolint:nilnil annotation rather than inventing a sentinel error. crn-evw98.3 --- internal/cairn/entry.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/cairn/entry.go b/internal/cairn/entry.go index a60a317b..37ebec9e 100644 --- a/internal/cairn/entry.go +++ b/internal/cairn/entry.go @@ -727,6 +727,7 @@ func FindCorrection(ctx context.Context, store, id string) (*Entry, error) { `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 { From 50cdb9195654fb5c87c914b095e88b31f25262ce Mon Sep 17 00:00:00 2001 From: cairn-reviewer-scratch Date: Wed, 19 Aug 2026 22:38:36 -0700 Subject: [PATCH 4/5] cairn get: plain-text redirect leads with a NOTE line naming both ids Review round 1 (crn-0mdsv) flagged spec criterion 5 as unmet: getCmd's plain-text output printed the corrector's "id: title" line first, then a separate "redirected_from: " line second -- never leading, never combining both ids on one line, no NOTE phrasing. Reorder/reword the two Printf calls so the NOTE line (when a redirect occurred) prints before the id:title line and names both the originally-requested id and the serving id in one line: NOTE: redirected to Update the two plain-text redirect tests to pin the new contract: TestGetRedirectsToCorrectionWhenOriginalIsRequested now asserts the leading line is exactly the NOTE line (order-sensitive, previously only an order-insensitive substring check that would not have caught this gap); TestGetDoesNotRedirectWhenNoCorrectionExists now asserts the absence of "NOTE:" rather than the no-longer-emitted "redirected_from" string. --json output (EntryResult.RedirectedFrom) is unchanged -- already correct per round 1's review. --- cmd/commands.go | 4 ++-- cmd/commands_test.go | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/cmd/commands.go b/cmd/commands.go index 8f918d9c..47fcafc9 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -255,10 +255,10 @@ var getCmd = &cobra.Command{ if len(e.Scope) > 0 { scope = strings.Join(e.Scope, " ") } - fmt.Printf("%s: %s\n", e.ID, e.Title) if redirectedFrom != "" { - fmt.Printf("redirected_from: %s\n", 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) fmt.Printf("type: %s kind: %s auto_actionable: %t\n", e.Type, kind, e.AutoActionable) diff --git a/cmd/commands_test.go b/cmd/commands_test.go index 4fd3eec3..12b756a4 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -339,15 +339,16 @@ func TestGetRedirectsToCorrectionWhenOriginalIsRequested(t *testing.T) { out, err := execRoot("get", "orig", "--store", dir) require.NoError(t, err) - assert.Contains(t, out, "redirected_from: orig", "get must note that the requested id was redirected") + 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 -// redirected_from line. +// 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() }) @@ -357,7 +358,7 @@ func TestGetDoesNotRedirectWhenNoCorrectionExists(t *testing.T) { out, err := execRoot("get", "g/a", "--store", dir) require.NoError(t, err) - assert.NotContains(t, out, "redirected_from") + assert.NotContains(t, out, "NOTE:") } // resetRunIDFlag clears rootCmd's --run-id flag around a test, mirroring From 169562d65af231f434d1a9a1ac124f6f338e265a Mon Sep 17 00:00:00 2001 From: cairn-reviewer-scratch Date: Thu, 20 Aug 2026 01:50:24 -0700 Subject: [PATCH 5/5] Add release gate doc for crn-v6jix (7/7 PASS) Cross-topic-key Corrects/Supersedes link, consulted at read time. --- release-gates/crn-v6jix-gate.md | 83 +++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 release-gates/crn-v6jix-gate.md diff --git a/release-gates/crn-v6jix-gate.md b/release-gates/crn-v6jix-gate.md new file mode 100644 index 00000000..ed012162 --- /dev/null +++ b/release-gates/crn-v6jix-gate.md @@ -0,0 +1,83 @@ +# Release Gate: crn-v6jix + +**Bead:** crn-v6jix (deploy) — source: crn-0mdsv (review), build: crn-evw98.3, parent design: crn-evw98 +**Commit:** `50cdb9195654fb5c87c914b095e88b31f25262ce`, cut onto `deploy/crn-v6jix-gate` +**Date:** 2026-08-20 + +## Background + +`crn-pip8`'s `OverriddenDuplicateOf` pattern links entries only when they +share a `topic_key` (an inferred same-topic match). This change adds a +distinct, explicit `Corrects` field — author-declared via `--corrects`, +followed at read time regardless of `topic_key` — so a lookup on an +original entry redirects to whatever explicitly corrects/supersedes it, +even when that corrector lives under a different topic. This is facet +"cross-topic-key Corrects/Supersedes" of `crn-evw98`'s two-facet design +(the other facet, branch-aware discovery for pending `remember/*` reviews, +is `crn-evw98.1`/`.2` — independent, already merged via #123/#125, no +overlap with this change). + +Four commits: RED (`b565913`) / GREEN (`c5e9e79`) for the core +`Corrects`/`FindCorrection` mechanism and `cairn get` redirect, a post-green +self-review nilnil lint fix (`e576acf`), and a round-2 fix (`50cdb91`) +addressing the sole review-round-1 gap — the plain-text redirect notice +didn't lead with a combined line naming both ids. 8 files touched, +299 insertions / 12 deletions. + +## Criteria + +| # | Criterion | Result | Evidence | +|---|-----------|--------|----------| +| 1 | Review PASS for exact deployed commit | PASS | `crn-0mdsv` round 2 verdict: *"pass"*, metadata `deploy_commit: 50cdb9195654fb5c87c914b095e88b31f25262ce` — exact match to `DEPLOY_SHA` (R = D). Independently re-resolved this session via `git rev-parse --verify --quiet "^{commit}"`, not trusted as transcribed text. Round 1 (verdict: request-changes, one presentational gap on criterion 5) is superseded by round 2's full independent re-verification, not merely referenced. | +| 2 | Acceptance criteria met | PASS | Verified directly via `git diff ..50cdb919 -- cmd/commands.go internal/cairn/entry.go`, not the review write-up alone: `Entry.Corrects` field added (TOML `corrects,omitempty`); `FindCorrection` queries `WHERE corrects = ?` with **no topic_key filter** — matches crn-evw98.3's design exactly ("followed... regardless of topic_key"); explicitly single-hop and most-recent-wins by design (doc comment); bound `?` param, no injection surface. `getCmd`'s plain-text branch now prints `NOTE: redirected to ` **before** the id:title line — matches round 1's criterion 5 gap resolution exactly, confirmed by direct code read, not just trusting the round-2 review note. | +| 3 | Tests pass | PASS | Independently re-run by the deployer on `deploy/crn-v6jix-gate` (cut from `50cdb919...`), not trusting the reviewer's report alone: `go test ./... -race -count=1`, exit 0, **7/7 packages ok**. All 11 diff-relevant tests re-run individually by name with `-v -race`: `TestGetJSONReportsRedirectedFrom`, `TestGetJSONOmitsRedirectedFromWhenNoCorrectionExists`, `TestGetRedirectsToCorrectionWhenOriginalIsRequested`, `TestGetDoesNotRedirectWhenNoCorrectionExists`, `TestRememberCorrectsSetsFieldAndPrintsLine`, `TestRememberCorrectsRefusesUnknownTargetBeforeAnyStoreWrite`, `TestEntryCorrectsRoundTripsThroughTOML`, `TestFindCorrectionReturnsNilWhenUncorrected`, `TestFindCorrectionFollowsExplicitCorrectsLinkRegardlessOfTopicKey`, `TestFindCorrectionPicksMostRecentWhenMultipleEntriesCorrectTheSameID`, `TestFindCorrectionDoesNotFollowChains` — all `--- PASS`. | +| 4 | No open HIGH findings | PASS | `bd list --status open` / `--status in_progress` filtered to this chain (`crn-v6jix`, `crn-0mdsv`, `crn-evw98.3`, `crn-evw98`) shows only `crn-evw98` (open — the independent facet-A umbrella, already disclosed as out of scope by `crn-evw98.3`'s own description and by the already-merged #123/#125) and `crn-v6jix` itself (in_progress, this session's own claim). Review's two disclosed findings (unscoped `Find` access-control note, redirect-not-separately-logged) are both explicitly "minor, non-blocking," consistent with approved design, not implementation defects — no HIGH findings anywhere in the chain. | +| 5 | Clean tree | PASS | `deploy/crn-v6jix-gate` cut directly from `50cdb919...^{commit}`; `git status --porcelain` empty before, during, and after the test run. | +| 6 | Clean divergence from main | PASS | `git rev-list --left-right --count origin/main...50cdb919` → `5 4`: `origin/main` has advanced 5 commits past the branch point (including #123/#125, this bead's own facet-A sibling work, and the just-merged #128) while this deploy carries its own 4-commit RED/GREEN/lint/round-2 sequence — not a fast-forward, but `git merge-tree origin/main 50cdb919` against the *current* `origin/main` tip (`93c2593`, confirmed unmoved since this session's fetch) shows **0 conflict markers**. No rebase needed. | +| 7 | Single feature theme | PASS | `assert_deploy_ancestry_scope origin/main 50cdb919 crn-v6jix crn-0mdsv crn-evw98.3 crn-evw98` → rc=0: no `.claude/**` paths touched, and all four non-merge commits in `origin/main..50cdb919` (`b565913` red, `c5e9e79` green, `e576acf` lint fix, `50cdb91` round-2 fix) cite `crn-evw98.3` in their message. No stray commits. | + +## Verdict: 7/7 PASS — proceeding to PR, self-merge authorized. + +## Process notes + +1. **Merge authority — self-merge under the REINSTATED standing + authorization**, not mayor-escalated. This bead's own description + carries the same generic *"GitHub remote detected — cut isolated + deploy/\-gate branch from main, open PR, route merge-request to + mayor/mpr. No self-merge"* boilerplate as the three precedent beads + (`crn-e6pc7`/`crn-daxbq`/`crn-y0caj`) and, unlike `crn-ybgbs`, does *not* + cite `gm-wisp-2yhv7u` directly in its own text. This is the exact, + already-understood stale-copy pattern: mayor ruling `gm-wisp-2yhv7u` + (2026-08-19 03:01:50, independently verified via `gc mail peek` during + this session's `crn-ybgbs` gate, not re-fetched here since the ruling + text itself hasn't changed) explicitly reinstated deployer self-merge for + `quad341/cairn` — because this repo is not covered by `mpr` at all — and + explicitly pre-authorized *not* re-escalating merely because this exact + boilerplate recurs in future bead bodies: *"that is now a KNOWN, + understood stale-copy issue."* `crn-v6jix` raises no new, distinct + objection beyond that boilerplate, so this is not a fresh signal — + proceeding under the same standing authorization applied to `crn-ybgbs`, + re-checked against the same 4 conditions immediately before merging (not + assumed to carry over from the gate above): + 1. Gate 7/7 PASS — this document. + 2. PR state confirmed via a **direct**, fresh `gh pr view --json` read + immediately before merging. + 3. CLEAN/MERGEABLE with both required checks (`build-test`, `lint`) + COMPLETED/SUCCESS. + 4. No `--auto` arming — merge deliberately with plain + `gh pr merge --squash`, then independently verify + `state=MERGED`/`mergedAt` non-null afterward (FR-03: never trust exit + code alone). + + Anything short of all four on the fresh pre-merge read routes to mayor + instead, same as always. + +2. **Branch target:** the review was performed against a specific reviewed + SHA, not a shared builder branch tip. `deploy/crn-v6jix-gate` was cut + directly from `50cdb919...^{commit}` via `resolve_deploy_branch_target`, + guarded first by `assert_deploy_ancestry_scope` (criterion 7 above). + +3. **SHA integrity:** `DEPLOY_SHA` was independently re-resolved via + `git rev-parse --verify --quiet "^{commit}"` before any gate step + ran, per the sha-integrity discipline — never trusted as an eyeballed or + transcribed string.