Skip to content

feat(worker): complete learning-memory-v4 follow-up phases#135

Merged
thebtf merged 13 commits into
mainfrom
fix/privacy-scope-leak-and-contradiction-killswitch
Apr 11, 2026
Merged

feat(worker): complete learning-memory-v4 follow-up phases#135
thebtf merged 13 commits into
mainfrom
fix/privacy-scope-leak-and-contradiction-killswitch

Conversation

@thebtf
Copy link
Copy Markdown
Owner

@thebtf thebtf commented Apr 11, 2026

Summary

  • ship the post-MVP learning-memory-v4 follow-up phases on the hotfix branch
  • add project briefing, trigger expansion, write-time merge, graph BFS, and learning dashboard follow-up hardening
  • unblock the next release/watchtower rollout so T048 can be validated on live

Test plan

  • go test ./...
  • go test ./internal/worker -run TestParseHitRateAnalyticsTextPreservesTitleSpacesAndFlags -count=1
  • go test ./internal/worker -run TestSetupRoutes_RegistersLearningHitRateRoute -count=1
  • npm --prefix ui run build

Summary by CodeRabbit

  • New Features

    • Аналитика Hit Rate в разделе «Обучение»
    • Автоматическая генерация и сохранение проектной сводки
    • Новый HTTP-эндпоинт /api/memory/triggers для контекстных совпадений (Edit/Write/Bash/Read)
    • Поле commands_run в наблюдениях и API
  • Improvements

    • Контекстная фильтрация по редактируемым файлам и унифицированная инъекция (молчание вместо шума)
    • Typed‑lanes для поиска с per‑type порогами/ранжированием и BFS‑инъекцией графа
    • Поведение write‑merge/суперсиде/обновлений улучшено; feedback заменён на rating ("useful"/"not_useful")
  • Documentation

    • Обновлены README, GOTCHAS и примеры инструментов/плагинов с новыми настройками и примерами

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 11, 2026

Walkthrough

Добавлены типизированные поисковые линии и их применение в retrieval, LLM-управляемое решение о слиянии наблюдений при записи (DecideMerge), проектные брифинги с генерирующей задачей обслуживания, новый серверный endpoint /api/memory/triggers, file-scoped retrieval (files_being_edited), опциональная BFS-инжекция графа, изменение супрессии по флагу и множественные тесты и документация.

Changes

