diff --git a/cmd/engram/main.go b/cmd/engram/main.go index 1bc73c975..94ef424ba 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -1220,7 +1220,7 @@ func cmdTUI(cfg store.Config) { func cmdSearch(cfg store.Config) { if len(os.Args) < 3 { - fmt.Fprintln(os.Stderr, "usage: engram search [--type TYPE] [--project PROJECT|--all] [--scope SCOPE] [--limit N] [--match all|any]") + fmt.Fprintln(os.Stderr, "usage: engram search [--type TYPE] [--project PROJECT|--all] [--scope SCOPE] [--org ORG] [--limit N] [--match all|any]") exitFunc(1) } @@ -1260,6 +1260,14 @@ func cmdSearch(cfg store.Config) { opts.MatchMode = os.Args[i+1] i++ } + case "--org": + if i+1 >= len(os.Args) || strings.HasPrefix(os.Args[i+1], "-") { + fmt.Fprintln(os.Stderr, "error: --org requires a value") + exitFunc(1) + return + } + opts.Org = strings.TrimSpace(os.Args[i+1]) + i++ default: queryParts = append(queryParts, os.Args[i]) } @@ -1301,14 +1309,18 @@ func cmdSearch(cfg store.Config) { if r.Project != nil { project = fmt.Sprintf(" | project: %s", *r.Project) } - fmt.Printf("[%d] #%d (%s) — %s\n %s\n %s%s | scope: %s\n\n", + org := "" + if r.Org != nil { + org = fmt.Sprintf(" | org: %s", *r.Org) + } + fmt.Printf("[%d] #%d (%s) — %s\n %s\n %s%s | scope: %s%s\n\n", i+1, r.ID, r.Type, r.Title, truncate(r.Content, 300), - timeutil.FormatLocal(r.CreatedAt), project, r.Scope) + timeutil.FormatLocal(r.CreatedAt), project, r.Scope, org) } } -const saveUsage = "usage: engram save <content> [--type TYPE] [--project PROJECT] [--scope SCOPE] [--topic TOPIC_KEY]" +const saveUsage = "usage: engram save <title> <content> [--type TYPE] [--project PROJECT] [--scope SCOPE] [--topic TOPIC_KEY] [--org ORG]" type saveArgs struct { title string @@ -1317,6 +1329,7 @@ type saveArgs struct { projectName string scope string topicKey string + org string } // parseSaveArgs accepts save flags anywhere around the two required positionals. @@ -1333,7 +1346,7 @@ func parseSaveArgs(args []string) (saveArgs, error) { continue } if !endOfOptions && strings.HasPrefix(arg, "-") { - if arg != "--type" && arg != "--project" && arg != "--scope" && arg != "--topic" { + if arg != "--type" && arg != "--project" && arg != "--scope" && arg != "--topic" && arg != "--org" { return saveArgs{}, fmt.Errorf("unknown save flag: %s", arg) } if i+1 >= len(args) || strings.TrimSpace(args[i+1]) == "" || strings.HasPrefix(args[i+1], "-") { @@ -1349,6 +1362,8 @@ func parseSaveArgs(args []string) (saveArgs, error) { parsed.scope = args[i] case "--topic": parsed.topicKey = args[i] + case "--org": + parsed.org = strings.TrimSpace(args[i]) } continue } @@ -1379,6 +1394,7 @@ func cmdSave(cfg store.Config) { projectName := args.projectName scope := args.scope topicKey := args.topicKey + org := args.org // Reject titleless saves before opening the store or creating a session // (#459). The store applies the same rule as a backstop. @@ -1427,6 +1443,15 @@ func cmdSave(cfg store.Config) { return } + // --org wins when given explicitly; otherwise inherit from the nearest + // .engram/config.json org field for this cwd, independent of how the + // project name itself was resolved (#776). + if strings.TrimSpace(org) == "" { + if cfgResult := detectProjectFull(cwd); cfgResult.Org != "" { + org = cfgResult.Org + } + } + s, err := storeNew(cfg) if err != nil { fatal(err) @@ -1445,6 +1470,7 @@ func cmdSave(cfg store.Config) { Project: projectName, Scope: scope, TopicKey: topicKey, + Org: org, }) if err != nil { fatal(err) @@ -2299,6 +2325,7 @@ func cmdObsidianExport(cfg store.Config) { var ( vault string project string + org string limit int since string force bool @@ -2320,6 +2347,14 @@ func cmdObsidianExport(cfg store.Config) { project = os.Args[i+1] i++ } + case "--org": + if i+1 >= len(os.Args) || strings.HasPrefix(os.Args[i+1], "-") { + fmt.Fprintln(os.Stderr, "error: --org requires a value") + exitFunc(1) + return + } + org = strings.TrimSpace(os.Args[i+1]) + i++ case "--all": allProjects = true case "--limit": @@ -2398,6 +2433,7 @@ func cmdObsidianExport(cfg store.Config) { exportCfg := obsidian.ExportConfig{ VaultPath: vault, Project: project, + Org: org, Limit: limit, Force: force, GraphConfig: graphMode, @@ -2596,13 +2632,27 @@ func cmdProjectsRescueOwnership(cfg store.Config) { } func cmdProjectsList(cfg store.Config) { + org := "" + for i := 3; i < len(os.Args); i++ { + switch os.Args[i] { + case "--org": + if i+1 >= len(os.Args) || strings.HasPrefix(os.Args[i+1], "-") { + fmt.Fprintln(os.Stderr, "error: --org requires a value") + exitFunc(1) + return + } + org = strings.TrimSpace(os.Args[i+1]) + i++ + } + } + s, err := storeNew(cfg) if err != nil { fatal(err) } defer s.Close() - projects, err := s.ListProjectsWithStats() + projects, err := s.ListProjectsWithStats(org) if err != nil { fatal(err) } @@ -2612,7 +2662,11 @@ func cmdProjectsList(cfg store.Config) { return } - fmt.Printf("Projects (%d):\n", len(projects)) + if org != "" { + fmt.Printf("Projects (%d) — org: %s\n", len(projects), org) + } else { + fmt.Printf("Projects (%d):\n", len(projects)) + } for _, p := range projects { sessionWord := "sessions" if p.SessionCount == 1 { @@ -2783,7 +2837,7 @@ func cmdProjectsConsolidate(cfg store.Config) { // Only normalization-equivalent legacy names are safe automatic candidates. similar := findNormalizationEquivalentProjects(canonical, allNames) - allStats, _ := s.ListProjectsWithStats() + allStats, _ := s.ListProjectsWithStats("") statsMap := make(map[string]store.ProjectStats) for _, ps := range allStats { statsMap[ps.Name] = ps @@ -2872,7 +2926,7 @@ func cmdProjectsConsolidate(cfg store.Config) { } // --all mode: group all projects by normalization equivalence. - projects, err := s.ListProjectsWithStats() + projects, err := s.ListProjectsWithStats("") if err != nil { fatal(err) } @@ -3027,7 +3081,7 @@ func cmdProjectsPrune(cfg store.Config) { } defer s.Close() - allStats, err := s.ListProjectsWithStats() + allStats, err := s.ListProjectsWithStats("") if err != nil { fatal(err) } @@ -3575,8 +3629,8 @@ Commands: test [suite] [--quick] [--json] Run isolated local reliability and performance self-tests suites: reliability, performance (default: both) - search <query> Search memories [--type TYPE] [--project PROJECT|--all] [--scope SCOPE] [--limit N] [--match all|any] - save <title> <msg> Save a memory [--type TYPE] [--project PROJECT] [--scope SCOPE] + search <query> Search memories [--type TYPE] [--project PROJECT|--all] [--scope SCOPE] [--org ORG] [--limit N] [--match all|any] + save <title> <msg> Save a memory [--type TYPE] [--project PROJECT] [--scope SCOPE] [--org ORG] delete <obs_id> Delete an observation [--hard] (soft-delete by default; --hard removes permanently) delete session <id> Delete a session by ID (session must have no observations) @@ -3604,7 +3658,8 @@ Commands: import <file> Import memories from a JSON export file init [name] Initialize an Engram project (.engram/config.json) in current directory --force, -f Overwrite existing .engram/config.json - projects list List all projects with observation, session, and prompt counts + projects list [--org ORG] + List all projects with observation, session, and prompt counts projects consolidate [--all] [--dry-run] Merge similar project names into one canonical name --all Scan ALL projects for similar name groups @@ -3631,6 +3686,7 @@ Commands: Export memories to an Obsidian-compatible markdown vault --vault Path to Obsidian vault root (required) --project Filter export to a single project (optional; cannot combine with --all) + --org Filter export to a single org (optional) --all Export every project --limit Cap exported observations at N (optional) --since Export only observations after this date, e.g. 2026-01-01 (optional) diff --git a/cmd/engram/main_test.go b/cmd/engram/main_test.go index 76736a5ff..88a61ba56 100644 --- a/cmd/engram/main_test.go +++ b/cmd/engram/main_test.go @@ -859,6 +859,250 @@ func TestCmdSaveExplicitProjectFlagBeatsEnvironmentOverride(t *testing.T) { assertCmdSaveOwnedBy(t, cfg, "Flag-Project", "flag-project") } +func TestCmdSaveResolvesConfiguredOrgWithoutFlag(t *testing.T) { + stubExitWithPanic(t) + cfg := testConfig(t) + cwd := t.TempDir() + configDir := filepath.Join(cwd, ".engram") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("create project config directory: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(`{"project_name":"Configured-Project","org":"acme-corp"}`), 0644); err != nil { + t.Fatalf("write project config: %v", err) + } + withCwd(t, cwd) + withArgs(t, "engram", "save", "resolved-title", "resolved-content") + + stdout, stderr := captureOutput(t, func() { cmdSave(cfg) }) + if stderr != "" || !strings.Contains(stdout, "Memory saved:") { + t.Fatalf("cmdSave output = stdout %q stderr %q", stdout, stderr) + } + + s, err := store.New(cfg) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer func() { _ = s.Close() }() + observations, err := s.RecentObservations("configured-project", "project", 10) + if err != nil || len(observations) != 1 { + t.Fatalf("resolved observations = %#v, err=%v", observations, err) + } + if observations[0].Org == nil || *observations[0].Org != "acme-corp" { + t.Fatalf("expected org inherited from config, got %#v", observations[0].Org) + } +} + +func TestCmdSaveExplicitOrgFlagBeatsConfig(t *testing.T) { + stubExitWithPanic(t) + cfg := testConfig(t) + cwd := t.TempDir() + configDir := filepath.Join(cwd, ".engram") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("create project config directory: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(`{"project_name":"Configured-Project","org":"acme-corp"}`), 0644); err != nil { + t.Fatalf("write project config: %v", err) + } + withCwd(t, cwd) + withArgs(t, "engram", "save", "resolved-title", "resolved-content", "--org", "globex-inc") + + stdout, stderr := captureOutput(t, func() { cmdSave(cfg) }) + if stderr != "" || !strings.Contains(stdout, "Memory saved:") { + t.Fatalf("cmdSave output = stdout %q stderr %q", stdout, stderr) + } + + s, err := store.New(cfg) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer func() { _ = s.Close() }() + observations, err := s.RecentObservations("configured-project", "project", 10) + if err != nil || len(observations) != 1 { + t.Fatalf("resolved observations = %#v, err=%v", observations, err) + } + if observations[0].Org == nil || *observations[0].Org != "globex-inc" { + t.Fatalf("expected explicit --org to beat config, got %#v", observations[0].Org) + } +} + +// TestCmdSaveRejectsInvalidOrgValue mirrors TestCmdSearchRejectsInvalidOrgValue +// for `engram save --org`. +func TestCmdSaveRejectsInvalidOrgValue(t *testing.T) { + t.Run("--org at end of args errors", func(t *testing.T) { + cfg := testConfig(t) + withArgs(t, "engram", "save", "a-title", "a-content", "--org") + _, stderr, exitCode := captureExitPanic(t, func() { cmdSave(cfg) }) + if exitCode != 1 || !strings.Contains(stderr, "--org requires a value") { + t.Fatalf("exitCode=%d stderr=%q, want exit 1 and a clear --org error", exitCode, stderr) + } + }) + + t.Run("--org followed by another flag errors", func(t *testing.T) { + cfg := testConfig(t) + withArgs(t, "engram", "save", "a-title", "a-content", "--org", "--scope", "personal") + _, stderr, exitCode := captureExitPanic(t, func() { cmdSave(cfg) }) + if exitCode != 1 || !strings.Contains(stderr, "--org requires a value") { + t.Fatalf("exitCode=%d stderr=%q, want exit 1 and a clear --org error", exitCode, stderr) + } + }) + + t.Run("--org with a real value still works", func(t *testing.T) { + cfg := testConfig(t) + withArgs(t, "engram", "save", "a-title", "a-content", "--org", "acme-corp") + _, stderr, exitCode := captureExitPanic(t, func() { cmdSave(cfg) }) + if exitCode != 0 || strings.Contains(stderr, "--org requires a value") { + t.Fatalf("exitCode=%d stderr=%q, want a normal save", exitCode, stderr) + } + }) +} + +func TestCmdSearchFiltersByOrg(t *testing.T) { + cfg := testConfig(t) + + withArgs(t, "engram", "save", "acme-title", "acme-content", "--project", "alpha", "--org", "acme-corp") + if _, stderr := captureOutput(t, func() { cmdSave(cfg) }); stderr != "" { + t.Fatalf("unexpected stderr saving acme observation: %q", stderr) + } + withArgs(t, "engram", "save", "globex-title", "globex-content", "--project", "alpha", "--org", "globex-inc") + if _, stderr := captureOutput(t, func() { cmdSave(cfg) }); stderr != "" { + t.Fatalf("unexpected stderr saving globex observation: %q", stderr) + } + + withArgs(t, "engram", "search", "content", "--project", "alpha", "--org", "acme-corp", "--limit", "10") + stdout, stderr := captureOutput(t, func() { cmdSearch(cfg) }) + if stderr != "" { + t.Fatalf("expected no stderr, got: %q", stderr) + } + if !strings.Contains(stdout, "acme-title") || strings.Contains(stdout, "globex-title") { + t.Fatalf("expected only the acme-corp result, got: %q", stdout) + } +} + +// TestCmdSearchRejectsInvalidOrgValue is a regression test: --org at the end +// of the args, or immediately followed by another flag, was silently +// consumed as an empty or flag-shaped org instead of erroring, so a typo'd +// invocation quietly searched with the wrong (or no) org filter. +func TestCmdSearchRejectsInvalidOrgValue(t *testing.T) { + t.Run("--org at end of args errors", func(t *testing.T) { + cfg := testConfig(t) + withArgs(t, "engram", "search", "content", "--org") + _, stderr, exitCode := captureExitPanic(t, func() { cmdSearch(cfg) }) + if exitCode != 1 || !strings.Contains(stderr, "--org requires a value") { + t.Fatalf("exitCode=%d stderr=%q, want exit 1 and a clear --org error", exitCode, stderr) + } + }) + + t.Run("--org followed by another flag errors", func(t *testing.T) { + cfg := testConfig(t) + withArgs(t, "engram", "search", "content", "--org", "--scope", "personal") + _, stderr, exitCode := captureExitPanic(t, func() { cmdSearch(cfg) }) + if exitCode != 1 || !strings.Contains(stderr, "--org requires a value") { + t.Fatalf("exitCode=%d stderr=%q, want exit 1 and a clear --org error", exitCode, stderr) + } + }) + + t.Run("--org with a real value still works", func(t *testing.T) { + cfg := testConfig(t) + withArgs(t, "engram", "search", "content", "--org", "acme-corp") + _, stderr, exitCode := captureExitPanic(t, func() { cmdSearch(cfg) }) + if exitCode != 0 || strings.Contains(stderr, "--org requires a value") { + t.Fatalf("exitCode=%d stderr=%q, want a normal search run", exitCode, stderr) + } + }) +} + +// TestCmdSearchDisplaysOrgInResults is a regression test: search results +// printed project and scope but never org, so a cross-project search could +// not tell which org each result belonged to. Non-nil org must render the +// same way project already does; an untagged result must omit it. +func TestCmdSearchDisplaysOrgInResults(t *testing.T) { + cfg := testConfig(t) + + withArgs(t, "engram", "save", "acme-title", "acme-content", "--project", "alpha", "--org", "acme-corp") + if _, stderr := captureOutput(t, func() { cmdSave(cfg) }); stderr != "" { + t.Fatalf("unexpected stderr saving acme observation: %q", stderr) + } + withArgs(t, "engram", "save", "globex-title", "globex-content", "--project", "alpha", "--org", "globex-inc") + if _, stderr := captureOutput(t, func() { cmdSave(cfg) }); stderr != "" { + t.Fatalf("unexpected stderr saving globex observation: %q", stderr) + } + withArgs(t, "engram", "save", "untagged-title", "untagged-content", "--project", "alpha") + if _, stderr := captureOutput(t, func() { cmdSave(cfg) }); stderr != "" { + t.Fatalf("unexpected stderr saving untagged observation: %q", stderr) + } + + withArgs(t, "engram", "search", "content", "--project", "alpha", "--limit", "10") + stdout, stderr := captureOutput(t, func() { cmdSearch(cfg) }) + if stderr != "" { + t.Fatalf("expected no stderr, got: %q", stderr) + } + if !strings.Contains(stdout, "| org: acme-corp") { + t.Fatalf("expected acme-corp org label in results, got: %q", stdout) + } + if !strings.Contains(stdout, "| org: globex-inc") { + t.Fatalf("expected globex-inc org label in results, got: %q", stdout) + } + if got := strings.Count(stdout, "| org:"); got != 2 { + t.Fatalf("expected exactly 2 org labels (untagged result must omit org), got %d in: %q", got, stdout) + } +} + +func TestCmdProjectsListFiltersByOrg(t *testing.T) { + cfg := testConfig(t) + + withArgs(t, "engram", "save", "acme-title", "acme-content", "--project", "acme-project", "--org", "acme-corp") + if _, stderr := captureOutput(t, func() { cmdSave(cfg) }); stderr != "" { + t.Fatalf("unexpected stderr saving acme observation: %q", stderr) + } + withArgs(t, "engram", "save", "globex-title", "globex-content", "--project", "globex-project", "--org", "globex-inc") + if _, stderr := captureOutput(t, func() { cmdSave(cfg) }); stderr != "" { + t.Fatalf("unexpected stderr saving globex observation: %q", stderr) + } + + withArgs(t, "engram", "projects", "list", "--org", "acme-corp") + stdout, stderr := captureOutput(t, func() { cmdProjectsList(cfg) }) + if stderr != "" { + t.Fatalf("expected no stderr, got: %q", stderr) + } + if !strings.Contains(stdout, "Projects (1) — org: acme-corp") { + t.Fatalf("expected org-scoped header, got: %q", stdout) + } + if !strings.Contains(stdout, "acme-project") || strings.Contains(stdout, "globex-project") { + t.Fatalf("expected only acme-project in org-filtered listing, got: %q", stdout) + } +} + +// TestCmdProjectsListRejectsInvalidOrgValue mirrors +// TestCmdSearchRejectsInvalidOrgValue for `engram projects list --org`. +func TestCmdProjectsListRejectsInvalidOrgValue(t *testing.T) { + t.Run("--org at end of args errors", func(t *testing.T) { + cfg := testConfig(t) + withArgs(t, "engram", "projects", "list", "--org") + _, stderr, exitCode := captureExitPanic(t, func() { cmdProjectsList(cfg) }) + if exitCode != 1 || !strings.Contains(stderr, "--org requires a value") { + t.Fatalf("exitCode=%d stderr=%q, want exit 1 and a clear --org error", exitCode, stderr) + } + }) + + t.Run("--org followed by another flag errors", func(t *testing.T) { + cfg := testConfig(t) + withArgs(t, "engram", "projects", "list", "--org", "--all") + _, stderr, exitCode := captureExitPanic(t, func() { cmdProjectsList(cfg) }) + if exitCode != 1 || !strings.Contains(stderr, "--org requires a value") { + t.Fatalf("exitCode=%d stderr=%q, want exit 1 and a clear --org error", exitCode, stderr) + } + }) + + t.Run("--org with a real value still works", func(t *testing.T) { + cfg := testConfig(t) + withArgs(t, "engram", "projects", "list", "--org", "acme-corp") + _, stderr, exitCode := captureExitPanic(t, func() { cmdProjectsList(cfg) }) + if exitCode != 0 || strings.Contains(stderr, "--org requires a value") { + t.Fatalf("exitCode=%d stderr=%q, want a normal listing run", exitCode, stderr) + } + }) +} + func TestCmdSaveUsesDetectionSeamAndPrintsNormalizationWarning(t *testing.T) { stubExitWithPanic(t) cfg := testConfig(t) @@ -1905,7 +2149,7 @@ func TestCmdProjectsPrunePathsOnlyDryRun(t *testing.T) { t.Fatalf("store.New: %v", err) } defer s.Close() - stats, err := s.ListProjectsWithStats() + stats, err := s.ListProjectsWithStats("") if err != nil { t.Fatalf("ListProjectsWithStats: %v", err) } @@ -1957,7 +2201,7 @@ func TestCmdProjectsPrunePathsOnly(t *testing.T) { t.Fatalf("pruned session %q still exists", sessionID) } } - stats, err := s.ListProjectsWithStats() + stats, err := s.ListProjectsWithStats("") if err != nil { t.Fatalf("ListProjectsWithStats: %v", err) } @@ -2838,6 +3082,41 @@ func TestObsidianExportGraphConfigInvalid(t *testing.T) { } } +// TestObsidianExportRejectsInvalidOrgValue mirrors +// TestCmdSearchRejectsInvalidOrgValue for `engram obsidian-export --org`. +func TestObsidianExportRejectsInvalidOrgValue(t *testing.T) { + t.Run("--org at end of args errors", func(t *testing.T) { + cfg := testConfig(t) + vaultDir := t.TempDir() + withArgs(t, "engram", "obsidian-export", "--vault", vaultDir, "--org") + _, stderr, code := captureExitPanic(t, func() { cmdObsidianExport(cfg) }) + if code != 1 || !strings.Contains(stderr, "--org requires a value") { + t.Fatalf("exitCode=%d stderr=%q, want exit 1 and a clear --org error", code, stderr) + } + }) + + t.Run("--org followed by another flag errors", func(t *testing.T) { + cfg := testConfig(t) + vaultDir := t.TempDir() + withArgs(t, "engram", "obsidian-export", "--vault", vaultDir, "--org", "--all") + _, stderr, code := captureExitPanic(t, func() { cmdObsidianExport(cfg) }) + if code != 1 || !strings.Contains(stderr, "--org requires a value") { + t.Fatalf("exitCode=%d stderr=%q, want exit 1 and a clear --org error", code, stderr) + } + }) + + t.Run("--org with a real value still works", func(t *testing.T) { + cfg := testConfig(t) + vaultDir := t.TempDir() + mustSeedObservation(t, cfg, "obsidian-org-flag", "obsidian-org-flag", "bugfix", "Org flag export", "content", "project") + withArgs(t, "engram", "obsidian-export", "--vault", vaultDir, "--project", "obsidian-org-flag", "--org", "acme-corp") + _, stderr, code := captureExitPanic(t, func() { cmdObsidianExport(cfg) }) + if code != 0 || strings.Contains(stderr, "--org requires a value") { + t.Fatalf("exitCode=%d stderr=%q, want a normal export run", code, stderr) + } + }) +} + // TestObsidianExportGraphConfigDefaultsToPreserve verifies that when --graph-config // is not set, the exporter is called with GraphConfigPreserve. (REQ-GRAPH-01) func TestObsidianExportGraphConfigDefaultsToPreserve(t *testing.T) { diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 71ed1cde1..d0a4dbd1a 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -454,6 +454,9 @@ func registerTools(srv *server.MCPServer, s *store.Store, cfg MCPConfig, allowli mcp.WithString("scope", mcp.Description("Filter by scope: project, personal, or global. Omit to apply no scope filter."), ), + mcp.WithString("org", + mcp.Description("Filter by org — a free-text grouping axis orthogonal to scope. Omit to apply no org filter."), + ), mcp.WithString("match_mode", mcp.Description("Token matching: \"all\" (default — every token must match, FTS5 AND) or \"any\" (any token matches — broader recall for multi-token queries). Any other value returns an error."), ), @@ -528,6 +531,9 @@ Examples: mcp.WithString("project", mcp.Description("Optional explicit project for this memory. Accepted only when backed by known context (existing project, matching session, repo config, or ambiguous-project recovery); invalid or unbacked names fail loudly."), ), + mcp.WithString("org", + mcp.Description("Optional org — a free-text grouping axis orthogonal to scope. Overrides the org inherited from .engram/config.json for the current directory, if any."), + ), mcp.WithString("project_choice_reason", mcp.Description("Must be user_selected_after_ambiguous_project, and only after the user explicitly chose one of available_projects from an ambiguous_project error."), ), @@ -994,6 +1000,9 @@ Duplicates are automatically detected and skipped — safe to call multiple time mcp.WithDestructiveHintAnnotation(false), mcp.WithIdempotentHintAnnotation(true), mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString("org", + mcp.Description("Filter by org — a free-text grouping axis orthogonal to scope. Omit to apply no org filter."), + ), ), handleListProjects(s), ) @@ -1137,7 +1146,9 @@ ERROR: Returns IsError=true if IDs are unknown, relation is invalid, or cross-pr // a store-query failure is surfaced as a tool error. func handleListProjects(s *store.Store) server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - projects, err := s.ListProjectsWithStats() + org, _ := req.GetArguments()["org"].(string) + org = strings.TrimSpace(org) + projects, err := s.ListProjectsWithStats(org) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("List projects failed: %v", err)), nil } @@ -1163,9 +1174,15 @@ func handleCurrentProject(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc cwd, _ := os.Getwd() res := projectpkg.DetectProjectFull(cwd) if processRes, ok, err := processProjectResult(cfg.DefaultProject); ok { + // A process-level override (ENGRAM_PROJECT / mcp --project) only + // decides Project/Source/Path — it never reads .engram/config.json, + // so it can't know org. Carry the cwd-detected Org through so the + // override doesn't silently hide a repo's org label (#776). + org := res.Org if err != nil { - res = projectpkg.DetectionResult{Source: projectpkg.SourceProcessOverride, Error: err} + res = projectpkg.DetectionResult{Source: projectpkg.SourceProcessOverride, Error: err, Org: org} } else { + processRes.Org = org res = processRes } } @@ -1177,6 +1194,9 @@ func handleCurrentProject(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc "cwd": cwd, "available_projects": res.AvailableProjects, } + if res.Org != "" { + envelope["org"] = res.Org + } if res.Warning != "" { envelope["warning"] = res.Warning } @@ -1195,6 +1215,8 @@ func handleSearch(s *store.Store, cfg MCPConfig, activity *SessionActivity) serv typ, _ := req.GetArguments()["type"].(string) projectOverride, _ := req.GetArguments()["project"].(string) scope, _ := req.GetArguments()["scope"].(string) + org, _ := req.GetArguments()["org"].(string) + org = strings.TrimSpace(org) matchMode, _ := req.GetArguments()["match_mode"].(string) responseFormat, _ := req.GetArguments()["response_format"].(string) allProjects := boolArg(req, "all_projects", false) @@ -1244,6 +1266,7 @@ func handleSearch(s *store.Store, cfg MCPConfig, activity *SessionActivity) serv Type: typ, Project: searchProject, Scope: scope, + Org: org, Limit: limit, MatchMode: matchMode, }) @@ -1314,6 +1337,9 @@ func handleSearch(s *store.Store, cfg MCPConfig, activity *SessionActivity) serv if r.TopicKey != nil && *r.TopicKey != "" { entry["topic_key"] = *r.TopicKey } + if r.Org != nil { + entry["org"] = *r.Org + } if r.ReviewAfter != nil { entry["review_after"] = *r.ReviewAfter } @@ -1452,6 +1478,8 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server sessionID, _ := req.GetArguments()["session_id"].(string) scope, _ := req.GetArguments()["scope"].(string) topicKey, _ := req.GetArguments()["topic_key"].(string) + org, _ := req.GetArguments()["org"].(string) + org = strings.TrimSpace(org) projectChoice, _ := req.GetArguments()["project"].(string) _, explicitProjectProvided := req.GetArguments()["project"] projectChoiceReason, _ := req.GetArguments()["project_choice_reason"].(string) @@ -1515,6 +1543,17 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server // Ensure the implicit MCP session exists with the current working directory. _ = ensureImplicitSessionWithCWD(s, sessionID, project) + // org wins when given explicitly; otherwise inherit from the nearest + // .engram/config.json org field for cwd, independent of how the project + // name itself was resolved (#776). + if strings.TrimSpace(org) == "" { + if cwd, cwdErr := os.Getwd(); cwdErr == nil { + if cfgResult := projectpkg.DetectProjectFull(cwd); cfgResult.Org != "" { + org = cfgResult.Org + } + } + } + truncation := s.ContentTruncation(content) savedID, err := s.AddObservation(store.AddObservationParams{ @@ -1525,6 +1564,7 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server Project: project, Scope: scope, TopicKey: topicKey, + Org: org, }) if err != nil { return mcp.NewToolResultError("Failed to save: " + err.Error()), nil @@ -2318,12 +2358,23 @@ func handleSessionSummary(s *store.Store, cfg MCPConfig, activity *SessionActivi // Ensure the implicit MCP session exists with the current working directory. _ = ensureImplicitSessionWithCWD(s, sessionID, project) + // org inherits from the nearest .engram/config.json org field for cwd, + // the same as handleSave (#776) — mem_session_summary has no explicit + // org argument, so config inheritance is the only source. + var org string + if cwd, cwdErr := os.Getwd(); cwdErr == nil { + if cfgResult := projectpkg.DetectProjectFull(cwd); cfgResult.Org != "" { + org = cfgResult.Org + } + } + savedID, err := s.AddObservation(store.AddObservationParams{ SessionID: sessionID, Type: "session_summary", Title: fmt.Sprintf("Session summary: %s", project), Content: content, Project: project, + Org: org, }) if err != nil { return mcp.NewToolResultError("Failed to save session summary: " + err.Error()), nil diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index fd0b10210..512a119f9 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -650,6 +650,75 @@ func TestHandleSaveAcceptsObservationAliasForContent(t *testing.T) { t.Fatalf("expected pending observation upsert sync mutation, got %#v", mutations) } +// ─── Org grouping axis (#776) ──────────────────────────────────────────────── + +func TestHandleSave_OrgInheritsFromConfig(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".engram"), 0755); err != nil { + t.Fatalf("create config dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, ".engram", "config.json"), []byte(`{"project_name":"engram","org":"acme-corp"}`), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + t.Chdir(dir) + + s := newMCPTestStore(t) + h := handleSave(s, MCPConfig{}, NewSessionActivity(10*time.Minute)) + + res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{ + "title": "Inherited org save", + "content": "This should inherit org from .engram/config.json", + }}}) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected save error: %s", callResultText(t, res)) + } + + obs, err := s.RecentObservations("engram", "project", 5) + if err != nil { + t.Fatalf("recent observations: %v", err) + } + if len(obs) != 1 || obs[0].Org == nil || *obs[0].Org != "acme-corp" { + t.Fatalf("expected org inherited from config, got %#v", obs) + } +} + +func TestHandleSave_ExplicitOrgOverridesConfig(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".engram"), 0755); err != nil { + t.Fatalf("create config dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, ".engram", "config.json"), []byte(`{"project_name":"engram","org":"acme-corp"}`), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + t.Chdir(dir) + + s := newMCPTestStore(t) + h := handleSave(s, MCPConfig{}, NewSessionActivity(10*time.Minute)) + + res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{ + "title": "Explicit org save", + "content": "The org parameter should win over config", + "org": "globex-inc", + }}}) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected save error: %s", callResultText(t, res)) + } + + obs, err := s.RecentObservations("engram", "project", 5) + if err != nil { + t.Fatalf("recent observations: %v", err) + } + if len(obs) != 1 || obs[0].Org == nil || *obs[0].Org != "globex-inc" { + t.Fatalf("expected explicit org to override config, got %#v", obs) + } +} + func TestHandleSaveRejectsMissingContent(t *testing.T) { s := newMCPTestStore(t) h := handleSave(s, MCPConfig{}, NewSessionActivity(10*time.Minute)) @@ -1682,6 +1751,59 @@ func TestHandleSearchOmitsEmptyPulledTopicKey(t *testing.T) { } } +func TestHandleSearch_FiltersByOrg(t *testing.T) { + s := newMCPTestStore(t) + if err := s.CreateSession("s-mcp", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + + if _, err := s.AddObservation(store.AddObservationParams{ + SessionID: "s-mcp", + Type: "decision", + Title: "Acme decision", + Content: "acme grouping content", + Project: "engram", + Scope: "project", + Org: "acme-corp", + }); err != nil { + t.Fatalf("add acme observation: %v", err) + } + if _, err := s.AddObservation(store.AddObservationParams{ + SessionID: "s-mcp", + Type: "decision", + Title: "Globex decision", + Content: "globex grouping content", + Project: "engram", + Scope: "project", + Org: "globex-inc", + }); err != nil { + t.Fatalf("add globex observation: %v", err) + } + + search := handleSearch(s, MCPConfig{}, NewSessionActivity(10*time.Minute)) + res, err := search(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{ + "query": "grouping", + "project": "engram", + "org": "acme-corp", + "limit": 5.0, + }}}) + if err != nil { + t.Fatalf("search handler error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected search error: %s", callResultText(t, res)) + } + body := callResultJSON(t, res) + results, ok := body["results"].([]any) + if !ok || len(results) != 1 { + t.Fatalf("expected exactly one org-filtered result, got %v", body["results"]) + } + firstResult, _ := results[0].(map[string]any) + if firstResult["org"] != "acme-corp" { + t.Fatalf("expected result org=acme-corp, got %v", firstResult["org"]) + } +} + func TestHandleSearch_PropagatesCanceledContext(t *testing.T) { s := newMCPTestStore(t) if err := s.CreateSession("s-canceled-search", "engram", "/tmp/engram"); err != nil { @@ -4006,6 +4128,73 @@ func TestHandleSessionSummaryCreatesProjectScopedSession(t *testing.T) { assertSessionSyncMutationDirectory(t, s, "manual-save-summary-session-project", dir) } +// TestHandleSessionSummary_OrgInheritsFromConfig is a regression test for +// #776: unlike handleSave, handleSessionSummary never inherited org from +// .engram/config.json, so session summaries saved in an org-scoped repo +// silently carried no org and vanished from org-filtered views. There is no +// explicit "org" argument on mem_session_summary, so config inheritance is +// the only source. +func TestHandleSessionSummary_OrgInheritsFromConfig(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".engram"), 0755); err != nil { + t.Fatalf("create config dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, ".engram", "config.json"), []byte(`{"project_name":"engram","org":"acme-corp"}`), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + t.Chdir(dir) + + s := newMCPTestStore(t) + h := handleSessionSummary(s, MCPConfig{}, NewSessionActivity(10*time.Minute)) + + res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{ + "content": "## Goal\nInherit org from config", + }}}) + if err != nil || res.IsError { + t.Fatalf("session summary: err=%v isError=%v text=%s", err, res.IsError, callResultText(t, res)) + } + + obs, err := s.RecentObservations("engram", "project", 5) + if err != nil { + t.Fatalf("recent observations: %v", err) + } + if len(obs) != 1 || obs[0].Org == nil || *obs[0].Org != "acme-corp" { + t.Fatalf("expected org inherited from config, got %#v", obs) + } +} + +// TestHandleSessionSummary_NoConfigLeavesOrgNil covers the counterpart: a +// repo with no .engram/config.json (or none with an org field) must save the +// summary with a nil org, not an empty-string placeholder. +func TestHandleSessionSummary_NoConfigLeavesOrgNil(t *testing.T) { + dir := t.TempDir() + initTestGitRepo(t, dir) + cmd := exec.Command("git", "-C", dir, "remote", "add", "origin", + "git@github.com:user/summary-no-org.git") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git remote add: %v\n%s", err, out) + } + t.Chdir(dir) + + s := newMCPTestStore(t) + h := handleSessionSummary(s, MCPConfig{}, NewSessionActivity(10*time.Minute)) + + res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{ + "content": "## Goal\nNo org configured", + }}}) + if err != nil || res.IsError { + t.Fatalf("session summary: err=%v isError=%v text=%s", err, res.IsError, callResultText(t, res)) + } + + obs, err := s.RecentObservations("summary-no-org", "project", 5) + if err != nil { + t.Fatalf("recent observations: %v", err) + } + if len(obs) != 1 || obs[0].Org != nil { + t.Fatalf("expected nil org without config, got %#v", obs) + } +} + func TestHandleSessionSummarySkipsConflictCandidates(t *testing.T) { s := newMCPTestStore(t) for _, sessionID := range []string{"summary-candidates-1", "summary-candidates-2"} { @@ -7209,6 +7398,94 @@ func TestMemCurrentProject_NormalResult(t *testing.T) { } } +// TestMemCurrentProject_IncludesOrgWhenConfigured verifies the "org" field +// (#776) surfaces in mem_current_project's response when .engram/config.json +// sets one, and is omitted when it doesn't (single-context users see no change). +func TestMemCurrentProject_IncludesOrgWhenConfigured(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".engram"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".engram", "config.json"), []byte(`{"project_name":"acme-app","org":"acme-corp"}`), 0644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + + s := newMCPTestStore(t) + h := handleCurrentProject(s, MCPConfig{}) + + res, err := h(context.Background(), mcppkg.CallToolRequest{}) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected error: %s", callResultText(t, res)) + } + body := callResultJSON(t, res) + if body["org"] != "acme-corp" { + t.Fatalf("expected org=acme-corp in response, got %v", body["org"]) + } +} + +func TestMemCurrentProject_OmitsOrgWhenNotConfigured(t *testing.T) { + dir := t.TempDir() + initTestGitRepo(t, dir) + t.Chdir(dir) + + s := newMCPTestStore(t) + h := handleCurrentProject(s, MCPConfig{}) + + res, err := h(context.Background(), mcppkg.CallToolRequest{}) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected error: %s", callResultText(t, res)) + } + body := callResultJSON(t, res) + if _, present := body["org"]; present { + t.Fatalf("expected org to be omitted when not configured, got %v", body["org"]) + } +} + +// TestMemCurrentProject_PreservesOrgUnderProcessOverride is a regression test +// for #776: a process-level override (ENGRAM_PROJECT / mcp --project) only +// resolves Project/Source/Path and never reads .engram/config.json, so it +// used to fully replace the cwd-detected DetectionResult and silently drop +// the org label. The override still wins for project identity; org must +// survive. +func TestMemCurrentProject_PreservesOrgUnderProcessOverride(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".engram"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".engram", "config.json"), []byte(`{"project_name":"acme-app","org":"acme-corp"}`), 0644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + + s := newMCPTestStore(t) + h := handleCurrentProject(s, MCPConfig{DefaultProject: "Trusted Project"}) + + res, err := h(context.Background(), mcppkg.CallToolRequest{}) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected error: %s", callResultText(t, res)) + } + body := callResultJSON(t, res) + if body["project"] != "trusted project" { + t.Fatalf("expected the process override to win project identity, got %v", body["project"]) + } + if body["project_source"] != sourceProcessOverride { + t.Fatalf("project_source = %v, want %s", body["project_source"], sourceProcessOverride) + } + if body["org"] != "acme-corp" { + t.Fatalf("expected org=acme-corp to survive the process override, got %v", body["org"]) + } +} + // TestMemCurrentProject_AmbiguousNoError: IsError==false, project=="", available_projects non-empty (REQ-313) func TestMemCurrentProject_AmbiguousNoError(t *testing.T) { parent := t.TempDir() @@ -10428,6 +10705,66 @@ func TestMemListProjects_ReturnsProjectsWithStats(t *testing.T) { } } +// TestMemListProjects_FiltersByOrg: mem_list_projects must honor an optional +// "org" argument the same way `engram projects list --org` does, scoping the +// listing to projects with at least one observation tagged with that org +// (engram#776). +func TestMemListProjects_FiltersByOrg(t *testing.T) { + s := newMCPTestStore(t) + if err := s.CreateSession("sess-acme", "acme-project", "/tmp/acme"); err != nil { + t.Fatalf("create session: %v", err) + } + if err := s.CreateSession("sess-globex", "globex-project", "/tmp/globex"); err != nil { + t.Fatalf("create session: %v", err) + } + if _, err := s.AddObservation(store.AddObservationParams{ + SessionID: "sess-acme", + Type: "decision", + Title: "Acme decision", + Content: "acme content", + Project: "acme-project", + Scope: "project", + Org: "acme-corp", + }); err != nil { + t.Fatalf("add acme observation: %v", err) + } + if _, err := s.AddObservation(store.AddObservationParams{ + SessionID: "sess-globex", + Type: "decision", + Title: "Globex decision", + Content: "globex content", + Project: "globex-project", + Scope: "project", + Org: "globex-inc", + }); err != nil { + t.Fatalf("add globex observation: %v", err) + } + + h := handleListProjects(s) + res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{ + "org": "acme-corp", + }}}) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected error: %s", callResultText(t, res)) + } + + var envelope struct { + Count int `json:"count"` + Projects []struct { + Name string `json:"name"` + } `json:"projects"` + } + if err := json.Unmarshal([]byte(callResultText(t, res)), &envelope); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + if envelope.Count != 1 || len(envelope.Projects) != 1 || envelope.Projects[0].Name != "acme-project" { + t.Fatalf("expected only acme-project when filtering by org=acme-corp, got %#v", envelope) + } +} + // TestMemListProjects_StoreErrorIsToolError: a store-query failure must // surface as a tool error, not as a success envelope with stale data — the // agent has to know discovery failed instead of trusting an empty answer. @@ -10749,3 +11086,29 @@ func TestHandleUpdateGlobalScope(t *testing.T) { t.Fatalf("expected Scope=global after update, got %q", obs.Scope) } } + +func TestHandleSave_TrimsExplicitOrgWhitespace(t *testing.T) { + s := newMCPTestStore(t) + h := handleSave(s, MCPConfig{}, NewSessionActivity(10*time.Minute)) + + res, err := h(context.Background(), mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{ + "title": "Padded org save", + "content": "Whitespace around org must not create a distinct organization", + "project": "engram", + "org": " globex-inc ", + }}}) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected save error: %s", callResultText(t, res)) + } + + obs, err := s.RecentObservations("engram", "project", 5) + if err != nil { + t.Fatalf("recent observations: %v", err) + } + if len(obs) != 1 || obs[0].Org == nil || *obs[0].Org != "globex-inc" { + t.Fatalf("expected padded org to be trimmed to globex-inc, got %#v", obs) + } +} diff --git a/internal/mcp/testdata/tool-contract-v1.json b/internal/mcp/testdata/tool-contract-v1.json index 8ea1f68c9..23270b619 100644 --- a/internal/mcp/testdata/tool-contract-v1.json +++ b/internal/mcp/testdata/tool-contract-v1.json @@ -98,6 +98,7 @@ "capture_prompt": {"type":["boolean"],"additionalProperties":true}, "content": {"type":["string"],"additionalProperties":true}, "observation": {"type":["string"],"additionalProperties":true}, + "org": {"type":["string"],"additionalProperties":true}, "project": {"type":["string"],"additionalProperties":true}, "project_choice_reason": {"type":["string"],"additionalProperties":true}, "recovery_token": {"type":["string"],"additionalProperties":true}, @@ -126,6 +127,7 @@ "all_projects": {"type":["boolean"],"additionalProperties":true}, "limit": {"type":["number"],"additionalProperties":true}, "match_mode": {"type":["string"],"additionalProperties":true}, + "org": {"type":["string"],"additionalProperties":true}, "project": {"type":["string"],"additionalProperties":true}, "query": {"type":["string"],"additionalProperties":true}, "response_format": {"type":["string"],"additionalProperties":true}, diff --git a/internal/obsidian/exporter.go b/internal/obsidian/exporter.go index 0e1caa1fa..1b9e8c8c3 100644 --- a/internal/obsidian/exporter.go +++ b/internal/obsidian/exporter.go @@ -14,6 +14,7 @@ import ( type ExportConfig struct { VaultPath string // --vault (required): path to the Obsidian vault root Project string // --project (optional): filter export to a single project + Org string // --org (optional): filter export to a single org (#776) Limit int // --limit (0 = no limit) Since time.Time // --since (zero = use state file) Force bool // --force: ignore state, full re-export @@ -38,6 +39,30 @@ func NewExporter(s StoreReader, cfg ExportConfig) *Exporter { return &Exporter{store: s, config: cfg} } +// matchesFilters reports whether obs is in scope for the current export's +// Project and Org filters. An empty filter matches everything for that axis. +func (e *Exporter) matchesFilters(obs store.Observation) bool { + if e.config.Project != "" { + proj := "" + if obs.Project != nil { + proj = *obs.Project + } + if proj != e.config.Project { + return false + } + } + if e.config.Org != "" { + org := "" + if obs.Org != nil { + org = *obs.Org + } + if org != e.config.Org { + return false + } + } + return true +} + // sanitizePathComponent strips path separators and dot-dot sequences from a // single path component (project name or observation type), preventing path // traversal attacks when the value is used inside filepath.Join. @@ -179,6 +204,29 @@ func (e *Exporter) Export() (*ExportResult, error) { } } + // ── Handle filter exclusions: clean up files for observations that no + // longer match the current Project/Org filters ─────────────────────────── + // The exporter is documented as a live mirror of the selected filters, so + // a previously-tracked observation that drops out of scope (e.g. a + // narrower --org on a later run) must lose its file too — otherwise it + // lingers as an orphan the filter can never reach again to clean up. + for _, obs := range data.Observations { + if obs.DeletedAt != nil { + continue // already handled above + } + relPath, tracked := state.Files[obs.ID] + if !tracked || e.matchesFilters(obs) { + continue + } + absPath := filepath.Join(engRoot, relPath) + if err := os.Remove(absPath); err != nil && !os.IsNotExist(err) { + result.Errors = append(result.Errors, fmt.Errorf("delete %s: %w", absPath, err)) + } else { + result.Deleted++ + delete(state.Files, obs.ID) + } + } + // ── Build session map for hub generation ───────────────────────────────── sessionMap := make(map[string]store.Session) for _, s := range data.Sessions { @@ -197,15 +245,8 @@ func (e *Exporter) Export() (*ExportResult, error) { continue } - // Project filter - if e.config.Project != "" { - proj := "" - if obs.Project != nil { - proj = *obs.Project - } - if proj != e.config.Project { - continue - } + if !e.matchesFilters(obs) { + continue } // Incremental filter: skip if updated_at <= cutoff AND already in state @@ -301,6 +342,12 @@ func (e *Exporter) Export() (*ExportResult, error) { } // ── Generate session hub notes ──────────────────────────────────────────── + // Rebuilt fresh from the current selection every run, same as state.Files: + // a hub whose session drops out of scope (filtered out, or its ref count + // falls below the hub threshold) must lose its tracked entry and file too, + // not linger pointing at content the current run no longer selects. + previousSessionHubs := state.SessionHubs + state.SessionHubs = make(map[string]string, len(sessionObsRefs)) for sessionID, refs := range sessionObsRefs { if len(refs) == 0 { continue @@ -320,8 +367,24 @@ func (e *Exporter) Export() (*ExportResult, error) { state.SessionHubs[sessionID] = filepath.Join("_sessions", sessionID+".md") result.HubsCreated++ } + for sessionID, relPath := range previousSessionHubs { + if _, stillSelected := state.SessionHubs[sessionID]; stillSelected { + continue + } + absPath := filepath.Join(engRoot, relPath) + if err := os.Remove(absPath); err != nil && !os.IsNotExist(err) { + result.Errors = append(result.Errors, fmt.Errorf("delete stale session hub %s: %w", absPath, err)) + // Keep the entry so the next export retries the deletion instead + // of orphaning the file once the pruned state is persisted. + state.SessionHubs[sessionID] = relPath + } else { + result.Deleted++ + } + } // ── Generate topic hub notes ────────────────────────────────────────────── + previousTopicHubs := state.TopicHubs + state.TopicHubs = make(map[string]string, len(topicObsRefs)) for prefix, refs := range topicObsRefs { if !ShouldCreateTopicHub(len(refs)) { continue @@ -336,6 +399,20 @@ func (e *Exporter) Export() (*ExportResult, error) { state.TopicHubs[prefix] = filepath.Join("_topics", safeName+".md") result.HubsCreated++ } + for prefix, relPath := range previousTopicHubs { + if _, stillSelected := state.TopicHubs[prefix]; stillSelected { + continue + } + absPath := filepath.Join(engRoot, relPath) + if err := os.Remove(absPath); err != nil && !os.IsNotExist(err) { + result.Errors = append(result.Errors, fmt.Errorf("delete stale topic hub %s: %w", absPath, err)) + // Keep the entry so the next export retries the deletion instead + // of orphaning the file once the pruned state is persisted. + state.TopicHubs[prefix] = relPath + } else { + result.Deleted++ + } + } // ── Persist updated state ───────────────────────────────────────────────── state.LastExportAt = time.Now().UTC().Format(time.RFC3339) diff --git a/internal/obsidian/exporter_test.go b/internal/obsidian/exporter_test.go index 42964bbb5..77844ab2b 100644 --- a/internal/obsidian/exporter_test.go +++ b/internal/obsidian/exporter_test.go @@ -506,6 +506,201 @@ func TestProjectFilter(t *testing.T) { }) } +// ─── Org filter (#776) ─────────────────────────────────────────────────────── + +func TestOrgFilter(t *testing.T) { + t.Run("--org flag limits exported observations to matching org", func(t *testing.T) { + dir := t.TempDir() + ms := &mockStore{ + exportData: &store.ExportData{ + Sessions: []store.Session{ + {ID: "sess-1", Project: "eng"}, + {ID: "sess-2", Project: "eng"}, + }, + Observations: []store.Observation{ + { + ID: 1, + SessionID: "sess-1", + Type: "bugfix", + Title: "Acme fix", + Content: "acme fix content", + Scope: "project", + CreatedAt: "2026-01-01T10:00:00Z", + UpdatedAt: "2026-01-01T10:00:00Z", + Project: strPtr("eng"), + Org: strPtr("acme-corp"), + }, + { + ID: 2, + SessionID: "sess-2", + Type: "decision", + Title: "Globex decision", + Content: "globex decision content", + Scope: "project", + CreatedAt: "2026-01-02T10:00:00Z", + UpdatedAt: "2026-01-02T10:00:00Z", + Project: strPtr("eng"), + Org: strPtr("globex-inc"), + }, + }, + Prompts: []store.Prompt{}, + }, + } + cfg := ExportConfig{VaultPath: dir, Org: "acme-corp"} + exp := NewExporter(ms, cfg) + result, err := exp.Export() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Created != 1 { + t.Errorf("Created: got %d, want 1 (only acme-corp org)", result.Created) + } + // The globex-inc obs must NOT have a file + globexFile := dir + "/engram/eng/decision/globex-decision-2.md" + if fileExists(globexFile) { + t.Errorf("unexpected file for filtered-out org: %s", globexFile) + } + }) + + t.Run("no org filter exports observations regardless of org", func(t *testing.T) { + dir := t.TempDir() + ms := &mockStore{ + exportData: &store.ExportData{ + Sessions: []store.Session{ + {ID: "sess-1", Project: "eng"}, + {ID: "sess-2", Project: "eng"}, + }, + Observations: []store.Observation{ + { + ID: 1, + SessionID: "sess-1", + Type: "bugfix", + Title: "Acme fix", + Content: "acme fix content", + Scope: "project", + CreatedAt: "2026-01-01T10:00:00Z", + UpdatedAt: "2026-01-01T10:00:00Z", + Project: strPtr("eng"), + Org: strPtr("acme-corp"), + }, + { + ID: 2, + SessionID: "sess-2", + Type: "decision", + Title: "Untagged decision", + Content: "untagged decision content", + Scope: "project", + CreatedAt: "2026-01-02T10:00:00Z", + UpdatedAt: "2026-01-02T10:00:00Z", + Project: strPtr("eng"), + }, + }, + Prompts: []store.Prompt{}, + }, + } + cfg := ExportConfig{VaultPath: dir, Org: ""} // no filter + exp := NewExporter(ms, cfg) + result, err := exp.Export() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Created != 2 { + t.Errorf("Created: got %d, want 2 (all orgs)", result.Created) + } + }) + + t.Run("re-export scoped to a narrower org removes files for observations that fell out of scope", func(t *testing.T) { + dir := t.TempDir() + ms := &mockStore{ + exportData: &store.ExportData{ + Sessions: []store.Session{ + {ID: "sess-1", Project: "eng"}, + {ID: "sess-2", Project: "eng"}, + }, + Observations: []store.Observation{ + { + ID: 1, + SessionID: "sess-1", + Type: "bugfix", + Title: "Acme fix", + Content: "acme fix content", + Scope: "project", + CreatedAt: "2026-01-01T10:00:00Z", + UpdatedAt: "2026-01-01T10:00:00Z", + Project: strPtr("eng"), + Org: strPtr("acme-corp"), + }, + { + ID: 2, + SessionID: "sess-2", + Type: "decision", + Title: "Globex decision", + Content: "globex decision content", + Scope: "project", + CreatedAt: "2026-01-02T10:00:00Z", + UpdatedAt: "2026-01-02T10:00:00Z", + Project: strPtr("eng"), + Org: strPtr("globex-inc"), + }, + }, + Prompts: []store.Prompt{}, + }, + } + + // First export: unfiltered, both observations land in the vault, each + // with its own session hub (sess-1 for acme, sess-2 for globex). + cfg := ExportConfig{VaultPath: dir} + exp := NewExporter(ms, cfg) + first, err := exp.Export() + if err != nil { + t.Fatalf("first Export() error: %v", err) + } + if first.Created != 2 { + t.Fatalf("first export Created: got %d, want 2", first.Created) + } + globexFile := dir + "/engram/eng/decision/globex-decision-2.md" + if !fileExists(globexFile) { + t.Fatalf("expected globex file after unfiltered export: %s", globexFile) + } + globexHub := dir + "/engram/_sessions/sess-2.md" + acmeHub := dir + "/engram/_sessions/sess-1.md" + if !fileExists(globexHub) { + t.Fatalf("expected globex session hub after unfiltered export: %s", globexHub) + } + if !fileExists(acmeHub) { + t.Fatalf("expected acme session hub after unfiltered export: %s", acmeHub) + } + + // Second export: scoped to acme-corp only. The globex-inc observation + // falls out of scope; its previously tracked file AND its now-empty + // session hub must both be removed rather than left behind as + // orphans — the exporter is documented as a live mirror of the + // current selection. + cfg2 := ExportConfig{VaultPath: dir, Org: "acme-corp"} + exp2 := NewExporter(ms, cfg2) + second, err := exp2.Export() + if err != nil { + t.Fatalf("second Export() error: %v", err) + } + if fileExists(globexFile) { + t.Errorf("expected globex file removed after re-export scoped to acme-corp: %s", globexFile) + } + if fileExists(globexHub) { + t.Errorf("expected globex session hub removed after re-export scoped to acme-corp: %s", globexHub) + } + if second.Deleted != 2 { + t.Errorf("second export Deleted: got %d, want 2 (orphaned globex file + session hub)", second.Deleted) + } + acmeFile := dir + "/engram/eng/bugfix/acme-fix-1.md" + if !fileExists(acmeFile) { + t.Errorf("expected acme file to remain after re-export scoped to acme-corp: %s", acmeFile) + } + if !fileExists(acmeHub) { + t.Errorf("expected acme session hub to remain after re-export scoped to acme-corp: %s", acmeHub) + } + }) +} + // ─── Task 2.9: TestFullExportPipeline ──────────────────────────────────────── func TestFullExportPipeline(t *testing.T) { @@ -1116,3 +1311,114 @@ func makeObs(id int64, sessionID, project, obsType, title, topicKey, ts string) } return obs } + +// ─── Stale hub deletion failure keeps state entries for retry ──────────────── + +func TestStaleHubDeletionFailureKeepsStateEntries(t *testing.T) { + dir := t.TempDir() + ms := &mockStore{ + exportData: &store.ExportData{ + Sessions: []store.Session{ + {ID: "sess-1", Project: "eng"}, + {ID: "sess-2", Project: "eng"}, + }, + Observations: []store.Observation{ + { + ID: 1, SessionID: "sess-1", Type: "bugfix", Title: "Acme fix", + Content: "acme fix content", Scope: "project", + CreatedAt: "2026-01-01T10:00:00Z", UpdatedAt: "2026-01-01T10:00:00Z", + Project: strPtr("eng"), Org: strPtr("acme-corp"), + }, + { + ID: 2, SessionID: "sess-2", Type: "decision", Title: "Globex one", + Content: "globex one content", Scope: "project", + CreatedAt: "2026-01-02T10:00:00Z", UpdatedAt: "2026-01-02T10:00:00Z", + Project: strPtr("eng"), Org: strPtr("globex-inc"), + TopicKey: strPtr("billing/one"), + }, + { + ID: 3, SessionID: "sess-2", Type: "decision", Title: "Globex two", + Content: "globex two content", Scope: "project", + CreatedAt: "2026-01-03T10:00:00Z", UpdatedAt: "2026-01-03T10:00:00Z", + Project: strPtr("eng"), Org: strPtr("globex-inc"), + TopicKey: strPtr("billing/two"), + }, + }, + Prompts: []store.Prompt{}, + }, + } + + // First export, unfiltered: sess-2 session hub and billing topic hub exist. + if _, err := NewExporter(ms, ExportConfig{VaultPath: dir}).Export(); err != nil { + t.Fatalf("first Export() error: %v", err) + } + sessionsDir := filepath.Join(dir, "engram", "_sessions") + topicsDir := filepath.Join(dir, "engram", "_topics") + staleSessionHub := filepath.Join(sessionsDir, "sess-2.md") + staleTopicHub := filepath.Join(topicsDir, "billing.md") + if !fileExists(staleSessionHub) || !fileExists(staleTopicHub) { + t.Fatalf("expected globex hubs after unfiltered export: %s, %s", staleSessionHub, staleTopicHub) + } + + // Replace each stale hub file with a non-empty directory so os.Remove + // fails with a real error (not IsNotExist) on any OS and any euid — + // root-owned CI included — when the second export prunes stale hubs. + for _, hub := range []string{staleSessionHub, staleTopicHub} { + if err := os.Remove(hub); err != nil { + t.Fatalf("replace hub %s: %v", hub, err) + } + if err := os.MkdirAll(filepath.Join(hub, "marker"), 0o755); err != nil { + t.Fatalf("create blocking dir %s: %v", hub, err) + } + } + + // Second export, scoped to acme-corp: both globex hubs fall out of the + // selection, deletion fails, and the state must keep both entries so a + // later export can retry. + second, err := NewExporter(ms, ExportConfig{VaultPath: dir, Org: "acme-corp"}).Export() + if err != nil { + t.Fatalf("second Export() error: %v", err) + } + if len(second.Errors) < 2 { + t.Fatalf("second export Errors: got %d, want >= 2 (session hub + topic hub): %v", len(second.Errors), second.Errors) + } + stateFile := filepath.Join(dir, "engram", ".engram-sync-state.json") + state, err := ReadState(stateFile) + if err != nil { + t.Fatalf("ReadState after failed prune: %v", err) + } + if _, kept := state.SessionHubs["sess-2"]; !kept { + t.Errorf("state.SessionHubs lost sess-2 after failed deletion; retry is impossible") + } + if _, kept := state.TopicHubs["billing"]; !kept { + t.Errorf("state.TopicHubs lost billing after failed deletion; retry is impossible") + } + + // Empty the blocking directories: the retry can now delete them, files + // go away, and the state entries are pruned. + for _, hub := range []string{staleSessionHub, staleTopicHub} { + if err := os.Remove(filepath.Join(hub, "marker")); err != nil { + t.Fatalf("clear blocking dir %s: %v", hub, err) + } + } + third, err := NewExporter(ms, ExportConfig{VaultPath: dir, Org: "acme-corp"}).Export() + if err != nil { + t.Fatalf("third Export() error: %v", err) + } + if len(third.Errors) != 0 { + t.Fatalf("third export Errors: got %v, want none", third.Errors) + } + if fileExists(staleSessionHub) || fileExists(staleTopicHub) { + t.Errorf("stale hubs still present after retry export: %s, %s", staleSessionHub, staleTopicHub) + } + state, err = ReadState(stateFile) + if err != nil { + t.Fatalf("ReadState after retry: %v", err) + } + if _, kept := state.SessionHubs["sess-2"]; kept { + t.Errorf("state.SessionHubs still tracks sess-2 after successful retry deletion") + } + if _, kept := state.TopicHubs["billing"]; kept { + t.Errorf("state.TopicHubs still tracks billing after successful retry deletion") + } +} diff --git a/internal/project/detect.go b/internal/project/detect.go index b758e3721..c9488c040 100644 --- a/internal/project/detect.go +++ b/internal/project/detect.go @@ -117,6 +117,9 @@ type DetectionResult struct { Error error // AvailableProjects is populated only when Error==ErrAmbiguousProject. AvailableProjects []string + // Org is the optional grouping label from .engram/config.json's "org" field. + // Only populated when Source==SourceConfig; empty means unset (#776). + Org string } // DetectProjectFull resolves the project for dir using a 6-case algorithm: @@ -225,6 +228,9 @@ func detectFromGitBinding(dir string) (DetectionResult, bool) { type configFile struct { ProjectName string `json:"project_name"` + // Org is a free-text grouping axis orthogonal to scope (#776). Unlike + // ProjectName it has no canonicalization beyond trimming whitespace. + Org string `json:"org"` } func detectFromConfig(dir string) (DetectionResult, bool) { @@ -286,7 +292,7 @@ func readConfigAt(projectDir string) (DetectionResult, bool) { if err != nil { return invalidConfigResult(projectDir, err), true } - return DetectionResult{Project: projectName, Source: SourceConfig, Path: projectDir}, true + return DetectionResult{Project: projectName, Source: SourceConfig, Path: projectDir, Org: strings.TrimSpace(cfg.Org)}, true } func invalidConfigResult(path string, err error) DetectionResult { diff --git a/internal/project/detect_test.go b/internal/project/detect_test.go index cf3a06914..f4623169d 100644 --- a/internal/project/detect_test.go +++ b/internal/project/detect_test.go @@ -306,6 +306,47 @@ func TestDetectProjectFull_ConfigCanonicalizesRepeatedSeparators(t *testing.T) { } } +// TestDetectProjectFull_ConfigOrgIsExposedAndTrimmed verifies the "org" field +// (#776) is read from .engram/config.json, trimmed, and exposed on +// DetectionResult when the org grouping label is set. +func TestDetectProjectFull_ConfigOrgIsExposedAndTrimmed(t *testing.T) { + dir := t.TempDir() + configDir := filepath.Join(dir, ".engram") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(`{"project_name":"acme-app","org":" acme-corp "}`), 0o644); err != nil { + t.Fatal(err) + } + + res := DetectProjectFull(dir) + if res.Source != SourceConfig || res.Project != "acme-app" { + t.Fatalf("config detection = %+v, want source=%q project=%q", res, SourceConfig, "acme-app") + } + if res.Org != "acme-corp" { + t.Fatalf("expected trimmed org %q, got %q", "acme-corp", res.Org) + } +} + +// TestDetectProjectFull_ConfigWithoutOrgLeavesItEmpty proves an unset "org" +// field never leaks a stray value onto DetectionResult (#776) — single-context +// users with an existing config.json see zero behavior change. +func TestDetectProjectFull_ConfigWithoutOrgLeavesItEmpty(t *testing.T) { + dir := t.TempDir() + configDir := filepath.Join(dir, ".engram") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(`{"project_name":"acme-app"}`), 0o644); err != nil { + t.Fatal(err) + } + + res := DetectProjectFull(dir) + if res.Org != "" { + t.Fatalf("expected empty org when config omits it, got %q", res.Org) + } +} + func TestDetectProjectFull_NearestSubprojectConfigOverridesRepoRoot(t *testing.T) { root := t.TempDir() initGit(t, root) diff --git a/internal/store/store.go b/internal/store/store.go index 6b05a3e0f..6a4852607 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -137,15 +137,18 @@ const ( ) type Observation struct { - ID int64 `json:"id"` - SyncID string `json:"sync_id"` - SessionID string `json:"session_id"` - Type string `json:"type"` - Title string `json:"title"` - Content string `json:"content"` - ToolName *string `json:"tool_name,omitempty"` - Project *string `json:"project,omitempty"` - Scope string `json:"scope"` + ID int64 `json:"id"` + SyncID string `json:"sync_id"` + SessionID string `json:"session_id"` + Type string `json:"type"` + Title string `json:"title"` + Content string `json:"content"` + ToolName *string `json:"tool_name,omitempty"` + Project *string `json:"project,omitempty"` + Scope string `json:"scope"` + // Org is an optional, free-text grouping axis orthogonal to Scope (#776). + // Nil for observations saved before this field existed or without an org set. + Org *string `json:"org,omitempty"` TopicKey *string `json:"topic_key,omitempty"` RevisionCount int `json:"revision_count"` DuplicateCount int `json:"duplicate_count"` @@ -198,6 +201,7 @@ type SearchPreviewResult struct { Project *string `json:"project,omitempty"` TopicKey *string `json:"topic_key,omitempty"` Scope string `json:"scope"` + Org *string `json:"org,omitempty"` ReviewAfter *string `json:"review_after,omitempty"` Pinned bool `json:"-"` CreatedAt string `json:"created_at"` @@ -256,6 +260,7 @@ type SearchOptions struct { Type string `json:"type,omitempty"` Project string `json:"project,omitempty"` Scope string `json:"scope,omitempty"` + Org string `json:"org,omitempty"` Limit int `json:"limit,omitempty"` MatchMode string `json:"match_mode,omitempty"` // "all" (default) | "any" } @@ -269,6 +274,8 @@ type AddObservationParams struct { Project string `json:"project,omitempty"` Scope string `json:"scope,omitempty"` TopicKey string `json:"topic_key,omitempty"` + // Org is a free-text grouping axis orthogonal to Scope (#776). Empty means unset. + Org string `json:"org,omitempty"` } type UpdateObservationParams struct { @@ -372,7 +379,7 @@ var decayReviewAfterMonths = map[string]int{ } const observationSelectColumns = `id, ifnull(sync_id, '') as sync_id, session_id, type, title, content, tool_name, project, - scope, topic_key, revision_count, duplicate_count, last_seen_at, review_after, pinned, created_at, updated_at, deleted_at` + scope, org, topic_key, revision_count, duplicate_count, last_seen_at, review_after, pinned, created_at, updated_at, deleted_at` type SyncState struct { TargetKey string `json:"target_key"` @@ -578,6 +585,7 @@ type syncObservationPayload struct { ToolName *string `json:"tool_name,omitempty"` Project *string `json:"project,omitempty"` Scope string `json:"scope"` + Org *string `json:"org,omitempty"` TopicKey *string `json:"topic_key,omitempty"` RevisionCount int `json:"revision_count"` DuplicateCount int `json:"duplicate_count"` @@ -1391,6 +1399,16 @@ func (s *Store) migrate() error { } } + // ── Phase: org-grouping-axis (#776) ───────────────────────────────────── + // Additive nullable "org" column: a second grouping axis orthogonal to + // scope. Empty/NULL by default so single-context users see no change. + if err := s.addColumnIfNotExists("observations", "org", "TEXT"); err != nil { + return err + } + if _, err := s.execHook(s.db, `CREATE INDEX IF NOT EXISTS idx_obs_org ON observations(org)`); err != nil { + return err + } + // ── Phase: memory-conflict-surfacing — B.2 ────────────────────────────── // Create the memory_relations table (idempotent via IF NOT EXISTS). // source_id / target_id are TEXT sync_id keys (cross-machine portable). @@ -3184,10 +3202,11 @@ func (s *Store) AddObservation(p AddObservationParams) (int64, error) { WHERE topic_key = ? AND ifnull(project, '') = ifnull(?, '') AND scope = ? + AND ifnull(org, '') = ifnull(?, '') AND deleted_at IS NULL ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC LIMIT 1`, - topicKey, nullableString(p.Project), scope, + topicKey, nullableString(p.Project), scope, nullableString(p.Org), ).Scan(&existingID) if err == nil { if _, err := s.execHook(tx, @@ -3197,6 +3216,7 @@ func (s *Store) AddObservation(p AddObservationParams) (int64, error) { title = ?, content = ?, tool_name = ?, + org = ?, topic_key = ?, normalized_hash = ?, revision_count = revision_count + 1, @@ -3208,6 +3228,7 @@ func (s *Store) AddObservation(p AddObservationParams) (int64, error) { title, content, nullableString(p.ToolName), + nullableString(p.Org), nullableString(topicKey), normHash, existingID, @@ -3233,13 +3254,14 @@ func (s *Store) AddObservation(p AddObservationParams) (int64, error) { WHERE normalized_hash = ? AND ifnull(project, '') = ifnull(?, '') AND scope = ? + AND ifnull(org, '') = ifnull(?, '') AND type = ? AND title = ? AND deleted_at IS NULL AND datetime(created_at) >= datetime('now', ?) ORDER BY created_at DESC LIMIT 1`, - normHash, nullableString(p.Project), scope, p.Type, title, window, + normHash, nullableString(p.Project), scope, nullableString(p.Org), p.Type, title, window, ).Scan(&existingID) if err == nil { if _, err := s.execHook(tx, @@ -3265,10 +3287,10 @@ func (s *Store) AddObservation(p AddObservationParams) (int64, error) { syncID := newSyncID("obs") res, err := s.execHook(tx, - `INSERT INTO observations (sync_id, session_id, type, title, content, tool_name, project, scope, topic_key, normalized_hash, revision_count, duplicate_count, last_seen_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, CAST(? AS TEXT), ?, ?, ?, 1, 1, datetime('now'), datetime('now'))`, + `INSERT INTO observations (sync_id, session_id, type, title, content, tool_name, project, scope, org, topic_key, normalized_hash, revision_count, duplicate_count, last_seen_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, CAST(? AS TEXT), ?, ?, ?, ?, 1, 1, datetime('now'), datetime('now'))`, syncID, p.SessionID, p.Type, title, content, - nullableString(p.ToolName), nullableString(p.Project), scope, nullableString(topicKey), normHash, + nullableString(p.ToolName), nullableString(p.Project), scope, nullableString(p.Org), nullableString(topicKey), normHash, ) if err != nil { return err @@ -4315,6 +4337,10 @@ func (s *Store) SearchContext(ctx context.Context, query string, opts SearchOpti tkSQL += " AND scope = ?" tkArgs = append(tkArgs, normalizeScope(opts.Scope)) } + if opts.Org != "" { + tkSQL += " AND org = ?" + tkArgs = append(tkArgs, opts.Org) + } tkSQL += " ORDER BY updated_at DESC LIMIT ?" tkArgs = append(tkArgs, limit) @@ -4329,7 +4355,7 @@ func (s *Store) SearchContext(ctx context.Context, query string, opts SearchOpti var sr SearchResult if err := tkRows.Scan( &sr.ID, &sr.SyncID, &sr.SessionID, &sr.Type, &sr.Title, &sr.Content, - &sr.ToolName, &sr.Project, &sr.Scope, &sr.TopicKey, &sr.RevisionCount, &sr.DuplicateCount, + &sr.ToolName, &sr.Project, &sr.Scope, &sr.Org, &sr.TopicKey, &sr.RevisionCount, &sr.DuplicateCount, &sr.LastSeenAt, &sr.ReviewAfter, &sr.Pinned, &sr.CreatedAt, &sr.UpdatedAt, &sr.DeletedAt, ); err != nil { if ctxErr := ctx.Err(); ctxErr != nil { @@ -4388,7 +4414,7 @@ func (s *Store) SearchContext(ctx context.Context, query string, opts SearchOpti var sr SearchResult if err := rows.Scan( &sr.ID, &sr.SyncID, &sr.SessionID, &sr.Type, &sr.Title, &sr.Content, - &sr.ToolName, &sr.Project, &sr.Scope, &sr.TopicKey, &sr.RevisionCount, &sr.DuplicateCount, + &sr.ToolName, &sr.Project, &sr.Scope, &sr.Org, &sr.TopicKey, &sr.RevisionCount, &sr.DuplicateCount, &sr.LastSeenAt, &sr.ReviewAfter, &sr.Pinned, &sr.CreatedAt, &sr.UpdatedAt, &sr.DeletedAt, &sr.Rank, ); err != nil { @@ -4447,7 +4473,7 @@ func (s *Store) SearchPreviewsContext(ctx context.Context, query string, opts Se tkSQL := ` SELECT id, ifnull(sync_id, '') as sync_id, type, title, substr(content, 1, 300) as preview, length(content) > 300 as truncated, - project, topic_key, scope, review_after, pinned, created_at + project, topic_key, scope, org, review_after, pinned, created_at FROM observations WHERE topic_key = ? AND deleted_at IS NULL ` @@ -4464,6 +4490,10 @@ func (s *Store) SearchPreviewsContext(ctx context.Context, query string, opts Se tkSQL += " AND scope = ?" tkArgs = append(tkArgs, normalizeScope(opts.Scope)) } + if opts.Org != "" { + tkSQL += " AND org = ?" + tkArgs = append(tkArgs, opts.Org) + } tkSQL += " ORDER BY updated_at DESC LIMIT ?" tkArgs = append(tkArgs, limit) @@ -4567,13 +4597,13 @@ func buildSearchPromptsFTSQuery(ftsQuery, project string, limit int) (string, [] func buildSearchFTSQuery(ftsQuery string, opts SearchOptions, limit int) (string, []any) { return buildSearchFTSQueryWithColumns(`o.id, ifnull(o.sync_id, '') as sync_id, o.session_id, o.type, o.title, o.content, o.tool_name, o.project, - o.scope, o.topic_key, o.revision_count, o.duplicate_count, o.last_seen_at, o.review_after, o.pinned, o.created_at, o.updated_at, o.deleted_at`, ftsQuery, opts, limit) + o.scope, o.org, o.topic_key, o.revision_count, o.duplicate_count, o.last_seen_at, o.review_after, o.pinned, o.created_at, o.updated_at, o.deleted_at`, ftsQuery, opts, limit) } func buildSearchPreviewFTSQuery(ftsQuery string, opts SearchOptions, limit int) (string, []any) { return buildSearchFTSQueryWithColumns(`o.id, ifnull(o.sync_id, '') as sync_id, o.type, o.title, substr(o.content, 1, 300) as preview, length(o.content) > 300 as truncated, - o.project, o.topic_key, o.scope, o.review_after, o.pinned, o.created_at`, ftsQuery, opts, limit) + o.project, o.topic_key, o.scope, o.org, o.review_after, o.pinned, o.created_at`, ftsQuery, opts, limit) } func buildSearchFTSQueryWithColumns(columns, ftsQuery string, opts SearchOptions, limit int) (string, []any) { @@ -4618,6 +4648,10 @@ func buildSearchFTSQueryWithColumns(columns, ftsQuery string, opts SearchOptions sqlQ += " AND o.scope = ?" args = append(args, normalizeScope(opts.Scope)) } + if opts.Org != "" { + sqlQ += " AND o.org = ?" + args = append(args, opts.Org) + } sqlQ += " ORDER BY " + compositeRank + " ASC, COALESCE(NULLIF(o.sync_id, ''), printf('%020d', o.id)) ASC, o.id ASC LIMIT ?" return sqlQ, append(args, limit) @@ -4654,13 +4688,13 @@ func escapeLIKE(term string) string { func buildSearchLIKEQuery(query string, opts SearchOptions, limit int) (string, []any) { return buildSearchLIKEQueryWithColumns(`o.id, ifnull(o.sync_id, '') as sync_id, o.session_id, o.type, o.title, o.content, o.tool_name, o.project, - o.scope, o.topic_key, o.revision_count, o.duplicate_count, o.last_seen_at, o.review_after, o.pinned, o.created_at, o.updated_at, o.deleted_at`, query, opts, limit) + o.scope, o.org, o.topic_key, o.revision_count, o.duplicate_count, o.last_seen_at, o.review_after, o.pinned, o.created_at, o.updated_at, o.deleted_at`, query, opts, limit) } func buildSearchPreviewLIKEQuery(query string, opts SearchOptions, limit int) (string, []any) { return buildSearchLIKEQueryWithColumns(`o.id, ifnull(o.sync_id, '') as sync_id, o.type, o.title, substr(o.content, 1, 300) as preview, length(o.content) > 300 as truncated, - o.project, o.topic_key, o.scope, o.review_after, o.pinned, o.created_at`, query, opts, limit) + o.project, o.topic_key, o.scope, o.org, o.review_after, o.pinned, o.created_at`, query, opts, limit) } func buildSearchLIKEQueryWithColumns(columns, query string, opts SearchOptions, limit int) (string, []any) { @@ -4696,6 +4730,10 @@ func buildSearchLIKEQueryWithColumns(columns, query string, opts SearchOptions, sqlQ += " AND o.scope = ?" args = append(args, normalizeScope(opts.Scope)) } + if opts.Org != "" { + sqlQ += " AND o.org = ?" + args = append(args, opts.Org) + } sqlQ += " ORDER BY datetime(o.updated_at) DESC, o.id DESC LIMIT ?" return sqlQ, append(args, limit) } @@ -5399,16 +5437,25 @@ func (s *Store) Import(data *ExportData) (*ImportResult, error) { if duplicateCount <= 0 { duplicateCount = existing.DuplicateCount } - if _, err := s.execHook(tx, `UPDATE observations SET session_id = ?, type = ?, title = ?, content = ?, tool_name = CAST(? AS TEXT), project = ?, scope = ?, topic_key = ?, normalized_hash = ?, revision_count = ?, duplicate_count = ?, last_seen_at = ?, review_after = ?, pinned = ?, created_at = ?, updated_at = ?, deleted_at = ? WHERE id = ?`, - obs.SessionID, obs.Type, obs.Title, obs.Content, obs.ToolName, obs.Project, normalizeScope(obs.Scope), nullableString(normalizeTopicKey(derefString(obs.TopicKey))), hashNormalized(obs.Content), revisionCount, duplicateCount, obs.LastSeenAt, obs.ReviewAfter, obs.Pinned, createdAt, obs.UpdatedAt, obs.DeletedAt, existing.ID); err != nil { + // A snapshot from before org existed (#776) omits the field + // entirely, decoding to nil — preserve the existing org rather + // than clobbering it with NULL. A snapshot that carries org + // (including an explicit empty string) always wins, same as + // every other field here. + org := obs.Org + if org == nil { + org = existing.Org + } + if _, err := s.execHook(tx, `UPDATE observations SET session_id = ?, type = ?, title = ?, content = ?, tool_name = CAST(? AS TEXT), project = ?, scope = ?, org = ?, topic_key = ?, normalized_hash = ?, revision_count = ?, duplicate_count = ?, last_seen_at = ?, review_after = ?, pinned = ?, created_at = ?, updated_at = ?, deleted_at = ? WHERE id = ?`, + obs.SessionID, obs.Type, obs.Title, obs.Content, obs.ToolName, obs.Project, normalizeScope(obs.Scope), org, nullableString(normalizeTopicKey(derefString(obs.TopicKey))), hashNormalized(obs.Content), revisionCount, duplicateCount, obs.LastSeenAt, obs.ReviewAfter, obs.Pinned, createdAt, obs.UpdatedAt, obs.DeletedAt, existing.ID); err != nil { return nil, fmt.Errorf("import observation %d: %w", obs.ID, err) } result.ObservationsUpdated++ continue } res, err := s.execHook(tx, - `INSERT INTO observations (sync_id, session_id, type, title, content, tool_name, project, scope, topic_key, normalized_hash, revision_count, duplicate_count, last_seen_at, review_after, pinned, created_at, updated_at, deleted_at) - SELECT ?, ?, ?, ?, ?, ?, CAST(? AS TEXT), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + `INSERT INTO observations (sync_id, session_id, type, title, content, tool_name, project, scope, org, topic_key, normalized_hash, revision_count, duplicate_count, last_seen_at, review_after, pinned, created_at, updated_at, deleted_at) + SELECT ?, ?, ?, ?, ?, ?, CAST(? AS TEXT), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM observations WHERE sync_id = ?)`, syncID, obs.SessionID, @@ -5418,6 +5465,7 @@ func (s *Store) Import(data *ExportData) (*ImportResult, error) { obs.ToolName, obs.Project, normalizeScope(obs.Scope), + obs.Org, nullableString(normalizeTopicKey(derefString(obs.TopicKey))), hashNormalized(obs.Content), maxInt(obs.RevisionCount, 1), @@ -7411,16 +7459,24 @@ type ProjectStats struct { Directories []string `json:"directories"` // unique directories from sessions } -// ListProjectsWithStats returns all projects with aggregated counts. -// Ordered by observation count descending. -func (s *Store) ListProjectsWithStats() ([]ProjectStats, error) { +// ListProjectsWithStats returns all projects with aggregated counts. Ordered +// by observation count descending. When org is non-empty (#776), observation +// counts are scoped to that org and the result set is limited to projects +// that have at least one observation tagged with it — sessions and prompts +// have no org axis of their own, so they only enrich projects already +// selected by the org filter rather than reintroducing unfiltered ones. +func (s *Store) ListProjectsWithStats(org string) ([]ProjectStats, error) { // Observation counts per project - obsRows, err := s.queryItHook(s.db, - `SELECT project, COUNT(*) as cnt + obsQuery := `SELECT project, COUNT(*) as cnt FROM observations - WHERE project IS NOT NULL AND project != '' AND deleted_at IS NULL - GROUP BY project`, - ) + WHERE project IS NOT NULL AND project != '' AND deleted_at IS NULL` + obsArgs := []any{} + if org != "" { + obsQuery += " AND org = ?" + obsArgs = append(obsArgs, org) + } + obsQuery += " GROUP BY project" + obsRows, err := s.queryItHook(s.db, obsQuery, obsArgs...) if err != nil { return nil, fmt.Errorf("list projects obs: %w", err) } @@ -7476,6 +7532,11 @@ func (s *Store) ListProjectsWithStats() ([]ProjectStats, error) { for name, sd := range sessData { if statsMap[name] == nil { + if org != "" { + // This project had no observation tagged with org: skip it + // rather than reintroducing it via session data alone. + continue + } statsMap[name] = &ProjectStats{Name: name} } statsMap[name].SessionCount = sd.count @@ -7503,6 +7564,9 @@ func (s *Store) ListProjectsWithStats() ([]ProjectStats, error) { return nil, err } if statsMap[name] == nil { + if org != "" { + continue + } statsMap[name] = &ProjectStats{Name: name} } statsMap[name].PromptCount = cnt @@ -8365,8 +8429,8 @@ func (s *Store) enqueueRescuedProjectMutationsTx(tx *sql.Tx, target string, sess } for _, id := range p.ObservationIDs { var payload syncObservationPayload - err := tx.QueryRow(`SELECT sync_id, session_id, type, title, content, tool_name, project, scope, topic_key, revision_count, duplicate_count, last_seen_at, created_at, updated_at, deleted_at FROM observations WHERE id = ? AND project = ?`, id, target). - Scan(&payload.SyncID, &payload.SessionID, &payload.Type, &payload.Title, &payload.Content, &payload.ToolName, &payload.Project, &payload.Scope, &payload.TopicKey, &payload.RevisionCount, &payload.DuplicateCount, &payload.LastSeenAt, &payload.CreatedAt, &payload.UpdatedAt, &payload.DeletedAt) + err := tx.QueryRow(`SELECT sync_id, session_id, type, title, content, tool_name, project, scope, org, topic_key, revision_count, duplicate_count, last_seen_at, created_at, updated_at, deleted_at FROM observations WHERE id = ? AND project = ?`, id, target). + Scan(&payload.SyncID, &payload.SessionID, &payload.Type, &payload.Title, &payload.Content, &payload.ToolName, &payload.Project, &payload.Scope, &payload.Org, &payload.TopicKey, &payload.RevisionCount, &payload.DuplicateCount, &payload.LastSeenAt, &payload.CreatedAt, &payload.UpdatedAt, &payload.DeletedAt) if errors.Is(err, sql.ErrNoRows) { continue } @@ -9925,6 +9989,7 @@ func observationPayloadFromObservation(obs *Observation) syncObservationPayload ToolName: obs.ToolName, Project: obs.Project, Scope: obs.Scope, + Org: obs.Org, TopicKey: obs.TopicKey, RevisionCount: obs.RevisionCount, DuplicateCount: obs.DuplicateCount, @@ -10067,8 +10132,8 @@ func (s *Store) applyObservationUpsertTx(tx *sql.Tx, payload syncObservationPayl existing, err := s.getObservationBySyncIDTx(tx, payload.SyncID, true) if err == sql.ErrNoRows { _, err = s.execHook(tx, - `INSERT INTO observations (sync_id, session_id, type, title, content, tool_name, project, scope, topic_key, normalized_hash, revision_count, duplicate_count, last_seen_at, created_at, updated_at, deleted_at) - VALUES (?, ?, ?, ?, ?, ?, CAST(? AS TEXT), ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, + `INSERT INTO observations (sync_id, session_id, type, title, content, tool_name, project, scope, org, topic_key, normalized_hash, revision_count, duplicate_count, last_seen_at, created_at, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?, CAST(? AS TEXT), ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, payload.SyncID, payload.SessionID, payload.Type, @@ -10077,6 +10142,7 @@ func (s *Store) applyObservationUpsertTx(tx *sql.Tx, payload syncObservationPayl payload.ToolName, payload.Project, normalizeScope(payload.Scope), + payload.Org, payload.TopicKey, hashNormalized(payload.Content), revisionCount, @@ -10109,10 +10175,17 @@ func (s *Store) applyObservationUpsertTx(tx *sql.Tx, payload syncObservationPayl if strings.TrimSpace(payload.UpdatedAt) == "" { updatedAt = existing.UpdatedAt } + // A payload from before org existed (#776) omits the field entirely, + // decoding to nil — preserve the existing org rather than clobbering it + // with NULL. A payload that carries org (including an explicit empty + // string) always wins, same as every other field here. + if payload.Org == nil { + payload.Org = existing.Org + } _, err = s.execHook(tx, `UPDATE observations - SET session_id = ?, type = ?, title = ?, content = ?, tool_name = ?, project = CAST(? AS TEXT), scope = ?, topic_key = ?, normalized_hash = ?, revision_count = ?, duplicate_count = ?, last_seen_at = ?, created_at = ?, updated_at = ?, deleted_at = NULL + SET session_id = ?, type = ?, title = ?, content = ?, tool_name = ?, project = CAST(? AS TEXT), scope = ?, org = ?, topic_key = ?, normalized_hash = ?, revision_count = ?, duplicate_count = ?, last_seen_at = ?, created_at = ?, updated_at = ?, deleted_at = NULL WHERE id = ?`, payload.SessionID, payload.Type, @@ -10121,6 +10194,7 @@ func (s *Store) applyObservationUpsertTx(tx *sql.Tx, payload syncObservationPayl payload.ToolName, payload.Project, normalizeScope(payload.Scope), + payload.Org, payload.TopicKey, hashNormalized(payload.Content), revisionCount, @@ -10266,7 +10340,7 @@ type observationScanner interface { func scanObservationRow(scanner observationScanner, o *Observation) error { return scanner.Scan( &o.ID, &o.SyncID, &o.SessionID, &o.Type, &o.Title, &o.Content, - &o.ToolName, &o.Project, &o.Scope, &o.TopicKey, &o.RevisionCount, &o.DuplicateCount, &o.LastSeenAt, &o.ReviewAfter, + &o.ToolName, &o.Project, &o.Scope, &o.Org, &o.TopicKey, &o.RevisionCount, &o.DuplicateCount, &o.LastSeenAt, &o.ReviewAfter, &o.Pinned, &o.CreatedAt, &o.UpdatedAt, &o.DeletedAt, ) } @@ -10274,7 +10348,7 @@ func scanObservationRow(scanner observationScanner, o *Observation) error { func scanSearchPreviewRow(scanner observationScanner, r *SearchPreviewResult, withRank bool) error { dest := []any{ &r.ID, &r.SyncID, &r.Type, &r.Title, &r.Preview, &r.Truncated, - &r.Project, &r.TopicKey, &r.Scope, &r.ReviewAfter, &r.Pinned, &r.CreatedAt, + &r.Project, &r.TopicKey, &r.Scope, &r.Org, &r.ReviewAfter, &r.Pinned, &r.CreatedAt, } if withRank { dest = append(dest, &r.Rank) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index beaf92037..112dad4ac 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1143,6 +1143,68 @@ func TestRescueNullProjectOwnershipRefusesRecordsOwnedByAnotherSessionProject(t } } +// TestRescueNullProjectOwnershipJournalsOrgOnRescuedObservation is a +// regression test for #776: enqueueRescuedProjectMutationsTx used a +// hand-rolled SELECT/Scan pair (not observationSelectColumns/ +// scanObservationRow) that never learned about the org column, so a rescued +// observation's org silently dropped out of the journaled sync mutation. +func TestRescueNullProjectOwnershipJournalsOrgOnRescuedObservation(t *testing.T) { + s := newTestStore(t) + enrollTestProject(t, s, "target") + if err := s.CreateSession("legacy-session", "legacy", "/tmp"); err != nil { + t.Fatalf("CreateSession: %v", err) + } + observationID, err := s.AddObservation(AddObservationParams{ + SessionID: "legacy-session", + Type: "note", + Title: "legacy org obs", + Content: "content", + Project: "legacy", + Org: "acme-corp", + }) + if err != nil { + t.Fatalf("AddObservation: %v", err) + } + for _, statement := range []struct { + query string + args []any + }{ + {`UPDATE sessions SET project = '' WHERE id = ?`, []any{"legacy-session"}}, + {`UPDATE observations SET project = NULL WHERE id = ?`, []any{observationID}}, + {`DELETE FROM sync_mutations WHERE entity_key = (SELECT sync_id FROM observations WHERE id = ?)`, []any{observationID}}, + } { + if _, err := s.DB().Exec(statement.query, statement.args...); err != nil { + t.Fatalf("seed legacy ownership: %v", err) + } + } + + result, err := s.RescueNullProjectOwnership(ProjectRescueParams{TargetProject: "target", ObservationIDs: []int64{observationID}}) + if err != nil { + t.Fatalf("RescueNullProjectOwnership: %v", err) + } + if result.Rescued() != 2 || !result.Journaled { // observation + its unowned session + t.Fatalf("unexpected rescue result: %#v", result) + } + + var syncID, payloadJSON string + if err := s.DB().QueryRow(`SELECT sync_id FROM observations WHERE id = ?`, observationID).Scan(&syncID); err != nil { + t.Fatalf("read rescued sync_id: %v", err) + } + if err := s.DB().QueryRow( + `SELECT payload FROM sync_mutations WHERE entity = ? AND entity_key = ? ORDER BY seq DESC LIMIT 1`, + SyncEntityObservation, syncID, + ).Scan(&payloadJSON); err != nil { + t.Fatalf("read journaled mutation payload: %v", err) + } + var payload map[string]any + if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { + t.Fatalf("decode journaled payload: %v", err) + } + if payload["org"] != "acme-corp" { + t.Fatalf("expected journaled payload org=acme-corp, got %#v (payload=%s)", payload["org"], payloadJSON) + } +} + func TestEnqueueMissingLocalMutationRefusesBlankOwnedSession(t *testing.T) { s := newTestStore(t) err := s.withTx(func(tx *sql.Tx) error { @@ -1320,6 +1382,96 @@ func TestAddObservationDeduplicatesWithinWindow(t *testing.T) { } } +// TestAddObservationDeduplicatesSeparatelyPerOrg is a regression test for +// #776: the dedupe window match (normalized_hash + project + scope + type + +// title) did not include org, so two otherwise-identical observations saved +// under different orgs collapsed into a single row and silently dropped the +// second org. Identical content in different orgs must dedupe independently, +// the same way it already dedupes independently per project and per scope. +func TestAddObservationDeduplicatesSeparatelyPerOrg(t *testing.T) { + s := newTestStore(t) + + if err := s.CreateSession("s1", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + + acmeID, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "bugfix", + Title: "Fixed tokenizer", + Content: "Normalized tokenizer panic on edge case", + Project: "engram", + Scope: "project", + Org: "acme-corp", + }) + if err != nil { + t.Fatalf("add acme observation: %v", err) + } + + globexID, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "bugfix", + Title: "Fixed tokenizer", + Content: "Normalized tokenizer panic on edge case", + Project: "engram", + Scope: "project", + Org: "globex-inc", + }) + if err != nil { + t.Fatalf("add globex observation: %v", err) + } + + if acmeID == globexID { + t.Fatalf("expected identical content in different orgs to stay separate, both got id %d", acmeID) + } + + acmeObs, err := s.GetObservation(acmeID) + if err != nil { + t.Fatalf("get acme observation: %v", err) + } + if acmeObs.DuplicateCount != 1 { + t.Fatalf("expected acme observation duplicate_count=1 (not merged), got %d", acmeObs.DuplicateCount) + } + if acmeObs.Org == nil || *acmeObs.Org != "acme-corp" { + t.Fatalf("expected acme observation org=acme-corp, got %#v", acmeObs.Org) + } + + globexObs, err := s.GetObservation(globexID) + if err != nil { + t.Fatalf("get globex observation: %v", err) + } + if globexObs.DuplicateCount != 1 { + t.Fatalf("expected globex observation duplicate_count=1 (not merged), got %d", globexObs.DuplicateCount) + } + if globexObs.Org == nil || *globexObs.Org != "globex-inc" { + t.Fatalf("expected globex observation org=globex-inc, got %#v", globexObs.Org) + } + + // A true duplicate within the SAME org must still dedupe as before. + repeatID, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "bugfix", + Title: "Fixed tokenizer", + Content: "normalized tokenizer panic on EDGE case", + Project: "engram", + Scope: "project", + Org: "acme-corp", + }) + if err != nil { + t.Fatalf("add repeat acme observation: %v", err) + } + if repeatID != acmeID { + t.Fatalf("expected same-org duplicate to reuse id %d, got %d", acmeID, repeatID) + } + acmeObs, err = s.GetObservation(acmeID) + if err != nil { + t.Fatalf("get acme observation after repeat: %v", err) + } + if acmeObs.DuplicateCount != 2 { + t.Fatalf("expected acme observation duplicate_count=2 after same-org repeat, got %d", acmeObs.DuplicateCount) + } +} + func TestObservationWritesStoreProjectAsText(t *testing.T) { s := newTestStore(t) if err := s.CreateSession("s-project-storage", "engram", "/tmp/engram"); err != nil { @@ -2265,6 +2417,114 @@ func TestNewMigratesLegacyObservationIDSchema(t *testing.T) { } } +// TestNewMigratesPreOrgDatabaseIdempotently is a regression test for #776: +// migrate() must add the org column and its index to a database created +// before org existed, without disturbing existing rows, and reopening that +// same database afterward must be a no-op rather than erroring on a column +// that is already there. +func TestNewMigratesPreOrgDatabaseIdempotently(t *testing.T) { + cfg := mustDefaultConfig(t) + cfg.DataDir = t.TempDir() + cfg.DedupeWindow = time.Hour + + s, err := New(cfg) + if err != nil { + t.Fatalf("new store: %v", err) + } + if err := s.CreateSession("s1", "engram", "/tmp/engram"); err != nil { + _ = s.Close() + t.Fatalf("create session: %v", err) + } + preExistingID, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "decision", + Title: "Predates org", + Content: "Written before the org column existed", + Project: "engram", + Scope: "project", + }) + if err != nil { + _ = s.Close() + t.Fatalf("add pre-org observation: %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + // Simulate a database created before #776: every other migration has + // already run (this store just applied them), but org and its index + // have not. + dbPath := filepath.Join(cfg.DataDir, "engram.db") + raw, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("reopen raw db: %v", err) + } + if _, err := raw.Exec(`DROP INDEX IF EXISTS idx_obs_org`); err != nil { + _ = raw.Close() + t.Fatalf("drop org index: %v", err) + } + if _, err := raw.Exec(`ALTER TABLE observations DROP COLUMN org`); err != nil { + _ = raw.Close() + t.Fatalf("drop org column: %v", err) + } + if err := raw.Close(); err != nil { + t.Fatalf("close raw db: %v", err) + } + + // First reopen: migrate() must add org back without disturbing the + // pre-existing row. + s, err = New(cfg) + if err != nil { + t.Fatalf("new store after simulated pre-org schema: %v", err) + } + preExisting, err := s.GetObservation(preExistingID) + if err != nil { + _ = s.Close() + t.Fatalf("get pre-existing observation: %v", err) + } + if preExisting.Title != "Predates org" { + _ = s.Close() + t.Fatalf("expected pre-existing observation to survive migration, got %#v", preExisting) + } + if preExisting.Org != nil { + _ = s.Close() + t.Fatalf("expected pre-existing observation to have nil org, got %q", *preExisting.Org) + } + + newID, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "decision", + Title: "Post migration", + Content: "Written after org was migrated back in", + Project: "engram", + Scope: "project", + Org: "acme-corp", + }) + if err != nil { + _ = s.Close() + t.Fatalf("add observation after migration: %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + // Second reopen: migrate() must be idempotent on a database that already + // has org. + s, err = New(cfg) + if err != nil { + t.Fatalf("second reopen must be idempotent, got: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + newObs, err := s.GetObservation(newID) + if err != nil { + t.Fatalf("get post-migration observation: %v", err) + } + if newObs.Org == nil || *newObs.Org != "acme-corp" { + t.Fatalf("expected org %q to survive the idempotent reopen, got %#v", "acme-corp", newObs.Org) + } +} + func TestNewMigratesLegacyUserPromptsSyncIDSchema(t *testing.T) { dataDir := t.TempDir() dbPath := filepath.Join(dataDir, "engram.db") @@ -2575,6 +2835,102 @@ func TestTopicKeyUpsertIsScopedByProjectAndScope(t *testing.T) { } } +// TestTopicKeyUpsertIsScopedByOrg is a regression test for #776: the +// topic-key revision lookup (project+scope) did not include org, so saving +// to the same topic key under a different org silently hijacked the first +// org's row instead of starting an independent revision — the same identity +// boundary org must now respect, alongside project and scope. +func TestTopicKeyUpsertIsScopedByOrg(t *testing.T) { + s := newTestStore(t) + + if err := s.CreateSession("s1", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + + acmeID, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "architecture", + Title: "Auth model", + Content: "Acme uses JWT", + Project: "engram", + Scope: "project", + Org: "acme-corp", + TopicKey: "architecture/auth-model", + }) + if err != nil { + t.Fatalf("add acme observation: %v", err) + } + + globexID, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "architecture", + Title: "Auth model", + Content: "Globex uses OAuth", + Project: "engram", + Scope: "project", + Org: "globex-inc", + TopicKey: "architecture/auth-model", + }) + if err != nil { + t.Fatalf("add globex observation: %v", err) + } + + if acmeID == globexID { + t.Fatalf("expected the same topic key under different orgs to stay separate, both got id %d", acmeID) + } + + acmeObs, err := s.GetObservation(acmeID) + if err != nil { + t.Fatalf("get acme observation: %v", err) + } + if acmeObs.Content != "Acme uses JWT" || acmeObs.Org == nil || *acmeObs.Org != "acme-corp" { + t.Fatalf("expected acme observation to keep its own content and org, got %#v", acmeObs) + } + + globexObs, err := s.GetObservation(globexID) + if err != nil { + t.Fatalf("get globex observation: %v", err) + } + if globexObs.Content != "Globex uses OAuth" || globexObs.Org == nil || *globexObs.Org != "globex-inc" { + t.Fatalf("expected globex observation to keep its own content and org, got %#v", globexObs) + } + + // A third save to the same topic key, back to acme-corp, must revise the + // acme row in place and leave the globex row untouched. + reviseID, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "architecture", + Title: "Auth model", + Content: "Acme uses JWT with refresh rotation", + Project: "engram", + Scope: "project", + Org: "acme-corp", + TopicKey: "architecture/auth-model", + }) + if err != nil { + t.Fatalf("revise acme observation: %v", err) + } + if reviseID != acmeID { + t.Fatalf("expected acme revision to reuse id %d, got %d", acmeID, reviseID) + } + + acmeObs, err = s.GetObservation(acmeID) + if err != nil { + t.Fatalf("get revised acme observation: %v", err) + } + if acmeObs.Content != "Acme uses JWT with refresh rotation" { + t.Fatalf("expected acme observation content revised, got %q", acmeObs.Content) + } + + globexObs, err = s.GetObservation(globexID) + if err != nil { + t.Fatalf("get globex observation after acme revision: %v", err) + } + if globexObs.Content != "Globex uses OAuth" { + t.Fatalf("expected globex observation untouched by acme revision, got %q", globexObs.Content) + } +} + func TestPromptProjectNullScan(t *testing.T) { s := newTestStore(t) @@ -4773,36 +5129,158 @@ func TestApplyPulledObservationPreservesChronologyAndRevisionMetadata(t *testing } } -func TestApplyPulledChunkIsAtomicAndRetrySafe(t *testing.T) { +// TestApplyPulledObservationPreservesOrgOnInsertAndUpdate is a regression +// test for #776: applyObservationUpsertTx must carry org through both the +// insert branch (first pull of a synced observation) and the update branch +// (a later pull for the same sync_id), the same way it already preserves +// chronology and revision metadata. +func TestApplyPulledObservationPreservesOrgOnInsertAndUpdate(t *testing.T) { s := newTestStore(t) + if err := s.CreateSession("remote-org-session", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } - badChunk := []SyncMutation{ - { - Entity: SyncEntitySession, - EntityKey: "chunk-session", - Op: SyncOpUpsert, - Payload: `{"id":"chunk-session","project":"engram","directory":"/remote"}`, - }, - { - Entity: SyncEntityObservation, - EntityKey: "chunk-obs-bad", - Op: SyncOpUpsert, - Payload: `{"sync_id":"chunk-obs-bad","session_id":"missing-session","type":"note","title":"bad","content":"fails fk","project":"engram","scope":"project"}`, - }, + insert := SyncMutation{ + Seq: 10, + TargetKey: DefaultSyncTargetKey, + Entity: SyncEntityObservation, + EntityKey: "obs-org-1", + Op: SyncOpUpsert, + Payload: `{"sync_id":"obs-org-1","session_id":"remote-org-session","type":"decision","title":"org meta","content":"preserve org on insert","project":"engram","scope":"project","org":"acme-corp","created_at":"2024-01-01 00:00:00","updated_at":"2024-01-05 12:30:00"}`, + } + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, insert); err != nil { + t.Fatalf("apply pulled observation insert: %v", err) } - if err := s.ApplyPulledChunk(DefaultSyncTargetKey, "chunk-retry-safe", badChunk); err == nil { - t.Fatal("expected chunk apply error for invalid observation payload") + obs, err := s.GetObservationBySyncID("obs-org-1") + if err != nil { + t.Fatalf("get pulled observation after insert: %v", err) } - if _, err := s.GetSession("chunk-session"); err == nil { - t.Fatal("expected chunk session upsert to roll back after failed chunk apply") + if obs.Org == nil || *obs.Org != "acme-corp" { + t.Fatalf("expected org acme-corp after insert, got %#v", obs.Org) } - chunks, err := s.GetSyncedChunksForTarget(DefaultSyncTargetKey) + + update := SyncMutation{ + Seq: 11, + TargetKey: DefaultSyncTargetKey, + Entity: SyncEntityObservation, + EntityKey: "obs-org-1", + Op: SyncOpUpsert, + Payload: `{"sync_id":"obs-org-1","session_id":"remote-org-session","type":"decision","title":"org meta","content":"preserve org on update","project":"engram","scope":"project","org":"globex-inc","created_at":"2024-01-01 00:00:00","updated_at":"2024-01-06 08:00:00"}`, + } + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, update); err != nil { + t.Fatalf("apply pulled observation update: %v", err) + } + + obs, err = s.GetObservationBySyncID("obs-org-1") if err != nil { - t.Fatalf("get synced chunks: %v", err) + t.Fatalf("get pulled observation after update: %v", err) } - if chunks["chunk-retry-safe"] { - t.Fatal("failed chunk must not be marked as synced") + if obs.Org == nil || *obs.Org != "globex-inc" { + t.Fatalf("expected org globex-inc after update, got %#v", obs.Org) + } +} + +// TestApplyPulledObservationLegacyPayloadPreservesOrg is a regression test: +// a pulled payload from before org existed (#776) omits the "org" key +// entirely, decoding syncObservationPayload.Org as nil. applyObservationUpsertTx +// must preserve the existing org in that case rather than overwriting it with +// NULL — the same nil-check-preserve pattern it already applies to +// last_seen_at, created_at, and updated_at. A payload that explicitly carries +// an empty org must still be honored as a real clear, not preserved. +func TestApplyPulledObservationLegacyPayloadPreservesOrg(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("legacy-org-session", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + + seed := SyncMutation{ + Seq: 20, + TargetKey: DefaultSyncTargetKey, + Entity: SyncEntityObservation, + EntityKey: "obs-legacy-org", + Op: SyncOpUpsert, + Payload: `{"sync_id":"obs-legacy-org","session_id":"legacy-org-session","type":"decision","title":"legacy org meta","content":"seed with org","project":"engram","scope":"project","org":"acme-corp","created_at":"2024-01-01 00:00:00","updated_at":"2024-01-05 12:30:00"}`, + } + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, seed); err != nil { + t.Fatalf("seed pulled observation: %v", err) + } + + // A legacy payload with no "org" key at all must not clobber the seeded org. + legacyUpdate := SyncMutation{ + Seq: 21, + TargetKey: DefaultSyncTargetKey, + Entity: SyncEntityObservation, + EntityKey: "obs-legacy-org", + Op: SyncOpUpsert, + Payload: `{"sync_id":"obs-legacy-org","session_id":"legacy-org-session","type":"decision","title":"legacy org meta","content":"updated by a pre-org payload","project":"engram","scope":"project","created_at":"2024-01-01 00:00:00","updated_at":"2024-01-06 08:00:00"}`, + } + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, legacyUpdate); err != nil { + t.Fatalf("apply legacy-shaped update: %v", err) + } + obs, err := s.GetObservationBySyncID("obs-legacy-org") + if err != nil { + t.Fatalf("get observation after legacy update: %v", err) + } + if obs.Org == nil || *obs.Org != "acme-corp" { + t.Fatalf("expected org acme-corp preserved through legacy payload, got %#v", obs.Org) + } + if obs.Content != "updated by a pre-org payload" { + t.Fatalf("expected content updated by legacy payload, got %q", obs.Content) + } + + // A payload that explicitly carries an empty org must clear it, not + // preserve the old value — presence, not truthiness, is what matters. + explicitClear := SyncMutation{ + Seq: 22, + TargetKey: DefaultSyncTargetKey, + Entity: SyncEntityObservation, + EntityKey: "obs-legacy-org", + Op: SyncOpUpsert, + Payload: `{"sync_id":"obs-legacy-org","session_id":"legacy-org-session","type":"decision","title":"legacy org meta","content":"explicitly cleared org","project":"engram","scope":"project","org":"","created_at":"2024-01-01 00:00:00","updated_at":"2024-01-07 08:00:00"}`, + } + if err := s.ApplyPulledMutation(DefaultSyncTargetKey, explicitClear); err != nil { + t.Fatalf("apply explicit-clear update: %v", err) + } + obs, err = s.GetObservationBySyncID("obs-legacy-org") + if err != nil { + t.Fatalf("get observation after explicit clear: %v", err) + } + if derefString(obs.Org) != "" { + t.Fatalf("expected org cleared by an explicit empty value, got %#v", obs.Org) + } +} + +func TestApplyPulledChunkIsAtomicAndRetrySafe(t *testing.T) { + s := newTestStore(t) + + badChunk := []SyncMutation{ + { + Entity: SyncEntitySession, + EntityKey: "chunk-session", + Op: SyncOpUpsert, + Payload: `{"id":"chunk-session","project":"engram","directory":"/remote"}`, + }, + { + Entity: SyncEntityObservation, + EntityKey: "chunk-obs-bad", + Op: SyncOpUpsert, + Payload: `{"sync_id":"chunk-obs-bad","session_id":"missing-session","type":"note","title":"bad","content":"fails fk","project":"engram","scope":"project"}`, + }, + } + + if err := s.ApplyPulledChunk(DefaultSyncTargetKey, "chunk-retry-safe", badChunk); err == nil { + t.Fatal("expected chunk apply error for invalid observation payload") + } + if _, err := s.GetSession("chunk-session"); err == nil { + t.Fatal("expected chunk session upsert to roll back after failed chunk apply") + } + chunks, err := s.GetSyncedChunksForTarget(DefaultSyncTargetKey) + if err != nil { + t.Fatalf("get synced chunks: %v", err) + } + if chunks["chunk-retry-safe"] { + t.Fatal("failed chunk must not be marked as synced") } goodChunk := []SyncMutation{ @@ -5907,6 +6385,101 @@ func TestImportObservationUsesLastWriteWinsOrdering(t *testing.T) { } } +// TestImportPreservesOrgOnInsertAndUpdate is a regression test for #776: +// Import must carry org through both the insert branch (new sync_id) and +// the update branch (an existing sync_id with a newer snapshot), mirroring +// how the last-write-wins ordering test already covers title/content. +func TestImportPreservesOrgOnInsertAndUpdate(t *testing.T) { + s := newTestStore(t) + project := "engram" + acme := "acme-corp" + globex := "globex-inc" + + base := &ExportData{ + Sessions: []Session{{ID: "import-org-session", Project: project, Directory: "/tmp", StartedAt: "2026-01-01 00:00:00"}}, + Observations: []Observation{{SyncID: "import-org-observation", SessionID: "import-org-session", Type: "note", Title: "org on insert", Content: "org on insert", Project: &project, Scope: "project", Org: &acme, CreatedAt: "2026-01-01 00:00:00", UpdatedAt: "2026-01-01 00:00:00"}}, + } + if result, err := s.Import(base); err != nil || result.ObservationsImported != 1 { + t.Fatalf("base import = %+v, %v", result, err) + } + obs, err := s.GetObservationBySyncID("import-org-observation") + if err != nil { + t.Fatalf("get imported observation: %v", err) + } + if obs.Org == nil || *obs.Org != acme { + t.Fatalf("expected org %q after insert, got %#v", acme, obs.Org) + } + + newer := *base + newer.Observations = []Observation{{SyncID: "import-org-observation", SessionID: "import-org-session", Type: "note", Title: "org on update", Content: "org on update", Project: &project, Scope: "project", Org: &globex, CreatedAt: "2026-01-01 00:00:00", UpdatedAt: "2026-01-02 00:00:00"}} + if result, err := s.Import(&newer); err != nil || result.ObservationsUpdated != 1 { + t.Fatalf("newer import = %+v, %v", result, err) + } + obs, err = s.GetObservationBySyncID("import-org-observation") + if err != nil { + t.Fatalf("get updated observation: %v", err) + } + if obs.Org == nil || *obs.Org != globex { + t.Fatalf("expected org %q after update, got %#v", globex, obs.Org) + } +} + +// TestImportLegacySnapshotPreservesOrg is a regression test: a legacy export +// snapshot from before org existed (#776) has no Org field set on its +// Observation values at all — decoding to nil, same shape as a real legacy +// JSON export file missing the "org" key entirely. Import's update branch +// must preserve the existing org in that case rather than overwriting it +// with NULL, the same nil-check-preserve pattern it already applies to +// created_at, revision_count, and duplicate_count. A newer snapshot that +// explicitly carries an empty org must still be honored as a real clear. +func TestImportLegacySnapshotPreservesOrg(t *testing.T) { + s := newTestStore(t) + project := "engram" + acme := "acme-corp" + empty := "" + + seed := &ExportData{ + Sessions: []Session{{ID: "import-legacy-org-session", Project: project, Directory: "/tmp", StartedAt: "2026-01-01 00:00:00"}}, + Observations: []Observation{{SyncID: "import-legacy-org-observation", SessionID: "import-legacy-org-session", Type: "note", Title: "legacy org meta", Content: "seed with org", Project: &project, Scope: "project", Org: &acme, CreatedAt: "2026-01-01 00:00:00", UpdatedAt: "2026-01-01 00:00:00"}}, + } + if result, err := s.Import(seed); err != nil || result.ObservationsImported != 1 { + t.Fatalf("seed import = %+v, %v", result, err) + } + + // A legacy-shaped snapshot (Org never set, same as a pre-#776 export + // file) must not clobber the seeded org. + legacy := *seed + legacy.Observations = []Observation{{SyncID: "import-legacy-org-observation", SessionID: "import-legacy-org-session", Type: "note", Title: "legacy org meta", Content: "updated by a pre-org snapshot", Project: &project, Scope: "project", CreatedAt: "2026-01-01 00:00:00", UpdatedAt: "2026-01-02 00:00:00"}} + if result, err := s.Import(&legacy); err != nil || result.ObservationsUpdated != 1 { + t.Fatalf("legacy-shaped import = %+v, %v", result, err) + } + obs, err := s.GetObservationBySyncID("import-legacy-org-observation") + if err != nil { + t.Fatalf("get observation after legacy-shaped import: %v", err) + } + if obs.Org == nil || *obs.Org != acme { + t.Fatalf("expected org %q preserved through legacy-shaped snapshot, got %#v", acme, obs.Org) + } + if obs.Content != "updated by a pre-org snapshot" { + t.Fatalf("expected content updated by legacy-shaped snapshot, got %q", obs.Content) + } + + // A snapshot that explicitly carries an empty org must clear it, not + // preserve the old value. + cleared := *seed + cleared.Observations = []Observation{{SyncID: "import-legacy-org-observation", SessionID: "import-legacy-org-session", Type: "note", Title: "legacy org meta", Content: "explicitly cleared org", Project: &project, Scope: "project", Org: &empty, CreatedAt: "2026-01-01 00:00:00", UpdatedAt: "2026-01-03 00:00:00"}} + if result, err := s.Import(&cleared); err != nil || result.ObservationsUpdated != 1 { + t.Fatalf("explicit-clear import = %+v, %v", result, err) + } + obs, err = s.GetObservationBySyncID("import-legacy-org-observation") + if err != nil { + t.Fatalf("get observation after explicit clear: %v", err) + } + if derefString(obs.Org) != "" { + t.Fatalf("expected org cleared by an explicit empty value, got %#v", obs.Org) + } +} + func TestImportObservationPreservesFieldsFromPartialNewerSnapshot(t *testing.T) { project := "engram" base := Observation{ @@ -10470,7 +11043,7 @@ func TestListProjectsWithStats(t *testing.T) { t.Fatalf("AddObservation proj-b: %v", err) } - stats, err := s.ListProjectsWithStats() + stats, err := s.ListProjectsWithStats("") if err != nil { t.Fatalf("ListProjectsWithStats: %v", err) } @@ -16054,3 +16627,469 @@ func TestLimitContextBytesUTF8AndSmallBudget(t *testing.T) { t.Fatalf("small budget output produced invalid UTF-8: %q", got) } } + +// ─── Org grouping axis (#776) ──────────────────────────────────────────────── + +func TestAddObservation_PersistsAndReturnsOrg(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("org-sess", "org-proj", "/tmp/org"); err != nil { + t.Fatalf("create session: %v", err) + } + + id, err := s.AddObservation(AddObservationParams{ + SessionID: "org-sess", + Type: "decision", + Title: "Org tagged decision", + Content: "This memory belongs to a specific org", + Project: "org-proj", + Scope: "project", + Org: "acme-corp", + }) + if err != nil { + t.Fatalf("AddObservation: %v", err) + } + + obs, err := s.GetObservation(id) + if err != nil { + t.Fatalf("GetObservation: %v", err) + } + if obs.Org == nil || *obs.Org != "acme-corp" { + t.Fatalf("expected org %q, got %#v", "acme-corp", obs.Org) + } +} + +func TestAddObservation_OrgDefaultsToNilWhenUnset(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("no-org-sess", "no-org-proj", "/tmp/no-org"); err != nil { + t.Fatalf("create session: %v", err) + } + + id, err := s.AddObservation(AddObservationParams{ + SessionID: "no-org-sess", + Type: "decision", + Title: "Untagged decision", + Content: "This memory has no org", + Project: "no-org-proj", + Scope: "project", + }) + if err != nil { + t.Fatalf("AddObservation: %v", err) + } + + obs, err := s.GetObservation(id) + if err != nil { + t.Fatalf("GetObservation: %v", err) + } + if obs.Org != nil { + t.Fatalf("expected nil org, got %q", *obs.Org) + } +} + +// TestAddObservation_TopicKeyRevisionPreservesOrg is a regression test: the +// topic-key revision UPDATE must keep writing org on every revision. Org is +// now part of the topic-key identity match (#776, see +// TestTopicKeyUpsertIsScopedByOrg), so a revision can only happen when the +// incoming org already equals the existing row's org — dropping `org = ?` +// from the UPDATE's SET list would silently NULL it out on every revision +// without a row-count change to notice. +// +// This supersedes the scenario this test originally covered, where a second +// save with a DIFFERENT org was expected to "win" and revise the same row. +// Under the topic-key-includes-org identity model that is no longer +// reachable: a different org now starts a separate row instead (see +// TestTopicKeyUpsertIsScopedByOrg). +func TestAddObservation_TopicKeyRevisionPreservesOrg(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("org-sess", "org-proj", "/tmp/org"); err != nil { + t.Fatalf("create session: %v", err) + } + + firstID, err := s.AddObservation(AddObservationParams{ + SessionID: "org-sess", + Type: "architecture", + Title: "Auth model", + Content: "Uses JWT with refresh rotation", + Project: "org-proj", + Scope: "project", + Org: "acme-corp", + TopicKey: "architecture/auth-model", + }) + if err != nil { + t.Fatalf("add first revision: %v", err) + } + + secondID, err := s.AddObservation(AddObservationParams{ + SessionID: "org-sess", + Type: "architecture", + Title: "Auth model", + Content: "Uses JWT with refresh rotation and rate limiting", + Project: "org-proj", + Scope: "project", + Org: "acme-corp", + TopicKey: "architecture/auth-model", + }) + if err != nil { + t.Fatalf("add second revision: %v", err) + } + if secondID != firstID { + t.Fatalf("expected same-org topic-key save to revise the same row, got first=%d second=%d", firstID, secondID) + } + + obs, err := s.GetObservation(firstID) + if err != nil { + t.Fatalf("GetObservation: %v", err) + } + if obs.Org == nil || *obs.Org != "acme-corp" { + t.Fatalf("expected revision to keep org %q, got %#v", "acme-corp", obs.Org) + } + if obs.Content != "Uses JWT with refresh rotation and rate limiting" { + t.Fatalf("expected revision to update content, got %q", obs.Content) + } +} + +func TestSearch_FiltersByOrg(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "decision", + Title: "Acme rollout plan", + Content: "Keep the rollout gated behind a feature flag", + Project: "engram", + Scope: "project", + Org: "acme-corp", + }); err != nil { + t.Fatalf("add acme observation: %v", err) + } + + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "decision", + Title: "Globex rollout plan", + Content: "Keep the rollout gated behind a feature flag", + Project: "engram", + Scope: "project", + Org: "globex-inc", + }); err != nil { + t.Fatalf("add globex observation: %v", err) + } + + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "decision", + Title: "Unaffiliated rollout plan", + Content: "Keep the rollout gated behind a feature flag", + Project: "engram", + Scope: "project", + }); err != nil { + t.Fatalf("add untagged observation: %v", err) + } + + acmeResults, err := s.Search("rollout", SearchOptions{Project: "engram", Org: "acme-corp", Limit: 10}) + if err != nil { + t.Fatalf("search org=acme-corp: %v", err) + } + if len(acmeResults) != 1 || acmeResults[0].Title != "Acme rollout plan" { + t.Fatalf("expected only the acme-corp observation, got %#v", acmeResults) + } + + allResults, err := s.Search("rollout", SearchOptions{Project: "engram", Limit: 10}) + if err != nil { + t.Fatalf("search without org filter: %v", err) + } + if len(allResults) != 3 { + t.Fatalf("expected all 3 observations without an org filter, got %d", len(allResults)) + } +} + +// TestSearchPreviewsContext_FiltersByOrgFTS is a regression test for #776: +// buildSearchPreviewFTSQuery must apply the same org filter as the full +// buildSearchFTSQuery, and scanSearchPreviewRow must expose it on each +// preview result — matching how the FTS path is already covered for +// SearchContext by TestSearch_FiltersByOrg. +func TestSearchPreviewsContext_FiltersByOrgFTS(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "decision", + Title: "Acme rollout plan", + Content: "Keep the rollout gated behind a feature flag", + Project: "engram", + Scope: "project", + Org: "acme-corp", + }); err != nil { + t.Fatalf("add acme observation: %v", err) + } + + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "decision", + Title: "Globex rollout plan", + Content: "Keep the rollout gated behind a feature flag", + Project: "engram", + Scope: "project", + Org: "globex-inc", + }); err != nil { + t.Fatalf("add globex observation: %v", err) + } + + acmePreviews, err := s.SearchPreviewsContext(context.Background(), "rollout", SearchOptions{Project: "engram", Org: "acme-corp", Limit: 10}) + if err != nil { + t.Fatalf("search previews org=acme-corp: %v", err) + } + if len(acmePreviews) != 1 || acmePreviews[0].Title != "Acme rollout plan" { + t.Fatalf("expected only the acme-corp preview, got %#v", acmePreviews) + } + if acmePreviews[0].Org == nil || *acmePreviews[0].Org != "acme-corp" { + t.Fatalf("expected preview to expose org acme-corp, got %#v", acmePreviews[0].Org) + } + + allPreviews, err := s.SearchPreviewsContext(context.Background(), "rollout", SearchOptions{Project: "engram", Limit: 10}) + if err != nil { + t.Fatalf("search previews without org filter: %v", err) + } + if len(allPreviews) != 2 { + t.Fatalf("expected both previews without an org filter, got %d", len(allPreviews)) + } +} + +// TestSearchPreviewsContext_FiltersByOrgLIKE is a regression test for #776: +// a query with a term under 3 runes takes the LIKE fallback path +// (hasShortFTSTerm), and buildSearchPreviewLIKEQuery must apply the org +// filter there too, not just on the FTS path. +func TestSearchPreviewsContext_FiltersByOrgLIKE(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "decision", + Title: "Acme rollout plan", + Content: "Keep the rollout gated behind a feature flag", + Project: "engram", + Scope: "project", + Org: "acme-corp", + }); err != nil { + t.Fatalf("add acme observation: %v", err) + } + + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "decision", + Title: "Globex rollout plan", + Content: "Keep the rollout gated behind a feature flag", + Project: "engram", + Scope: "project", + Org: "globex-inc", + }); err != nil { + t.Fatalf("add globex observation: %v", err) + } + + const shortQuery = "ro" // 2 runes: forces hasShortFTSTerm's LIKE fallback + acmePreviews, err := s.SearchPreviewsContext(context.Background(), shortQuery, SearchOptions{Project: "engram", Org: "acme-corp", Limit: 10}) + if err != nil { + t.Fatalf("search previews org=acme-corp: %v", err) + } + if len(acmePreviews) != 1 || acmePreviews[0].Title != "Acme rollout plan" { + t.Fatalf("expected only the acme-corp preview via LIKE fallback, got %#v", acmePreviews) + } + if acmePreviews[0].Org == nil || *acmePreviews[0].Org != "acme-corp" { + t.Fatalf("expected preview to expose org acme-corp, got %#v", acmePreviews[0].Org) + } + + allPreviews, err := s.SearchPreviewsContext(context.Background(), shortQuery, SearchOptions{Project: "engram", Limit: 10}) + if err != nil { + t.Fatalf("search previews without org filter: %v", err) + } + if len(allPreviews) != 2 { + t.Fatalf("expected both previews without an org filter, got %d", len(allPreviews)) + } +} + +func TestSearch_TopicKeyDirectMatchFiltersByOrg(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "architecture", + Title: "Auth model", + Content: "Uses JWT with refresh rotation", + Project: "engram", + Scope: "project", + Org: "acme-corp", + TopicKey: "architecture/auth-model", + }); err != nil { + t.Fatalf("add observation: %v", err) + } + + results, err := s.Search("architecture/auth-model", SearchOptions{Project: "engram", Org: "globex-inc", Limit: 10}) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(results) != 0 { + t.Fatalf("expected topic_key direct match to respect org filter, got %#v", results) + } + + results, err = s.Search("architecture/auth-model", SearchOptions{Project: "engram", Org: "acme-corp", Limit: 10}) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected topic_key direct match for the matching org, got %#v", results) + } +} + +// TestGetObservation_BackwardCompatNullOrg proves that rows persisted before +// the org column existed (simulated here via a raw INSERT that omits it, +// leaving SQLite's default NULL) still load cleanly through the same +// observationSelectColumns/scanObservationRow path used everywhere else. +func TestGetObservation_BackwardCompatNullOrg(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("legacy-sess", "legacy-proj", "/tmp/legacy"); err != nil { + t.Fatalf("create session: %v", err) + } + + res, err := s.db.Exec( + `INSERT INTO observations (sync_id, session_id, type, title, content, project, scope, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`, + "obs-legacy-no-org", "legacy-sess", "manual", "Legacy row", "Predates the org column", "legacy-proj", "project", + ) + if err != nil { + t.Fatalf("raw legacy insert: %v", err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("last insert id: %v", err) + } + + obs, err := s.GetObservation(id) + if err != nil { + t.Fatalf("GetObservation on legacy row: %v", err) + } + if obs.Org != nil { + t.Fatalf("expected legacy row org to be nil, got %q", *obs.Org) + } + if obs.Title != "Legacy row" { + t.Fatalf("expected legacy row to load correctly, got %#v", obs) + } +} + +func TestListProjectsWithStats_FiltersByOrg(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "proj-acme", "/work/acme"); err != nil { + t.Fatalf("create session: %v", err) + } + if err := s.CreateSession("s2", "proj-globex", "/work/globex"); err != nil { + t.Fatalf("create session: %v", err) + } + + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s1", + Type: "decision", + Title: "acme obs", + Content: "acme content", + Project: "proj-acme", + Scope: "project", + Org: "acme-corp", + }); err != nil { + t.Fatalf("AddObservation proj-acme: %v", err) + } + + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s2", + Type: "decision", + Title: "globex obs", + Content: "globex content", + Project: "proj-globex", + Scope: "project", + Org: "globex-inc", + }); err != nil { + t.Fatalf("AddObservation proj-globex: %v", err) + } + + stats, err := s.ListProjectsWithStats("acme-corp") + if err != nil { + t.Fatalf("ListProjectsWithStats: %v", err) + } + if len(stats) != 1 || stats[0].Name != "proj-acme" { + t.Fatalf("expected only proj-acme when filtering by org=acme-corp, got %#v", stats) + } + + unfiltered, err := s.ListProjectsWithStats("") + if err != nil { + t.Fatalf("ListProjectsWithStats unfiltered: %v", err) + } + if len(unfiltered) < 2 { + t.Fatalf("expected at least 2 projects without an org filter, got %d", len(unfiltered)) + } +} + +// TestListProjectsWithStats_OrgFilterExcludesSessionAndPromptOnlyProjects is a +// regression test for #776: org lives only on observations, so an org filter +// must exclude a project that only has sessions and a project that only has +// prompts, rather than reintroducing them from session or prompt data alone +// (the guard clauses in ListProjectsWithStats' session/prompt merge loops). +func TestListProjectsWithStats_OrgFilterExcludesSessionAndPromptOnlyProjects(t *testing.T) { + s := newTestStore(t) + if err := s.CreateSession("s1", "session-only-proj", "/work/session-only"); err != nil { + t.Fatalf("create session-only session: %v", err) + } + if err := s.CreateSession("s2", "prompt-only-proj", "/work/prompt-only"); err != nil { + t.Fatalf("create prompt-only session: %v", err) + } + if _, err := s.AddPrompt(AddPromptParams{ + SessionID: "s2", + Content: "a prompt with no observation", + Project: "prompt-only-proj", + }); err != nil { + t.Fatalf("AddPrompt prompt-only-proj: %v", err) + } + if err := s.CreateSession("s3", "org-proj", "/work/org-proj"); err != nil { + t.Fatalf("create org-proj session: %v", err) + } + if _, err := s.AddObservation(AddObservationParams{ + SessionID: "s3", + Type: "decision", + Title: "org-tagged obs", + Content: "org-tagged content", + Project: "org-proj", + Scope: "project", + Org: "acme-corp", + }); err != nil { + t.Fatalf("AddObservation org-proj: %v", err) + } + + stats, err := s.ListProjectsWithStats("acme-corp") + if err != nil { + t.Fatalf("ListProjectsWithStats: %v", err) + } + if len(stats) != 1 || stats[0].Name != "org-proj" { + t.Fatalf("expected only org-proj when filtering by org=acme-corp, got %#v", stats) + } + + unfiltered, err := s.ListProjectsWithStats("") + if err != nil { + t.Fatalf("ListProjectsWithStats unfiltered: %v", err) + } + names := make(map[string]bool, len(unfiltered)) + for _, p := range unfiltered { + names[p.Name] = true + } + for _, want := range []string{"session-only-proj", "prompt-only-proj", "org-proj"} { + if !names[want] { + t.Fatalf("expected %q present without an org filter, got %#v", want, unfiltered) + } + } +}