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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/content/building-gormes/architecture_plan/progress.json
Original file line number Diff line number Diff line change
Expand Up @@ -1368,9 +1368,9 @@
},
{
"name": "Cross-chat deny-path fixtures",
"status": "planned",
"status": "complete",
"contract": "Same-chat default recall with explicit user-scope widening",
"contract_status": "draft",
"contract_status": "validated",
"slice_size": "small",
"execution_owner": "memory",
"trust_class": [
Expand Down Expand Up @@ -1401,7 +1401,7 @@
"Conflicting user bindings deny user-scope widening.",
"Allowed user-scope searches include source allowlist evidence."
],
"note": "TDD: prove unknown, unresolved, or conflicting user_id bindings cannot widen recall or session search, and pin same-chat fallback behavior with fixture-backed allow/deny cases before cross-chat access is exposed as shipped.",
"note": "TDD landed: recall and session-search fixtures now deny user-scope widening when the current chat binding is unknown, unresolved, or conflicting; denied paths fall back to same-chat/session behavior, and allowed user-scope GONCHO hits preserve origin_source evidence for source allowlists.",
"write_scope": [
"internal/memory/",
"internal/goncho/",
Expand Down
19 changes: 13 additions & 6 deletions internal/goncho/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"strings"
Expand Down Expand Up @@ -256,19 +257,25 @@ func (s *Service) searchTurnFallback(ctx context.Context, params SearchParams) (
return nil, err
}
hits, err := memory.SearchMessages(ctx, s.db, metas, memory.SearchFilter{
UserID: userID,
Sources: params.Sources,
Query: params.Query,
UserID: userID,
Sources: params.Sources,
Query: params.Query,
CurrentSessionID: params.SessionKey,
CurrentChatKey: params.SessionKey,
}, 6)
if errors.Is(err, memory.ErrUserScopeDenied) {
return findTurns(ctx, s.db, params.Query, params.SessionKey, 6)
}
if err != nil {
return nil, err
}
out := make([]SearchHit, 0, len(hits))
for _, hit := range hits {
out = append(out, SearchHit{
Source: "turn",
Content: hit.Content,
SessionKey: hit.SessionID,
Source: "turn",
OriginSource: hit.Source,
Content: hit.Content,
SessionKey: hit.SessionID,
})
}
return out, nil
Expand Down
73 changes: 68 additions & 5 deletions internal/goncho/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,12 @@ func TestService_SearchUserScopeRespectsSourceFilter(t *testing.T) {
}

got, err := svc.Search(ctx, SearchParams{
Peer: "user-juan",
Query: "Atlas",
MaxTokens: 200,
Scope: "user",
Sources: []string{"discord"},
Peer: "user-juan",
Query: "Atlas",
MaxTokens: 200,
SessionKey: "discord:chan-9",
Scope: "user",
Sources: []string{"discord"},
})
if err != nil {
t.Fatal(err)
Expand All @@ -254,6 +255,68 @@ func TestService_SearchUserScopeRespectsSourceFilter(t *testing.T) {
if got.Results[0].Source != "turn" || got.Results[0].SessionKey != "sess-discord" {
t.Fatalf("Search result = %+v, want discord turn bound to sess-discord", got.Results[0])
}
if got.Results[0].OriginSource != "discord" {
t.Fatalf("Search result origin_source = %q, want discord source allowlist evidence", got.Results[0].OriginSource)
}
}

func TestService_SearchUserScopeUnknownCurrentBindingFallsBackSameSession(t *testing.T) {
store, dir, svc, cleanup := newTestServiceWithDirectory(t)
defer cleanup()

ctx := context.Background()
if err := dir.PutMetadata(ctx, session.Metadata{
SessionID: "sess-telegram",
Source: "telegram",
ChatID: "42",
UserID: "user-juan",
}); err != nil {
t.Fatalf("PutMetadata telegram: %v", err)
}
now := time.Now().Unix()
for _, turn := range []struct {
sessionID string
chatID string
content string
ts int64
}{
{
sessionID: "sess-telegram",
chatID: "telegram:42",
content: "Atlas remote user-scope note.",
ts: now - 20,
},
{
sessionID: "sess-current",
chatID: "discord:chan-9",
content: "Atlas same-session fallback note.",
ts: now - 10,
},
} {
if _, err := store.DB().ExecContext(ctx,
`INSERT INTO turns(session_id, role, content, ts_unix, chat_id) VALUES (?, ?, ?, ?, ?)`,
turn.sessionID, "user", turn.content, turn.ts, turn.chatID,
); err != nil {
t.Fatalf("insert turn %s: %v", turn.sessionID, err)
}
}

got, err := svc.Search(ctx, SearchParams{
Peer: "user-juan",
Query: "Atlas",
MaxTokens: 200,
SessionKey: "discord:chan-9",
Scope: "user",
})
if err != nil {
t.Fatal(err)
}
if len(got.Results) != 1 {
t.Fatalf("Search results len = %d, want 1", len(got.Results))
}
if got.Results[0].Content != "Atlas same-session fallback note." {
t.Fatalf("Search result = %+v, want same-session fallback only", got.Results[0])
}
}

func newTestService(t *testing.T) (*Service, func()) {
Expand Down
9 changes: 5 additions & 4 deletions internal/goncho/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@ type SearchParams struct {

// SearchHit is one result entry returned by search.
type SearchHit struct {
ID int64 `json:"id,omitempty"`
Source string `json:"source"`
Content string `json:"content"`
SessionKey string `json:"session_key,omitempty"`
ID int64 `json:"id,omitempty"`
Source string `json:"source"`
OriginSource string `json:"origin_source,omitempty"`
Content string `json:"content"`
SessionKey string `json:"session_key,omitempty"`
}

// SearchResultSet is the stable JSON shape for honcho_search.
Expand Down
57 changes: 57 additions & 0 deletions internal/memory/recall.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,11 @@ func (p *Provider) allowedChatKeys(ctx context.Context, in RecallInput) []string
return fallbackChatScope(chatKey)
}

userID := strings.TrimSpace(in.UserID)
if !metadataAllowsCurrentChat(metadata, userID, chatKey) {
return fallbackChatScope(chatKey)
}

seen := make(map[string]struct{}, len(metadata))
chats := make([]string, 0, len(metadata))
for _, meta := range metadata {
Expand All @@ -257,6 +262,9 @@ func (p *Provider) allowedChatKeys(ctx context.Context, in RecallInput) []string
if source == "" || chatID == "" {
continue
}
if strings.TrimSpace(meta.UserID) != userID {
return fallbackChatScope(chatKey)
}
if len(allowedSources) > 0 {
if _, ok := allowedSources[source]; !ok {
continue
Expand All @@ -275,6 +283,55 @@ func (p *Provider) allowedChatKeys(ctx context.Context, in RecallInput) []string
return chats
}

func metadataAllowsCurrentChat(metadata []session.Metadata, userID, chatKey string) bool {
chatKey = strings.TrimSpace(chatKey)
if chatKey == "" {
return true
}
userID = strings.TrimSpace(userID)
if userID == "" {
return false
}

matchedCurrent := false
for _, meta := range metadata {
if !sameChatKey(metadataChatKey(meta), chatKey) {
continue
}
matchedCurrent = true
if strings.TrimSpace(meta.UserID) != userID {
return false
}
}
return matchedCurrent
}

func metadataChatKey(meta session.Metadata) string {
source := strings.ToLower(strings.TrimSpace(meta.Source))
chatID := strings.TrimSpace(meta.ChatID)
if source == "" || chatID == "" {
return ""
}
return source + ":" + chatID
}

func sameChatKey(a, b string) bool {
aSource, aID, aOK := splitChatKey(a)
bSource, bID, bOK := splitChatKey(b)
if !aOK || !bOK {
return strings.TrimSpace(a) == strings.TrimSpace(b)
}
return strings.EqualFold(aSource, bSource) && aID == bID
}

func splitChatKey(chatKey string) (string, string, bool) {
source, chatID, ok := strings.Cut(strings.TrimSpace(chatKey), ":")
if !ok || strings.TrimSpace(source) == "" || strings.TrimSpace(chatID) == "" {
return "", "", false
}
return strings.TrimSpace(source), strings.TrimSpace(chatID), true
}

func fallbackChatScope(chatKey string) []string {
if chatKey == "" {
return nil
Expand Down
112 changes: 112 additions & 0 deletions internal/memory/recall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package memory
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
Expand Down Expand Up @@ -217,6 +218,117 @@ func TestProvider_GetContext_CrossChatSourceFilter(t *testing.T) {
}
}

func TestProvider_GetContext_CrossChatUnknownCurrentBindingFallsBackSameChat(t *testing.T) {
s, p := openProviderWithRichGraph(t)
ctx := context.Background()
seedSameChatEntity(t, s, "Orchid", "same-chat only", "discord:7")

dir := session.NewMemMap()
if err := dir.PutMetadata(ctx, session.Metadata{
SessionID: "sess-telegram",
Source: "telegram",
ChatID: "42",
UserID: "user-juan",
}); err != nil {
t.Fatalf("PutMetadata telegram: %v", err)
}

out := p.WithDirectory(dir).GetContext(ctx, RecallInput{
UserMessage: "Acme Orchid status?",
ChatKey: "discord:7",
UserID: "user-juan",
CrossChat: true,
})
if !strings.Contains(out, "Orchid") {
t.Fatalf("unknown current binding should keep same-chat recall; got %q", out)
}
if strings.Contains(out, "Acme") {
t.Fatalf("unknown current binding widened into another chat; got %q", out)
}
}

func TestProvider_GetContext_CrossChatConflictingCurrentBindingFallsBackSameChat(t *testing.T) {
s, p := openProviderWithRichGraph(t)
ctx := context.Background()
seedSameChatEntity(t, s, "Orchid", "same-chat only", "discord:7")

p = p.WithDirectory(recallDirectoryFunc(func(context.Context, string) ([]session.Metadata, error) {
return []session.Metadata{
{
SessionID: "sess-current",
Source: "discord",
ChatID: "7",
UserID: "user-maria",
},
{
SessionID: "sess-telegram",
Source: "telegram",
ChatID: "42",
UserID: "user-juan",
},
}, nil
}))

out := p.GetContext(ctx, RecallInput{
UserMessage: "Acme Orchid status?",
ChatKey: "discord:7",
UserID: "user-juan",
CrossChat: true,
})
if !strings.Contains(out, "Orchid") {
t.Fatalf("conflicting current binding should keep same-chat recall; got %q", out)
}
if strings.Contains(out, "Acme") {
t.Fatalf("conflicting current binding widened into another chat; got %q", out)
}
}

func TestProvider_GetContext_CrossChatUnresolvedDirectoryFallsBackSameChat(t *testing.T) {
s, p := openProviderWithRichGraph(t)
ctx := context.Background()
seedSameChatEntity(t, s, "Orchid", "same-chat only", "discord:7")

p = p.WithDirectory(recallDirectoryFunc(func(context.Context, string) ([]session.Metadata, error) {
return nil, errors.New("metadata unavailable")
}))

out := p.GetContext(ctx, RecallInput{
UserMessage: "Acme Orchid status?",
ChatKey: "discord:7",
UserID: "user-juan",
CrossChat: true,
})
if !strings.Contains(out, "Orchid") {
t.Fatalf("unresolved current binding should keep same-chat recall; got %q", out)
}
if strings.Contains(out, "Acme") {
t.Fatalf("unresolved current binding widened into another chat; got %q", out)
}
}

type recallDirectoryFunc func(context.Context, string) ([]session.Metadata, error)

func (f recallDirectoryFunc) ListMetadataByUserID(ctx context.Context, userID string) ([]session.Metadata, error) {
return f(ctx, userID)
}

func seedSameChatEntity(t *testing.T, s *SqliteStore, name, desc, chatKey string) {
t.Helper()
if _, err := s.db.ExecContext(context.Background(),
`INSERT INTO entities(name, type, description, updated_at) VALUES(?,?,?,?)`,
name, "PROJECT", desc, time.Now().Unix(),
); err != nil {
t.Fatalf("insert same-chat entity: %v", err)
}
if _, err := s.db.ExecContext(context.Background(),
`INSERT INTO turns(session_id, role, content, ts_unix, chat_id)
VALUES(?, 'user', ?, ?, ?)`,
"sess-"+strings.ToLower(name), name+" belongs to this chat", time.Now().Unix(), chatKey,
); err != nil {
t.Fatalf("insert same-chat turn: %v", err)
}
}

// stubEmbedServer returns a fixed vector for any input — enough to seed
// the graph with embeddings for hybrid tests.
func stubEmbedServer(t *testing.T, returnVec []float32) *httptest.Server {
Expand Down
Loading