Module: ClaudeMemory::Audit::Checks
- Defined in:
- lib/claude_memory/audit/checks.rb
Overview
Individual audit checks. Each method takes a Store::StoreManager and returns an Array<Finding>. Checks must be read-only — write operations belong in dedicated commands the user opts into.
Adding a new check:
1. Define a method here with an explicit C### id assignment.
2. Append the method name to Runner::CHECK_METHODS.
3. Document it in docs/audit_runbook.md.
Constant Summary collapse
- SINGLE_CARDINALITY_PREDICATES =
C002 — Single-cardinality predicates with > 1 active fact.
%w[uses_database deployment_platform auth_method].freeze
- CHURN_THRESHOLD =
C010 — Recurring single-cardinality churn (history shows the same predicate has accumulated many superseded/disputed facts — sign of a persistent contamination source).
5- OBSERVATION_SCOPES =
Scopes whose stores carry an observations table. Observation checks iterate both DBs because observations may be project- or global-scoped.
%i[project global].freeze
- OBSERVATION_STATUSES =
Valid observation lifecycle states. Anything else means a writer or migration stamped a status the resolver/reflector never produce.
%w[active consolidated expired].freeze
Class Method Summary collapse
-
.auto_memory_unimported(manager) ⇒ Object
C009 — Auto-memory drift (markdown files newer than project DB facts).
-
.bare_conclusion_rate(manager) ⇒ Object
C007 — Bare-conclusion decisions/conventions (no reason clause).
-
.distillation_backlog(manager) ⇒ Object
C003 — Distillation backlog (warn ≥ 25, error ≥ 100).
-
.duplicate_global_conventions(manager) ⇒ Object
C006 — Duplicate global convention candidates (near-identical text).
- .normalize_convention(text) ⇒ Object
-
.observation_promotion_consistency(manager) ⇒ Object
C012 — Promotion consistency (promoted_at ⇔ promoted_fact_id, fact must exist + be active).
-
.observation_status_corroboration(manager) ⇒ Object
C014 — Status / corroboration sanity (known status set, corroboration ≥ 1).
- .observation_stores(manager) ⇒ Object
-
.observation_tombstone_chain(manager) ⇒ Object
C013 — Tombstone-chain validity (consolidated_into must point to a real, non-self row and a consolidated observation must not stay active).
-
.open_conflicts(manager) ⇒ Object
C001 — Open conflicts in either DB.
-
.orphaned_observations(manager) ⇒ Object
C011 — Orphaned observations (provenance points at a missing content item).
-
.project_starvation(manager) ⇒ Object
C008 — Project DB starvation (< 5 active facts may indicate broken ingest).
-
.shortcut_convention_scope(manager) ⇒ Object
C005 — memory.conventions returns no project facts despite project conventions existing.
-
.shortcut_decision_leak(manager) ⇒ Object
C004 — memory.decisions leaking non-decision predicates.
- .single_cardinality_churn(manager) ⇒ Object
- .single_cardinality_multiplicity(manager) ⇒ Object
Class Method Details
.auto_memory_unimported(manager) ⇒ Object
C009 — Auto-memory drift (markdown files newer than project DB facts).
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/claude_memory/audit/checks.rb', line 176 def auto_memory_unimported(manager) config = Configuration.new dir = Hook::AutoMemoryMirror.default_dir(config.project_dir, config.claude_config_dir) return [] unless Dir.exist?(dir) md_files = Dir.glob(File.join(dir, "*.md")).reject { |f| File.basename(f) == "MEMORY.md" } return [] if md_files.empty? store = manager.store_if_exists("project") return [] unless store # Look for auto_memory_import content items as evidence of prior # import. Count files that would be new on the next import. imported_count = store.content_items.where(source: "auto_memory_import").count net_new = md_files.size - imported_count return [] if net_new <= 0 [Finding.new( id: "C009", severity: :info, title: "#{net_new} auto-memory file(s) not yet imported", detail: "~/.claude/projects/<slug>/memory/*.md files contain durable knowledge that isn't reachable via memory.recall until imported. AutoMemoryMirror only surfaces them transiently at SessionStart.", suggestion: "Preview: claude-memory import-auto-memory --dry-run. Import: claude-memory import-auto-memory.", fact_ids: [] )] end |
.bare_conclusion_rate(manager) ⇒ Object
C007 — Bare-conclusion decisions/conventions (no reason clause).
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 |
# File 'lib/claude_memory/audit/checks.rb', line 137 def (manager) store = manager.store_if_exists("project") return [] unless store detector = Distill::BareConclusionDetector.new rows = store.facts.where(status: "active", predicate: %w[decision convention]).select(:id, :predicate, :object_literal).all = rows.select { |r| detector.(predicate: r[:predicate], object_literal: r[:object_literal]) } return [] if .empty? ratio = .size.to_f / rows.size return [] if ratio < 0.3 [Finding.new( id: "C007", severity: :info, title: "#{(ratio * 100).round}% of decisions/conventions lack reason clauses (#{.size}/#{rows.size})", detail: "Facts without 'because/so that/to avoid/...' lose their justification once context fades. Bare conclusions are dead weight when the team grows or you revisit a year later.", suggestion: "Inspect with: claude-memory explain <fact_id>. Reject low-value bare facts or rewrite with reason clauses via memory.store_extraction.", fact_ids: .map { |r| r[:id] } )] end |
.distillation_backlog(manager) ⇒ Object
C003 — Distillation backlog (warn ≥ 25, error ≥ 100).
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
# File 'lib/claude_memory/audit/checks.rb', line 57 def distillation_backlog(manager) store = manager.store_if_exists("project") return [] unless store distilled_ids = store.ingestion_metrics.select(:content_item_id).distinct pending = store.content_items.exclude(id: distilled_ids).count return [] if pending < 25 severity = (pending >= 100) ? :error : :warn [Finding.new( id: "C003", severity: severity, title: "#{pending} content items not yet deeply distilled", detail: "Backlog grows when SessionStart distillation prompts aren't acknowledged with memory.mark_distilled. A large backlog means the same text gets re-extracted across sessions, increasing hallucination rate.", suggestion: "Triage with /distill-transcripts (interactive) OR mark all distilled if you accept the backlog is noise: claude-memory sweep --mark-all-distilled", fact_ids: [] )] end |
.duplicate_global_conventions(manager) ⇒ Object
C006 — Duplicate global convention candidates (near-identical text).
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 |
# File 'lib/claude_memory/audit/checks.rb', line 113 def duplicate_global_conventions(manager) store = manager.store_if_exists("global") return [] unless store rows = store.facts.where(status: "active", predicate: "convention").select(:id, :object_literal).all return [] if rows.size < 2 # Group by normalized object text (lowercased, stripped of leading # "uses"/"prefers"/punctuation). Pairs with the same normalized # key are likely near-duplicates. groups = rows.group_by { |r| normalize_convention(r[:object_literal]) } dupe_groups = groups.select { |_, list| list.size > 1 } return [] if dupe_groups.empty? [Finding.new( id: "C006", severity: :info, title: "#{dupe_groups.size} near-duplicate global convention group(s)", detail: "Multiple global conventions normalize to the same phrasing. Pick the cleanest and reject the rest to keep memory.conventions output tight.", suggestion: "Review with: claude-memory recall <concept> --scope=global. Reject duplicates: claude-memory reject <fact_id>", fact_ids: dupe_groups.values.flatten.map { |r| r[:id] } )] end |
.normalize_convention(text) ⇒ Object
378 379 380 381 382 383 384 385 |
# File 'lib/claude_memory/audit/checks.rb', line 378 def normalize_convention(text) text.to_s .downcase .gsub(/\b(?:uses|prefers|always|never)\b/, "") .gsub(/[[:punct:]]/, "") .gsub(/\s+/, " ") .strip end |
.observation_promotion_consistency(manager) ⇒ Object
C012 — Promotion consistency (promoted_at ⇔ promoted_fact_id, fact must exist + be active).
266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 |
# File 'lib/claude_memory/audit/checks.rb', line 266 def observation_promotion_consistency(manager) observation_stores(manager).flat_map do |scope, store| active_fact_ids = store.facts.where(status: "active").select(:id) missing_fact_id = store.observations .exclude(promoted_at: nil) .where(promoted_fact_id: nil) .select(:id).all dangling_fact = store.observations .exclude(promoted_fact_id: nil) .exclude(promoted_fact_id: store.facts.select(:id)) .select(:id, :promoted_fact_id).all inactive_fact = store.observations .exclude(promoted_fact_id: nil) .exclude(promoted_fact_id: active_fact_ids) .exclude(promoted_fact_id: dangling_fact.map { |r| r[:promoted_fact_id] }) .select(:id, :promoted_fact_id).all = store.observations .exclude(promoted_fact_id: nil) .where(promoted_at: nil) .select(:id).all obs_ids = (missing_fact_id + dangling_fact + inactive_fact + ).map { |r| r[:id] }.uniq next [] if obs_ids.empty? problems = [] problems << "#{missing_fact_id.size} promoted but missing promoted_fact_id" unless missing_fact_id.empty? problems << "#{dangling_fact.size} promoted_fact_id pointing at a non-existent fact" unless dangling_fact.empty? problems << "#{inactive_fact.size} promoted into a non-active fact" unless inactive_fact.empty? problems << "#{.size} have promoted_fact_id but no promoted_at" unless .empty? [Finding.new( id: "C012", severity: :error, title: "#{obs_ids.size} observation(s) in #{scope} DB have inconsistent promotion state", detail: "Promotion must be atomic: a promoted observation has both promoted_at set and promoted_fact_id pointing at an existing, active fact. Violations (#{problems.join("; ")}) mean mark_observation_promoted ran partially or the target fact was later rejected/superseded, leaving the observation pointing at nothing usable.", suggestion: "Inspect the fact with claude-memory explain <fact_id>. If the fact was intentionally rejected, the observation should be re-opened for re-promotion via memory.promote_observation; if mark_observation_promoted half-ran, re-run promotion.", fact_ids: obs_ids )] end end |
.observation_status_corroboration(manager) ⇒ Object
C014 — Status / corroboration sanity (known status set, corroboration ≥ 1).
351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 |
# File 'lib/claude_memory/audit/checks.rb', line 351 def observation_status_corroboration(manager) observation_stores(manager).flat_map do |scope, store| bad_status = store.observations .exclude(status: OBSERVATION_STATUSES) .select(:id).all bad_corroboration = store.observations .where { corroboration_count < 1 } .select(:id).all flagged = (bad_status + bad_corroboration).map { |r| r[:id] }.uniq next [] if flagged.empty? problems = [] problems << "#{bad_status.size} with status outside #{OBSERVATION_STATUSES.inspect}" unless bad_status.empty? problems << "#{bad_corroboration.size} with corroboration_count < 1" unless bad_corroboration.empty? [Finding.new( id: "C014", severity: :warn, title: "#{flagged.size} observation(s) in #{scope} DB have invalid status/corroboration", detail: "Every observation should carry a known lifecycle status (#{OBSERVATION_STATUSES.join("/")}) and at least one sighting (corroboration_count ≥ 1; a fresh insert counts as 1). Violations (#{problems.join("; ")}) break the promotion gate (which keys off corroboration) and the recall filters (which key off status).", suggestion: "Inspect with memory.observations. A corroboration_count < 1 means increment_corroboration math went negative; an unknown status means a migration or external writer bypassed insert_observation. Re-derive via the Reflector if possible.", fact_ids: flagged )] end end |
.observation_stores(manager) ⇒ Object
237 238 239 240 241 |
# File 'lib/claude_memory/audit/checks.rb', line 237 def observation_stores(manager) OBSERVATION_SCOPES .map { |scope| [scope, manager.store_if_exists(scope.to_s)] } .reject { |_, store| store.nil? } end |
.observation_tombstone_chain(manager) ⇒ Object
C013 — Tombstone-chain validity (consolidated_into must point to a real, non-self row and a consolidated observation must not stay active).
310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 |
# File 'lib/claude_memory/audit/checks.rb', line 310 def observation_tombstone_chain(manager) observation_stores(manager).flat_map do |scope, store| obs_ids = store.observations.select(:id) dangling = store.observations .exclude(consolidated_into: nil) .exclude(consolidated_into: obs_ids) .select(:id, :consolidated_into).all self_link = store.observations .exclude(consolidated_into: nil) .where(Sequel[:consolidated_into] => Sequel[:id]) .select(:id).all active_but_tombstoned = store.observations .exclude(consolidated_into: nil) .where(status: "active") .select(:id).all consolidated_without_link = store.observations .where(status: "consolidated", consolidated_into: nil) .select(:id).all flagged = (dangling + self_link + active_but_tombstoned + consolidated_without_link).map { |r| r[:id] }.uniq next [] if flagged.empty? problems = [] problems << "#{dangling.size} consolidated_into → missing observation" unless dangling.empty? problems << "#{self_link.size} consolidated_into self-link" unless self_link.empty? problems << "#{active_but_tombstoned.size} active yet have a consolidated_into target" unless active_but_tombstoned.empty? problems << "#{consolidated_without_link.size} status=consolidated with no consolidated_into keeper" unless consolidated_without_link.empty? [Finding.new( id: "C013", severity: :error, title: "#{flagged.size} observation(s) in #{scope} DB have a broken tombstone chain", detail: "Tombstoning is append-only: a superseded observation gets status=consolidated and consolidated_into pointing at the surviving keeper. Violations (#{problems.join("; ")}) corrupt the lineage — recall could surface a tombstoned row, or a consolidated row could orphan its history.", suggestion: "Inspect with memory.observations. Re-run the deterministic Reflector (fires on PreCompact/SessionEnd) to re-derive consolidation; a self-link or active+tombstoned row indicates a Reflector bug — file it rather than hand-editing the append-only table.", fact_ids: flagged )] end end |
.open_conflicts(manager) ⇒ Object
C001 — Open conflicts in either DB.
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
# File 'lib/claude_memory/audit/checks.rb', line 17 def open_conflicts(manager) findings = [] {project: manager.store_if_exists("project"), global: manager.store_if_exists("global")}.each do |scope, store| next unless store conflicts = store.open_conflicts next if conflicts.empty? findings << Finding.new( id: "C001", severity: :error, title: "#{conflicts.size} open conflict(s) in #{scope} DB", detail: "Open conflicts indicate unresolved single-cardinality disputes. Each will keep re-firing until the losing fact is rejected.", suggestion: "claude-memory conflicts && claude-memory reject <fact_id>", fact_ids: conflicts.flat_map { |c| [c[:fact_a_id], c[:fact_b_id]] }.uniq ) end findings end |
.orphaned_observations(manager) ⇒ Object
C011 — Orphaned observations (provenance points at a missing content item).
244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 |
# File 'lib/claude_memory/audit/checks.rb', line 244 def orphaned_observations(manager) observation_stores(manager).flat_map do |scope, store| content_ids = store.content_items.select(:id) orphans = store.observations .exclude(source_content_item_id: nil) .exclude(source_content_item_id: content_ids) .select(:id) .all next [] if orphans.empty? [Finding.new( id: "C011", severity: :warn, title: "#{orphans.size} observation(s) in #{scope} DB reference a missing content item", detail: "An observation's source_content_item_id should point at the content_items row it was distilled from. A dangling pointer means the source row was pruned or never existed, so the observation's provenance can no longer be explained.", suggestion: "Inspect with memory.observations. These rows are append-only; if the provenance is unrecoverable, consolidate or expire them via the Reflector (PreCompact/SessionEnd) rather than deleting.", fact_ids: orphans.map { |r| r[:id] } )] end end |
.project_starvation(manager) ⇒ Object
C008 — Project DB starvation (< 5 active facts may indicate broken ingest).
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/claude_memory/audit/checks.rb', line 159 def project_starvation(manager) store = manager.store_if_exists("project") return [] unless store count = store.facts.where(status: "active").count return [] if count >= 5 [Finding.new( id: "C008", severity: :warn, title: "Only #{count} active project fact(s)", detail: "A nearly-empty project DB suggests either a fresh install (ignore) OR a broken ingest pipeline / overzealous rejection. Verify hooks are firing: claude-memory doctor.", suggestion: "claude-memory doctor; claude-memory stats; check .claude/settings.json hook configuration.", fact_ids: [] )] end |
.shortcut_convention_scope(manager) ⇒ Object
C005 — memory.conventions returns no project facts despite project conventions existing.
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/claude_memory/audit/checks.rb', line 92 def shortcut_convention_scope(manager) project_store = manager.store_if_exists("project") return [] unless project_store project_count = project_store.facts.where(status: "active", predicate: "convention").count return [] if project_count.zero? results = Shortcuts.conventions(manager, limit: 50) project_returned = results.count { |r| r[:source] == "project" } return [] if project_returned > 0 [Finding.new( id: "C005", severity: :warn, title: "memory.conventions returned 0 project facts despite #{project_count} project conventions existing", detail: "Pre-2026-05-21 audit, memory.conventions was hardcoded to scope=global. If you're seeing 0 project facts in a project with conventions, the shortcut has regressed.", suggestion: "Check Shortcuts.collect_facts in lib/claude_memory/shortcuts.rb. Re-run `bundle exec rspec spec/claude_memory/shortcuts_spec.rb`.", fact_ids: [] )] end |
.shortcut_decision_leak(manager) ⇒ Object
C004 — memory.decisions leaking non-decision predicates.
76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/claude_memory/audit/checks.rb', line 76 def shortcut_decision_leak(manager) results = Shortcuts.decisions(manager, limit: 50) leaked = results.map { |r| r[:fact][:predicate] }.uniq - ["decision"] return [] if leaked.empty? [Finding.new( id: "C004", severity: :error, title: "memory.decisions returns non-decision predicates: #{leaked.inspect}", detail: "memory.decisions should return only `decision`-predicate facts. Predicate leakage suggests the shortcut implementation has regressed back to text-search filtering (pre-2026-05-21 audit).", suggestion: "Inspect lib/claude_memory/shortcuts.rb — SHORTCUTS[:decisions][:predicates] should equal ['decision']. Run `bundle exec rspec spec/claude_memory/shortcuts_spec.rb`.", fact_ids: results.select { |r| leaked.include?(r[:fact][:predicate]) }.map { |r| r[:fact][:id] } )] end |
.single_cardinality_churn(manager) ⇒ Object
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/claude_memory/audit/checks.rb', line 208 def single_cardinality_churn(manager) store = manager.store_if_exists("project") return [] unless store SINGLE_CARDINALITY_PREDICATES.flat_map do |predicate| non_active = store.facts .where(predicate: predicate, status: %w[superseded disputed rejected]) .count next [] if non_active < CHURN_THRESHOLD [Finding.new( id: "C010", severity: :warn, title: "predicate=#{predicate} shows churn: #{non_active} historical non-active facts", detail: "Repeated supersession/dispute on a single-cardinality predicate usually means a contamination source (e.g., example text in CLAUDE.md or docs) keeps re-introducing the same hallucination.", suggestion: "Find the contamination source: claude-memory recall <bad_value> --scope=project. Wrap the trigger text in <no-memory> tags. See docs/audit_runbook.md.", fact_ids: [] )] end end |
.single_cardinality_multiplicity(manager) ⇒ Object
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
# File 'lib/claude_memory/audit/checks.rb', line 38 def single_cardinality_multiplicity(manager) store = manager.store_if_exists("project") return [] unless store SINGLE_CARDINALITY_PREDICATES.flat_map do |predicate| rows = store.facts.where(status: "active", predicate: predicate).all next [] if rows.size <= 1 [Finding.new( id: "C002", severity: :error, title: "predicate=#{predicate} has #{rows.size} active facts (single-cardinality)", detail: "Single-cardinality predicates must have at most one active value. Multiple actives mean resolver dropped a supersession or distillation produced contradictory claims.", suggestion: "Inspect with: claude-memory explain <fact_id>. Reject the wrong ones: claude-memory reject <fact_id>", fact_ids: rows.map { |r| r[:id] } )] end end |