Cohort / File(s) Summary
Документация & манифесты
CHANGELOG.md, README.md, README.ru.md, README.zh.md, docs/arch/GOTCHAS.md, plugin/engram/.claude-plugin/plugin.json, plugin/openclaw-engram/openclaw.plugin.json, plugin/openclaw-engram/package.json, plugin/engram/commands/..., plugin/engram/skills/...
Описание новых флагов/поведения (тихая инжекция, ENGRAM_INJECT_UNIFIED, ENGRAM_INJECTION_FLOOR), обновлён пример feedbackrating, обновлён CHANGELOG и версии плагинов.
Конфигурация
internal/config/config.go, internal/config/config_test.go
Добавлены флаги и тип SearchLaneConfig, дефолтные per-type lane настройки, загрузка/слияние из JSON/окружения и тесты.
Модель и персистентность
pkg/models/observation.go, internal/db/gorm/models.go, internal/db/gorm/observation_store.go, internal/db/gorm/observation_store_test.go
Добавлено поле CommandsRun с санитизацией, расширены поля обновления, добавлены методы поиска (GetProjectBriefingObservation, GetObservationsByCommandPrefix) и связанные тесты.
Retrieval / Lanes / Weights
internal/search/lanes.go, internal/search/manager.go, internal/search/lanes_test.go, internal/search/manager_test.go
Введены DefaultSearchLanes, ApplyLaneWeights и механика per-type MinScore/TopK/RerankerWeight для отбора и перевзвешивания результатов.
Vector API и WhereFilter
internal/vector/types.go, internal/vector/types_test.go, internal/vector/pgvector/client.go, internal/vector/pgvector/client_sql_test.go
Расширен BuildWhereFilter (опциональные filePaths, оператор в WhereClause), добавлен buildWhereClauses с валидацией колонок; добавлены тесты SQL-генерации.
Retrieval pipeline & graph BFS
internal/worker/retrieval.go, internal/worker/retrieval_helpers.go, internal/worker/retrieval_test.go
Добавлено поле RetrievalOptions.FilePaths, извлечение seed-ов сессии, lookupGraphSeedNeighbors и опциональная BFS-инъекция соседей в RRF-фьюжн; реализация typed-lane selection.
Write-time merge (LLM)
internal/learning/merge.go, internal/learning/merge_test.go, internal/worker/sdk/processor.go, internal/worker/sdk/processor_test.go
Добавлен контракт MergeDecision (CREATE_NEW/UPDATE/SUPERSEDE/SKIP) с LLM-вызовом (таймаут 3s), DecideMerge и интеграция в Processor (Processor теперь принимает vectorClient).
Project briefing / Maintenance
internal/learning/briefing.go, internal/learning/briefing_test.go, internal/maintenance/service.go, internal/maintenance/service_test.go
Генерация проектных брифингов через LLM, новый maintenance task для их обновления/создания и тесты.
Memory triggers & matcher
internal/worker/handlers_triggers.go, internal/worker/trigger_matcher.go, internal/worker/trigger_matcher_*.go (tests)
Новый POST /api/memory/triggers, матчеры для Edit/Write/Bash/Read, пороговые проверки, чтение по командам/файлам и набор тестов.
Handlers & Worker changes
internal/worker/handlers_context.go, internal/worker/handlers_data.go, internal/worker/handlers_ingest.go, internal/worker/handlers_*_test.go
Передача files_being_edited в RetrievalOptions, включение project_briefing в inject-ответ, Hit-rate аналитика handlers, conditional супрессия по StorePathSupersessionEnabled.
MCP / Tools / Store flow
internal/mcp/server.go, internal/mcp/tools_memory.go, internal/mcp/tools_store_consolidated.go, internal/mcp/*.go (tests)
Схемы инструментов: feedback заменяет usefulrating, store.type → enum; валидация типов, реорганизация store logic в storeExtractedObservation, и тесты супрессии/поведения.
Worker wiring & routes
internal/worker/service.go, internal/worker/service_routes_test.go
Передача vectorClient в SDK Processor, регистрация маршрутов /api/learning/hit-rate и /api/memory/triggers.
Telemetry / Hit-rate UI
internal/telemetry/similarity.go, internal/worker/handlers_data_test.go, ui/src/utils/api.ts, ui/src/views/LearningView.vue
Серверная классификация hit-rate, API, клиентская функция fetch и отображение Snapshot в UI.
Plugins (JS) — hooks & session files
plugin/engram/hooks/*.js, plugin/engram/hooks/*.test.js, plugin/engram/commands/..., plugin/openclaw-engram/src/*
Session file tracking (append/get), рефактор pre-tool-use (Edit/Write/Bash/Read), session-start/user-prompt экспорты и включение files_being_edited в URL/payload; обновления client rate→rating.
Мелкие изменения
множество вызовов vector.BuildWhereFilter(..., nil), импорт-реорганизации, форматирование и массовые тесты
Единообразные изменения вызовов vector.BuildWhereFilter(..., nil), форматирования, и ряд новых/обновлённых тестов по всему коду.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Клиент/IDE
    participant Handler as handleMemoryTriggers
    participant Vector as VectorClient
    participant Store as ObservationStore

    Client->>Handler: POST /api/memory/triggers (tool, project, params)
    Handler->>Vector: Query (BuildWhereFilter с project/fileScope)
    Vector-->>Handler: QueryResults (observation_ids)
    Handler->>Store: GetObservationsByIDs
    Store-->>Handler: Observations
    Handler->>Client: JSON matches (Kind, ObservationID, Blurb)
Loading
sequenceDiagram
    participant SDK as SDK Processor
    participant Vector as VectorClient
    participant Store as ObservationStore
    participant LLM as LLM Client

    SDK->>Vector: Query similar observations
    Vector-->>SDK: candidate IDs
    SDK->>Store: FetchObservationsByIDs
    Store-->>SDK: candidate Observations

    rect rgba(135, 206, 250, 0.5)
        SDK->>LLM: DecideMerge (newObs + candidates)
        LLM-->>SDK: {action, target_id}
    end

    alt SKIP
        SDK->>SDK: Пропустить сохранение
    else CREATE_NEW
        SDK->>Store: StoreObservation
    else UPDATE
        SDK->>Store: UpdateObservation (merge fields)
    else SUPERSEDE
        SDK->>Store: StoreObservation
        SDK->>Store: MarkAsSuperseded(target_id)
    end
Loading
sequenceDiagram
    participant Worker as Retrieval
    participant Vector as VectorClient
    participant Graph as GraphStore
    participant Search as Search Manager

    Worker->>Vector: BuildWhereFilter(..., filePaths?)
    Worker->>Vector: Query
    Vector-->>Worker: observations + scores

    alt InjectGraphBFSEnabled
        Worker->>Worker: ExtractSessionEntitySeeds
        Worker->>Graph: GetNeighbors(seed)
        Graph-->>Worker: neighbor IDs
        Worker->>Search: RRF fuse vector scores + neighbor seeds
        Search-->>Worker: fused scores
    end

    alt TypeLanesEnabled
        Worker->>Worker: Group by ObservationType -> filter by MinScore, TopK
        Worker->>Search: ApplyLaneWeights
        Search-->>Worker: reweighted results
    end

    Worker-->>Client: selected observations
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 В ветках кода — брифы, ленты и след,

LLM шепчет: «Слей, обнови или сбег!»
Триггеры звонят, файлы шуршат в ряд,
Графы соседей добавляют заряд.
Репо ликует — морковка в наград!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/privacy-scope-leak-and-contradiction-killswitch

@thebtf
Copy link
Copy Markdown
Owner Author

thebtf commented Apr 11, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 11, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the Learning Memory v4 feature wave, introducing file-scope prefiltering, per-type search lanes, project briefings, and write-time merge logic. Key improvements include a unified retrieval pipeline for both inject and search paths, and a new alarm model for semantic trigger matching. However, several critical issues were identified: a logic error in the write-merge path prevents new observations from being stored during supersession, RRF scores are incorrectly compared against cosine similarity thresholds in typed lanes when graph expansion is enabled, and session signal tracking relies on local temporary files which breaks distributed workstation setups. Additionally, the typed lane selection logic fails to respect global result limits and lacks a final re-sorting step after score adjustments.

Comment thread internal/worker/sdk/processor.go Outdated
Comment thread internal/worker/retrieval.go Outdated
Comment thread internal/worker/trigger_matcher.go
Comment thread internal/worker/retrieval.go
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
plugin/engram/hooks/lib.js (1)

348-400: ⚠️ Potential issue | 🟠 Major

Гонка при межпроцессном обновлении session signals.

Функции incrementSessionSignals (л. 348) и appendSessionFile (л. 376) используют паттерн read-modify-write на одном файле (_signalPath(sessionID)) без блокировки. Так как хуки работают как независимые процессы (см. комментарий на л. 334), параллельные вызовы приводят к потере обновлений: один процесс перезаписывает изменения другого, теряя либо счётчики, либо записи в массиве files.

Требуется добавить синхронизацию (например, через lock-файл или атомарную операцию) в общий helper для обновления состояния.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugin/engram/hooks/lib.js` around lines 348 - 400, Both
incrementSessionSignals and appendSessionFile perform unsafe read-modify-write
on the same file returned by _signalPath(sessionID); you must serialize access
to avoid lost updates—implement a small helper (e.g. with a lock-file or atomic
file-rename + retry loop) like withSessionSignalUpdate(sessionID, updater) that
acquires a per-session lock, reads JSON, calls updater(current) to get the new
state, writes atomically (write to temp + rename), and releases the lock; then
refactor incrementSessionSignals and appendSessionFile to call
withSessionSignalUpdate(sessionID, current => { ...modify current as before...
return next; }) so all updates are synchronized across processes.
internal/worker/handlers_data.go (1)

998-1008: ⚠️ Potential issue | 🟡 Minor

Несоответствие комментария Godoc и функции.

Комментарий Godoc на строках 998-1007 описывает handleGetSimilarityTelemetry, но следует функция handleMCPHitRateAnalytics. Вероятно, новая функция была добавлена перед существующим обработчиком без собственной документации.

📝 Предлагаемое исправление
-// handleGetSimilarityTelemetry godoc
-// `@Summary` Get similarity telemetry
-// `@Description` Returns the latest similarity telemetry data. Optionally filter by project to get a single snapshot.
-// `@Tags` Analytics
-// `@Produce` json
-// `@Security` ApiKeyAuth
-// `@Param` project query string false "Filter by project"
-// `@Success` 200 {object} map[string]interface{}
-// `@Failure` 500 {string} string "internal error"
-// `@Router` /api/telemetry/similarity [get]
+// handleMCPHitRateAnalytics generates hit rate analytics in Markdown format for MCP tools.
 func (s *Service) handleMCPHitRateAnalytics(ctx context.Context, project string, limit int) (string, error) {

И перенесите исходный комментарий Godoc к функции handleGetSimilarityTelemetry на строке 1164.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/worker/handlers_data.go` around lines 998 - 1008, Godoc над функцией
сейчас описывает handleGetSimilarityTelemetry, тогда как под ним объявлена
handleMCPHitRateAnalytics; переместите/восстановите правильный комментарий:
вытащите существующий блок Godoc (котрый описывает handleGetSimilarityTelemetry)
и перенесите его над реальной функции handleGetSimilarityTelemetry, а над
текущей функции handleMCPHitRateAnalytics добавьте корректную документацию (или
новый краткий Godoc), чтобы комментарии соответствовали именам функций
handleGetSimilarityTelemetry и handleMCPHitRateAnalytics.
internal/worker/retrieval.go (1)

227-323: ⚠️ Potential issue | 🟠 Major

Файл-скоуп теряется при fallback и BFS fusion.

opts.FilePaths используется только в vector.BuildWhereFilter(...). Если векторный путь недоступен/пустой, дальше возвращаются project-wide результаты из searchFallbackObservations(...), а затем BFS может добавить ещё соседей без повторной фильтрации по файлам. В результате новый FilePaths не является инвариантом финальной выдачи.

Что лучше сделать
 	if !usedVector || len(observations) == 0 {
 		if vectorSearchFailed {
 			log.Info().Str("project", project).Msg("Using FTS fallback due to vector search failure")
 		}
 		scopeFilter := gorm.ScopeFilter{Project: project, AgentID: state.agentID}
 		fallbackObservations, fallbackErr := s.searchFallbackObservations(ctx, query, scopeFilter, limit)
 		if fallbackErr != nil {
 			return nil, nil, fallbackErr
 		}
 		observations = fallbackObservations
 	}
+	observations = filterObservationsByFilePaths(observations, opts.FilePaths)
 
 	if s.config != nil && s.config.InjectGraphBFSEnabled {
 		...
 	}
+	observations = filterObservationsByFilePaths(observations, opts.FilePaths)

Здесь нужен общий post-filter по FilesRead / FilesModified, чтобы любое ответвление retrieval pipeline соблюдало один и тот же file scope.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/worker/retrieval.go` around lines 227 - 323, The retrieval pipeline
loses file-scoping because opts.FilePaths is only applied to
vector.BuildWhereFilter and not enforced after the FTS fallback or BFS fusion;
add a post-filter that enforces FilesRead/FilesModified against opts.FilePaths
(e.g., a helper like filterObservationsByFilePaths(ctx, observations,
opts.FilePaths) that checks observation.FilesRead/FilesModified), and invoke it
whenever observations is assigned from fetchObservationsByID or
searchFallbackObservations (references: vector.BuildWhereFilter,
searchFallbackObservations, ExtractSessionEntitySeeds, lookupGraphSeedNeighbors,
fetchObservationsByID) so the final observations always respect the original
FilePaths scope.
🧹 Nitpick comments (6)
internal/vector/types_test.go (1)

39-43: Снизьте хрупкость теста к внутренней форме фильтра.

Проверка require.Len(t, clause.OrGroup, 0) для каждого clause излишне привязывает тест к текущему внутреннему представлению. Лучше оставить проверку только семантики (отсутствие files_modified/files_read), чтобы безопасно рефакторить builder.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/vector/types_test.go` around lines 39 - 43, The test is too coupled
to the filter's internal form by asserting require.Len(t, clause.OrGroup, 0);
remove that assertion and only assert the semantic expectations (that none of
the clauses has clause.Column equal to "files_modified" or "files_read"),
keeping the loop over filter.Clauses but dropping checks against clause.OrGroup
so the builder implementation can be refactored safely; specifically update the
test that iterates filter.Clauses to no longer reference clause.OrGroup and only
perform the assert.NotEqual checks.
plugin/openclaw-engram/src/client.ts (1)

370-388: Рассмотрите более надёжный парсинг MCP-ответа.

Текущая проверка через includes() на строку Rated observation ${id} as ${rating} хрупка — если формат сообщения на сервере изменится, метод вернёт false без индикации ошибки. Рассмотрите проверку на структуру успешного MCP-ответа вместо текстового содержимого.

♻️ Возможный рефакторинг
   async rateObservation(id: number, rating: 'useful' | 'not_useful'): Promise<boolean> {
-    const resp = await this.post<{ result?: { content?: Array<{ text?: string }> } }>(
+    const resp = await this.post<{ result?: { content?: Array<{ text?: string }> }; error?: { message?: string } }>(
       '/mcp',
       {
         jsonrpc: '2.0',
         id: 1,
         method: 'tools/call',
         params: {
           name: 'feedback',
           arguments: {
             action: 'rate',
             id,
             rating,
           },
         },
       },
     );
-    return resp?.result?.content?.[0]?.text?.includes(`Rated observation ${id} as ${rating}`) ?? false;
+    if (resp?.error) {
+      console.error(`[engram] rateObservation failed: ${resp.error.message}`);
+      return false;
+    }
+    return resp?.result?.content != null && resp.result.content.length > 0;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugin/openclaw-engram/src/client.ts` around lines 370 - 388, The current
rateObservation implementation relies on string matching the message text which
is brittle; update rateObservation to validate the MCP response structure
instead: inspect the returned object from this.post (the resp variable) and
check for an explicit success indicator (e.g., a boolean or status field under
resp.result or in resp.result.content[0]) or a structured payload (e.g.,
content[0].type === 'status' and content[0].status === 'ok' or content[0].id ===
id and content[0].rating === rating) rather than using includes on text; if the
expected structure is absent, treat it as an error (throw or return false) so
failures are detectable. Use the existing rateObservation and post symbols to
locate and modify the parsing logic and add clear guards for resp, resp.result,
and resp.result.content array shape before evaluating success.
internal/worker/retrieval_helpers.go (1)

230-240: Рассмотрите логирование ошибок при получении entity observations.

Ошибки при получении entity observations игнорируются молча. Хотя это может быть намеренным для обеспечения отказоустойчивости, логирование на уровне Debug помогло бы при диагностике проблем.

♻️ Предлагаемое изменение
 	var entityObservations []*models.Observation
 	if s.retrievalHooks != nil && s.retrievalHooks.getEntityObservationsBySession != nil {
-		entityObservations, _ = s.retrievalHooks.getEntityObservationsBySession(ctx, sessionID)
+		var err error
+		entityObservations, err = s.retrievalHooks.getEntityObservationsBySession(ctx, sessionID)
+		if err != nil {
+			log.Debug().Err(err).Str("session_id", sessionID).Msg("failed to get entity observations via hook")
+		}
 	} else if s.observationStore != nil {
-		allSessionObservations, _ := s.observationStore.GetObservationsBySession(ctx, sessionID)
+		allSessionObservations, err := s.observationStore.GetObservationsBySession(ctx, sessionID)
+		if err != nil {
+			log.Debug().Err(err).Str("session_id", sessionID).Msg("failed to get session observations")
+		}
 		for _, obs := range allSessionObservations {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/worker/retrieval_helpers.go` around lines 230 - 240, The code
silently ignores errors when fetching entity observations; update the branches
that call s.retrievalHooks.getEntityObservationsBySession and
s.observationStore.GetObservationsBySession to capture their returned errors and
log them (at Debug level) via the existing logger (include context like
sessionID and the error), then proceed with fallback behavior; target the
variables/functions entityObservations,
s.retrievalHooks.getEntityObservationsBySession,
s.observationStore.GetObservationsBySession and models.Observation so the
error-logging is added immediately after each call without changing the current
success/fallback flow.
internal/worker/sdk/processor.go (2)

485-488: Ошибки DecideMerge обрабатываются молча.

При ошибке DecideMerge функция возвращает nil, false, nil, что приводит к fallback на create path. Это обеспечивает отказоустойчивость, но может скрывать постоянные проблемы с LLM.

🔍 Рекомендация

Добавьте логирование ошибки на уровне Warning для облегчения диагностики:

 	decision, err := learning.DecideMerge(ctx, p.llmClient, newObs, candidates)
 	if err != nil {
+		log.Warn().Err(err).Str("project", project).Msg("write-merge: DecideMerge failed, falling back to create path")
 		return nil, false, nil
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/worker/sdk/processor.go` around lines 485 - 488, В блоке где
вызывается learning.DecideMerge (decision, err := learning.DecideMerge(ctx,
p.llmClient, newObs, candidates)) не логируется ошибка — при err вы просто
возвращаете nil, false, nil; добавьте логирование уровня Warning/Warningf перед
возвратом, включив текст ошибки и полезный контекст (например идентификаторы
запроса/обсервации или длину candidates) используя текущий логгер в p (или
processLogger), чтобы не терять информацию о повторяющихся проблемах с LLM.

691-696: Логика определения UPDATE-пути хрупкая.

Проверка mergedObs.Narrative.String != obs.Narrative || mergedObs.Title.String != obs.Title || len(mergedObs.Facts) != len(obs.Facts) для определения, был ли выполнен UPDATE, может дать ложноположительные результаты если merge просто объединил идентичные значения.

Рекомендуется использовать явный флаг или возвращать тип действия из applyWriteMergeDecision.

♻️ Предлагаемый подход

Измените возвращаемое значение applyWriteMergeDecision чтобы явно указывать тип выполненного действия:

type writeMergeResult struct {
    observation *models.Observation
    action      string // "update", "supersede", "skip", ""
}

func (p *Processor) applyWriteMergeDecision(...) (writeMergeResult, error) {
    // ...
    case learning.MergeActionUpdate:
        // ...
        return writeMergeResult{observation: updated, action: "update"}, nil
    // ...
}

Это позволит точно определить, какое действие было выполнено, без сравнения полей.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/worker/sdk/processor.go` around lines 691 - 696, The current
UPDATE-path detection is fragile because it compares mergedObs fields to obs;
modify applyWriteMergeDecision to return an explicit result type (e.g.,
writeMergeResult with fields observation *models.Observation and action
string/const like "update"/"supersede"/"skip") and update all callers to check
result.action instead of comparing mergedObs.Narrative/Title/Facts; specifically
replace the mergedObs vs obs comparison around storedCount with a check like
result.action == "update" (or the chosen enum) and use result.observation for
the updated row; update any tests and call sites that expect the old return
signature accordingly.
internal/worker/sdk/processor_test.go (1)

1889-1920: Не собирайте test XML конкатенацией без escaping.

Этот helper теперь сериализует и commands_run, а shell-команды очень быстро приносят &, < и >. Как только в фикстуре появится что-то вроде go test ./... && go vet ./..., тест начнёт падать на невалидном XML вместо проверки merge-логики.

Лучше собирать payload через encoding/xml или хотя бы прогонять значения через xml.EscapeText перед вставкой.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/worker/sdk/processor_test.go` around lines 1889 - 1920,
testObservationXML builds XML by concatenating raw strings which breaks when
values contain XML-special chars (e.g. commands_run with "&" or "<"); update
testObservationXML to escape all inserted text (title, narrative) and each
element in facts, concepts, filesRead, filesModified and commands (commands_run)
using encoding/xml (e.g. xml.EscapeText) or by constructing the payload with
encoding/xml marshalling instead of string concat so the resulting XML is always
valid; ensure the function still returns an <observation> with the same element
names and that commands are serialized into <commands_run>/<command> elements.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/config/config.go`:
- Around line 753-777: Add handling for the ENGRAM_TYPE_SEARCH_LANES environment
override: read strings.TrimSpace(os.Getenv("ENGRAM_TYPE_SEARCH_LANES")), and if
non-empty attempt to json.Unmarshal it into the existing lane-overrides map on
the config (e.g., cfg.TypeSearchLanes or the TypeSearch lane overrides field),
and only assign the parsed map when unmarshalling succeeds (log or ignore errors
consistent with nearby blocks). Place this new block alongside the other env
flag parses (near cfg.TypeLanesEnabled, cfg.ProjectBriefingEnabled, etc.) so the
runtime can override the settings.json rollout map via environment variable.

In `@internal/db/gorm/observation_store.go`:
- Around line 524-531: The GetProjectBriefingObservation function can run the
query with project=="" and match global/empty-project rows; add a guard at the
start of GetProjectBriefingObservation to reject empty project values (e.g.,
return an error such as gorm.ErrRecordNotFound or a validation error) before
calling s.db.Scopes(activeObservationFilter(), importanceOrdering()) so the DB
WHERE("project = ?", project) is never executed with an empty string; update
callers/tests if they rely on empty-project semantics. Ensure references:
GetProjectBriefingObservation, dbObservation, activeObservationFilter,
importanceOrdering.
- Around line 565-579: The query in GetObservationsByCommandPrefix builds
pattern from user input and passes it to LIKE, so unescaped '%' and '_' in
command will act as wildcards; fix by escaping '%' and '_' in the command before
appending '%' (e.g. use strings.ReplaceAll to replace "%" -> "\\%" and "_" ->
"\\_"), set pattern := escaped + "%" and update the SQL predicate to use LIKE ?
ESCAPE '\' (i.e. WHERE "EXISTS (SELECT 1 FROM
jsonb_array_elements_text(commands_run) AS cmd WHERE cmd LIKE ? ESCAPE '\\')")
so commands_run is matched as a literal prefix.

In `@internal/learning/merge.go`:
- Around line 83-93: parseMergeDecision currently accepts MergeActionUpdate and
MergeActionSupersede even when the parsed.MergeDecision has no valid target
(TargetID == 0); update/supersede must require a concrete target. Modify
parseMergeDecision (and use the MergeDecision struct's TargetID field) so that
after unmarshalling you additionally check that for actions MergeActionUpdate
and MergeActionSupersede parsed.TargetID > 0; if not, return MergeDecision{}
false (so caller can fallback to CREATE_NEW) — keep existing returns for
MergeActionCreateNew and MergeActionSkip unchanged.

In `@internal/mcp/tools_memory.go`:
- Around line 198-210: The code enters the dedupResult.Action ==
dedup.ActionUpdate branch but when storePathSupersessionEnabled is false it logs
that nothing was superseded while leaving contradictionAction as UPDATE; change
the branch so that when storePathSupersessionEnabled is false you explicitly set
the contradictionAction (or dedupResult.Action used downstream) to a
non-update/no-op value (e.g., dedup.ActionIgnore/Noop) to reflect that no update
occurred; make the same fix in the similar spots around the later handling (the
logic around contradictionAction at lines ~260 and the block at ~317-327) so the
API and subsequent code behave consistently.

In `@internal/vector/pgvector/client.go`:
- Around line 341-359: The code builds SQL fragments using oc.Operator (e.g., in
the orParts assembly in client.go) without validating it, allowing malformed
operators into the query; add an explicit allowlist of permitted operators
(e.g., "=", "!=","<>", "<", ">", "<=", ">=", "ILIKE", "LIKE", "?|", "@>", "<@")
and validate/normalize operator := oc.Operator against that list before using it
in fmt.Sprintf; if the operator is empty default to "=" and if it is not in the
allowlist return an error (or sanitize to a safe default) so invalid or
malicious operators cannot reach the constructed SQL; apply the same validation
logic to the other similar block referenced around lines 366-371 that also
injects oc.Operator into orParts/andParts or WHERE building (e.g., WhereFilter
handling).
- Around line 319-326: The allowlist in buildWhereClauses includes
files_modified and files_read which do not exist on the vectors table and will
cause "column does not exist" SQL errors when Query() builds WHERE clauses;
remove "files_modified" and "files_read" from the allowedColumns map in
buildWhereClauses (and any other allowlist definitions) so Query() will not emit
filters for those fields until storage for metadata is implemented, or
alternatively implement a proper storage change: add a JSONB metadata column to
the vectors table via a migration, add a corresponding field to the vectorRecord
GORM model, and update sync.go to persist Document.Metadata into that column
before re-enabling those keys in the allowlist.

In `@internal/worker/handlers_ingest.go`:
- Around line 219-221: The current branch that calls
s.observationStore.MarkAsSuperseded (guarded by
config.Get().StorePathSupersessionEnabled) swallows the returned error; change
it to capture the error from MarkAsSuperseded, and on non-nil error either
return it from the handler or propagate/report it (e.g., wrap with context about
ingestDedupResult.ExistingID and the operation) instead of ignoring it so the
handler does not continue a success flow when supersession fails.

In `@internal/worker/retrieval.go`:
- Around line 82-128: The feature flag check is too strict: typeLanesEnabled()
currently requires s.config.TypeSearchLanes to be non-empty so a config that
only wants default lanes won’t enable type lanes; remove the len(...) > 0
requirement so the flag respects s.config.TypeLanesEnabled when a config exists
(update typeLanesEnabled to return s.config != nil &&
s.config.TypeLanesEnabled). Also ensure laneWeightMap() returns weights for
defaults when the config has no TypeSearchLanes by merging
search.DefaultSearchLanes with s.config.TypeSearchLanes (use
models.ObservationType(name) keys, preferring explicit config values but falling
back to search.DefaultSearchLanes), and verify related consumers like
laneConfigForType / typedLaneMinScore still get appropriate fallbacks.

In `@internal/worker/sdk/parser.go`:
- Around line 97-98: The CommandsRun values extracted in parser.go via
extractArrayElements must be sanitized to mask secrets before persistence;
update the parsing path (in parser.go around the extractArrayElements call and
the extractArrayElements implementation) to run each command string through a
sanitizer function (e.g., MaskSecrets or SanitizeCommand) that detects and
redacts tokens, passwords, API keys and SSH credentials, or alternatively call
that sanitizer from the persistence layer in observation_store.go before saving
to JSONB; reuse the same masking conventions used for EncryptedSecret handling
so the redaction format is consistent across credential observations and ensure
both occurrences (lines ~97–98 and ~158–159) use this sanitizer.

In `@internal/worker/trigger_matcher_bash_test.go`:
- Around line 46-58: The tests seed observations into a shared DB using fixed
project/sdkSessionID which breaks isolation across runs; modify the setup in
testObservationStoreForWorker / the calls to sessionStore.CreateSDKSession and
seedBashTriggerObservation to use a unique namespace (e.g., include t.Name() or
a UUID in the project or sdkSessionID) or add explicit teardown that deletes
inserted rows from observationStore after the test completes (ensure cleanup
removes rows created by seedBashTriggerObservation and any session rows created
by CreateSDKSession) so repeated or parallel runs don't affect assertions like
Len == 3 and Len == 0.
- Line 23: The test uses a literal "Bearer sk-..." token in secretCommand which
will trigger secret scanners; change secretCommand so the token is not a
contiguous realistic-looking secret (e.g., build it from concatenated parts or
replace with a safe sentinel string that still triggers
privacy.ContainsSecrets). Update the declaration of secretCommand in the test
and any assertions that rely on its value (keep the header and URL semantics) so
the test behavior is preserved but the raw "sk-..." pattern is not present as a
single literal.

In `@internal/worker/trigger_matcher.go`:
- Around line 106-114: The fallback in filePathObservations ignores the project
parameter and calls observationStore.GetObservationsByFile only with filePath,
breaking project isolation; modify filePathObservations to respect project by
either calling a project-aware store method (e.g.,
observationStore.GetObservationsByProjectAndFile(ctx, project, filePath, limit))
or by filtering the results of GetObservationsByFile to only include
observations whose Project equals the given project; ensure this change is
applied in the branch where retrievalHooks.filePathObservations is nil and when
observationStore is non-nil so project-scoped observations are returned.
- Around line 244-245: The buildEditWriteTriggerQuery function currently embeds
the entire params payload (via toJSONString(params)) which can leak secrets and
large user text; update buildEditWriteTriggerQuery to serialize only a safe
whitelist of allowed keys from params (e.g., small metadata fields like action,
filePath, userId, timestamp or whatever project-safe keys you define) and omit
file content/patch fields and any keys that may contain secrets; locate the
function buildEditWriteTriggerQuery and replace the full toJSONString(params)
usage with a routine that constructs a new map containing only the permitted
keys and then serializes that map for the query.

In `@pkg/models/observation.go`:
- Around line 239-240: The CommandsRun field (type JSONStringArray) is currently
persisted and returned raw, which exposes secrets in shell/Bash commands; update
the handling around CommandsRun (and any serialization paths that emit it) to
either persist a redacted/normalized version or omit secret-containing entries
before saving/serializing—implement a sanitizer function (e.g.,
redactShellSecrets) and call it from the model's marshaling/save flow where
CommandsRun is prepared (and likewise for any code paths that read/write
CommandsRun, such as JSON marshaling helpers or DB write helpers) so stored and
API-output values do not include raw tokens/secrets.

In `@plugin/engram/hooks/pre-tool-use.js`:
- Around line 131-146: In fetchTriggerContext, the normalized command/filename
values (derived from aliases like cmd/command and file/filename via getString()
and extractFilePath()) are computed but you still pass the original toolInput
into lib.requestPost; update the payload to send the canonicalized object (e.g.,
build a normalizedInput with normalized command and file_path fields or use the
URLSearchParams values) so lib.requestPost('/api/memory/triggers', ...) receives
the normalized keys; modify the lib.requestPost call inside fetchTriggerContext
to send that normalizedInput instead of toolInput and ensure the session/project
fields remain included.

In `@README.md`:
- Line 20: Unify the wording about the inject behavior: choose one phrasing
(either "returns nothing" or "returns an empty relevant-memory block") and make
it consistent in the intro, the paragraph that currently reads "Since
learning-memory-v4, context injection treats **silence as valid**..." and the
referenced lines around 138-139; update the sentence that mentions "returns
nothing instead of force-filling..." or the sentence that mentions "returns an
empty block" to use the same term and ensure the ENGRAM_INJECT_UNIFIED note
remains intact so readers understand inject/search share the unified retrieval
pipeline semantics.

In `@ui/src/views/LearningView.vue`:
- Around line 37-42: The hit-rate widget is being fetched without the selected
period, so it doesn't update with Last 7/30/90; update the calls to
fetchHitRateAnalytics to include the selectedDays period (e.g., pass
selectedDays.value or map to the API param) where it's invoked (the Promise.all
call with fetchHitRateAnalytics and the other occurrences around lines
referenced), and if the API signature lacks a period parameter, add an optional
period argument to fetchHitRateAnalytics and thread it through the API client so
the widget receives period-filtered data; alternatively, if you intentionally
want a snapshot, rename the UI section to "Snapshot" instead of changing the
API.

---

Outside diff comments:
In `@internal/worker/handlers_data.go`:
- Around line 998-1008: Godoc над функцией сейчас описывает
handleGetSimilarityTelemetry, тогда как под ним объявлена
handleMCPHitRateAnalytics; переместите/восстановите правильный комментарий:
вытащите существующий блок Godoc (котрый описывает handleGetSimilarityTelemetry)
и перенесите его над реальной функции handleGetSimilarityTelemetry, а над
текущей функции handleMCPHitRateAnalytics добавьте корректную документацию (или
новый краткий Godoc), чтобы комментарии соответствовали именам функций
handleGetSimilarityTelemetry и handleMCPHitRateAnalytics.

In `@internal/worker/retrieval.go`:
- Around line 227-323: The retrieval pipeline loses file-scoping because
opts.FilePaths is only applied to vector.BuildWhereFilter and not enforced after
the FTS fallback or BFS fusion; add a post-filter that enforces
FilesRead/FilesModified against opts.FilePaths (e.g., a helper like
filterObservationsByFilePaths(ctx, observations, opts.FilePaths) that checks
observation.FilesRead/FilesModified), and invoke it whenever observations is
assigned from fetchObservationsByID or searchFallbackObservations (references:
vector.BuildWhereFilter, searchFallbackObservations, ExtractSessionEntitySeeds,
lookupGraphSeedNeighbors, fetchObservationsByID) so the final observations
always respect the original FilePaths scope.

In `@plugin/engram/hooks/lib.js`:
- Around line 348-400: Both incrementSessionSignals and appendSessionFile
perform unsafe read-modify-write on the same file returned by
_signalPath(sessionID); you must serialize access to avoid lost
updates—implement a small helper (e.g. with a lock-file or atomic file-rename +
retry loop) like withSessionSignalUpdate(sessionID, updater) that acquires a
per-session lock, reads JSON, calls updater(current) to get the new state,
writes atomically (write to temp + rename), and releases the lock; then refactor
incrementSessionSignals and appendSessionFile to call
withSessionSignalUpdate(sessionID, current => { ...modify current as before...
return next; }) so all updates are synchronized across processes.

---

Nitpick comments:
In `@internal/vector/types_test.go`:
- Around line 39-43: The test is too coupled to the filter's internal form by
asserting require.Len(t, clause.OrGroup, 0); remove that assertion and only
assert the semantic expectations (that none of the clauses has clause.Column
equal to "files_modified" or "files_read"), keeping the loop over filter.Clauses
but dropping checks against clause.OrGroup so the builder implementation can be
refactored safely; specifically update the test that iterates filter.Clauses to
no longer reference clause.OrGroup and only perform the assert.NotEqual checks.

In `@internal/worker/retrieval_helpers.go`:
- Around line 230-240: The code silently ignores errors when fetching entity
observations; update the branches that call
s.retrievalHooks.getEntityObservationsBySession and
s.observationStore.GetObservationsBySession to capture their returned errors and
log them (at Debug level) via the existing logger (include context like
sessionID and the error), then proceed with fallback behavior; target the
variables/functions entityObservations,
s.retrievalHooks.getEntityObservationsBySession,
s.observationStore.GetObservationsBySession and models.Observation so the
error-logging is added immediately after each call without changing the current
success/fallback flow.

In `@internal/worker/sdk/processor_test.go`:
- Around line 1889-1920: testObservationXML builds XML by concatenating raw
strings which breaks when values contain XML-special chars (e.g. commands_run
with "&" or "<"); update testObservationXML to escape all inserted text (title,
narrative) and each element in facts, concepts, filesRead, filesModified and
commands (commands_run) using encoding/xml (e.g. xml.EscapeText) or by
constructing the payload with encoding/xml marshalling instead of string concat
so the resulting XML is always valid; ensure the function still returns an
<observation> with the same element names and that commands are serialized into
<commands_run>/<command> elements.

In `@internal/worker/sdk/processor.go`:
- Around line 485-488: В блоке где вызывается learning.DecideMerge (decision,
err := learning.DecideMerge(ctx, p.llmClient, newObs, candidates)) не логируется
ошибка — при err вы просто возвращаете nil, false, nil; добавьте логирование
уровня Warning/Warningf перед возвратом, включив текст ошибки и полезный
контекст (например идентификаторы запроса/обсервации или длину candidates)
используя текущий логгер в p (или processLogger), чтобы не терять информацию о
повторяющихся проблемах с LLM.
- Around line 691-696: The current UPDATE-path detection is fragile because it
compares mergedObs fields to obs; modify applyWriteMergeDecision to return an
explicit result type (e.g., writeMergeResult with fields observation
*models.Observation and action string/const like "update"/"supersede"/"skip")
and update all callers to check result.action instead of comparing
mergedObs.Narrative/Title/Facts; specifically replace the mergedObs vs obs
comparison around storedCount with a check like result.action == "update" (or
the chosen enum) and use result.observation for the updated row; update any
tests and call sites that expect the old return signature accordingly.

In `@plugin/openclaw-engram/src/client.ts`:
- Around line 370-388: The current rateObservation implementation relies on
string matching the message text which is brittle; update rateObservation to
validate the MCP response structure instead: inspect the returned object from
this.post (the resp variable) and check for an explicit success indicator (e.g.,
a boolean or status field under resp.result or in resp.result.content[0]) or a
structured payload (e.g., content[0].type === 'status' and content[0].status ===
'ok' or content[0].id === id and content[0].rating === rating) rather than using
includes on text; if the expected structure is absent, treat it as an error
(throw or return false) so failures are detectable. Use the existing
rateObservation and post symbols to locate and modify the parsing logic and add
clear guards for resp, resp.result, and resp.result.content array shape before
evaluating success.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1fc6bfd6-2b19-48ae-800c-b615243fbd25

📥 Commits

Reviewing files that changed from the base of the PR and between bec12e6 and 5c88e70.

📒 Files selected for processing (75)
  • CHANGELOG.md
  • README.md
  • README.ru.md
  • README.zh.md
  • docs/arch/GOTCHAS.md
  • internal/config/config.go
  • internal/config/config_test.go
  • internal/db/gorm/models.go
  • internal/db/gorm/observation_store.go
  • internal/db/gorm/observation_store_test.go
  • internal/dedup/checker.go
  • internal/instincts/dedup.go
  • internal/learning/briefing.go
  • internal/learning/briefing_test.go
  • internal/learning/merge.go
  • internal/learning/merge_test.go
  • internal/maintenance/near_dedup.go
  • internal/maintenance/service.go
  • internal/maintenance/service_test.go
  • internal/mcp/server.go
  • internal/mcp/server_test.go
  • internal/mcp/store_supersession_test.go
  • internal/mcp/tools_memory.go
  • internal/mcp/tools_store_consolidated.go
  • internal/relation/detector.go
  • internal/search/lanes.go
  • internal/search/lanes_test.go
  • internal/search/manager.go
  • internal/search/manager_test.go
  • internal/telemetry/similarity.go
  • internal/vector/pgvector/client.go
  • internal/vector/pgvector/client_sql_test.go
  • internal/vector/types.go
  • internal/vector/types_test.go
  • internal/worker/handlers_context.go
  • internal/worker/handlers_context_briefing_test.go
  • internal/worker/handlers_context_inject_unified_test.go
  • internal/worker/handlers_data.go
  • internal/worker/handlers_data_test.go
  • internal/worker/handlers_ingest.go
  • internal/worker/handlers_ingest_test.go
  • internal/worker/handlers_triggers.go
  • internal/worker/handlers_triggers_test.go
  • internal/worker/retrieval.go
  • internal/worker/retrieval_helpers.go
  • internal/worker/retrieval_test.go
  • internal/worker/sdk/parser.go
  • internal/worker/sdk/processor.go
  • internal/worker/sdk/processor_test.go
  • internal/worker/service.go
  • internal/worker/service_routes_test.go
  • internal/worker/trigger_matcher.go
  • internal/worker/trigger_matcher_bash_test.go
  • internal/worker/trigger_matcher_read_test.go
  • internal/worker/trigger_matcher_test.go
  • pkg/models/observation.go
  • plugin/engram/.claude-plugin/plugin.json
  • plugin/engram/commands/issue.md
  • plugin/engram/commands/retro.md
  • plugin/engram/hooks/lib.js
  • plugin/engram/hooks/lib.test.js
  • plugin/engram/hooks/pre-tool-use.js
  • plugin/engram/hooks/pre-tool-use.test.js
  • plugin/engram/hooks/session-start.js
  • plugin/engram/hooks/session-start.test.js
  • plugin/engram/hooks/user-prompt.js
  • plugin/engram/hooks/user-prompt.test.js
  • plugin/engram/skills/memory/SKILL.md
  • plugin/openclaw-engram/openclaw.plugin.json
  • plugin/openclaw-engram/package.json
  • plugin/openclaw-engram/src/client.ts
  • plugin/openclaw-engram/src/tools/engram-issues.ts
  • plugin/openclaw-engram/src/tools/engram-rate.ts
  • ui/src/utils/api.ts
  • ui/src/views/LearningView.vue

Comment thread internal/config/config.go
Comment thread internal/db/gorm/observation_store.go
Comment thread internal/db/gorm/observation_store.go
Comment thread internal/learning/merge.go
Comment thread internal/mcp/tools_memory.go
Comment thread internal/worker/trigger_matcher.go Outdated
Comment thread pkg/models/observation.go
Comment thread plugin/engram/hooks/pre-tool-use.js
Comment thread README.md Outdated
Comment thread ui/src/views/LearningView.vue
thebtf added 2 commits April 11, 2026 23:58
…iggers

Fix write-merge action handling for SUPERSEDE/UPDATE, preserve typed-lane MinScore semantics under graph fusion, and make repeated-read triggers work in remote worker setups by passing read-count signals in trigger requests.
Scope read-trigger file lookups by project, strip sensitive payloads from Edit/Write trigger queries, normalize trigger request params in pre-tool-use hook, and reject UPDATE/SUPERSEDE merge decisions without target IDs.
@thebtf
Copy link
Copy Markdown
Owner Author

thebtf commented Apr 11, 2026

🤖 PR Review MCP State (auto-managed, do not edit)
{
  "version": 2,
  "parentChildren": {},
  "resolvedNitpicks": {
    "coderabbit-nitpick-fcc92962-691": {
      "resolvedAt": "2026-04-11T21:06:57.434Z",
      "resolvedBy": "agent"
    },
    "coderabbit-outside-diff-fcc92962-691": {
      "resolvedAt": "2026-04-11T21:07:00.460Z",
      "resolvedBy": "agent"
    },
    "coderabbit-nitpick-83d257e9-485": {
      "resolvedAt": "2026-04-11T21:07:03.065Z",
      "resolvedBy": "agent"
    },
    "coderabbit-outside-diff-83d257e9-485": {
      "resolvedAt": "2026-04-11T21:07:05.771Z",
      "resolvedBy": "agent"
    },
    "coderabbit-nitpick-c839c8da-39": {
      "resolvedAt": "2026-04-11T21:38:50.052Z",
      "resolvedBy": "agent"
    },
    "coderabbit-nitpick-55d37369-370": {
      "resolvedAt": "2026-04-11T21:38:52.679Z",
      "resolvedBy": "agent"
    },
    "coderabbit-nitpick-d5dafde0-230": {
      "resolvedAt": "2026-04-11T21:38:55.637Z",
      "resolvedBy": "agent"
    },
    "coderabbit-nitpick-6c36cc5c-1889": {
      "resolvedAt": "2026-04-11T21:38:58.760Z",
      "resolvedBy": "agent"
    },
    "coderabbit-outside-diff-68337db7-39": {
      "resolvedAt": "2026-04-11T21:39:01.387Z",
      "resolvedBy": "agent"
    },
    "coderabbit-outside-diff-55d37369-370": {
      "resolvedAt": "2026-04-11T21:39:04.025Z",
      "resolvedBy": "agent"
    },
    "coderabbit-outside-diff-d5dafde0-230": {
      "resolvedAt": "2026-04-11T21:39:06.727Z",
      "resolvedBy": "agent"
    },
    "coderabbit-outside-diff-6c36cc5c-1889": {
      "resolvedAt": "2026-04-11T21:39:09.303Z",
      "resolvedBy": "agent"
    },
    "coderabbit-nitpick-d52599af-1828": {
      "resolvedAt": "2026-04-11T22:49:23.707Z",
      "resolvedBy": "agent"
    },
    "coderabbit-nitpick-4d0e6e77-2117": {
      "resolvedAt": "2026-04-11T22:49:27.325Z",
      "resolvedBy": "agent"
    },
    "coderabbit-nitpick-c054b22e-402": {
      "resolvedAt": "2026-04-11T23:04:43.399Z",
      "resolvedBy": "agent"
    },
    "coderabbit-nitpick-bf4d34b3-280": {
      "resolvedAt": "2026-04-11T23:04:48.411Z",
      "resolvedBy": "agent"
    },
    "coderabbit-nitpick-ff82e760-63": {
      "resolvedAt": "2026-04-11T23:07:07.137Z",
      "resolvedBy": "agent"
    },
    "coderabbit-nitpick-d6431c9b-500": {
      "resolvedAt": "2026-04-11T23:07:10.086Z",
      "resolvedBy": "agent"
    }
  },
  "updatedAt": "2026-04-11T23:07:10.523Z"
}

thebtf added 2 commits April 12, 2026 00:24
…nitization

Addresses unresolved review threads for lane config env override, command-prefix escaping,
commands_run redaction, trigger query hardening, and test isolation improvements.
Resolve remaining CodeRabbit findings by reducing test coupling, hardening MCP response parsing, adding debug logging for entity observation fetch failures, escaping XML in test payload construction, and aligning wording/UI labels with snapshot behavior.

Reviewer comment ids: coderabbit-nitpick-c839c8da-39, coderabbit-nitpick-55d37369-370, coderabbit-nitpick-d5dafde0-230, coderabbit-nitpick-6c36cc5c-1889, PRRC_kwDORY-ek8625tfQ, PRRC_kwDORY-ek8625tfS
Thread ids: coderabbit-nitpick-c839c8da-39, coderabbit-nitpick-55d37369-370, coderabbit-nitpick-d5dafde0-230, coderabbit-nitpick-6c36cc5c-1889, PRRT_kwDORY-ek856Uw-D, PRRT_kwDORY-ek856Uw-F
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/db/gorm/observation_store.go (1)

1009-1043: ⚠️ Potential issue | 🟠 Major

Санитизируйте commands_run и в raw-scan путях.

StoreObservation и toModelObservation редактируют команды, но оба raw-query path'а (SearchObservationsFTSScored и scanObservation) читают commands_run как есть. Для старых строк или данных, записанных в обход store, это снова открывает утечку secret-ов через search/recall ответы.

Also applies to: 1508-1539

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/db/gorm/observation_store.go` around lines 1009 - 1043, The raw-scan
code paths (SearchObservationsFTSScored and scanObservation) currently unmarshal
commands_run into obs.CommandsRun without sanitizing, which can leak secrets;
update both raw-scan locations to: unmarshal commandsRunJSON into
obs.CommandsRun and then apply the same command-sanitization used by
StoreObservation and toModelObservation (i.e., call the existing sanitization
routine those functions use) so stored/legacy rows are normalized before being
returned; make the identical change in the other raw-scan block referenced (the
1508-1539 region).
♻️ Duplicate comments (3)
internal/worker/handlers_ingest.go (1)

219-226: ⚠️ Potential issue | 🟠 Major

Не продолжайте ingest как успешный, если supersede не сработал.

После StoreObservation новый observation уже записан. Если MarkAsSuperseded падает, старый contradictory row остаётся active, новый тоже остаётся active, а handler всё равно отвечает 202 Accepted. Здесь нужен error/compensation path, а не тихий success flow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/worker/handlers_ingest.go` around lines 219 - 226, The handler
currently continues and returns 202 even if s.observationStore.MarkAsSuperseded
fails, leaving both old and new observations active; change the flow so that
when config.Get().StorePathSupersessionEnabled is true and
s.observationStore.MarkAsSuperseded(r.Context(), ingestDedupResult.ExistingID)
returns an error you abort the ingest and surface an error response instead of
treating it as success, and perform a compensation/rollback for the
already-created observation from StoreObservation (e.g., call the store's
delete/undo method for the new observation ID or otherwise revert the write)
before returning the error; update the handler to return a non-202 error and
include the original error in the log/response so callers know the supersede
failed.
internal/worker/trigger_matcher.go (1)

117-133: ⚠️ Potential issue | 🟠 Major

Фильтрация после unscoped Limit всё ещё теряет project-local matches.

GetObservationsByFile режет выборку до limit до всякой project-фильтрации. Для общих путей вроде README.md top-N легко забьётся чужими проектами, а локальные/глобальные observations отфильтруются уже после этого и вернётся ложный пустой результат. Лучше делать project/global scope прямо на стороне store-запроса.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/worker/trigger_matcher.go` around lines 117 - 133, The current code
calls s.observationStore.GetObservationsByFile(...) unscoped and then filters by
project in-memory, which lets the store's limit drop project-local matches;
change this so scoping happens in the store query itself: add or use a store
method that accepts a project (e.g., GetObservationsByFileWithProject or extend
GetObservationsByFile to accept a project/global flag), call that from the
TriggerMatcher logic instead of post-filtering, and ensure the store applies the
limit after scoping so the returned slice already contains only the requested
project's observations (remove the in-function scoped post-filtering loop).
internal/db/gorm/observation_store.go (1)

578-584: ⚠️ Potential issue | 🟠 Major

Literal-prefix экранирование для LIKE всё ещё сломано.

В parameter value сейчас попадают двойные \ (\\%, \\_), поэтому % и _ не становятся literal-символами, а одиночный \ вообще не экранируется. Для LIKE ... ESCAPE '\' здесь нужны \%, \_ и \\ в самом значении pattern.

Как быстро проверить текущую форму pattern
#!/bin/bash
python - <<'PY'
samples = ["printf '%s\n'", "echo foo_bar", r"type C:\Temp\file.txt"]
for s in samples:
    out = s.replace(r"\\", r"\\\\").replace("%", r"\\%").replace("_", r"\\_")
    print(f"{s!r} -> {out!r}")
PY

Ожидаемый сигнал: перед % и _ в результате окажутся два \, а не один.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/db/gorm/observation_store.go` around lines 578 - 584, The current
replacer produces the wrong number of backslashes for SQL LIKE ESCAPE '\' — fix
by first replacing every backslash in command with two backslashes, then
prefixing a single backslash before any % and _ characters, and finally append
"%" for the prefix search; update the code around escaper and pattern (replace
the strings.NewReplacer usage and pattern construction) so you perform these two
sequential ReplaceAll steps on strings.TrimSpace(command) before using pattern
in the s.db query (keep the SAME query filter uses: projectScopeFilter,
activeObservationFilter, importanceOrdering and the EXISTS ... LIKE ... ESCAPE
'\\' clause).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/worker/sdk/processor.go`:
- Around line 536-552: The syncObservationFunc call in processor.go is executed
synchronously after observationStore.UpdateObservation (in function/method using
p.observationStore.UpdateObservation and variable updated), which can block the
hot path; change this to dispatch the sync to the same bounded
worker/worker-pool path used by create-path (or at minimum run
p.syncObservationFunc(updated) in a non-blocking goroutine/submit-to-worker
call) so the UpdateObservation return and main processing are not delayed by
vector-sync; ensure you only dispatch when updated != nil and
p.syncObservationFunc != nil, and preserve error/context handling semantics.
- Around line 697-707: StoreObservation is inserting the replacement row before
MarkAsSuperseded and on failure you just continue, leaving the DB in an
inconsistent half-updated state; change the flow to ensure atomicity by either
performing both operations inside a single DB transaction (use a transaction API
on p.observationStore or a BeginTx/WithTx helper to call StoreObservation and
MarkAsSuperseded in one TX and commit/rollback as a unit) or, if transactions
aren't available, delete the newly inserted row (use the returned id from
StoreObservation) and return/propagate an error instead of continue so the
caller sees the failure; update the code paths around
p.observationStore.StoreObservation, mergedObs handling and
p.observationStore.MarkAsSuperseded to implement one of these fixes and ensure
you log and propagate the error rather than skipping broadcast/vector sync.

In `@plugin/engram/hooks/pre-tool-use.js`:
- Around line 29-37: shouldSkipPath fails for Windows because windowsTempMarker
is built as 'T\e\m\p' and the check is case-sensitive; update shouldSkipPath to
normalize the incoming filePath (e.g., toLowerCase()) and check for a proper
Windows temp boundary (e.g., include '\\temp\\' or use a case-insensitive regex
like /[\\/]temp[\\/]/i) instead of the current join-built token; keep the
unixTempMarker and dependencyDir checks but perform them against the normalized
path (or use path.sep-aware checks) so C:\...\Temp\... and c:\...\temp\... are
correctly filtered.
- Around line 177-183: В блоке, где вы формируете triggerInput для Read
(toolName === 'Read'), вы всегда принудительно ставите read_counts[filePath] =
3, из‑за чего threshold repeated-read отключается; исправьте так, чтобы не
перезаписывать существующие счётчики из toolInput/сессии — только установить 3
если для этого filePath нет уже значения (т.е. merge/assign только тогда, когда
triggerInput.read_counts[filePath] === undefined), оставляя extractFilePath,
triggerInput и вызов fetchTriggerContext без других изменений.

---

Outside diff comments:
In `@internal/db/gorm/observation_store.go`:
- Around line 1009-1043: The raw-scan code paths (SearchObservationsFTSScored
and scanObservation) currently unmarshal commands_run into obs.CommandsRun
without sanitizing, which can leak secrets; update both raw-scan locations to:
unmarshal commandsRunJSON into obs.CommandsRun and then apply the same
command-sanitization used by StoreObservation and toModelObservation (i.e., call
the existing sanitization routine those functions use) so stored/legacy rows are
normalized before being returned; make the identical change in the other
raw-scan block referenced (the 1508-1539 region).

---

Duplicate comments:
In `@internal/db/gorm/observation_store.go`:
- Around line 578-584: The current replacer produces the wrong number of
backslashes for SQL LIKE ESCAPE '\' — fix by first replacing every backslash in
command with two backslashes, then prefixing a single backslash before any % and
_ characters, and finally append "%" for the prefix search; update the code
around escaper and pattern (replace the strings.NewReplacer usage and pattern
construction) so you perform these two sequential ReplaceAll steps on
strings.TrimSpace(command) before using pattern in the s.db query (keep the SAME
query filter uses: projectScopeFilter, activeObservationFilter,
importanceOrdering and the EXISTS ... LIKE ... ESCAPE '\\' clause).

In `@internal/worker/handlers_ingest.go`:
- Around line 219-226: The handler currently continues and returns 202 even if
s.observationStore.MarkAsSuperseded fails, leaving both old and new observations
active; change the flow so that when config.Get().StorePathSupersessionEnabled
is true and s.observationStore.MarkAsSuperseded(r.Context(),
ingestDedupResult.ExistingID) returns an error you abort the ingest and surface
an error response instead of treating it as success, and perform a
compensation/rollback for the already-created observation from StoreObservation
(e.g., call the store's delete/undo method for the new observation ID or
otherwise revert the write) before returning the error; update the handler to
return a non-202 error and include the original error in the log/response so
callers know the supersede failed.

In `@internal/worker/trigger_matcher.go`:
- Around line 117-133: The current code calls
s.observationStore.GetObservationsByFile(...) unscoped and then filters by
project in-memory, which lets the store's limit drop project-local matches;
change this so scoping happens in the store query itself: add or use a store
method that accepts a project (e.g., GetObservationsByFileWithProject or extend
GetObservationsByFile to accept a project/global flag), call that from the
TriggerMatcher logic instead of post-filtering, and ensure the store applies the
limit after scoping so the returned slice already contains only the requested
project's observations (remove the in-function scoped post-filtering loop).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e08a3f7c-6395-48a2-a0ba-5c9144da7922

📥 Commits

Reviewing files that changed from the base of the PR and between 5c88e70 and 18dc94a.

📒 Files selected for processing (16)
  • internal/config/config.go
  • internal/db/gorm/observation_store.go
  • internal/learning/merge.go
  • internal/learning/merge_test.go
  • internal/mcp/tools_memory.go
  • internal/vector/pgvector/client.go
  • internal/vector/pgvector/client_sql_test.go
  • internal/worker/handlers_ingest.go
  • internal/worker/retrieval.go
  • internal/worker/retrieval_test.go
  • internal/worker/sdk/processor.go
  • internal/worker/trigger_matcher.go
  • internal/worker/trigger_matcher_bash_test.go
  • internal/worker/trigger_matcher_test.go
  • plugin/engram/hooks/pre-tool-use.js
  • plugin/engram/hooks/pre-tool-use.test.js
✅ Files skipped from review due to trivial changes (3)
  • plugin/engram/hooks/pre-tool-use.test.js
  • internal/config/config.go
  • internal/worker/retrieval_test.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • internal/vector/pgvector/client.go
  • internal/vector/pgvector/client_sql_test.go
  • internal/learning/merge_test.go
  • internal/worker/trigger_matcher_test.go
  • internal/worker/retrieval.go

Comment thread internal/worker/sdk/processor.go Outdated
Comment thread internal/worker/sdk/processor.go
Comment thread plugin/engram/hooks/pre-tool-use.js Outdated
Comment thread plugin/engram/hooks/pre-tool-use.js
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
internal/worker/sdk/processor_test.go (2)

1828-1840: Хрупкое сравнение строки системного промпта.

Сравнение systemPrompt == "You decide whether a new observation should be CREATE_NEW, UPDATE, SUPERSEDE, or SKIP. Return JSON only." на строке 1836 точно соответствует промпту из internal/learning/merge.go:37, но при изменении промпта тест молча начнёт возвращать неверный ответ вместо того, чтобы упасть.

Рекомендуется вынести промпт в экспортируемую константу в пакете learning, чтобы тест использовал её напрямую.

♻️ Предлагаемый рефакторинг

В internal/learning/merge.go:

+// MergeSystemPrompt is the system prompt used for merge decisions.
+const MergeSystemPrompt = "You decide whether a new observation should be CREATE_NEW, UPDATE, SUPERSEDE, or SKIP. Return JSON only."
+
 func DecideMerge(ctx context.Context, llm LLMClient, newObs *models.Observation, candidates []*models.Observation) (MergeDecision, error) {
 	// ...
-	systemPrompt := "You decide whether a new observation should be CREATE_NEW, UPDATE, SUPERSEDE, or SKIP. Return JSON only."
+	systemPrompt := MergeSystemPrompt

В тесте:

 func (m *testMergeLLMClient) Complete(_ context.Context, systemPrompt, _ string) (string, error) {
 	m.calls++
-	if systemPrompt == "You decide whether a new observation should be CREATE_NEW, UPDATE, SUPERSEDE, or SKIP. Return JSON only." {
+	if systemPrompt == learning.MergeSystemPrompt {
 		return m.mergeResponse, nil
 	}
 	return m.observationXML, nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/worker/sdk/processor_test.go` around lines 1828 - 1840, The test
uses a fragile literal string comparison in testMergeLLMClient.Complete
(comparing systemPrompt to "You decide whether a new observation should be
CREATE_NEW, UPDATE, SUPERSEDE, or SKIP. Return JSON only."), so export that
prompt as a constant in the learning package (e.g., LearningMergeSystemPrompt or
MergeSystemPrompt) in internal/learning/merge.go and update the test to import
learning and compare against that constant instead of the hardcoded string;
change the test's Complete method to use learning.MergeSystemPrompt to decide
which response to return.

2117-2170: Дублирование логики управления переменными окружения и использование require в defer.

Тест корректно проверяет поведение при отключённом ContradictionDetectionEnabled: действие SUPERSEDE откатывается к созданию новой записи без пометки старой как superseded (соответствует processor.go:512-519).

Однако:

  1. Управление ENGRAM_CONTRADICTION_DETECTION_ENABLED (строки 2119-2131) дублирует паттерн из setWriteMergeEnabledForTest. Рассмотрите обобщение хелпера.
  2. Использование require.NoError в defer (строки 2125-2130) имеет ту же проблему — лучше использовать t.Errorf.
♻️ Предлагаемый рефакторинг: обобщённый хелпер для env vars
func setEnvForTest(t *testing.T, key string, value string) {
	t.Helper()
	original, hadOriginal := os.LookupEnv(key)
	if err := os.Setenv(key, value); err != nil {
		t.Fatalf("failed to set %s: %v", key, err)
	}
	_, _, err := config.Reload()
	if err != nil {
		t.Fatalf("failed to reload config: %v", err)
	}
	t.Cleanup(func() {
		var restoreErr error
		if hadOriginal {
			restoreErr = os.Setenv(key, original)
		} else {
			restoreErr = os.Unsetenv(key)
		}
		if restoreErr != nil {
			t.Errorf("failed to restore %s: %v", key, restoreErr)
		}
		if _, _, reloadErr := config.Reload(); reloadErr != nil {
			t.Errorf("failed to reload config: %v", reloadErr)
		}
	})
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/worker/sdk/processor_test.go` around lines 2117 - 2170, The test
TestProcessObservation_ContradictionDetectionDisabledKeepsCreateNewPath
duplicates env-var setup/teardown logic and uses require.NoError inside a defer;
extract a reusable helper (e.g., setEnvForTest) similar to
setWriteMergeEnabledForTest to set ENGRAM_CONTRADICTION_DETECTION_ENABLED, call
config.Reload(), and register restoration with t.Cleanup instead of a defer, and
in cleanup use t.Errorf (or t.Fatalf when failing earlier) rather than require
to avoid fatal assertions from deferred code; update the test to call the new
helper and remove the manual os.LookupEnv/os.Setenv/config.Reload block and its
defer.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/worker/retrieval_helpers.go`:
- Around line 85-107: The hook path currently returns raw neighbor IDs while the
graphStore path filters out non-positive IDs and deduplicates; update the branch
that calls s.retrievalHooks.getGraphNeighbors to apply the same normalization:
call s.retrievalHooks.getGraphNeighbors(ctx, seedID, 2, 10), then iterate the
returned slice, skip IDs <= 0, dedupe using a seen map (like the graphStore
branch uses on neighbor.ObsID), and return the normalized []int64; ensure you
reference the hook call (s.retrievalHooks.getGraphNeighbors) and the same
filtering/dedupe logic applied to neighbor.ObsID from s.graphStore.GetNeighbors.
- Around line 215-273: The seed selection is too permissive because entity
titles are added into the same nameSet used to match seeds and the hook branch
can return non-entity observations; update getEntityObservationsBySession
handling to filter results to only models.ObsTypeEntity (apply same filter
currently used for observationStore), and change the nameSet population so it
comes only from prompt tokens and file base names (FilesRead/FilesModified) but
do NOT add obs.Title.String into nameSet when iterating entityObservations; then
keep the existing seedIDs logic that matches obs.Title against nameSet (so an
entity only becomes a seed when its title matches prompt/files, not because its
own title was pre-inserted). Use identifiers: loadLastUserPromptBySession,
s.retrievalHooks.getEntityObservationsBySession,
observationStore.GetObservationsBySession, entityObservations, nameSet,
appendName, seedIDs, and obs.Title.String.

In `@internal/worker/sdk/processor_test.go`:
- Around line 1872-1889: The cleanup function in setWriteMergeEnabledForTest
uses require.NoError inside t.Cleanup which can call t.FailNow() after test
completion; change the cleanup to avoid require and instead use assert.NoError
(from testify) or record errors with t.Errorf/t.Logf inside the anonymous
function used in t.Cleanup to restore the environment and call config.Reload(),
ensuring restoreErr and reloadErr are reported without invoking FailNow. Locate
setWriteMergeEnabledForTest and replace the require.NoError calls inside the
t.Cleanup closure with assert.NoError or conditional t.Errorf so cleanup errors
are reported safely.

---

Nitpick comments:
In `@internal/worker/sdk/processor_test.go`:
- Around line 1828-1840: The test uses a fragile literal string comparison in
testMergeLLMClient.Complete (comparing systemPrompt to "You decide whether a new
observation should be CREATE_NEW, UPDATE, SUPERSEDE, or SKIP. Return JSON
only."), so export that prompt as a constant in the learning package (e.g.,
LearningMergeSystemPrompt or MergeSystemPrompt) in internal/learning/merge.go
and update the test to import learning and compare against that constant instead
of the hardcoded string; change the test's Complete method to use
learning.MergeSystemPrompt to decide which response to return.
- Around line 2117-2170: The test
TestProcessObservation_ContradictionDetectionDisabledKeepsCreateNewPath
duplicates env-var setup/teardown logic and uses require.NoError inside a defer;
extract a reusable helper (e.g., setEnvForTest) similar to
setWriteMergeEnabledForTest to set ENGRAM_CONTRADICTION_DETECTION_ENABLED, call
config.Reload(), and register restoration with t.Cleanup instead of a defer, and
in cleanup use t.Errorf (or t.Fatalf when failing earlier) rather than require
to avoid fatal assertions from deferred code; update the test to call the new
helper and remove the manual os.LookupEnv/os.Setenv/config.Reload block and its
defer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 727a5b6c-d0b7-4c6e-afc7-3aff3864cc5c

📥 Commits

Reviewing files that changed from the base of the PR and between 18dc94a and 13b8706.

📒 Files selected for processing (6)
  • README.md
  • internal/vector/types_test.go
  • internal/worker/retrieval_helpers.go
  • internal/worker/sdk/processor_test.go
  • plugin/openclaw-engram/src/client.ts
  • ui/src/views/LearningView.vue
✅ Files skipped from review due to trivial changes (1)
  • internal/vector/types_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • ui/src/views/LearningView.vue
  • README.md

Comment thread internal/worker/retrieval_helpers.go Outdated
Comment thread internal/worker/retrieval_helpers.go Outdated
Comment thread internal/worker/sdk/processor_test.go Outdated
Address all currently unresolved CodeRabbit threads by hardening write-merge consistency, normalizing retrieval seed/neighbor behavior, and fixing hook trigger/path edge cases with targeted tests.

Reviewer comment ids: coderabbit-nitpick-d52599af-1828, coderabbit-nitpick-4d0e6e77-2117, PRRC_kwDORY-ek86256dL, PRRC_kwDORY-ek86256dP, PRRC_kwDORY-ek86256dS, PRRC_kwDORY-ek86256dT, PRRC_kwDORY-ek86257x9, PRRC_kwDORY-ek86257x_, PRRC_kwDORY-ek86257yB
Thread ids: coderabbit-nitpick-d52599af-1828, coderabbit-nitpick-4d0e6e77-2117, PRRT_kwDORY-ek856U7gL, PRRT_kwDORY-ek856U7gP, PRRT_kwDORY-ek856U7gS, PRRT_kwDORY-ek856U7gT, PRRT_kwDORY-ek856U8jS, PRRT_kwDORY-ek856U8jU, PRRT_kwDORY-ek856U8jW
@thebtf
Copy link
Copy Markdown
Owner Author

thebtf commented Apr 11, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 11, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
internal/worker/retrieval_test.go (1)

280-329: Усилите BFS-тест проверкой отбрасывания невалидных neighbor ID

Сейчас тест проверяет только то, что scores[42] выставлен. Поскольку вход содержит 0 и -1, полезно явно зафиксировать, что такие ID не попадают в скоринг/результаты.

Предложение (минимальное усиление проверки)
 	observations, scores, err := service.RetrieveRelevant(context.Background(), "engram", "auth query", RetrievalOptions{MaxResults: 10, SessionID: "session-1"})
 	require.NoError(t, err)
 	require.Len(t, observations, 1)
 	require.Equal(t, int64(1), observations[0].ID)
 	require.NotZero(t, scores[42])
+	require.Zero(t, scores[0], "invalid neighbor id should not be scored")
+	require.Zero(t, scores[-1], "invalid neighbor id should not be scored")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/worker/retrieval_test.go` around lines 280 - 329, Test
TestRetrieveRelevant_InjectGraphBFSEnabled_FusesGraphNeighbors should explicitly
assert that invalid neighbor IDs from getGraphNeighbors (0 and -1) are ignored:
update the test to check that scores for ID 0 and ID -1 are absent or zero in
the returned scores map and that observations slice does not include
observations with those IDs (use the existing observations, scores variables
from the call to RetrieveRelevant and the mocked getGraphNeighbors behavior to
locate where to add assertions).
internal/learning/merge.go (1)

63-66: Уточнить prompt для LLM: индекс vs ID.

Кандидаты нумеруются индексами [0], [1], но LLM должен вернуть реальный target_id (например, id=12345). Это может привести к тому, что LLM вернёт индекс вместо ID.

📝 Предлагаемое улучшение prompt
 	for i := 0; i < maxCandidates; i++ {
-		sb.WriteString(fmt.Sprintf("[%d] %s\n", i, formatMergeObservation(candidates[i])))
+		sb.WriteString(fmt.Sprintf("- %s\n", formatMergeObservation(candidates[i])))
 	}
-	sb.WriteString("Respond as JSON: {\"action\":\"CREATE_NEW|UPDATE|SUPERSEDE|SKIP\",\"target_id\":number}\n")
+	sb.WriteString("Respond as JSON: {\"action\":\"CREATE_NEW|UPDATE|SUPERSEDE|SKIP\",\"target_id\":<observation id from candidates>}\n")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/learning/merge.go` around lines 63 - 66, The prompt currently lists
candidates by index using formatMergeObservation(candidates[i]) and then asks
"Respond as JSON:
{\"action\":\"CREATE_NEW|UPDATE|SUPERSEDE|SKIP\",\"target_id\":number}", which
risks the LLM returning the displayed index instead of the real record ID;
update the prompt string (the line that writes "Respond as JSON: ...") to
explicitly state that "target_id" must be the actual record ID (e.g., id=12345)
and not the displayed candidate index, and add a short clarifier after the
candidate lines (referencing the candidates slice and formatMergeObservation)
like "Note: candidate labels [0],[1],... are indexes for reference only — return
the database record's actual id in target_id." Ensure the change is applied
where sb.WriteString builds the prompt in merge.go.
internal/worker/sdk/processor.go (2)

402-418: Рекомендация: добавить capacity hint при создании слайса.

unionStrings создаёт слайс merged без указания ёмкости, хотя можно оценить примерный размер на основе входных данных.

♻️ Предлагаемое улучшение
 func unionStrings(parts ...[]string) []string {
 	seen := make(map[string]struct{})
-	merged := make([]string, 0)
+	totalLen := 0
+	for _, part := range parts {
+		totalLen += len(part)
+	}
+	merged := make([]string, 0, totalLen)
 	for _, part := range parts {
 		for _, item := range part {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/worker/sdk/processor.go` around lines 402 - 418, The unionStrings
function builds merged without a capacity hint causing potential reallocations;
compute an estimated capacity by summing len(...) over the input parts, then
create merged with make([]string, 0, totalCap) (and optionally pre-size the seen
map with make(map[string]struct{}, totalCap)) before the loops so appends and
map inserts avoid repeated growth and reduce allocations.

500-503: Ошибка LLM молча игнорируется.

При ошибке вызова learning.DecideMerge функция возвращает nil, false, "", nil, не логируя причину. Это затрудняет диагностику проблем с LLM в production.

🔧 Предлагаемое улучшение
 	decision, err := learning.DecideMerge(ctx, p.llmClient, newObs, candidates)
 	if err != nil {
+		log.Debug().Err(err).Str("project", project).Msg("write-merge: DecideMerge failed, falling back to create")
 		return nil, false, "", nil
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/worker/sdk/processor.go` around lines 500 - 503, The call to
learning.DecideMerge silences errors; instead log the error with context and
return it: at the decision,err := learning.DecideMerge(ctx, p.llmClient, newObs,
candidates) site, replace the silent return with a log call using the
processor's logger (e.g. p.logger or p.processLogger).Include the error and key
context (newObs, len(candidates), maybe p.llmClient id) in the log message, then
propagate the error by returning it (or wrapping it) instead of returning nil as
the error value so the caller can handle it.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/learning/merge.go`:
- Around line 63-66: The prompt currently lists candidates by index using
formatMergeObservation(candidates[i]) and then asks "Respond as JSON:
{\"action\":\"CREATE_NEW|UPDATE|SUPERSEDE|SKIP\",\"target_id\":number}", which
risks the LLM returning the displayed index instead of the real record ID;
update the prompt string (the line that writes "Respond as JSON: ...") to
explicitly state that "target_id" must be the actual record ID (e.g., id=12345)
and not the displayed candidate index, and add a short clarifier after the
candidate lines (referencing the candidates slice and formatMergeObservation)
like "Note: candidate labels [0],[1],... are indexes for reference only — return
the database record's actual id in target_id." Ensure the change is applied
where sb.WriteString builds the prompt in merge.go.

In `@internal/worker/retrieval_test.go`:
- Around line 280-329: Test
TestRetrieveRelevant_InjectGraphBFSEnabled_FusesGraphNeighbors should explicitly
assert that invalid neighbor IDs from getGraphNeighbors (0 and -1) are ignored:
update the test to check that scores for ID 0 and ID -1 are absent or zero in
the returned scores map and that observations slice does not include
observations with those IDs (use the existing observations, scores variables
from the call to RetrieveRelevant and the mocked getGraphNeighbors behavior to
locate where to add assertions).

In `@internal/worker/sdk/processor.go`:
- Around line 402-418: The unionStrings function builds merged without a
capacity hint causing potential reallocations; compute an estimated capacity by
summing len(...) over the input parts, then create merged with make([]string, 0,
totalCap) (and optionally pre-size the seen map with make(map[string]struct{},
totalCap)) before the loops so appends and map inserts avoid repeated growth and
reduce allocations.
- Around line 500-503: The call to learning.DecideMerge silences errors; instead
log the error with context and return it: at the decision,err :=
learning.DecideMerge(ctx, p.llmClient, newObs, candidates) site, replace the
silent return with a log call using the processor's logger (e.g. p.logger or
p.processLogger).Include the error and key context (newObs, len(candidates),
maybe p.llmClient id) in the log message, then propagate the error by returning
it (or wrapping it) instead of returning nil as the error value so the caller
can handle it.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3af52f18-e5ad-41ce-b8a8-eb22efe9ce85

📥 Commits

Reviewing files that changed from the base of the PR and between 13b8706 and d5abf0a.

📒 Files selected for processing (7)
  • internal/learning/merge.go
  • internal/worker/retrieval_helpers.go
  • internal/worker/retrieval_test.go
  • internal/worker/sdk/processor.go
  • internal/worker/sdk/processor_test.go
  • plugin/engram/hooks/pre-tool-use.js
  • plugin/engram/hooks/pre-tool-use.test.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/worker/sdk/processor_test.go
  • internal/worker/retrieval_helpers.go

@thebtf thebtf merged commit fd8ce26 into main Apr 11, 2026
2 checks passed
@thebtf thebtf deleted the fix/privacy-scope-leak-and-contradiction-killswitch branch April 11, 2026 23:16
thebtf added a commit that referenced this pull request Apr 18, 2026
Integration test TestCredentialDecryptRoundTripAfterMigration in
internal/grpcserver/credential_migration_test.go verifies the core US3
invariant: credentials migrated from `observations` into the dedicated
`credentials` table via migration 090 preserve AES-256-GCM ciphertext
byte-for-byte, and the vault can still decrypt every migrated secret
back to its original plaintext with 100% fidelity.

Why this gate exists:
  Migration 090 copies encrypted_secret from observations into
  credentials via a SQL SELECT into a BYTEA column. If either end
  silently mutated the bytes (encoding, trimming, pad handling),
  AES-GCM decrypt would fail authentication-tag check and the
  production vault would become unusable. That would lose all 13
  prod credentials irrecoverably.

What the test does:
  1. Opens a real Postgres + pgvector DB, applies migrations through 090.
  2. Creates a real crypto.Vault with a deterministic test key
     (not the prod key — the test is scoped to a unique test project
     slug to avoid colliding with other data).
  3. Encrypts 5 diverse plaintexts via the real vault:
     - short ASCII
     - empty plaintext (GCM edge case: 0-byte plaintext produces
       nonce+tag = 28-byte ciphertext)
     - unicode mixed (Cyrillic + CJK + emoji, multi-byte UTF-8)
     - long plaintext (~1KB of repeated bytes)
     - binary-like (embedded NUL bytes + high-bit bytes, guards
       against any future code that reads the secret as a text string
       instead of raw bytes)
  4. Seeds each plaintext as an observations row with the ciphertext
     + fingerprint + type='credential' + the required NOT NULL columns.
  5. Seeds 3 ADVERSARIAL EXCLUDED observations (is_suppressed=true,
     is_archived=1, is_superseded=1 — one each). Migration 090 filters
     these out via its WHERE clause; if the filters ever get loosened,
     one of the assertions in sub-test adversarial_excluded_rows_not_
     migrated will fire.
  6. Seeds 1 ORPHAN observation encrypted with a different (wrong)
     fingerprint to exercise CountWithDifferentFingerprint.
  7. Re-runs the exact migration 090 credentials INSERT SQL scoped to
     the test project slug (the SQL is copy-pasted verbatim from
     internal/db/gorm/migrations.go — drift will fail the test).
  8. Reads each credential back via CredentialStore.Get and asserts:
     - encrypted_secret bytes == original ciphertext (byte-for-byte)
     - encryption_key_fingerprint == vault fingerprint (exact)
     - vault.Decrypt(migrated_ciphertext) == original plaintext
  9. Three adversarial sub-tests:
     - orphan_credential_visible_via_fingerprint_check: verifies the
       badFP orphan is visible in List and counted globally by
       CountWithDifferentFingerprint(goodFP).
     - adversarial_excluded_rows_not_migrated: verifies each of the
       three excluded observations (suppressed / archived / superseded)
       is NOT present in the credentials table. This is the load-
       bearing test for the WHERE clause filters.
     - tampered_ciphertext_fails_decrypt: flips one byte in the
       migrated ciphertext and verifies AES-GCM authentication
       catches the mutation. This guards against "decrypt accepts
       anything" — which would make the gate a stub.

Anti-stub proof:
  Swap vault.Decrypt body -> return "", nil ⇒ short_ascii / unicode /
  long / binary sub-tests all fail (decrypted != plaintext) AND
  tampered_ciphertext_fails_decrypt fails (require.Error vs nil).
  Test cannot pass with a stubbed decrypt path.

Resource hygiene:
  NewStore's *Store is captured and closed via defer gormStore.Close().
  A separate direct gorm.Open handle is used for raw INSERT seeds and
  the project-scoped migration re-run, explicitly closed via its
  sqlDB.Close() defer.

Scope guard:
  ONE new file: internal/grpcserver/credential_migration_test.go.
  No production code changed. Uses only exported APIs:
  crypto.NewVault, crypto.Vault.Encrypt/Decrypt/Fingerprint,
  gorm.NewStore, gorm.Config, gorm.Store.Close, gorm.NewCredentialStore,
  gorm.CredentialStore.Get/List/CountWithDifferentFingerprint.

Verified:
  DATABASE_DSN=postgres://.../engram_f1_test — TestCredentialDecrypt-
  RoundTripAfterMigration PASS (1 top-level + 8 sub-tests, ~1.8s wall).
  Sub-tests: short_ascii, empty_plaintext, unicode_mixed,
  long_plaintext_1kb, binary_like_with_nul_and_high_bytes,
  orphan_credential_visible_via_fingerprint_check,
  adversarial_excluded_rows_not_migrated, tampered_ciphertext_fails_decrypt.

Does NOT verify (intentional — out of scope for CI F-1 gate):
  - The production invariants credential_count=13, fingerprint=
    aa78e55cf896508c, mismatch_count=0. Those belong to the
    staging dry-run against the real 2026-04-17 pg_dump snapshot,
    run out of band by the operator before Commit G lands. F-1 CI
    gate proves the PRIMITIVE works (round-trip byte-preserving);
    staging dry-run proves the MIGRATION ran correctly on prod data.

Pre-existing test failures (NOT regressions from this commit):
  - TestSyncProjectState_* (internal/grpcserver) — introduced in
    d324f01 (PR #170, Phase 4) with a minimal schema that conflicts
    with the full projects-table migration. Fails on any DB with
    migrations 045+ applied.
  - TestHandleMemoryTriggers_BashCommand* (internal/worker) — from
    fd8ce26 (PR #135, learning-memory-v4) — returns 500 not 200
    on fresh pgvector DB. Unrelated to US3.

Ref: .agent/specs/engram-v5-baseline/changes/CR-001-initial-scope/
tasks.md T022 AC 1-5. Addresses two review findings:
(1) NewStore return value leaked pool — fixed via capture+Close.
(2) Exclusion filter coverage gap — fixed via adversarial sub-test.
thebtf added a commit that referenced this pull request Apr 18, 2026
Two interconnected regressions in GetObservationsByCommandPrefix and its
test harness, shipped in PR #135 (fd8ce26, learning-memory-v4). Never
passed on a fresh pgvector DB.

1. SQL: ESCAPE '\\\\' in Go double-quoted source produces '\\\\' in the SQL
   text, which under standard_conforming_strings=on (PostgreSQL 9.1+ default)
   is a 2-character escape string. PostgreSQL rejects multi-character ESCAPE
   with SQLSTATE 22025 ('invalid escape string'). Any call to this function
   against a real Postgres returned 500 from handleMemoryTriggers.

2. Semantics: the function name says 'ByCommandPrefix' — intent is to find
   observations whose stored commands_run entry is a PREFIX of the incoming
   query (e.g. stored 'git push --force' matches query 'git push --force
   origin main'). The old SQL implemented the reverse direction:
   cmd LIKE 'query%' finds stored values that START WITH the query,
   which no real case exercises.

Fix: use starts_with(?, cmd) — PostgreSQL 11+ native function with clean
semantics (no escape hazard, no wildcard ambiguity, both sides treated as
literals). The escaper + % suffix are removed as no longer needed.

Additionally, internal/worker/trigger_matcher_bash_test.go seeded
commands with the narrative prefix 'Executed: ' (e.g. 'Executed: git
push --force'), which does not reflect how the Bash tool records commands
in production (raw command, no prefix). The test could never pass with
a correct prefix matcher because the seed shape mismatched the matcher
shape. Fix: strip 'Executed: ' from all 5 seed call sites.

Verified (fresh pgvector/pgvector:pg17 with truncated observations):
  go test -count=1 -run '^TestHandleMemoryTriggers_' ./internal/worker/
  — 6 subtests all PASS. TestHandleMemoryTriggers_BashCommandPrefixMatchesTop3Warnings
  now correctly returns the 3 bugfix+pitfall prefix matches (excluding
  the discovery-typed row and the non-prefix force-with-lease row).
thebtf added a commit that referenced this pull request Apr 18, 2026
…ules (PR-A, US3) (#181)

* feat(us3): migration 087_credentials + CredentialStore skeleton (Commit A, PR-A)

Create dedicated credentials table as additive schema for US3 observations split.

Migration ID note: spec tasks.md says 077_credentials, but US1+US2 consumed 083-086
(083 drop orphan joins, 084 Gemini-fix Rollback, 085 drop content_chunks,
086 drop used_vector). US3 migrations therefore shift to 087 onwards (next-free).
This commit creates 087; subsequent commits in PR-A will create 088 (memories),
089 (behavioral_rules), 090 (data migration 3-way split).

Changes:
- internal/db/gorm/migrations.go: add migration 087_credentials
  CREATE TABLE credentials per spec.md §Data Model (id BIGSERIAL PK, project,
  key, encrypted_secret BYTEA, encryption_key_fingerprint, scope, version,
  edited_by, created_at, updated_at, deleted_at, UNIQUE(project, key)).
  Partial indexes on project and encryption_key_fingerprint WHERE deleted_at
  IS NULL. Rollback drops indexes + table.
- pkg/models/credential.go (new): domain Credential struct, no GORM tags
  (mirrors pkg/models/observation.go pattern — infra concerns stay in
  internal/db/gorm).
- internal/db/gorm/models.go: add GORM Credential row type with tags matching
  all columns + TableName() returning "credentials".
- internal/db/gorm/credential_store.go (new): CredentialStore with Create,
  Get, List, Delete (soft-delete via UPDATE deleted_at), CountCredentials,
  CountWithDifferentFingerprint, DeleteOrphanedByFingerprint. Signatures
  mirror ObservationStore vault helpers so callers in handlers_vault.go
  can swap receiver types without touching call sites. Conversion via
  credentialRowToModel() keeps pkg/models isolated from gorm internals.
- internal/db/gorm/credential_store_test.go (new): integration smoke tests
  skip unless DATABASE_DSN is set (matches migrations_integration_test.go
  pattern). Exercises Create→Get→List→Count→Delete round-trip against a
  real pgvector/pgvector:pg17 instance. Anti-stub: replacing any method
  body with `return nil` causes the round-trip test to fail.

Protected invariants (NONE violated by this commit — additive-only):
- observations table UNTOUCHED (still has 2775 live rows / 13 credentials)
- content table UNTOUCHED
- vault ciphertext bytes unchanged
- fingerprint aa78e55cf896508c / credential_count 13 / mismatch_count 0
  (verified pre-flight 2026-04-17, nothing in this commit touches prod data)

Verification:
- go build ./... — green
- go vet ./internal/db/gorm/... — green
- go test ./internal/db/gorm/... -run Credential — 3/3 PASS against
  pgvector/pgvector:pg17 with full migration chain 001→087 applied fresh
- TestMigrationsIntegration failure is pre-existing on main 83bbc62
  (stale vector-dim assertion, legacy US2 leftover) — not introduced here

Task: T017 Commit A
Spec: .agent/specs/engram-v5-baseline/spec.md §Data Model §credentials
Plan: .agent/specs/engram-v5-baseline/plan.md §Phase 5
Phase 5 gate: F-1 decrypt round-trip (T022) — not yet

* feat(us3): migrations 088_memories + 089_behavioral_rules (Commit B, PR-A)

CREATE TABLE only — no Go store code, no data migration, no handlers.
Commit C will add memory_store + behavioral_rules_store + gRPC dispatch.
Commit D will migrate data from observations.

Migration ID shift: spec.md says 078/079; Commit A took 087; next-free IDs
are 088 (memories) + 089 (behavioral_rules).

Schemas match spec.md §Data Model verbatim (column-for-column verified):

MEMORIES (internal/db/gorm/migrations.go migration 088):
- id BIGSERIAL PK, project TEXT NOT NULL, content TEXT NOT NULL,
  tags JSONB DEFAULT '[]', source_agent TEXT, version INT DEFAULT 1,
  edited_by TEXT, created_at/updated_at TIMESTAMPTZ DEFAULT NOW(),
  deleted_at TIMESTAMPTZ, search_vector tsvector GENERATED ALWAYS AS
  STORED (dual-dict english + simple per migration 076 pattern).
- Indexes: idx_memories_project_created partial WHERE deleted_at IS NULL,
  idx_memories_fts GIN, idx_memories_tags GIN.
- Rollback drops indexes (reverse order) then table.

BEHAVIORAL_RULES (migration 089):
- id BIGSERIAL PK, project TEXT (NULLable — NULL = global rule),
  content TEXT NOT NULL, priority INT DEFAULT 0, version INT DEFAULT 1,
  edited_by TEXT, created_at/updated_at TIMESTAMPTZ DEFAULT NOW(),
  deleted_at TIMESTAMPTZ.
- Indexes: idx_behavioral_rules_project_priority partial WHERE
  deleted_at IS NULL, idx_behavioral_rules_global partial WHERE
  project IS NULL AND deleted_at IS NULL.
- Rollback drops indexes (reverse order) then table.

NOT carried over from observations: importance_score, relevance_score,
effectiveness_*, cited, inject_count — per spec S1 (scoring dropped).
No FTS on behavioral_rules — rules are injected unconditionally at
session-start, not searched.

Protected invariants (NONE violated — additive only):
- observations table UNTOUCHED (still 2775 live rows / 13 credentials)
- credentials table from Commit A UNTOUCHED
- content table UNTOUCHED
- vault ciphertext bytes unchanged
- fingerprint aa78e55cf896508c / credential_count 13 / mismatch_count 0

Verification:
- go build ./... — green
- Migration chain 001→089 "all migrations passed" against
  pgvector/pgvector:pg17 fresh DB
- TestCredentialStore_* 3/3 PASS + rotation sub-test + 5 validation
  sub-tests (regression for Commit A — unchanged)
- psql SELECT tablename FROM pg_tables WHERE tablename IN
  ('credentials','memories','behavioral_rules') — 3 rows
- 11 indexes created (pkey + idx_* across 3 tables)
- TestMigrationsIntegration vector-dim failure is pre-existing on
  main 83bbc62 (US2 leftover, not introduced here)

Task: T018 Commit B
Spec: .agent/specs/engram-v5-baseline/spec.md §Data Model §memories + §behavioral_rules

* feat(us3): memory_store + behavioral_rules_store + store_rule/list_rules MCP tools (Commit C, PR-A)

Implement CRUD stores for the new static-entity tables (migrations 088_memories,
089_behavioral_rules from Commit B) and wire two new MCP tools (store_rule,
list_rules) that read/write through them.

Scope boundary respected (per tasks.md T019):
- handleStoreMemory / handleRecall STILL route to observations (Commit E switches them)
- handlers_vault.go + credential_store.go UNTOUCHED (Commit E scope)
- No data migrations touched (Commit D = T020)
- No observations dropped (Commit G = T023)

FILES CREATED (6 new):

- pkg/models/memory.go: domain Memory struct (no GORM tags, Tags []string real slice,
  search_vector intentionally absent from domain — it's a GENERATED column).
- pkg/models/behavioral_rule.go: domain BehavioralRule struct (Project *string for
  NULLable global-rule semantics; *time.Time pointers for all timestamps).
- internal/db/gorm/memory_store.go: MemoryStore with Create/Get/List/Update/Delete.
  Create returns a new *models.Memory (does not mutate input — per Commit A
  code-review HIGH-1 immutability rule). Delete is HARD (Unscoped) per Commit A
  code-review HIGH-2 rotation analysis — deleted_at column remains in schema
  for future use but is not populated. Update uses gorm.Expr("version + 1") +
  re-fetch. Empty-arg guards on Get/List.
- internal/db/gorm/memory_store_test.go: 3 integration tests skip without
  DATABASE_DSN. Covers full round-trip (Create→Get→Update→List→Delete),
  validation errors (nil/empty/empty), and per-project filtering (2 projects,
  3 memories, asserts isolation).
- internal/db/gorm/behavioral_rules_store.go: BehavioralRulesStore with same CRUD
  shape + global-rule semantics: List(nil, limit) returns only rows where
  project IS NULL; List(&proj, limit) returns rows WHERE project = proj OR
  project IS NULL (global rules always apply).
- internal/db/gorm/behavioral_rules_store_test.go: 3 integration tests.
  TestBehavioralRulesStore_List_GlobalRulesAlwaysIncluded validates the
  union semantics: 1 global + 1 project-scoped row; List(&"p1") returns both,
  List(nil) returns only the global.
- internal/mcp/tools_rules.go: handleStoreRule + handleListRules. Both
  validate inputs, wrap errors with %w, return JSON responses with RFC3339
  timestamps. handleStoreRule requires content, defaults priority to 0,
  project optional (nil = global). handleListRules bounds limit 1-500
  (default 50), project optional.

FILES MODIFIED (2):

- internal/db/gorm/models.go: +38 lines appending GORM row structs for Memory
  (Tags via models.JSONStringArray matching existing JSONB convention; deleted_at
  *time.Time; TableName returns "memories") and BehavioralRule (Project *string
  NULLable; TableName returns "behavioral_rules").
- internal/mcp/server.go: +47 lines.
  * Server struct: 2 new fields memoryStore + behavioralRulesStore after issueStore.
  * 2 new setters SetMemoryStore + SetBehavioralRulesStore (mirrors SetIssueStore
    pattern — called by worker bootstrap after async init).
  * handleToolsList: 2 new Tool entries (store_rule, list_rules) at tierUseful.
  * callTool switch: 2 new cases routing to tools_rules.go handlers (after
    merge_observations, before get_observation).
- internal/worker/service.go: +9 lines.
  * Construct memoryStore and behavioralRulesStore via gorm.New*Store(store)
    alongside reasoningStore.
  * Wire both into mcpServer via the new setters, alongside SetReasoningStore
    + SetIssueStore (consistent bootstrap order).

Protected invariants (NONE violated — additive only, no observation writes):
- observations table UNTOUCHED (still 2775 live rows / 13 credentials)
- credentials table UNTOUCHED
- memories + behavioral_rules tables gain functional CRUD (no production data yet)
- content table UNTOUCHED
- vault ciphertext bytes unchanged
- fingerprint aa78e55cf896508c / credential_count 13 / mismatch_count 0

Verification:
- go build ./... — green
- go vet ./internal/db/gorm/... ./internal/mcp/... — clean
- go test ./internal/db/gorm/... -run "Memory|BehavioralRules|Credential" -v
  against pgvector/pgvector:pg17 fresh DB:
  PASS TestBehavioralRulesStore_CreateGetUpdateListDelete (65s — includes migration chain)
  PASS TestBehavioralRulesStore_Create_ValidationErrors (2 sub-tests)
  PASS TestBehavioralRulesStore_List_GlobalRulesAlwaysIncluded
  PASS TestCredentialStore_CreateGetCountDelete (regression preserved)
  PASS TestCredentialStore_DeleteOrphanedByFingerprint (regression preserved)
  PASS TestCredentialStore_Create_ValidationErrors (5 sub-tests — regression)
  PASS TestMemoryStore_CreateGetUpdateListDelete
  PASS TestMemoryStore_Create_ValidationErrors (3 sub-tests)
  PASS TestMemoryStore_List_FiltersByProject
  TOTAL: 9 top-level + 10 sub-tests, all green, ~78s incl. migration chain.

Task: T019 Commit C
Spec: .agent/specs/engram-v5-baseline/spec.md §Data Model + §FR-4

* feat(us3): migration 090_observations_to_static_entities (Commit D, PR-A)

3-way data split: observations → credentials + behavioral_rules + memories.
Ciphertext preserved byte-for-byte; DO-block enforces 2 invariants.

Migration ID shift: plan.md originally drafted this as 080_...; actual next-free
is 090 (US1+US2 consumed 083-086; Commit A=087, Commit B=088+089).

Plan.md column mapping amended 2026-04-18 (see plan.md §Amendment 2026-04-18) —
the original draft assumed columns that do not exist on observations:
- content (creds)   → title                  (per ObservationStore.GetCredential)
- content (mem/rul) → COALESCE(NULLIF(TRIM(narrative),''), title, '')
- always_inject col → concepts @> ["always-inject"]::jsonb
- creation_path fil → dropped (migrate ALL live non-credential non-always-inject)
- tags              → concepts (JSONStringArray stored jsonb)
- source_agent      → agent_source
- updated_at        → reuse created_at (observations has no updated_at)
- timestamp cast    → TO_TIMESTAMP(created_at_epoch) — unambiguous from int64
- priority (rules)  → derived from importance_score (≥0.8→10, ≥0.5→5, else 0)
- live filter       → is_suppressed=false AND is_archived=0 AND is_superseded=0

Three INSERTs in order:

1. credentials — type='credential' rows with encrypted_secret IS NOT NULL AND
   encryption_key_fingerprint IS NOT NULL AND title IS NOT NULL AND live.
   Ciphertext + fingerprint bytes preserved verbatim (no re-encryption).
2. behavioral_rules — observations where concepts @> '["always-inject"]'::jsonb
   AND type != 'credential' AND live AND has content. priority from importance.
3. memories — remaining live non-credential non-always-inject observations with
   non-empty content. tags = concepts verbatim. project NULL → '' fallback.

Sanity check DO block (BOTH invariants active tripwires):
- (a) dst_count < src_count/2 → RAISE EXCEPTION
- (b) credentials.count != observations WHERE type='credential' live count
     → RAISE EXCEPTION — every vault credential MUST migrate byte-for-byte

Rollback: TRUNCATE the 3 static tables in reverse order (memories → behavioral_rules
→ credentials). Forward migration only READS observations, so rollback == undo
the data copy. Schema remains (migrations 087-089 Rollback handles schema).

Protected invariants (NONE violated by this commit — observations UNTOUCHED):
- observations table unchanged (2775 live / 13 credentials in production)
- credentials/memories/behavioral_rules tables GAIN data; no lossy transform
- vault ciphertext bytes preserved verbatim
- production fingerprint aa78e55cf896508c / count 13 / mismatch 0

Verification:
- go build ./... — green
- go vet ./internal/db/gorm/... — clean
- Migration chain 001→090 "all migrations passed" on empty pgvector/pgvector:pg17
- Credential/Memory/BehavioralRules tests 9 top-level + 10 sub-tests — all green
- Synthetic-data test: seeded 6 observations (5 live + 1 archived), ran migration
  logic via psql; got credentials=2 memories=2 behavioral_rules=1 (archived row
  correctly excluded); DO block NOTICE fired with correct counts
- Invariant (b) tripwire confirmed: DELETE 1 credential row → RAISE EXCEPTION
  "credential invariant FAIL cred=1 cred_live=2"
- Invariant (a) tripwire confirmed: TRUNCATE all static tables → RAISE EXCEPTION
  "sanity FAIL dst=0 src=5" (fires first since order is a → b)
- TestMigrationsIntegration vector-dim failure remains pre-existing (US2 leftover,
  not introduced here)

Next: Commit E (T021) switches handlers (handleStoreMemory/handleRecall) to use
the new stores; current commit leaves handlers routed to observations still.
After Commit E comes Commit F-1 (HARD GATE decrypt round-trip test) — BLOCKER
for PR-B drop migrations (commits G+H) per spec §FR-9 + §NFR-7.

Task: T020 Commit D
Spec: .agent/specs/engram-v5-baseline/spec.md §FR-4 + plan.md §Phase 5 (Amendment 2026-04-18)

* feat(us3): switch vault handlers to credentialStore + add /api/memories routes (Commit E, PR-A)

Route vault HTTP handlers through the new credentialStore, expose a new set of
/api/memories HTTP endpoints backed by memoryStore, and wire all three static-
entity stores (credentialStore + memoryStore + behavioralRulesStore) as fields
on the worker Service struct.

Dual-read preserved: this commit DOES NOT touch handleStoreMemory / handleRecall
inside internal/mcp - those still route to observationStore. The actual MCP
handler switch happens in Commit G (post Commit F-1 decrypt round-trip gate).

Files changed:
- internal/worker/service.go: 3 new fields on Service struct
  (credentialStore, memoryStore, behavioralRulesStore); initializeAsync
  constructs credentialStore alongside existing memoryStore + behavioralRulesStore
  (now promoted from locals to fields); initMu block wires all 3 pointers;
  setupRoutes registers POST/GET/DELETE /api/memories routes inside the
  authenticated router group.
- internal/worker/handlers_vault.go: handleVaultStatus switched to
  credentialStore.CountCredentials + .CountWithDifferentFingerprint;
  handleDeleteOrphanedCredentials switched to
  credentialStore.DeleteOrphanedByFingerprint. Signatures adapted to new
  method names on CredentialStore.
- internal/worker/handlers_memories.go (NEW): thin HTTP wrappers over
  memoryStore. handleStoreMemoryExplicit validates project+content non-empty,
  returns 201 on success, 503 if store not initialised. handleListMemories
  requires project query param, defaults limit 50, returns [] not null.
  handleDeleteMemoryByID parses chi URL param, maps gorm.ErrRecordNotFound
  to 404.
- internal/worker/handlers_memories_test.go (NEW): 4 integration tests
  (skip without DATABASE_DSN) - RoundTrip (POST+GET), ValidationErrors
  (2 sub-tests for empty project + empty content), DeleteByID RoundTrip
  (POST+DELETE+GET returns empty), DeleteByID NotFound (404 on missing id).
- .gitignore: add .tmp_gocache/ and .tmp_gomodcache/ underscore variants
  (GoCache and GoModCache directories use either hyphen or underscore
  depending on tool version).

Scope boundary respected:
- handleStoreMemory / handleRecall in internal/mcp UNTOUCHED (dual-read preserved)
- observationStore.CountCredentials / DeleteOrphanedCredentials methods still
  exist - only CALLERS migrated. Commit G removes the methods when observations
  drops.
- No migration changes (087-090 frozen)
- No observations data writes from this commit

Protected invariants (NONE violated - additive + caller redirection only):
- observations table UNTOUCHED (dual-read still works via MCP store/recall)
- credentials / memories / behavioral_rules schemas UNTOUCHED
- Vault ciphertext bytes unchanged
- Production fingerprint aa78e55cf896508c / count 13 / mismatch 0 preserved -
  vault handlers now read the same 13 credentials from the credentials table
  populated by migration 090 instead of observations

Verification:
- go build ./... - green
- go vet ./internal/worker/... ./internal/mcp/... ./internal/db/gorm/... - clean
- go test ./internal/worker/ -run "HandleStoreMemoryExplicit|HandleDeleteMemoryByID" - all 4 PASS + 2 sub-tests
- go test ./internal/db/gorm/ -run "Credential|Memory|BehavioralRules" - 9 top-level + 10 sub-tests PASS (regression)

Task: T021 Commit E
Spec: .agent/specs/engram-v5-baseline/spec.md FR-4 + plan.md Phase 5

* chore: ignore .cache/ directory (Go test cache artifacts)

* feat(us3): Commit F-1 — DECRYPT ROUND-TRIP HARD GATE (T022)

Integration test TestCredentialDecryptRoundTripAfterMigration in
internal/grpcserver/credential_migration_test.go verifies the core US3
invariant: credentials migrated from `observations` into the dedicated
`credentials` table via migration 090 preserve AES-256-GCM ciphertext
byte-for-byte, and the vault can still decrypt every migrated secret
back to its original plaintext with 100% fidelity.

Why this gate exists:
  Migration 090 copies encrypted_secret from observations into
  credentials via a SQL SELECT into a BYTEA column. If either end
  silently mutated the bytes (encoding, trimming, pad handling),
  AES-GCM decrypt would fail authentication-tag check and the
  production vault would become unusable. That would lose all 13
  prod credentials irrecoverably.

What the test does:
  1. Opens a real Postgres + pgvector DB, applies migrations through 090.
  2. Creates a real crypto.Vault with a deterministic test key
     (not the prod key — the test is scoped to a unique test project
     slug to avoid colliding with other data).
  3. Encrypts 5 diverse plaintexts via the real vault:
     - short ASCII
     - empty plaintext (GCM edge case: 0-byte plaintext produces
       nonce+tag = 28-byte ciphertext)
     - unicode mixed (Cyrillic + CJK + emoji, multi-byte UTF-8)
     - long plaintext (~1KB of repeated bytes)
     - binary-like (embedded NUL bytes + high-bit bytes, guards
       against any future code that reads the secret as a text string
       instead of raw bytes)
  4. Seeds each plaintext as an observations row with the ciphertext
     + fingerprint + type='credential' + the required NOT NULL columns.
  5. Seeds 3 ADVERSARIAL EXCLUDED observations (is_suppressed=true,
     is_archived=1, is_superseded=1 — one each). Migration 090 filters
     these out via its WHERE clause; if the filters ever get loosened,
     one of the assertions in sub-test adversarial_excluded_rows_not_
     migrated will fire.
  6. Seeds 1 ORPHAN observation encrypted with a different (wrong)
     fingerprint to exercise CountWithDifferentFingerprint.
  7. Re-runs the exact migration 090 credentials INSERT SQL scoped to
     the test project slug (the SQL is copy-pasted verbatim from
     internal/db/gorm/migrations.go — drift will fail the test).
  8. Reads each credential back via CredentialStore.Get and asserts:
     - encrypted_secret bytes == original ciphertext (byte-for-byte)
     - encryption_key_fingerprint == vault fingerprint (exact)
     - vault.Decrypt(migrated_ciphertext) == original plaintext
  9. Three adversarial sub-tests:
     - orphan_credential_visible_via_fingerprint_check: verifies the
       badFP orphan is visible in List and counted globally by
       CountWithDifferentFingerprint(goodFP).
     - adversarial_excluded_rows_not_migrated: verifies each of the
       three excluded observations (suppressed / archived / superseded)
       is NOT present in the credentials table. This is the load-
       bearing test for the WHERE clause filters.
     - tampered_ciphertext_fails_decrypt: flips one byte in the
       migrated ciphertext and verifies AES-GCM authentication
       catches the mutation. This guards against "decrypt accepts
       anything" — which would make the gate a stub.

Anti-stub proof:
  Swap vault.Decrypt body -> return "", nil ⇒ short_ascii / unicode /
  long / binary sub-tests all fail (decrypted != plaintext) AND
  tampered_ciphertext_fails_decrypt fails (require.Error vs nil).
  Test cannot pass with a stubbed decrypt path.

Resource hygiene:
  NewStore's *Store is captured and closed via defer gormStore.Close().
  A separate direct gorm.Open handle is used for raw INSERT seeds and
  the project-scoped migration re-run, explicitly closed via its
  sqlDB.Close() defer.

Scope guard:
  ONE new file: internal/grpcserver/credential_migration_test.go.
  No production code changed. Uses only exported APIs:
  crypto.NewVault, crypto.Vault.Encrypt/Decrypt/Fingerprint,
  gorm.NewStore, gorm.Config, gorm.Store.Close, gorm.NewCredentialStore,
  gorm.CredentialStore.Get/List/CountWithDifferentFingerprint.

Verified:
  DATABASE_DSN=postgres://.../engram_f1_test — TestCredentialDecrypt-
  RoundTripAfterMigration PASS (1 top-level + 8 sub-tests, ~1.8s wall).
  Sub-tests: short_ascii, empty_plaintext, unicode_mixed,
  long_plaintext_1kb, binary_like_with_nul_and_high_bytes,
  orphan_credential_visible_via_fingerprint_check,
  adversarial_excluded_rows_not_migrated, tampered_ciphertext_fails_decrypt.

Does NOT verify (intentional — out of scope for CI F-1 gate):
  - The production invariants credential_count=13, fingerprint=
    aa78e55cf896508c, mismatch_count=0. Those belong to the
    staging dry-run against the real 2026-04-17 pg_dump snapshot,
    run out of band by the operator before Commit G lands. F-1 CI
    gate proves the PRIMITIVE works (round-trip byte-preserving);
    staging dry-run proves the MIGRATION ran correctly on prod data.

Pre-existing test failures (NOT regressions from this commit):
  - TestSyncProjectState_* (internal/grpcserver) — introduced in
    d324f01 (PR #170, Phase 4) with a minimal schema that conflicts
    with the full projects-table migration. Fails on any DB with
    migrations 045+ applied.
  - TestHandleMemoryTriggers_BashCommand* (internal/worker) — from
    fd8ce26 (PR #135, learning-memory-v4) — returns 500 not 200
    on fresh pgvector DB. Unrelated to US3.

Ref: .agent/specs/engram-v5-baseline/changes/CR-001-initial-scope/
tasks.md T022 AC 1-5. Addresses two review findings:
(1) NewStore return value leaked pool — fixed via capture+Close.
(2) Exclusion filter coverage gap — fixed via adversarial sub-test.

* fix(grpcserver): SyncProjectState pq.Array + heartbeat precision

Two pre-existing regressions in internal/grpcserver/sync_project_state.go
and its test, shipped since PR #170 (d324f01, 2026-04-18 gRPC Phase 4):

1. Raw(...ANY(?)..., localIDs) and Exec(...ANY(?)..., now, localIDs)
   passed []string directly. The gorm postgres driver does NOT encode
   []string as a PostgreSQL array for positional params, so the query
   failed with ERROR: malformed array literal (SQLSTATE 22P02). Fix:
   wrap localIDs with pq.Array() at both call sites. Mirrors the working
   pattern in internal/sessions/store.go:189.

2. TestSyncProjectState_HeartbeatUpdated flaked on Windows: Go's
   time.Now().UTC() has 100-nanosecond resolution from the monotonic
   clock; PostgreSQL timestamptz stores microsecond precision. When the
   captured 'before' fraction was in the same microsecond bucket as the
   server's UPDATE timestamp, the round-tripped newHB was strictly
   less-than 'before' and the assertion fired. Fix: Truncate(time.Microsecond)
   on 'before' so the comparison operates at the precision the DB stores.

Verified:
  go test -count=3 -run ^TestSyncProjectState ./internal/grpcserver/
  — 5 tests x 3 iterations = 15 passes, none flaked.

* fix(gorm): command prefix matcher + correct test seed format

Two interconnected regressions in GetObservationsByCommandPrefix and its
test harness, shipped in PR #135 (fd8ce26, learning-memory-v4). Never
passed on a fresh pgvector DB.

1. SQL: ESCAPE '\\\\' in Go double-quoted source produces '\\\\' in the SQL
   text, which under standard_conforming_strings=on (PostgreSQL 9.1+ default)
   is a 2-character escape string. PostgreSQL rejects multi-character ESCAPE
   with SQLSTATE 22025 ('invalid escape string'). Any call to this function
   against a real Postgres returned 500 from handleMemoryTriggers.

2. Semantics: the function name says 'ByCommandPrefix' — intent is to find
   observations whose stored commands_run entry is a PREFIX of the incoming
   query (e.g. stored 'git push --force' matches query 'git push --force
   origin main'). The old SQL implemented the reverse direction:
   cmd LIKE 'query%' finds stored values that START WITH the query,
   which no real case exercises.

Fix: use starts_with(?, cmd) — PostgreSQL 11+ native function with clean
semantics (no escape hazard, no wildcard ambiguity, both sides treated as
literals). The escaper + % suffix are removed as no longer needed.

Additionally, internal/worker/trigger_matcher_bash_test.go seeded
commands with the narrative prefix 'Executed: ' (e.g. 'Executed: git
push --force'), which does not reflect how the Bash tool records commands
in production (raw command, no prefix). The test could never pass with
a correct prefix matcher because the seed shape mismatched the matcher
shape. Fix: strip 'Executed: ' from all 5 seed call sites.

Verified (fresh pgvector/pgvector:pg17 with truncated observations):
  go test -count=1 -run '^TestHandleMemoryTriggers_' ./internal/worker/
  — 6 subtests all PASS. TestHandleMemoryTriggers_BashCommandPrefixMatchesTop3Warnings
  now correctly returns the 3 bugfix+pitfall prefix matches (excluding
  the discovery-typed row and the non-prefix force-with-lease row).

* fix(gorm): migration 090 epoch ms + scope column + safe rollback + store hardening

Addresses 6 review findings across 3 files:

migrations.go (3 CRITICAL, 1 MAJOR, 1 MAJOR):
  - TO_TIMESTAMP(created_at_epoch) was interpreting milliseconds as
    seconds, producing year-56247 dates. Divided by 1000.0 in all
    three INSERT sites (credentials, behavioral_rules, memories).
  - credentials INSERT now copies 'scope' column via
    COALESCE(NULLIF(scope,''),'project'); schema has the column,
    legacy observations carry it, previous SELECT dropped it silently.
  - Migration 090 rollback previously TRUNCATEd the 3 tables, which
    would wipe data created AFTER the forward migration. Replaced
    with an explicit error instructing pg_restore from the 2026-04-17
    snapshot per rollback-procedure.md §3b.

observation_store.go (LOW):
  - Documentation note: starts_with() is PostgreSQL 9.1+ (gemini
    reviewer claimed 17+ — verified against release notes).
    engram targets PG 17 in all live deployments; no portability
    concern for current scope.

credential_store.go (MEDIUM, LOW, MEDIUM):
  - Create: capture a single 'now' up-front so CreatedAt==UpdatedAt
    deterministically; copy the secret bytes into a defensive slice
    so subsequent zeroize/reuse by the caller cannot alias the row.
  - Delete: added 'deleted_at IS NULL' WHERE predicate so soft-deleted
    rows remain soft-deleted (was possible to hard-delete a tombstone
    and return success, instead of gorm.ErrRecordNotFound).
  - Preserve hard-delete for the rotation case (UNIQUE(project,key)
    constraint + documented design note).

Verified: go build + go vet clean; TestCredentialDecryptRoundTripAfterMigration
+ TestCredentialStore_* all green on pgvector/pgvector:pg17.

* fix(stores): break pointer aliasing in BehavioralRules.Create

Copy rule.Project into row.Project by value so the returned model
does not share a backing string with the caller's variable. If the
caller mutates its original input after Create returns, the stored
model should not change — matches the API contract implied by
'Create returns the persisted row'.

* fix(handlers): memory limit cap, error disclosure, global-scope credential guards

Addresses 3 MAJOR + 2 MEDIUM review findings in HTTP handlers:

handlers_memories.go:
  - handleListMemories now caps 'limit' at 500 and rejects
    non-positive values with 400. Prevents an adversarial or
    misconfigured client from triggering an unbounded SELECT and
    multi-MB JSON response.
  - 500 responses no longer include err.Error() verbatim — both
    store + request failures now emit stable messages; full error
    stays in the server log (information-leak mitigation).

handlers_vault.go:
  - handleStoreCredential rejects scope='global' with 400 until a
    schema migration admits project-less credentials (credentials.project
    is NOT NULL); previously the write would either fail the NOT NULL
    constraint or create a row that no READ path could retrieve (LIST,
    GET, DELETE all require project).
  - handleGetCredential and handleDeleteCredential now require project
    to be non-empty; returning 400 instead of silently returning [] /
    ErrRecordNotFound for empty-project requests.
  - 500 responses no longer leak err.Error() — stable client message,
    full error in log.

* fix(mcp,test,ci): guard store_rule/list_rules on nil, sync F-1 to migration 090 schema, gitignore cleanup

Addresses 2 MEDIUM + 1 MAJOR review findings + infra hygiene:

internal/mcp/server.go (MEDIUM):
  - store_rule and list_rules MCP tools are now only registered when
    s.behavioralRulesStore != nil. Previously tools/list always
    advertised them even though the handlers returned 503 at dispatch
    time — clients would see the tool, invoke it, and get an error.
    Same conditional pattern as versionedDocumentStore elsewhere in
    this file.

internal/grpcserver/credential_migration_test.go (MAJOR — F-1 sync):
  - Test SQL updated to match migration 090's corrected form: scope
    column added to the INSERT list, TO_TIMESTAMP divided by 1000.0.
    Without this sync the HARD GATE would pass against stale SQL while
    production would use the fixed SQL — defeating the purpose of the
    gate. The test still copy-maintains the SQL verbatim; a drift
    detection mechanism is out of scope for this PR and noted for
    future work.
  - Seed timestamps now use UnixMilli() to match observations schema
    convention (created_at_epoch is milliseconds per models.go).

.gitignore:
  - Deduplicated Go cache patterns (collapsed hyphen/underscore
    variants into one), moved .cache/ into the dedicated Go cache
    section, removed stray duplicates.

Verified: all target tests green on pgvector/pgvector:pg17 after these
commits — TestCredentialDecryptRoundTripAfterMigration (1 top + 8 sub),
TestSyncProjectState_* (5), TestHandleMemoryTriggers_Bash* (3),
TestCredentialStore_* (9 incl sub-tests), behavioral_rules + memory
store suites.

* fix(handlers-vault): remove err.Error() from all 500 responses

Addresses CodeRabbit MINOR finding PRRT_kwDORY-ek8577YWO on re-review of PR #181:
'Don't disclose error details in 500-response' (handlers_vault.go:62).

Initial fix only covered some handlers; this commit extends the same
mitigation to the remaining five 500 sites in handlers_vault.go:

- handleListCredentials line 62: 'list credentials: ' + err.Error() → 'failed to list credentials'
- handleGetCredential line 111: 'vault not available: ' + err.Error() → 'vault not available'
- handleGetCredential line 137: 'decrypt credential: ' + err.Error() → 'failed to decrypt credential'
- handleStoreCredential line 207: 'vault not available: ' + err.Error() → 'vault not available'
- handleStoreCredential line 214: 'encrypt credential: ' + err.Error() → 'failed to encrypt credential'
- handleStoreCredential line 229: 'store credential: ' + err.Error() → 'failed to store credential'

All sites now call log.Error().Err(err).Msg(...) first so server-side
debugging still has full context. The 400 (invalid JSON body) site keeps
err.Error() in the body — 400 is a client-parse error and the detail
helps the client diagnose their own malformed request; this is intentional
and not flagged by the reviewer.

Verified: go build + go vet clean.

---------

Co-authored-by: Kirill Turanskiy <thebtf@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant