diff --git a/cmd/commands.go b/cmd/commands.go index 7e402b20..47fcafc9 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, }) } @@ -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) 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..12b756a4 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -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 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 e43a4310..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()) } } @@ -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) +} diff --git a/internal/cairn/entry.go b/internal/cairn/entry.go index ac59207e..37ebec9e 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,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 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) 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 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.