diff --git a/cmd/commands.go b/cmd/commands.go index 3d948dd..98408d1 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -136,6 +136,10 @@ type EntryResult struct { ReviewStatus string `json:"review_status"` 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{ @@ -156,6 +160,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" @@ -227,6 +245,7 @@ var getCmd = &cobra.Command{ ReviewStatus: e.ReviewStatus, Conflicts: nonNil(conflicts), Body: e.Body, + RedirectedFrom: redirectedFrom, }) } @@ -238,6 +257,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 35f1180..22dccd6 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 ca696ee..12b756a 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 a5982a7..73a2810 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 3c239de..c45d6b3 100644 --- a/cmd/remember_test.go +++ b/cmd/remember_test.go @@ -329,7 +329,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("")) @@ -370,14 +370,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()) } } @@ -2039,3 +2044,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 9112d6c..aa9d248 100644 --- a/internal/cairn/entry.go +++ b/internal/cairn/entry.go @@ -86,6 +86,11 @@ type Entry struct { // branch (or freshly Created) and "merged" once ReviewMergeBranch lands it // (crn-evw98.1). ReviewStatus string `toml:"review_status,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:"-"` @@ -967,6 +972,37 @@ func storeRelPath(store, p string) (string, error) { return filepath.Rel(absStore, absP) } +// 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 57ff0a2..1ca2fdc 100644 --- a/internal/cairn/entry_test.go +++ b/internal/cairn/entry_test.go @@ -1377,6 +1377,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 a2451db..3204200 100644 --- a/internal/cairn/index.go +++ b/internal/cairn/index.go @@ -49,7 +49,8 @@ CREATE TABLE IF NOT EXISTS entries ( promoted_bead_id TEXT, last_recalled_at TEXT, overridden_duplicate_of TEXT, - review_status TEXT + review_status TEXT, + corrects TEXT ); CREATE INDEX IF NOT EXISTS idx_entries_topic ON entries(topic_key); CREATE TABLE IF NOT EXISTS index_meta ( @@ -85,6 +86,7 @@ var entriesMigrationCols = []struct{ name, def string }{ {"title_source", "TEXT"}, {"summary_source", "TEXT"}, {"review_status", "TEXT"}, + {"corrects", "TEXT"}, } // indexSchemaVersion is the shape of the index this binary expects. BUMP IT @@ -378,8 +380,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, review_status - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + overridden_duplicate_of, review_status, 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, @@ -390,21 +392,22 @@ func reindexUpsertChunkTx(ctx context.Context, db *sql.DB, batch []*Entry) error verified_at=excluded.verified_at, created_by=excluded.created_by, created_at=excluded.created_at, overridden_duplicate_of=excluded.overridden_duplicate_of, - review_status=excluded.review_status`, + review_status=excluded.review_status, + 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 and review_status are body-sourced (like - // created_at), not index-only, so unlike those they ARE in the UPDATE - // SET -- a --force correction or a review-status flip edited into the - // body must overwrite a stale indexed copy. + // overridden_duplicate_of, review_status, and corrects are body-sourced + // (like created_at), not index-only, so unlike those they ARE in the + // UPDATE SET -- a --force correction, a review-status flip, or a + // corrects edit made into 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.ReviewStatus, + e.OverriddenDuplicateOf, e.ReviewStatus, e.Corrects, ); err != nil { _ = tx.Rollback() return err diff --git a/release-gates/crn-6b1g8-gate.md b/release-gates/crn-6b1g8-gate.md new file mode 100644 index 0000000..feb08c0 --- /dev/null +++ b/release-gates/crn-6b1g8-gate.md @@ -0,0 +1,84 @@ +# Release Gate: crn-6b1g8 + +**Bead:** crn-6b1g8 (deploy) — source: crn-w0gc3 (review), feature: crn-evw98.3 +**Commit:** `161eaa9e7fe38668a24c47ad198b393e146cf8bf`, cut onto `deploy/crn-6b1g8-gate` +**Date:** 2026-08-20 + +## Background + +This is the redo of a previously gate-FAILED deploy round for the same feature +(Cross-topic-key Corrects/Supersedes link, consulted at read time, crn-evw98.3). +The prior deploy bead crn-v6jix (reviewing commit 50cdb919, review crn-0mdsv +round 2) hit a genuine criterion-6 merge conflict against origin/main (PR #129, +mergeable=CONFLICTING) and was correctly routed back to the builder rather than +auto-resolved — confirmed non-trivial by an aborted bounded self-rebase attempt. +The builder rebased builder/crn-evw98.3 onto current main and landed the fix for +round 1's outstanding presentational gap (spec criterion 5: plain-text NOTE-line +format/ordering), producing commit 161eaa9e. crn-w0gc3 (this round's review) +independently re-verified the full 8-file feature diff (storage: +internal/cairn/entry.go / index.go; write path: cmd/remember.go; read path: +cmd/commands.go; plus tests) and passed. crn-6b1g8 is the resulting deploy bead. + +Per mayor's standing authorization (recorded on crn-v6jix's notes, 2026-08-20): +once this redo lands on a fresh branch/PR, any seat may close the stale PR #129 +and delete origin/deploy/crn-v6jix-gate — planned as post-merge cleanup below. + +## Criteria + +| # | Criterion | Result | Evidence | +|---|-----------|--------|----------| +| 1 | Review PASS for exact deployed commit | PASS | crn-w0gc3 (closed, close_reason=pass) metadata.deploy_commit and notes' `deploy_commit:` field both cite `161eaa9e7fe38668a24c47ad198b393e146cf8bf`, byte-identical to crn-6b1g8's own `**Commit:**` field (D = R). Independently re-resolved via `git rev-parse --verify --quiet "^{commit}"` — a real commit object, not a transcribed string. | +| 2 | Acceptance criteria met | PASS | This deploy exists specifically to close spec criterion 5 (plain-text NOTE-line format), the one gap crn-0mdsv round 1 found. crn-w0gc3 independently re-verified via a manual CLI smoke test on this exact SHA: `cairn get orig` → NOTE line leads (`NOTE: orig redirected to fix`); `cairn get fix` (uncorrected) → no NOTE line. Both directions are also pinned as automated, order-sensitive assertions in `TestGetRedirectsToCorrectionWhenOriginalIsRequested` / `TestGetDoesNotRedirectWhenNoCorrectionExists`, independently re-run by me (see #3) and confirmed passing on this branch. | +| 3 | Tests pass | PASS* | `go test ./... -race -count=1` on `deploy/crn-6b1g8-gate` (=161eaa9e): 7/8 packages clean (root, cmd, formulas, internal/cairn, obslog, scripts all `ok`). One flake: `internal/critic`'s `TestRunFreshnessScenarioPasses` FAILed once under full concurrent load (`context deadline exceeded`). Not diff-owned — internal/critic is absent from this deploy's 8-file diff, so its source is byte-identical to origin/main — zero path overlap, and it reproduces clean 5/5 in an isolated re-run (`-run TestRunFreshnessScenarioPasses -count=5`, ~0.29s each vs. 18.65s under full-suite contention). Consistent with the fleet's tracked internal/critic load-sensitivity precedent (crn-mxvn / crn-u8c0 / crn-9k30 — same package/class, different specific test/symptom). Filed as crn-n7svm for investigator follow-up; treated as non-blocking per the non-diff-owned-failure-attribution protocol (not diff-owned, tracked precedent predates this run, proven same-bytes-as-main, zero path overlap). | +| 4 | No open HIGH findings | PASS | crn-w0gc3: security_findings none (OWASP walk: no injection/auth/exposure/XXE/SSRF surface — pure Printf reorder), style_findings none (gofmt/vet/golangci-lint all clean). Independently corroborated on this exact branch: `gofmt -l .` empty, `go vet ./...` clean (rc 0). | +| 5 | Clean tree | PASS | `deploy/crn-6b1g8-gate` cut directly from `161eaa9e...^{commit}` via `resolve_deploy_branch_target`; `git status --porcelain` empty throughout gate evaluation. | +| 6 | Clean divergence from main | PASS | `git merge-tree --write-tree origin/main 161eaa9e` → exit 0, single tree `242d7988f43db206817fd45c7967f4db197843c5`, no conflict markers — clean merge despite main having advanced 2 commits since the branch's merge-base. No self-rebase needed. | +| 7 | Single feature theme | PASS | `assert_deploy_ancestry_scope main 161eaa9e crn-6b1g8 crn-w0gc3 crn-0mdsv crn-evw98.3 crn-v6jix` → rc=0: no `.claude/**` paths touched; all 4 non-merge commits in `main..161eaa9e` cite an accepted id (`crn-evw98.3` ×3, `crn-0mdsv` ×1 in-body). `diff --stat` confirms all 8 changed files sit under `cmd/` and `internal/cairn/` — one coherent feature (cross-topic-key corrects/supersedes link) end to end: storage, write path, read path, tests. | + +## Verdict: PASS — proceeding to PR. + +## Process notes + +1. **Merge authority — proceeding under the standing self-merge authorization**, + per fleet memory `cairn-auto-merge-requires-explicit-strategy`. Re-read the + full memory (not a cached paraphrase) before acting: reinstated 2026-08-19 + (mayor ruling gm-wisp-2yhv7u — quad341/cairn is not mpr-covered), and + reaffirmed as recently as today via an addendum (2026-08-20, mayor msg + gm-wisp-fvdoja, re PR #130) that describes the deployer merging "under its + own authority going forward" — mayor's own most recent message treats + self-merge as ongoing practice, not something to re-litigate per bead. + crn-6b1g8's bead body carries the familiar "route through mayor/mpr" + boilerplate; per the ruling this is the known stale-copy artifact, not a + fresh signal requiring escalation. + + Mechanism (THE RULE, verbatim from the memory): check + `gh pr view --json mergeStateStatus,mergeable,autoMergeRequest` fresh + before merging. If already CLEAN/MERGEABLE with CI green: plain + `gh pr merge --squash --delete-branch` (never `--auto` in this state — + it has nothing left to arm). If checks are still running: + `gh pr merge --auto --squash --delete-branch`, then verify + `autoMergeRequest` is non-null. Either way, verify via a fresh read + afterward (state=MERGED/mergedAt for plain; autoMergeRequest non-null for + armed) — never trust exit code alone (FR-03). + + 4 standing conditions (mayor, 2026-08-19), checked per-PR, not assumed + carried-over: (1) gate 7/7 PASS — this doc; (2) PR state via a direct fresh + `gh pr view --json` read; (3) CLEAN/MERGEABLE with both required checks + (`build-test`, `lint`) COMPLETED/SUCCESS; (4) `--delete-branch` appended by + default (2026-08-20 addendum — closes the crn-v6jix stale-branch failure + class at the source). + +2. **Branch target:** `builder/crn-evw98.3` is provenance-only per the deploy + bead's own instruction — a possibly shared builder branch, not a push + target. `deploy/crn-6b1g8-gate` was cut fresh from the exact reviewed SHA + via `resolve_deploy_branch_target`; `assert_safe_push_target` confirmed the + derived name does not match the shared-worktree-branch signature. + +3. **SHA integrity:** the deploy commit was independently re-resolved via + `git rev-parse --verify --quiet "^{commit}"` before any gate step ran. + +4. **Post-merge cleanup (pre-authorized):** once this PR is merged, close the + now-fully-superseded PR #129 and delete `origin/deploy/crn-v6jix-gate` per + mayor's standing authorization recorded on crn-v6jix's notes (2026-08-20) — + that branch is frozen at a pre-rebase commit (169562d) and this deploy is + the redo it was waiting on.