woods 1.6.0 → 2.0.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1908 -0
  3. data/CONTRIBUTING.md +202 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +620 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +415 -0
  19. data/docs/INTERNALS.md +415 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +197 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +40 -1
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2509 -189
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +63 -21
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +138 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +408 -34
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +223 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer.rb +21 -5
  218. data/lib/woods/session_tracer/file_store.rb +138 -19
  219. data/lib/woods/session_tracer/middleware.rb +1 -2
  220. data/lib/woods/session_tracer/redis_store.rb +122 -12
  221. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  222. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  223. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  224. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  225. data/lib/woods/session_tracer/store.rb +14 -1
  226. data/lib/woods/storage/metadata_store.rb +230 -26
  227. data/lib/woods/storage/pgvector.rb +180 -22
  228. data/lib/woods/storage/qdrant.rb +367 -41
  229. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  230. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  231. data/lib/woods/storage/snapshotter.rb +23 -5
  232. data/lib/woods/storage/vector_store.rb +49 -8
  233. data/lib/woods/storage_identity.rb +28 -0
  234. data/lib/woods/tasks.rb +53 -2
  235. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  236. data/lib/woods/temporal/snapshot_store.rb +139 -42
  237. data/lib/woods/unblocked/client.rb +119 -17
  238. data/lib/woods/unblocked/document_builder.rb +34 -2
  239. data/lib/woods/unblocked/exporter.rb +63 -27
  240. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  241. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  242. data/lib/woods/update_check.rb +24 -1
  243. data/lib/woods/util/uuid5.rb +124 -0
  244. data/lib/woods/version.rb +1 -1
  245. data/lib/woods/watch/daemon.rb +1345 -0
  246. data/lib/woods/watch/listen_watcher.rb +81 -0
  247. data/lib/woods/watch/polling_watcher.rb +137 -0
  248. data/lib/woods/watch/status.rb +169 -0
  249. data/lib/woods/watch/tree_scan.rb +163 -0
  250. data/lib/woods/watch/watcher.rb +100 -0
  251. data/lib/woods.rb +53 -9
  252. data/plugin/.claude-plugin/plugin.json +18 -0
  253. data/plugin/hooks/hooks.json +29 -0
  254. data/plugin/hooks/woods-post-edit.sh +226 -0
  255. data/plugin/hooks/woods-session-start.sh +77 -0
  256. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  257. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  258. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  259. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  260. data/plugin/skills/woods-setup/SKILL.md +99 -0
  261. metadata +134 -23
  262. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  263. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  264. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  265. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  266. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  267. data/lib/woods/console/bridge.rb +0 -210
  268. data/lib/woods/formatting/claude_adapter.rb +0 -98
  269. data/lib/woods/formatting/generic_adapter.rb +0 -56
  270. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  271. data/lib/woods/notion/mapper.rb +0 -40
  272. data/lib/woods/observability/health_check.rb +0 -79
  273. data/lib/woods/observability/instrumentation.rb +0 -34
data/CHANGELOG.md CHANGED
@@ -7,6 +7,1914 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Fixed
11
+
12
+ - **Release validation names the live-backends CI job as it is called.** The release
13
+ validator required a CI job named `Live backends (pgvector + Qdrant + Solid Cache)`,
14
+ but the job gained `+ Redis` in its name, so every release dispatch failed at
15
+ release-context with nothing published. The prefix now matches, and a spec checks every
16
+ required contract job against the names in `ci.yml` so a rename cannot drift again.
17
+ - **Release candidate jobs find the downloaded artifact.** `actions/download-artifact` with
18
+ `artifact-ids` extracts into `dist/<artifact-name>/`, so the digest check and the install
19
+ in `dist/` failed with a missing file on every dispatch since the switch to artifact ids.
20
+ Both downloads now set `merge-multiple: true`; the workflow spec requires it.
21
+ - **Release candidate tests accept a prerelease version.** The clean-install smoke specs
22
+ pinned `2.0.0` as a literal in the dummy app's Gemfile, the loaded-version check, and a
23
+ snapshot fixture, so the first prerelease failed them (Bundler never resolves a prerelease
24
+ from an unpinned requirement). They now use `Woods::VERSION`. The candidate host also
25
+ installs `webrick` so `woods-mcp-http` finds a Rack handler on Rubies that no longer ship one.
26
+
27
+ ## [2.0.0.beta1] - 2026-09-09
28
+
29
+ ### Added
30
+
31
+ - **`WOODS_GIT_DIR` names the canonical git directory outright.** It wins over
32
+ whatever repository Woods would otherwise find, at all three of Woods's git
33
+ call sites: per-unit enrichment, `manifest.json` provenance, and the
34
+ `woods:incremental` diff range. All three build their command line with the
35
+ new `Woods::GitCommand.argv`. This is the escape hatch for a container that
36
+ can mount the canonical git directory but not the host path a linked
37
+ worktree's `gitdir:` pointer names.
38
+ - **Database-partition layer for multi-database apps (#280).** Model units record
39
+ `metadata[:database]` from `connection_db_config` (Rails 6.1+, `nil` on 6.0), so a model
40
+ that inherits `connects_to` from an abstract class reports the inherited database.
41
+ Association entries carry `from_db`, `to_db`, `through_db` (the has_many :through join
42
+ model's database, nil for a plain association), and `disable_joins`; `metadata[:foreign_keys]`
43
+ lists each foreign key's `from_table`, `to_table`, and `column` (the target table's owning
44
+ database is a graph-level lookup, not stored per model). The dependency graph gains additive
45
+ node keys (`database`, `table`, `foreign_key_tables`) and edge keys (`through`, `through_db`,
46
+ `disable_joins`); a graph with none of these serializes exactly as before.
47
+ - **`cross_database_edges` report.** `GraphAnalyzer#analyze` lists association and
48
+ foreign-key edges that cross databases, with `kind` set to `join_through_across_databases`
49
+ when `disable_joins` is false and `from_db`, `through_db`, or `to_db` disagree (a nil
50
+ `through_db` falls back to comparing the two ends). A foreign key never resolves to an
51
+ owner living in its own source database, even when another database also claims the
52
+ table; only when every owner sits elsewhere, across more than one database, does the
53
+ entry come back with `to: nil` and an `ambiguous_owners` list instead of guessing.
54
+ Written to `graph_analysis.json`; exposed through `graph_analysis` in Task 10.
55
+ - **A phase breakdown of one full extraction of the fixture app**, in
56
+ `docs/WATCH_DAEMON.md`. Graph analysis, PageRank included, is 3.2% of the run;
57
+ git enrichment is 11.6%; the one phase over 15% is `RailsSourceExtractor`,
58
+ which is 52.6% only because 119 of the fixture's 147 units are framework
59
+ sources.
60
+ - **Positioning against Rubydex, rails-mcp-server, and ruby-lsp-rails.** `docs/WHY_WOODS.md`
61
+ gains a comparison table with versions checked on 2026-09-08, and frames Rubydex as
62
+ complementary on symbol references.
63
+ - **Git commit facts land on graph nodes before analysis (#280).** Both extraction
64
+ paths copy `commit_count` and `change_frequency` from a unit's git metadata onto its
65
+ graph node: full extraction after git enrichment and before graph analysis
66
+ (`Extractor#annotate_graph_with_git_data`), incremental extraction as part of the
67
+ per-unit JSON patch (`Extractor#annotate_node_from_git`). A patch missing one of the
68
+ two keys leaves the node's existing value for that key alone rather than clearing it.
69
+
70
+ This gives `GraphAnalyzer` access to git facts without holding every unit in memory,
71
+ which an incremental run never does.
72
+ - **`volatile_dependencies` report (#280).** Git churn and graph edges finally meet:
73
+ `GraphAnalyzer#analyze` lists edges whose dependency has at least
74
+ `config.volatile_dependency_ratio` (default `3.0`) times the dependent's commit count,
75
+ ranked by the dependency's PageRank. Young classes (fewer than 5 commits, or `new`)
76
+ are skipped, and the report is informational only. The top 20 are persisted in
77
+ `graph_analysis.json` (`stats.volatile_dependency_count` reports the full qualifying
78
+ total); the top 5 appear in `SUMMARY.md`. Each entry carries both ends' node types
79
+ (`from_type`/`to_type`). PageRank now runs inside `analyze`.
80
+ - **`Woods::PublishedIndex` (#280).** A small read-only Ruby API over one published index
81
+ generation for RuboCop cops and gate scripts: look up units by identifier (optionally
82
+ scoped to a type, to avoid a same-identifier collision across type directories), iterate
83
+ edges with their attributes, build a table-to-database map, pin a retained `payloads/gen-N`
84
+ under the same retention lock protocol as `PayloadStore#prune`, and get a checksum keyed to
85
+ the pinned payload for `external_dependency_checksum`. Raises
86
+ `Woods::PublishedIndex::CorruptPointerError` for a `generation.json` that exists but will
87
+ not parse, rather than reporting zero published generations. `docs/PUBLISHED_INDEX.md`
88
+ includes a worked `Multidb/ForeignKeyAcrossDatabases` cop.
89
+ - **Agent-level ablation harness (#280).** `woods:evaluate:ablation[task_set]` runs a task
90
+ set twice per task, with the index on and off, through any agent command that prints
91
+ `claude -p --output-format json` style output. Each trial runs in a disposable git
92
+ worktree checked out from a fixed baseline SHA, verifies Woods availability before
93
+ running, and enforces a per-trial timeout. Reports resolution rate, tokens, cost, turns,
94
+ and errors per condition with the delta, plus provenance (agent command, model, MCP
95
+ config, Woods generation, baseline SHA) per result. No Rails boot. This is a harness for
96
+ collecting paired runs, not causal evidence. `docs/EVALUATION.md` now documents
97
+ `woods:evaluate`, the baseline, and the ablation in one place.
98
+ - **Plugin hooks for index freshness (#280).** The Claude Code plugin (2.3.0) ships two
99
+ opt-in hooks under `plugin/hooks/`: a `PostToolUse` hook that runs `woods:incremental`
100
+ in the background when an edit touches models, routes, migrations, schema, or a
101
+ `package.yml`, reading `cwd` from the hook payload so linked worktrees refresh their own
102
+ index; and a `SessionStart` hook that warns when the published generation predates the
103
+ last commit (scoped to commit timestamps, so it does not cover uncommitted edits or an
104
+ older checkout). Both do nothing until `WOODS_HOOKS_ENABLED=1` is set, honor
105
+ `WOODS_HOOKS_DISABLED=1`, and resolve the index directory the same no-boot way
106
+ `woods:watch_status` does (`WOODS_OUTPUT`, default `tmp/woods`). Lock contention batches
107
+ concurrent edits instead of dropping them: a busy hook appends its path to
108
+ `hook-pending.txt` and returns, and the lock holder drains it in a loop until empty
109
+ (mkdir-based lock on hosts without `flock`, reclaimed after `WOODS_HOOK_LOCK_STALE_SECONDS`,
110
+ default 1800, when a crashed run leaves it behind). Docs gain the Rails 8.1 `config/ci.rb` step
111
+ `step "Woods: refresh", "bin/rails woods:incremental"`.
112
+ - **Packwerk package layer (#280).** A new `package` unit type reads every `package.yml`
113
+ `enforce_dependencies`, `layer`, `public_path`, `owner`, with a `package_dependency`
114
+ edge per declared dependency. Any `package.yml` change re-runs the extractor wholesale
115
+ during incremental extraction. Woods reports boundaries; `pks check` and
116
+ `packwerk check` keep enforcement. A pack-resident file-based unit (a service under
117
+ `packs/billing/app/services/`) is still not discovered by `PathDispatcher`, so it never
118
+ becomes a unit at all (follow-up B-175).
119
+ - **Package membership on units (#280).** Every discovered app-owned unit under a package
120
+ root carries `metadata[:package]` and its graph node carries `package`, on full and
121
+ incremental runs alike; a `package.yml` change re-annotates the affected units in the
122
+ same incremental run.
123
+ - **`undeclared_package_edges` report.** `GraphAnalyzer#analyze` lists every edge that
124
+ crosses a package boundary the source package never declared, read from the `package`
125
+ node attribute and each package unit's own `package_dependency` edges. `dependents`,
126
+ `lookup`, and `domain_clusters` already show the units; this shows the boundary an
127
+ agent is about to cross. Enforcement stays with `packwerk check` / `pks check`.
128
+ - **`graph_analysis` exposes the cross-database and volatile-dependency reports.**
129
+ `analysis` accepts `cross_database_edges`, `volatile_dependencies`, and
130
+ `undeclared_package_edges`; `all` paginates them like every other section, and the
131
+ markdown and plain renderers print edge-shaped items on one line each.
132
+ - **`woods:check:moved_messages` (#280).** Compares two retained payload generations through
133
+ `Woods::PublishedIndex` and lists every public method name that looks like it moved between
134
+ units while its `test_coverage` edge did not follow. Each row is a candidate move into a unit
135
+ without mapped tests, not a proven coverage loss. `WOODS_CHECK_STRICT=1` exits 1 for CI. No
136
+ Rails boot.
137
+
138
+ - **MCP 2026-07-28 support** (B-111–B-114). The gemspec now requires `mcp >= 1.2, < 2.0`,
139
+ the release that added the 2026-07-28 protocol revision.
140
+ - **Stateless Streamable HTTP by default.** `woods-mcp-http` no longer mints
141
+ `Mcp-Session-Id`, so restarting it (gem upgrade, machine sleep, worktree rebuild) is
142
+ invisible to connected clients instead of invalidating every session, and several
143
+ instances can serve one volume-mounted index without sticky routing. Set
144
+ `WOODS_MCP_HTTP_STATELESS=0` for a client that still needs sessions, the GET SSE
145
+ stream, or DELETE teardown — a transitional escape hatch, since all three are gone
146
+ from the specification.
147
+ - **Tasks extension** (`io.modelcontextprotocol/tasks`). `pipeline_extract` and
148
+ `pipeline_embed` return a durable task handle to clients that declare the extension:
149
+ poll with `tasks/get`. Cancellation is not advertised: `tasks/cancel` returns
150
+ `Method not found` because Woods cannot safely stop work already holding the
151
+ pipeline lock or prevent it from publishing. Records live on disk under
152
+ `<index_dir>/tasks/`, so a run reports real success or failure, a client that drops
153
+ mid-run can reconnect — even to a restarted server — and collect the result, and a
154
+ task whose owning process died resolves to `failed` instead of leaving an agent
155
+ polling forever. Clients that do not declare the extension get the previous
156
+ fire-and-forget behaviour, unchanged.
157
+ - **Cache hints and deterministic tool ordering.** List and read results carry
158
+ `ttlMs` (default 10s, `WOODS_MCP_CACHE_TTL_MS`) and `cacheScope: "private"`, and tools
159
+ are advertised in sorted order so a host with optional integrations wired presents the
160
+ same tool block as one without.
161
+ - **`server/discover`** is answered, advertising supported versions, capabilities and
162
+ the Tasks extension.
163
+
164
+ - **`embedding_provider = :fake`** — the deterministic bag-of-words provider is now a
165
+ first-class citizen (promoted from spec support), and `Builder` also accepts an injected
166
+ provider object responding to `#embed`/`#embed_batch`. `woods:embed` → `woods:retrieve`
167
+ now runs fully offline; `woods:retrieve` resolves all four backends through the
168
+ configuration instead of hardcoding Ollama + in-memory stores (#178).
169
+ - **Notion sync manifest** — unchanged pages cost zero API calls on re-sync; changed pages
170
+ update by cached page id without a title query; `WOODS_NOTION_FORCE=1` bypasses for one
171
+ run (#207).
172
+ - **`woods:validate`** warns for units whose `file_path` resolves neither as written nor
173
+ under `Rails.root` (#169).
174
+
175
+ - **`rake woods:watch` — a resident extraction daemon** (#164, phase 2). Watches the app and
176
+ keeps the index current as files change, instead of as-fresh-as-the-last-explicit-run. One
177
+ cycle is watch → debounce → classify → reload if needed → extract → publish. Freshness was
178
+ pull-based because every sync from a cold process pays a full Rails boot; a process that
179
+ stays booted removes that tax without giving up runtime-true extraction. Development only —
180
+ it adds no network listener. See `docs/WATCH_DAEMON.md`.
181
+ - **Restart triggers, Spring-style.** A change to `Gemfile.lock`, `config/**`, or the schema
182
+ stops the daemon with a degraded status and exit `75` for a supervisor, because Rails'
183
+ reloader re-runs none of it.
184
+ - **Failure posture.** A failed reload (the mid-edit syntax error) publishes a degraded
185
+ status naming the reason and leaves the index intact at its last good generation. The
186
+ daemon never crash-loops, never publishes a partial write, and never advances the
187
+ generation over work that didn't land.
188
+ - **Storm handling.** Above a changed-file threshold (default 50) a branch switch falls back
189
+ to one full extraction rather than N incremental steps.
190
+ - **Two watcher backends.** The `listen` gem when the host has it; a dependency-free polling
191
+ scan otherwise — which is also the right choice inside a container, where native FS events
192
+ don't cross bind mounts reliably.
193
+ - **Multi-instance operation across worktrees** (#164, phase 4). Worktrees stay disjoint by
194
+ construction (own `Rails.root`, own `tmp/woods`), so the work is within one worktree: the
195
+ daemon, a manual `woods:extract`, and a hook-triggered `woods:incremental` now share the
196
+ existing `PipelineLock`. The daemon yields to another writer and carries its paths into the
197
+ next cycle rather than losing them; manual runs wait up to 30 s and then proceed with a
198
+ warning rather than hanging a terminal; and `woods:incremental` skips entirely when a live
199
+ daemon is already watching the tree (`WOODS_IGNORE_WATCH=1` overrides). New
200
+ `rake woods:watch_status` exits 0 when a daemon is alive, so a worktree hook can revive one
201
+ without parsing anything. `Woods::Watch::Daemon`'s `idle_timeout` (off by default) stops a
202
+ daemon in a dormant slot so it stops holding a booted app's memory.
203
+ - **An MCP freshness contract** (#164, phase 3). `woods_status` now reports the index
204
+ `generation` (number, when it moved, what moved it), whether the **working tree** is dirty
205
+ plus a fingerprint of it, and the watch daemon's state (`running` / `degraded` + reason /
206
+ `stopped` / `absent`). `git_sha_matches_head` only ever saw *committed* HEAD, so an agent
207
+ forty uncommitted edits deep was told the index matched while every answer described the
208
+ tree before those edits.
209
+ - **`IndexReader` self-refreshes on a generation change**, making the MCP `reload` tool an
210
+ optimization rather than a correctness requirement. A long-lived server used to hold
211
+ whatever it read at boot, so an agent working alongside a running extraction silently got
212
+ answers describing the tree as of the last server start. The check costs one `File.stat` of
213
+ a ~100-byte file per read. `IndexReader#with_pinned_generation` extends it across a sequence
214
+ of reads. Indexes with no generation file behave exactly as before.
215
+ - **`Woods::Generation`** — a monotonic marker for "which version of the index is on disk",
216
+ written atomically as the last step of a successful run by *every* extraction mode (full,
217
+ incremental, targeted refresh, daemon cycle). Never advanced by a run that failed or changed
218
+ nothing, so staleness stays honest.
219
+ - **`Extractor#refresh(*keys)` and `rake "woods:refresh[routes]"`** (#164, phase 1). Re-runs
220
+ named extractors wholesale against an already-booted app, replacing every unit of the types
221
+ they own. The unit types with no per-file entry point — routes, middleware, engines,
222
+ scheduled jobs, state machines, factories, events, database views — were only reachable by
223
+ full extraction from a cold boot, which was an artifact of the boot cost rather than
224
+ anything inherent: in a booted process re-running one extractor takes seconds. A routes
225
+ refresh cascades to the extractors that embed the route table. Any extractor key is
226
+ accepted, so `refresh(:models)` is a legitimate way to re-derive models after a schema
227
+ change.
228
+ - **`Woods::ReloadPolicy`** — the reload-trigger inventory (#164). Classifies a changed path
229
+ as `:ignore`, `:reextract` (Woods reads bytes; no Rails involvement), `:reload` (an
230
+ autoloaded constant changed) or `:restart` (boot-captured state changed — initializers,
231
+ `config/**`, `Gemfile.lock`, schema). Consumed by `Watch::Daemon` on every cycle, and
232
+ tested against the `railties >= 6.0` support matrix. `spec/reload_policy_spec.rb` derives
233
+ its samples from the `PathDispatcher` rules themselves, so the two cannot drift apart
234
+ silently.
235
+ - **Differential test harness for incremental extraction**
236
+ (`spec/integration/incremental_equivalence_spec.rb`, tagged `:booted_app`). Applies
237
+ randomized create/modify/delete/rename sequences to a booted fixture app and asserts, at
238
+ every step, that the incrementally-maintained index matches a cold full extraction: same
239
+ units, same unit content, same graph, PageRank recomputed. Tune with `WOODS_DIFF_OPS` and
240
+ `WOODS_DIFF_SEEDS`; runs in CI on every Rails-matrix row.
241
+ - `Woods::ChangeSet` — one normalization of "what changed" (absolutize, de-duplicate, split
242
+ present from vanished) shared by every entry point, so the git-diff caller and the watch
243
+ daemon can't drift apart.
244
+
245
+ ### Performance
246
+
247
+ - **Controller and mailer chunk extraction parses each file once, not once per
248
+ action (P1).** `AstSourceExtraction#extract_action_source` re-read and re-parsed
249
+ the defining file for every action, so a 30-action controller cost 30 reads and
250
+ 30 parses of one source file. The defining file is now read and parsed once per
251
+ extractor instance and every action is answered from that parse; per-action
252
+ chunk output is byte-identical.
253
+
254
+ - **Event extraction reads each file once per run (P2).** `EventExtractor`
255
+ re-read every publisher/subscriber file for every event that referenced it
256
+ (once per event in pass 2, on top of the pass-1 scan), so a widely-shared file
257
+ was read once per event. Both passes now share one read per path per run.
258
+
259
+ - **Rake task extraction reads and parses each `.rake` file once per run
260
+ (P9a).** `all_definitions` — the sibling-definition index built on the first
261
+ `sibling_definitions` call — re-read and re-parsed every `.rake` file the main
262
+ extraction path had just handled. Both paths now share a memoized read+parse.
263
+
264
+ - **GraphQL model-reference scanning makes one pass per type file (P9c).** The
265
+ constant follow-up check ran one full-source scan per unique capitalized
266
+ constant; one combined scan now collects the constants followed by a model
267
+ call. Emitted dependencies are unchanged.
268
+
269
+ - **Git metadata batching sends each pathspec once (P9d).** Multi-unit files
270
+ repeated their path in every 500-path git log batch; `batch_git_data` now
271
+ deduplicates before slicing. Output is keyed by relative path and unchanged.
272
+
273
+ - **User search regexes are time-bounded (P5).** `search` compiled the query as
274
+ a raw Ruby regex with no time limit, so a pattern with catastrophic
275
+ backtracking could stall the dispatch thread indefinitely. On Ruby 3.2+ the
276
+ compiled pattern carries a 1s per-match limit; an overrun aborts the scan
277
+ into a partial response with a note instead of hanging. Invalid patterns
278
+ still fall back to an escaped literal match.
279
+
280
+ - **`dependency_graph.json` parses once per generation (P6).** The Index
281
+ Server's `dependency_graph` and `raw_graph_data` each parsed the same file,
282
+ holding two copies of a large graph per generation; the typed graph now
283
+ builds from the single raw parse. Because `raw_graph_data` exposes that
284
+ shared parsed object, Woods recursively freezes it before publication so a
285
+ caller cannot mutate nested nodes or edges and corrupt later graph reads.
286
+
287
+ - **JSON temporal snapshots are pruned by retention (P8).** `snapshots/`
288
+ wrote one file per SHA and never pruned, growing unboundedly on long-lived
289
+ repos. Capture now keeps the newest `WOODS_PAYLOAD_RETENTION` snapshots
290
+ (default 3, same variable and default as payload retention), and the bound
291
+ holds even when the just-captured snapshot's timestamp ties or precedes
292
+ older entries; `diff` and `unit_history` beyond the retention window
293
+ return empty.
294
+
295
+ - **Redis session-tracer eviction uses a recency ZSET (P4).** `prune_sessions`
296
+ read every candidate session's history on every record once `max_sessions`
297
+ was reached (up to 1000 session reads per request). The `woods:sessions`
298
+ index is now a ZSET scored by each request's timestamp, so eviction touches
299
+ a bounded window and never reads session histories. A legacy SET index from
300
+ a previous version migrates automatically on the first record through a
301
+ single atomic server-side script, so concurrent writers racing the legacy
302
+ index cannot erase each other's members or fail mid-migration; eviction
303
+ order (oldest last request) is unchanged. Adds a live-Redis contract spec
304
+ (`spec/session_tracer/redis_store_live_spec.rb`, `WOODS_RUN_LIVE_BACKENDS=1`).
305
+
306
+ ### Documentation
307
+
308
+ - **The documentation set is restructured for a cold reader** (from the
309
+ 2026-09-03 documentation review). The README opens with the pitch and
310
+ reaches setup before any migration content, links Why Woods, and points
311
+ 1.x users at the upgrade guide instead of restating it;
312
+ `docs/ARCHITECTURE.md` is renamed `docs/INTERNALS.md` (ending the name
313
+ collision with generated self-analysis output); Troubleshooting's quick
314
+ reference sits under its intro; the FAQ, tool cookbook, and configuration
315
+ reference open with question/scenario/options indexes; reference-doc
316
+ headings use sentence case; the release-cutting runbook lives in
317
+ CONTRIBUTING.md; and implementation plans moved under `docs/design/`.
318
+ `woods:self_analyze` no longer emits `call-graph.md`, `dependency-map.md`,
319
+ and `dataflow.md` — byte-identical duplicates of sections
320
+ `architecture.md` already embeds (13k generated lines removed).
321
+
322
+ - **The distributed plugin grows from three skills to five, with upgrade
323
+ coverage and reworked triggering** (plugin 2.2.0). `woods-investigate`
324
+ triggers on audits, code reviews, investigations, debugging, onboarding,
325
+ and change-impact assessment, distilling AGENT_GUIDE.md's tool-selection
326
+ workflow so index consumption — not just setup — has a skill.
327
+ `woods-agent-enable` wires Woods into a repository's own agent tooling:
328
+ project MCP configuration, index-first guidance in CLAUDE.md/AGENTS.md,
329
+ and an optional project skill seeded from real cluster/pagerank output.
330
+ `woods-setup` now covers the 1.x→2.0 upgrade path (clean re-index, no
331
+ in-place durable-index upgrade, full re-embed) and triggers on upgrades.
332
+ Every skill description states what the skill does as well as when to use
333
+ it, and skill prose no longer hardcodes a patch-precise version floor or a
334
+ pinned MCP protocol date: the marketplace entry is the authoritative
335
+ minimum, and skills operate against the preflight-recorded installed
336
+ version.
337
+
338
+ - **`woods-mcp-start` is described as what it is: a preflight wrapper (MCP-10).**
339
+ It validates the index directory and published manifest, then `exec`s
340
+ `woods-mcp`; there is no supervision and no restart loop. The "self-healing
341
+ MCP wrapper" description in the header comment and the CLAUDE.md architecture
342
+ table is gone. (`docs/MCP_SERVERS.md` and `docs/DOCKER_SETUP.md` already
343
+ described it accurately.)
344
+ - **Reload documentation drift (MCP-7).** `Bootstrapper.populate_vector_metadata`
345
+ named `populate_reloaded_vector_metadata`, a method the M7 transaction removed;
346
+ `Ranker#invalidate_pagerank_cache!` claimed a bootstrapper caller that does not
347
+ exist (the transaction installs a fresh Ranker instead, deliberately). Both
348
+ comments now describe the current design; `invalidate_pagerank_cache!` is
349
+ re-documented as an API for direct embedders that repoint a Ranker's graph
350
+ store without rebuilding it.
351
+ - **The unreleased-2.0 story is legible from the repository root.** `README.md`
352
+ gains a version banner naming both the documented 2.0.0 line and the published
353
+ 1.6.1 gem, a "What's new in 2.0" comparison table, and a compact upgrade
354
+ checklist; `docs/UPGRADING_TO_2.md` covers the Notion physical-column re-sync,
355
+ the new task exit codes, the incremental baseline guard, and `reload`'s
356
+ write-access requirement; every claim that expires at tag time is wrapped in a
357
+ `release-state` fence, listed in the new release note in `docs/README.md`.
358
+
359
+ ### Upgrade Notes
360
+
361
+ This is a major release: the full-gem review (#210) corrected how several extractors
362
+ derive unit identifiers, which changes the index format's observable contract.
363
+
364
+ - **Unit identifiers have changed shape.** Namespaces are now derived correctly by a
365
+ position-aware nesting parser (#174), abstract-model and mixin-module artifacts no
366
+ longer leak into names, and GraphQL inner classes are no longer folded into
367
+ identifiers (#194). Concretely: a state machine that indexed as `Payment::aasm` is now
368
+ `Billing::Payment::aasm`; a service that indexed as bare `IssueInvoice` is now
369
+ `Billing::IssueInvoice`; concern units are no longer misnamed `ClassMethods`.
370
+ **Anything that cached the old identifiers will miss**: saved retrieval queries,
371
+ external notes, exported Notion pages and Unblocked documents, MCP clients holding
372
+ identifier lists.
373
+ - **The remedy is one clean re-index**: `woods:clean` followed by `woods:extract`
374
+ (then `woods:embed` if you embed, and a re-export if you sync Notion/Obsidian/
375
+ Unblocked — the exporters reconcile renamed units as delete-plus-add).
376
+ - **Durable vector stores are now reconciled against extraction output** (#211). The
377
+ first `woods:embed` / `woods:embed_incremental` after upgrading will **delete** vectors
378
+ for units the extraction no longer produces — including every unit the identifier
379
+ renames moved. Deleting more than 30% of the store (or purging into an empty load) is
380
+ refused with an explanation; `WOODS_ALLOW_PURGE=1` overrides after you've confirmed the
381
+ deletion is intentional. On a rename-heavy index, expect to need it once.
382
+ - **An embedding-dimension mismatch now refuses to embed up front** (#214).
383
+ `woods:embed` compares the provider's dimension against what the store actually holds
384
+ (pgvector's column type, Qdrant's collection config) before embedding anything, raising
385
+ `Woods::MCP::DimensionMismatch` with both widths — instead of embedding everything and
386
+ failing per-row on insert. If you changed `embedding_model` at some point and it
387
+ "worked", this check may now surface the latent mismatch; the remedy is a full re-embed
388
+ into a store created at the new width.
389
+ - **New environment variables**: `WOODS_ALLOW_PURGE` (override the vector purge guard,
390
+ above) and `WOODS_NOTION_FORCE` (bypass the Notion sync manifest for one run, forcing
391
+ a full re-push).
392
+
393
+ ### Changed
394
+
395
+ - **Release flow: `main` carries an alpha development marker.** `Woods::VERSION` is
396
+ `X.Y.Z.alpha` between releases, so `main` never claims a released version and the
397
+ gemspec points its source, changelog, and documentation URIs at the branch rather
398
+ than at a tag that does not exist. A release is an explicit commit plus a tag:
399
+ `bin/rake "release:prepare[<version>]"` bumps VERSION, folds `## [Unreleased]` into
400
+ `## [<version>] - <date>` with one block per `###` heading, restates the documentation
401
+ fences (renamed from `v2-unreleased-note` to `release-state`, now driven by VERSION
402
+ rather than by hand), regenerates the surface inventory, and prints the tag and
403
+ dispatch commands. `bin/rake "release:reopen[<next>.alpha]"` reopens development after
404
+ a release. `spec/release_v2/version_state_spec.rb` enforces the state on every commit,
405
+ both release validators refuse an alpha tag, and the `release` task `bundler/gem_tasks`
406
+ installs is blocked: nothing is published from a laptop. Betas and release candidates
407
+ are supported states, and RubyGems treats them as prereleases, so a `~> 1.6` or
408
+ `~> 2.0` constraint never resolves one. See the release flow section of
409
+ `CONTRIBUTING.md`.
410
+
411
+ - **Dead code removed.** The unwired formatting adapters (Claude, GPT, Generic),
412
+ console job/cache adapters, `StubBridge`, `HealthCheck`, `Instrumentation`,
413
+ `Notion::Mapper`, and a dozen spec-only methods are gone. `config.add_gem`
414
+ now warns like `config.extractors`: accepted, not implemented.
415
+ - **Docs rewritten for readability**: shorter sentences, tables for
416
+ comparisons, no em-dashes, one owner per fact. The MCP 2026 handoff document
417
+ was folded into the strategy ADR.
418
+
419
+ - **The packaged gem ships only user-facing files.** Internal release machinery
420
+ (`lib/tasks/release_v2.rake`, `lib/woods/release_v2/`) and non-user-facing
421
+ documentation subdirectories are excluded from the package; the repo keeps
422
+ them for CI. Historical build-phase design documents were removed from
423
+ `docs/` for the release and remain in git history.
424
+ - **The Claude plugin releases with the gem.** `plugin.json` is 2.0.2.
425
+ - **`config.extractors` warns when set.** The knob is accepted for forward
426
+ compatibility but extractor selection is not implemented; all extractors run.
427
+ Docs no longer teach it as a live setting. The unused `log_level` accessor
428
+ is removed.
429
+ - **`woods-mcp-start` no longer pins the MCP protocol version.** It defaulted
430
+ `MCP_PROTOCOL_VERSION` to `2024-11-05` — the oldest revision there is — which silently
431
+ opted every user out of four protocol revisions. The SDK server is dual-era, answering
432
+ `initialize` for legacy clients while serving `server/discover` and per-request metadata
433
+ for modern ones, so the unpinned server is the *more* compatible one. The variable
434
+ remains as an escape hatch and now announces itself on stderr when set.
435
+ **No action required:** legacy clients keep working, and no re-extraction or re-embedding
436
+ is implied — no on-disk artifact format changed.
437
+
438
+ - `woods:watch_status` no longer depends on `:environment`. It reads one small JSON file, and
439
+ the point is that a worktree hook can call it *before* deciding whether to do real work —
440
+ paying a full Rails boot to find out cost more than the sync it exists to avoid.
441
+ - `WOODS_WATCH_POLL=1` forces the polling backend. `docs/WATCH_DAEMON.md` told container hosts
442
+ watching a bind mount to do this; nothing exposed it. `WOODS_WATCH_IDLE_TIMEOUT` and
443
+ `WOODS_WATCH_CATCH_UP` are exposed on the rake task for the same reason.
444
+ - The debounce window now genuinely coalesces. The watcher callback merges into a pending set
445
+ and returns, so a save, the formatter's rewrite and the linter's touch become one cycle
446
+ rather than three — previously `settle` only delayed the first of the three.
447
+ - `spec/reload_policy_spec.rb` derives its samples from the `PathDispatcher` rules rather than
448
+ a hand-written list, so a rule added for a new extractor is covered without editing the
449
+ spec. `spec/support/index_comparison.rb` compares PageRank *values* (to 6 dp) rather than
450
+ just keys — comparing keys alone said nothing about the modify-only operations most likely
451
+ to leave scores stale.
452
+ - `GraphAnalyzer` output is now a pure function of graph content. `hubs` breaks ties on
453
+ identifier instead of graph-insertion order, and `bridges` samples from a sorted node list,
454
+ so two extractions of the same tree publish the same analysis.
455
+
456
+ - New `Woods::Extractors::LineNeutralizer`: the quote-aware comment stripper (formerly private to
457
+ `SharedDependencyScanner`) plus string- and heredoc-blanking variants, shared by SourceNesting,
458
+ the rake and factory parsers, and CallbackAnalyzer.
459
+ - New `Woods::Extractors::ReferencePatterns`: the namespace-capable service, mailer, and job-enqueue
460
+ regexes, shared by `SharedDependencyScanner`, `JobExtractor`, and `CallbackAnalyzer`.
461
+
462
+ - **`FlowPrecomputer`'s `fail_closed:` switch is gone (EXTB-14).** Both callers
463
+ passed `true` and CLAUDE.md documents both paths as fail-closed, but the YARD
464
+ still promised the full path a "log-and-skip contract" and the
465
+ `Rails.logger.error` branch it described was dead code. Flow assembly now always
466
+ raises `Woods::ExtractionError` on a per-action failure.
467
+
468
+ - **An unlisted integer tool parameter fails the server build with a named error
469
+ (MCP-8).** `ToolContract::INTEGER_BOUNDS.fetch` raised a bare
470
+ `KeyError: key not found: "count"` at `Server.build`, with no pointer to the
471
+ table that needs the entry. It now raises an `ArgumentError` naming the tool,
472
+ the property, and `INTEGER_BOUNDS`.
473
+
474
+ - CI gains a `c-locale` job running the artifact-reader specs (`spec/mcp`,
475
+ `spec/feedback`, `spec/session_tracer`, `spec/evaluation`) under `LC_ALL=C`. The
476
+ runners default to a UTF-8 locale, so the whole encoding family could regress
477
+ with the suite green.
478
+ - Three specs that simulate an unreadable/read-only path with `chmod` now skip as
479
+ root, where `chmod` is ineffective (`spec/operator/pipeline_guard_spec.rb` x2,
480
+ `spec/mcp/tasks/pipeline_tasks_spec.rb`).
481
+ - `docs/backlog.json` gains B-140..B-163: the seven prior-audit deferred lows that
482
+ never received IDs (R2-2 — L3, L7, L12, L13, L14, L15, L21) plus seventeen
483
+ deferrals from this audit (CORE-3, CORE-5, EXTA-7, EXTA-9, EXTA-11, EXTA-13,
484
+ EXTA-15, CON-4, STO-7, STO-10, STO-14, INF-5, INF-6, INF-13, R2-4, R2-5, R2-6).
485
+
486
+ - **STO-12**: Corrected the `Storage::Snapshotter` doc comment: `Snapshotter::Metadata.validate_store!` can use plain `respond_to?` only because `MetadataStore::Interface` defines neither `#each_entry` nor `#bulk_load` — adding either stub would silently convert the check into the B-108 bug. `spec/storage/snapshotter/vector_spec.rb`'s float-truncation tolerance block (which passed whether or not the load raised, stale since the M10 fix) now asserts the raise.
487
+
488
+ ### Fixed
489
+
490
+ - **The release contract specs pass in every version state.** `spec/release_v2`
491
+ copied this checkout's `CHANGELOG.md`, `README.md`, `CONTRIBUTING.md`, and
492
+ `docs/UPGRADING_TO_2.md` into its temporary repository, so eleven examples
493
+ assumed the checked-in tree was an alpha with a non-empty Unreleased section.
494
+ A release commit produced by `release:prepare` therefore could not pass its
495
+ own specs, which defeats the flow. The transition specs now run against a
496
+ self-contained fixture (`spec/fixtures/release_repository`), each of them
497
+ keeps one example that reads this checkout and asserts only the invariants of
498
+ the state it finds, and `spec/release_v2/release_state_matrix_spec.rb`
499
+ generates the alpha, prerelease, and final states from the fixture in one run
500
+ and checks all three plus the running checkout against the same invariants.
501
+ The gemspec metadata example asserts the ref rule (`tree/main` for an alpha,
502
+ `tree/v<VERSION>` otherwise) instead of a fixed state.
503
+
504
+ - **Components outside the eager-load paths are indexed.** `PhlexExtractor` and
505
+ `ViewComponentExtractor` discovered units from `component_base.descendants`
506
+ after `eager_load!`, so a component under an autoloaded but not eager-loaded
507
+ subtree of `app/views` was never a descendant and never indexed. Both now ask
508
+ the autoloader for every Ruby file under the component directories first, and
509
+ the directory list is configurable through `config.component_paths` (default
510
+ `app/components`, `app/views/components`, `app/views`; an empty array walks
511
+ nothing). Nested directories collapse into their ancestor, and one debug line
512
+ per run reports files whose directory is not on an autoload path. The
513
+ ViewComponent path is skipped entirely when `ViewComponent::Base` is
514
+ undefined.
515
+ - **Git enrichment refuses to invent history it cannot read.** Over a
516
+ containerized linked worktree, `git rev-parse --git-dir` succeeds while no ref
517
+ resolves (the private git directory reaches the shared one through a relative
518
+ `commondir` pointer), `git log` exits 0 with no output, and every unit was
519
+ written with `commit_count: 0` and `change_frequency: "new"`,
520
+ indistinguishable from a file that was never committed. Enrichment now
521
+ requires `git rev-parse HEAD` to succeed: when it does not, the git keys are
522
+ omitted from every unit, provenance records `"unknown"`, and one warning names
523
+ git's own reason. `docs/TROUBLESHOOTING.md` and
524
+ `docs/CONFIGURATION_REFERENCE.md` state that `GIT_DIR` alone is not enough for
525
+ a linked worktree.
526
+ - **`dependents` and `dependencies` are bounded and say when they truncate.**
527
+ Both tools now return at most 50 traversal nodes by default, accept `limit`
528
+ and `offset` like `graph_analysis`, and print the same
529
+ `Showing N of M (truncated)` line; their descriptions name `depth`, `types`
530
+ and `via` as the controls that shrink an answer rather than page it. Every
531
+ partial answer carries `nodes_total`, the last page of a walk included, so a
532
+ page is never mistaken for a complete result. Rows carry the unit's database,
533
+ but only in a graph that spans more than one, so a single-database index
534
+ renders exactly as before.
535
+ - **`graph_analysis.json` states its own volatile-dependency cap.**
536
+ `stats.volatile_dependencies_limit` (20) now sits beside
537
+ `stats.volatile_dependency_count`, so a reader of the truncated
538
+ `volatile_dependencies` array can tell a page from the whole population.
539
+ `docs/INTERNALS.md` gains a table mapping every analysis section to its stat
540
+ key and naming which sections the artifact caps.
541
+ - **`graph_sha` is now a function of graph content, not of registration order.**
542
+ `DependencyGraph#to_h` sorted neither the members of `reverse`, `file_map` and
543
+ `type_index` nor the keys of any section, and the extractor wrote PageRank in
544
+ node-registration order, so a no-op incremental run republished an identical
545
+ graph under a new digest and every consumer caching on `graph_sha` redid its
546
+ work. The incremental equivalence oracle now compares `graph_sha` as well.
547
+ - Respect MySQL session quote modes throughout Console SQL checks, including
548
+ `ANSI_QUOTES` and `NO_BACKSLASH_ESCAPES`, without rejecting ordinary escaped literals.
549
+ - Preserve absolute Ruby constants through both AST backends and static-map resolution.
550
+ - Compare evaluation baselines against public names after typed storage transitions.
551
+
552
+ - Preserve coexisting typed units through embedding, checkpoints, retrieval and
553
+ snapshots. SQLite migration 007 retains old snapshot rows; older JSON snapshots
554
+ remain readable. Remove metadata when the corresponding vanished vectors are pruned.
555
+ - Keep precomputed flows for namespaced and underscore-named controllers separately
556
+ addressable, and resolve known lexical module dependencies in the static self-map.
557
+ - Remove machine-specific session and host instructions from shared contributor context.
558
+ - Keep release-inventory drift tests from temporarily modifying real source and guide files.
559
+
560
+
561
+ - **Wrapper-nested classes no longer collide on one identifier.** A file under
562
+ a managed autoload path is now named for the constant its path spells
563
+ (Zeitwerk-governed naming): `app/services/domain/container/parser.rb`
564
+ declaring `module Domain; class Container; class Parser` indexes as
565
+ `Domain::Container::Parser` instead of the wrapper `Domain::Container`.
566
+ Previously every sibling under the same wrapper indexed as the wrapper and
567
+ same-type dedup silently dropped all but one. The source parser remains the
568
+ fallback for unmanaged or unconventional paths, and extraction now aborts
569
+ naming both file paths when one type+identifier is still derived from two
570
+ different files. **Re-extract after upgrading** — embeddings, exports, and
571
+ saved queries keyed by the old identifiers need regeneration.
572
+
573
+ - **Console stdio setup now explains production token validation.** Stdio clients do
574
+ not send the HTTP bearer token, but Rails still requires a 32-character-or-longer
575
+ `console_mcp_token` at production boot whenever Console MCP is enabled. The upgrade,
576
+ direct, Docker, FAQ, and agent setup paths now state that boundary explicitly.
577
+
578
+ - **Console SQL gate no longer has MySQL comment and dollar-quote blind spots.**
579
+ `SqlNoiseStripper` did not know `#` line comments or `/*! ... */` executable
580
+ comments (both live SQL on MySQL), and treated a `$` inside a PostgreSQL
581
+ identifier as a dollar-quote opener, so a blocked table could be hidden from
582
+ `TableGate`. The `TABLE name` statement form was never scanned at all. All
583
+ four are closed, with the `#` rule gated on the MySQL dialect.
584
+ - **Redacted columns are refused as query inputs, not only masked on output.**
585
+ `console_aggregate(column:)`, scope keys (including `_matches`), `find(by:)`,
586
+ and `recent(order_by:)` accepted `console_redacted_columns`, which gave a
587
+ plaintext aggregate or a comparison oracle over a secret.
588
+ - **The MySQL console timeout no longer leaks into the host's connection pool.**
589
+ `SET max_execution_time` is session-scoped and survives rollback; the prior
590
+ value is now read and restored in `ensure`.
591
+ - **Console SQL validation stops rejecting columns named `do`, `start`,
592
+ `lock`, `release`, or `handler`.** Forbidden statement keywords now match only
593
+ at a statement-leader position. `EXPLAIN ANALYSE` (the PostgreSQL spelling) is
594
+ rejected like `ANALYZE`. `console_association_count` gates the rendered SQL, so
595
+ a blocked `through` table is refused.
596
+ - **Long-running MCP tasks no longer expire the moment they complete.** Terminal
597
+ task ttl is measured from the terminal transition, not from creation. A task
598
+ minted under a different boot identity (a container) is left alone by a host
599
+ reader instead of being marked failed.
600
+ - **Extractor accuracy batch.** `permitted_params` no longer leaks across method
601
+ bodies (and reads Rails 8 `params.expect`); GraphQL complexity is read from a
602
+ real match; per-file GraphQL classification agrees with the runtime pass;
603
+ `form_action` edges stop at `do`/`end`; `SourceNesting` pops on `end.freeze`;
604
+ `render :partial => 'x'` resolves the real partial; a rake task whose name
605
+ contains `do` no longer swallows its neighbours; inline-namespaced migrations
606
+ are extracted; mounted engines are unwrapped from `Mapper::Constraints` so
607
+ `mounted_path` is populated, and `Rails::Application` is no longer reported as
608
+ an engine.
609
+ - **An empty vector dump no longer refuses to boot.** `woods:embed` over an empty
610
+ payload wrote `dimension = 0` and every later boot raised `DimensionMismatch`.
611
+ - **Storage hardening.** Interface stubs are no longer probed with `respond_to?`
612
+ (B-108) in the retryable provider, builder, indexer, and ranker; Notion
613
+ read-only POSTs retry on a network failure; the watch daemon's stale-claim
614
+ reclaim checks the claim inode before removing it and falls back to an
615
+ exclusive create where `File.link` is unsupported; `InMemory#delete_by_filter`
616
+ honours array filters like `#search`.
617
+ - **Task orphan detection compares pid namespaces, not only boot ids.** Docker
618
+ on Linux shares the host kernel boot id, so a host reader could judge a
619
+ container's task by an unrelated host pid. The producer identity now carries
620
+ `/proc/<pid>/ns/pid`, and a task from another namespace is left alone.
621
+ - **Daemon claim reclaim runs under an `flock`.** A byte comparison before the
622
+ delete still left a read-then-unlink window where two starters could both
623
+ end up as claim owners; the whole reclaim-and-create loop is now one
624
+ critical section on a sidecar lock file, released by the kernel on death.
625
+ - **Snapshot capture retries a locked SQLite database.** SQLite skips the busy
626
+ handler in its deadlock-avoidance case, so two concurrent captures could
627
+ fail at `BEGIN IMMEDIATE` despite `busy_timeout`. Three bounded attempts.
628
+ - **`woods:clean` and `woods:validate` no longer raise `NameError` in a host
629
+ app.** `woods.rake` reached `Woods::Generation` through the extractor, which
630
+ those tasks never load. Caught by every woods-testbed variant.
631
+ - **The daemon's stale-claim race guard compares bytes, not the inode.** Linux
632
+ reuses a freed inode for the next file in the directory, so the inode check
633
+ let a just-replaced live claim be deleted. Failed on CI, passed on macOS.
634
+ - **Routes that differ only by constraint are all indexed.** The identifier is
635
+ `VERB /path`, qualified by request constraints when the route has any:
636
+ `GET /users [subdomain=api]`, `GET /users [format=json]`, `constraint=proc`
637
+ for a callable. Routes that still collide are numbered in route order
638
+ (`GET /users #2`) instead of being dropped. Unconstrained routes keep their
639
+ old identifier; a constrained one changes, so the clean re-index above
640
+ covers it.
641
+ - **A rake task reopened in two `.rake` files is one unit**, the way Rake sees
642
+ it: its source carries every definition, `metadata.defined_in` lists the
643
+ files, and a per-file incremental run produces the same merged unit as a
644
+ full run. Previously the second file overwrote the first.
645
+ - **`woods:validate` and `Resilience::IndexValidator` are one implementation.**
646
+ The task now runs the class, which gained the task's manifest-count,
647
+ unit-file, file-path, and dependency-graph checks (`app_root:` opts into
648
+ the file-path check).
649
+ - **`EXPLAIN (FORMAT JSON) SELECT` is accepted.** The option list was read as
650
+ a call to a function named `EXPLAIN`. `EXPLAIN ANALYZE` in any form is
651
+ still refused.
652
+ - **Class names are position-aware in every file-scanning extractor.** Jobs,
653
+ serializers, decorators, policies, Pundit policies, managers, and validators
654
+ took the first `class` token in the file, so `module Billing; class ChargeJob`
655
+ indexed as bare `ChargeJob` (and the class-based second pass then added a
656
+ duplicate `Billing::ChargeJob`), while the decorator scanner joined every
657
+ `module` token, including helpers nested inside the class. All of them now
658
+ go through `SourceNesting#qualified_first_class_name` (#174).
659
+ - **Policy `evaluated_models` no longer invents models from parameter syntax.**
660
+ `def initialize(order, user = nil, strict: false)` produced `Nil`, `Strict`,
661
+ and `False` model edges; only bare positional parameters are read now.
662
+ - **The release gate requires the booted-extraction matrix.**
663
+ `script/validate-release-run` did not list the `rails-matrix` CI job, so a
664
+ red Rails 6.0 to 8.1 row could not block a release.
665
+ - **Spec order no longer leaks a nil `Woods.configuration`.** Four spec files
666
+ nil it out in `after` hooks; `spec_helper` now restores whatever each example
667
+ started with, which fixes two seed-dependent failures in `extractor_spec`.
668
+
669
+ - **Every MCP entry point boots a payload-layout index.** The #226 layout moved
670
+ `manifest.json` into `payloads/gen-<N>/`, but `woods-mcp`, `woods-mcp-http`,
671
+ `woods-mcp-start`, the retriever's graph hydration, and the operator status
672
+ reporter still looked for root-level artifacts: a fresh 2.0 extract was refused
673
+ at boot with "Run `rake woods:extract` first", the retriever's graph store
674
+ hydrated empty (silent loss of PageRank and graph expansion), and status read
675
+ `:not_extracted`. All five now resolve through the generation pointer, with the
676
+ legacy flat layout still accepted.
677
+ - **MCP `reload` no longer crashes the stdio server on pgvector/Qdrant.** The
678
+ reload metadata backfill guarded on `respond_to?(:each_entry)`, which the
679
+ vector-store interface answers true for while raising `NotImplementedError`
680
+ (the B-108 anti-pattern); on stdio that unwound the transport loop and killed
681
+ the process. The guard is now an ownership check.
682
+ - **Wholesale replacement prunes by `(identifier, type)`.** The #225 typed-graph
683
+ work missed one call site: a factories (or any wholesale) re-run removing a
684
+ vanished unit deleted every type sharing the identifier, so a same-named
685
+ Scenic view's node and JSON file vanished from the index until a full run.
686
+ Colliding identifiers also now serialize dependents on every sharing unit and
687
+ carry each type's own git metadata instead of one type's history.
688
+ - **Incremental runs abort instead of publishing a collapsed index.** When
689
+ payload creation failed over a payload-born index, the degrade path published
690
+ a near-empty flat root and redirected readers to it. Incremental and refresh
691
+ runs now raise without bumping the generation; full runs keep the flat
692
+ fallback (their write set is complete). Payload seeding also gained the
693
+ cross-device copy fallback, and payload pruning survives a restarted
694
+ generation counter.
695
+ - **Woods artifacts are read encoding-safely everywhere.** Twelve remaining bare
696
+ `File.read` sites (extractor incremental path, rake validate/stats/flow, the
697
+ index validator) crashed with `Encoding::InvalidByteSequenceError` under
698
+ `LANG=C` (the documented daemon container environment) on any multibyte byte.
699
+ All now use `AtomicFile.read`.
700
+ - **Extraction-family rake tasks honor `config.output_dir`.** They hardcoded
701
+ `tmp/woods` while the embed and export families used the configured
702
+ directory, so a host that set `output_dir` split its index in two silently.
703
+ - **The watch daemon captures files changed during startup catch-up.** The
704
+ watcher started only after catch-up finished, so a save during a long
705
+ catch-up extraction was lost until the next edit. The watcher now starts
706
+ first. A dead container daemon's startup claim also no longer blocks a
707
+ host-side daemon forever (host identity is compared before trusting pid
708
+ liveness), and a lock-release failure after a successful cycle no longer
709
+ relabels the cycle as a lock failure.
710
+ - **Retrieval ranking and budgeting defects.** RRF source merging demoted
711
+ strong cross-source hits into the supporting section; the framework partition
712
+ never fired for real graph-expansion candidates; empty metadata could shadow
713
+ real metadata; `:within_type_fallback` was reported for types the fallback
714
+ never returned; an empty supporting section stranded ~35% of the token budget;
715
+ keyword scores encoded arbitrary database row order as the dominant ranking
716
+ signal. All fixed; keyword scores now derive from matched-field counts.
717
+ - **Console SQL validation stops rejecting English.** Body keyword scans ran
718
+ over string literals, so `WHERE body = 'please update the record'` was refused
719
+ as an UPDATE; scans now run over noise-stripped SQL while comment-hidden
720
+ injections stay caught. `MERGE` joined the forbidden set, recursive writable
721
+ CTEs get the specific error, and the stdio transport passes the table map so
722
+ qualified `table.column` references validate like they do over HTTP.
723
+ - **Exporter clients treat ambiguous 503s honestly.** A 503 for a
724
+ non-idempotent create (Notion `create_page`, Unblocked `create_collection`)
725
+ can arrive from an intermediary after the origin committed, so those now
726
+ raise the ambiguous-outcome error instead of retrying into a duplicate; 429
727
+ and idempotent requests retry as before. Notion's `query_all` gained the
728
+ nil-cursor loop guard, and the two clients' retry budgets now agree.
729
+ - **Session tracer fairness and hygiene.** The Redis store evicted arbitrary
730
+ sessions at the cap (now oldest-first, matching the file store), and
731
+ client-controlled header values are escaped before landing in the
732
+ session-context document served to agents.
733
+ - **Storage edge paths.** Dump-capability detection uses ownership checks
734
+ (typed `InapplicableBackend` instead of a bare `NotImplementedError`), the
735
+ in-memory metadata search no longer matches on injected timestamps, dump
736
+ pruning can never delete the just-promoted dump after a backward clock step,
737
+ and the tasks store sweeps corrupt records older than the TTL.
738
+ - **A generation's payload is published atomically (#226).** `generation.json` was
739
+ bumped atomically and last, but it named the output root — a directory of
740
+ independently-written files — so a reader refreshing mid-publish could load a
741
+ unit from generation N+1 beside a manifest from N. Extraction now publishes
742
+ into `payloads/gen-<N>/` and names that directory from `generation.json`, so
743
+ the single atomic write of that file commits the whole payload; a reader sees
744
+ one generation whole, including artifacts it had not read yet. Incremental
745
+ runs seed their directory from the published one with hardlinks, so an
746
+ unchanged artifact costs a directory entry rather than a copy. Three
747
+ generations are retained by default (`WOODS_PAYLOAD_RETENTION`).
748
+ **This changes the on-disk layout.** No re-index is required — the first run
749
+ after upgrading publishes a payload and reading is unaffected, since every
750
+ Woods reader resolves the pointer — but anything outside Woods that reads
751
+ `tmp/woods/manifest.json`, `tmp/woods/dependency_graph.json` or
752
+ `tmp/woods/<type>/*.json` directly must now read `generation.json`, take its
753
+ `payload` value, and resolve relative to the index directory. Files left at
754
+ the root by a pre-2.0 run are stale from the first payload publish onward;
755
+ `woods:clean` removes them.
756
+ - **Units of different types no longer collapse onto one graph node (#225).** A Scenic
757
+ view `reports` and a factory `reports` are two units and the index has always written
758
+ them to two files, but `DependencyGraph` keyed nodes on the bare identifier, so
759
+ registering the second destroyed the first's reverse edges, `file_map` entry and
760
+ `type_index` entry. Both now coexist as typed nodes. Deleting one type's source file
761
+ removes that type's node and its JSON only; incremental re-extraction, git enrichment
762
+ and the `dependents` rewrite fan out over every type an identifier names; and the MCP
763
+ traversal tools follow both units' edges and report `types` when an identifier is
764
+ ambiguous instead of picking one silently. **No re-index is required** — the persisted
765
+ graph is unchanged for any index with no shared identifier, and identifiers that are
766
+ shared add a `variants` array that older graphs simply do not carry.
767
+ - **Release-hardening batch (2026-08-07, #211–#218, #220):**
768
+ - *Embedding durability:* every pgvector/Qdrant embed run crashed at the very end and
769
+ discarded its work — `Indexer#persistable?` asked `respond_to?(:each_entry)`, which
770
+ the storage interface answers true for by definition; it now asks which module owns
771
+ the method (B-108, #220). Durable stores are reconciled against extraction output on
772
+ full and incremental runs, with the purge guard shared with the dump path (#211).
773
+ Checkpoint hits on durable backends are verified against the store, so switching
774
+ `vector_store` from `:local` to pgvector/Qdrant no longer strands unchanged units
775
+ (#211). Qdrant mutating point operations pass `wait=true`, so a delete is readable
776
+ as deleted (#220). Dimension mismatches are detected before embedding, not per-row
777
+ after (#214).
778
+ - *Extraction fidelity:* blockless factories (`factory :admin, parent: :user`) are
779
+ extracted; abstract models no longer enter the model-name scan (which inflated
780
+ `ApplicationRecord`'s PageRank); `private def` methods are no longer reported
781
+ public; `EventExtractor` no longer mints phantom events from non-Wisper `.on(:sym)`
782
+ calls; `ManagerExtractor` resolves multi-word models (`order_item` → `OrderItem`,
783
+ not `Order_item`) (#215).
784
+ - *Flow/graph determinism:* `find_node_by_suffix` is memoized (was a full-graph scan
785
+ per call) and resolves ambiguous short names deterministically; `case` predicates
786
+ are no longer misattributed as branch operations; `domain_clusters` output no longer
787
+ depends on registration order; vector dumps record the embedding model in the WVF1
788
+ header (#216).
789
+ - *Export hardening:* the Unblocked client redacts its bearer token in error paths and
790
+ describes its per-run (not "daily") budget honestly; `Retry-After` is capped at 120s
791
+ in both export clients; Unblocked citations use the ref recorded in the manifest
792
+ rather than hardcoded `blob/main`, with path segments percent-encoded; Notion aborts
793
+ fast on 401/403 instead of spending the whole cold sync failing per-unit (#217).
794
+ - *Retrieval/observability residuals:* ranking signals no longer go neutral on chunked
795
+ corpora (the ranker now strips chunk suffixes like every other consumer); metadata
796
+ keyword search is case-insensitive on both adapters with the contract pinned by
797
+ shared examples; `IndexReader`'s LRU is thread-safe under the HTTP transport;
798
+ `GapDetector` counts queries, not keyword occurrences; `RedisStore#sessions` returns
799
+ recent sessions rather than arbitrary ones (#218).
800
+ - *MCP:* the `pipeline_extract` tool loads the extractor lazily, so it works in a
801
+ standalone `woods-mcp` process instead of dying in the background with
802
+ `NameError: uninitialized constant Woods::Extractor` (B-110).
803
+ - **The evaluation harness is runnable** (#212). `woods:evaluate` existed on no host (its
804
+ rake file was never loaded by the railtie), called accessors that had never existed, and
805
+ no adapter implemented `all_identifiers` for the baselines. It now loads, builds stores
806
+ through the MCP bootstrapper so evaluation reads the same persisted index that semantic
807
+ search serves, and ships an offline end-to-end smoke on the `:fake` provider. The
808
+ ground-truth taxonomy now *is*
809
+ `QueryClassifier::INTENTS`/`SCOPES`, so annotations compare against what the pipeline
810
+ actually classified (#218's open item, closed here).
811
+
812
+ - **Full-gem review batch (2026-07-30): 30 defects fixed** (#183–#209 and pre-existing
813
+ #149, #150, #169, #170, #174–#178; see each issue for the full analysis). Highlights,
814
+ grouped by blast radius:
815
+ - *Host-app safety:* enabling the documented console-MCP mode no longer 401s the entire
816
+ host application (guards are path-scoped, enablement is decided at request time), and
817
+ the enable flags now work from `config/initializers/woods.rb` (#183).
818
+ - *Retrieval correctness:* type-filtered `codebase_retrieve` no longer returns empty on
819
+ every booted server (symbol-keyed vector metadata on boot and reload, #150); the
820
+ weighted ranking layer actually ranks (live keyword signal, normalized RRF, assembler
821
+ honors ranked order, PageRank memo invalidated on reload, #185); classifier-derived
822
+ target types no longer hard-filter vector search on common English words (#184);
823
+ framework units are no longer duplicated across context sections (#186).
824
+ - *Extraction fidelity:* concern inlining works for compact-style class declarations and
825
+ concern-defined callbacks now yield side effects, for models and (new) controllers
826
+ (#193, #175); a shared position-aware nesting parser fixes namespace derivation in five
827
+ source-parsing extractors (#174); polymorphic associations, ApplicationController
828
+ discovery, cache-call attribution, GraphQL inner classes, YAML anchors, Whenever
829
+ blocks, label-form rake tasks, and `RSpec.describe Klass, type:` test mapping all
830
+ parse correctly (#194, #199–#204, #176); full extraction sweeps orphaned unit files
831
+ so reused output dirs stop over-reporting (#177).
832
+ - *Pipeline integrity:* `rails_source`/`gem_source` route through the real write pipeline
833
+ as a Gemfile.lock-keyed whole-app extractor and `include_framework_sources` genuinely
834
+ gates it (#169); every index writer — `woods:clean`, the embed tasks, MCP
835
+ `pipeline_extract` — now takes the pipeline lock (#170); incremental extraction cannot
836
+ prune units on a failed extractor construction or a degraded eager-load boot (#198);
837
+ the write-skip optimization actually fires (#208).
838
+ - *Embedding durability:* a mis-pointed `woods:embed_incremental` can no longer wipe the
839
+ vector index (30% purge guard + empty-load refusal, #191); non-ASCII identifiers stop
840
+ re-embedding forever (WVF1 ids hydrate as UTF-8, #192); a single 429 no longer aborts
841
+ an embed run — providers are wrapped in the previously-unwired resilience layer with
842
+ Retry-After honored (#188); pgvector works via the documented setup path and dedupes
843
+ in-batch ids (#187, #181); PageRank keeps rank mass for duplicate/unresolvable edges
844
+ (#205); temporal snapshots stop leaking a unit set per same-SHA re-capture (#206).
845
+ - *Robustness:* the embed pipeline, Unblocked manifest, StatusReporter, and flow layer
846
+ survive `LANG=C` and torn files (`AtomicFile` everywhere, #189, #190); flow artifacts
847
+ are portable (relative paths, #190); a standing-down watch daemon no longer clobbers
848
+ the live daemon's status, and the lock heartbeat cannot resurrect a released lock
849
+ (#196, #197); Notion/Unblocked clients no longer retry non-idempotent POSTs on read
850
+ timeout (#150); Notion multi-model sync no longer corrupts the Columns database
851
+ (qualified `table.column` titles with legacy-page adoption, #149); metadata search
852
+ validates field names and escapes LIKE metacharacters (#209).
853
+
854
+ - **`woods:embed_incremental` no longer discards the vectors it embeds** (B-059, #148). On the
855
+ `:local` and `:shared_filesystem` presets the vector store is in-memory and the dump under
856
+ `dumps/` is the *only* durable copy — but `Indexer#index_incremental` never called
857
+ `persist_snapshot`, while `process_units` advanced `checkpoint.json` regardless. Each
858
+ incremental run therefore embedded changed units into a store that died with the process,
859
+ wrote nothing to `dumps/`, and left a checkpoint claiming the work was done, so no later
860
+ incremental run would ever produce those vectors again: unrecoverable without a full
861
+ re-embed, and silent — stats reported `processed: 1`. An incremental run now hydrates the
862
+ store from `dumps/latest` before embedding and dumps afterwards, so the dump it writes is
863
+ cumulative. The invariant now enforced is that **`checkpoint.json` never advances over a
864
+ unit whose vector was not durably stored**: on the dump-backed path the checkpoint is
865
+ written only after the dump is on disk and the `latest` pointer is flipped (the interval
866
+ checkpoints are suppressed there — a dump is a whole-store snapshot, so there is no partial
867
+ durability for them to record), and a checkpoint hit is honoured only when the hydrated
868
+ artifact actually holds a vector for that unit. A checkpoint that ran ahead of its dump —
869
+ an older gem with this bug, an interrupted promote, a store swap — self-heals into a
870
+ re-embed and says so on stderr. A dump that cannot be read (corrupt file, dimension
871
+ mismatch after a model switch) warns and falls back to re-embedding everything, which is
872
+ the documented remedy for both. Durable backends (pgvector, Qdrant) are unaffected: their
873
+ `store_batch` *is* the durable write, so they keep the interval checkpoints and never
874
+ hydrate.
875
+
876
+ - **Woods' own JSON artifacts are read as UTF-8, not as the process locale** (#164 review,
877
+ round 4). `AtomicFile.write` uses `binmode` so bytes land verbatim, but a plain `File.read`
878
+ tags the result with the default *external* encoding — US-ASCII in a container with no
879
+ locale set, which is a plain Docker image and exactly where the watch daemon is documented
880
+ to run. The daemon writes status reasons containing em dashes, so one ordinary lock
881
+ contention under `LANG=C` raised `Encoding::InvalidByteSequenceError` out of
882
+ `Watch::Status#read` (which rescued `JSON::ParserError` and `SystemCallError`, neither of
883
+ which that is), taking `woods:watch_status`, the hook sync's daemon-deference check and the
884
+ `woods_status` tool down with it until something rewrote the file with an ASCII-only reason.
885
+ New `AtomicFile.read` is the counterpart to `.write`; `Status`, `Generation`, the daemon's
886
+ pending/graph reads and `woods_status` all go through it, and `Status#read` now also rescues
887
+ `EncodingError`.
888
+ - **A cycle that writes an index without publishing a generation now reports degraded**
889
+ (#164 review, round 4). `Extractor#publish_generation` rescues its own failures so a good
890
+ index is not discarded over an unwritable marker — right, but the marker *is* the freshness
891
+ contract, so readers kept serving the previous index while the daemon reported `running`,
892
+ and the next incremental could be a no-op that bumped nothing either. The daemon now
893
+ cross-checks that the number moved when units were written, carries the paths forward, and
894
+ logs at error rather than warn.
895
+ - **`graph_analysis.json` no longer depends on registration order** (#164 review, round 4).
896
+ `orphans` and `dead_ends` were emitted in graph-registration order and `cycles` started its
897
+ DFS from the same, so a full and an incremental extraction of one tree published different
898
+ analysis — the opposite of what the docs claimed. The differential harness could not see it:
899
+ its oracle `deep_sort`ed both sides of that file before comparing. Sorting there and
900
+ asserting determinism here cannot both be load-bearing; the analyzer is now genuinely
901
+ order-independent and the oracle compares the file exactly. Guarded by a registration-order
902
+ rotation in `spec/graph_analyzer_spec.rb`.
903
+ - **The harness oracle keys units by filename, not by their own contents** (#164 review,
904
+ round 4). `unit_snapshot` keyed on the identifier *inside* each document, so a stale file
905
+ whose identifier a newly-written file also carried collapsed onto one entry with
906
+ last-write-wins — a leftover unit read as no difference at all — and content written under
907
+ the wrong name compared equal while the directories plainly were not.
908
+ - **A class removed from a file that still exists is now pruned** (#164 review, round 4).
909
+ Deletion keyed on the source file being gone, which cannot see this: two models in one `.rb`
910
+ with one deleted leaves no missing path, and class-based units register a *convention* path
911
+ derived from the constant name, so the second class was never attributed to the file it
912
+ actually lived in. Nothing in the run removed it, so it outlived every subsequent
913
+ incremental — a permanent divergence from a full extraction. Class-based reconciliation now
914
+ runs in both directions, with removal gated on the eager load having completed: on the
915
+ documented NameError fallback the discovery sets are known-partial, and deleting by the type
916
+ is a far worse failure than a stale unit. The booted harness cannot cover this — Zeitwerk
917
+ unloads only a file's expected constant, so the side-effect class survives the reload and
918
+ the in-process full extraction the oracle compares against emits it too.
919
+ - **`RailsReloader#reload!` no longer carries an unreachable interlock wrapper** (#164 review,
920
+ round 4). The call was guarded by `interlock.respond_to?(:done)`, and
921
+ `ActiveSupport::Dependencies::Interlock` has never had a `#done` — so the guard was false on
922
+ every Rails version, the wrapper never ran, and the comment above it described locking that
923
+ was not happening. It is also not needed: `reload!` takes the unload lock itself via
924
+ `class_unload!` → `require_unload_lock!`. Found by writing the first test that drives the
925
+ real reloader instead of a double; it was stubbed in every spec and so ran on zero of the
926
+ seven matrix rows.
927
+ - **New GraphQL files are indexed incrementally** (#164 review, round 3). `app/graphql` had no
928
+ `PathDispatcher` rule and GraphQL types are not class-discoverable, so a created type,
929
+ mutation or resolver routed nowhere and never entered the index, and a rename lost the unit
930
+ entirely — #164 gap 1 verbatim, in the one corner the gap-1 fix missed. The coverage guard
931
+ missed it too: `GRAPHQL_TYPES` is its own constant, so deriving the expectation from
932
+ `FILE_BASED` left a hole exactly the size of the bug. The guard now works by subtraction —
933
+ every unit type must be reachable per file, wholesale, or by class discovery, with
934
+ `rails_source` the one stated exception.
935
+ - **`resolve_head_sha` no longer folds git's stderr into the SHA** (#164 review, round 3). The
936
+ same `capture2e` hazard as the working-tree probe one method over: a warning on an otherwise
937
+ successful `rev-parse` was concatenated into the value and then compared against the manifest
938
+ as if it were a SHA. The status spec's git stub had also gone dead when the working-tree
939
+ probe moved to `capture3`, so real git was running against `/tmp` in those examples.
940
+ - **Startup catch-up now notices deletion-only downtime** (#164 review, round 2). The
941
+ reconciliation scanned mtimes of files that exist, so a file deleted while no daemon was
942
+ running left no trace: the daemon logged "index is current at startup" and the ghost units
943
+ survived until the next unrelated event. Catch-up now also checks the graph's registered
944
+ paths for files gone from disk and, if any, runs one cycle with an *empty* change set — the
945
+ extractor's bounded sweep reaches the ghosts, with the bounds that keep nominal paths
946
+ (Rails < 7.1 `SchemaMigration`) safe from authoritative deletion.
947
+ - **The drain guard is an atomic test-and-set** (#164 review, round 2). The re-entrancy guard
948
+ was a check-then-act boolean — the exact race it guarded against: two `listen` callback
949
+ threads could both read `false` before either wrote `true` and run two overlapping drain
950
+ loops. It is now `Mutex#try_lock`; the refused caller's paths are already in the pending set,
951
+ so the winning loop picks them up and nothing is lost.
952
+ - **`IndexReader` freshness bookkeeping is safe under a threaded transport** (#164 review,
953
+ round 2). The generation check-and-reload was unguarded check-then-act, and the pin was a
954
+ boolean — under `woods-mcp-http`, whose tool handlers run on the Rack server's request
955
+ threads, two concurrent reads could double-reload or drop each other's caches mid-sequence,
956
+ and the first of two overlapping `with_pinned_generation` blocks to finish unpinned the
957
+ reader for both. The check-and-reload now runs under a per-reader mutex and pins are
958
+ refcounted: invalidation resumes when the *last* pin releases.
959
+ - **A cycle that can't land its work no longer loses it** (#164 review). Lock contention
960
+ already carried its paths into the next cycle; a *failed reload* did not. Saving a valid
961
+ `post.rb` while `user.rb` sat half-typed produced one app-wide reload failure covering both,
962
+ and when `user.rb` was fixed the event named only `user.rb` — so `post.rb`'s change never
963
+ reached the index at all. Failed reloads and raising extractions now carry forward too, and
964
+ the drain lives inside `Daemon#process`, so an embedded host gets the same retry behaviour
965
+ `#run` does.
966
+ - **A quiet daemon is no longer declared dead** (#164 review). `Status#alive?` disbelieves a
967
+ record older than 15 minutes and only cycle boundaries wrote one, so a healthy daemon
968
+ watching a worktree nobody was typing in read as stopped — and every caller that stands down
969
+ for a live daemon started contending for its lock instead. A heartbeat now re-stamps the
970
+ last published record every 5 minutes, republishing `degraded` as `degraded` rather than
971
+ claiming recovery.
972
+ - **The daemon reconciles changes that predate it** (#164 review). It only ever reacted to
973
+ events it personally witnessed, so the documented hook pattern — start a daemon, then sync —
974
+ stood the sync down over changes the fresh daemon had never seen. `Daemon#run` now
975
+ reconciles against the index's own watermark (`generation.json`'s mtime) before waiting for
976
+ its first event. `woods:incremental` also no longer stands down for a *degraded* daemon:
977
+ alive but not updating is not coverage.
978
+ - **`woods:refresh` serializes with the other writers** (#164 review). It rewrites the whole
979
+ dependency graph and took no lock, so a refresh racing a daemon cycle silently discarded the
980
+ other's work and then bumped the generation over it — atomic writes don't help, because each
981
+ write is individually intact and the *set* is not. It now runs under `PipelineLock` like
982
+ `woods:extract` and `woods:incremental`, and records its own generation reason instead of
983
+ masquerading as an incremental run.
984
+ - **The polling watcher no longer loses a same-second write** (#164 review). Snapshots
985
+ truncated mtime with `to_i`, so a second write inside the same second was invisible
986
+ permanently — there is no later event to catch it — and save-then-formatter at the default
987
+ 1s interval is entirely ordinary. Snapshots now carry full-resolution mtime plus size.
988
+ - **`IndexReader` no longer misses a same-size generation bump** (#164 review). The freshness
989
+ signature was `[mtime, size]`, and equal size is the daemon's steady state (reason
990
+ `"incremental"` every cycle). On a coarse-mtime filesystem — including the volume-mounted
991
+ Docker deployment the Index Server is documented for — two bumps in one tick were
992
+ indistinguishable and the reader served a stale index indefinitely. The inode is now part of
993
+ the signature, which `AtomicFile`'s rename-per-write guarantees moves.
994
+ - **A clean working tree no longer reports dirty** (#164 review). `resolve_working_tree_status`
995
+ used `capture2e`, folding git's stderr into the porcelain output — so any warning on an
996
+ otherwise successful run (a stale `index.lock` notice, `core.fsmonitor` chatter) read as
997
+ uncommitted changes, and the fingerprint tracked the warning rather than the code.
998
+ - **Index artifacts are written atomically.** `dependency_graph.json`, `manifest.json`,
999
+ `_index.json` and every per-unit file went through plain `File.write`. With a resident daemon
1000
+ writing while resident MCP readers read, a reader could catch a truncated file mid-write;
1001
+ all of them now route through `Woods::AtomicFile`.
1002
+ - **A class-based file moved between autoload directories is no longer dropped for a run**
1003
+ (#164 review). Reconciliation ran before pruning, so a file moved with its constant unchanged
1004
+ still looked "known" and was not re-extracted, then was pruned for its vanished path.
1005
+ `extract_changed` now reconciles once more after pruning.
1006
+ - The `listen` backend degrades instead of dying: only its setup is wrapped in the
1007
+ `WatcherError` rescue, so a failure raised once it is merely parked (including from the
1008
+ extraction inside a callback) is no longer relabelled "failed to start", and inotify
1009
+ exhaustion falls back to polling. Both watchers also honour a `stop` that races startup.
1010
+ - The storm threshold counts only paths the reload policy considers actionable — sixty edited
1011
+ markdown files plus one model is a one-model change, not a storm.
1012
+
1013
+ - **Token estimates now describe the file that is written.** `ExtractedUnit#estimated_tokens`
1014
+ measured `metadata.to_json`, which with ActiveSupport loaded applies HTML-safe escaping (`>`
1015
+ becomes `\u003e`), while the unit file is written with `JSON.generate`. Any unit whose
1016
+ metadata contained a lambda scope was therefore indexed with a token count that described a
1017
+ document that was never written — and differed depending on whether a full or an incremental
1018
+ run last touched it. Both sides now measure `JSON.generate`.
1019
+
1020
+ - **Incremental extraction is now equivalent to a full extraction** (#164, phase 0). Five
1021
+ confirmed correctness gaps in `woods:incremental` are closed. They mattered most in an
1022
+ incremental CI chain, where the previous graph is restored and `woods:incremental` runs per
1023
+ merge: a missed unit propagated forward run over run instead of being erased by the next
1024
+ full rebuild.
1025
+ - **New files are indexed.** Changes routed only through `DependencyGraph#affected_by`,
1026
+ which resolves a path via the graph's file map — populated only from already-registered
1027
+ units — so a file that did not exist at the last extraction routed nowhere and was
1028
+ silently ignored. A new `Woods::PathDispatcher` supplies the missing direction, path →
1029
+ extractor, for file-based types; class-based types (models, controllers, mailers,
1030
+ components, channels) are reconciled against each extractor's own runtime discovery set,
1031
+ now exposed as `#discoverable_classes`.
1032
+ - **Deleted files no longer leave ghosts.** Units whose source file has vanished are pruned
1033
+ — unit JSON removed, graph node unregistered, reverse edges withdrawn, type index
1034
+ regenerated. Deletions named in the change set are authoritative; a sweep over registered
1035
+ paths catches callers whose change set omits them. A rename resolves to delete-plus-add.
1036
+ - **Files defining several units reconcile as a whole.** `DependencyGraph`'s file map is now
1037
+ multi-valued (`path => Set<identifier>`), so a task removed from a multi-task `.rake` file
1038
+ is dropped rather than left behind. Graphs written before this load unchanged.
1039
+ - **Whole-app unit types refresh.** `route`, `middleware`, `engine`, `scheduled_job`,
1040
+ `state_machine`, `factory`, `event`, and `database_view` are re-run wholesale when their
1041
+ trigger paths change, instead of being skipped while the run still rewrote the manifest
1042
+ and zeroed `staleness_seconds`. A routes change also re-extracts the types that embed the
1043
+ route table (controllers, mailers, components, view templates).
1044
+ - **Derived data no longer drifts.** Incremental runs recompute `graph_analysis.json`, and
1045
+ refresh each affected unit's `dependents` list and `metadata.git` — all previously
1046
+ full-extraction-only. A run that extracts nothing now leaves the manifest timestamp alone
1047
+ rather than reporting the index as freshly synced.
1048
+
1049
+ - **Assignment-form multi-line conditionals no longer unbalance depth
1050
+ tracking.** `block_opener?` counted `if`/`unless` only in line-leading
1051
+ position while `case`/`begin`/`do` matched anywhere, so
1052
+ `value = if cond … end` popped a frame that was never pushed and closed
1053
+ the enclosing construct one `end` early. In a `.rake` file every task
1054
+ after the conditional lost its namespace prefix (wrong identifiers, a
1055
+ public contract); in `SourceNesting` a sibling class after the
1056
+ conditional lost its qualification (absorbed by governed naming for
1057
+ conventional paths); in the factory parser the enclosing factory
1058
+ completed early and attributes after the conditional were dropped.
1059
+ `if`/`unless` now also count directly after an assignment operator
1060
+ (`value = if x`, `@memo ||= unless y`), still excluding trailing
1061
+ modifiers and self-balancing one-liners, in all three copies of the
1062
+ rule (`RakeTaskExtractor`, `SourceNesting` — which
1063
+ `StateMachineExtractor` shares — and `FactoryExtractor`);
1064
+ `SemanticChunker` already counted assignment position. Surfaced by the
1065
+ v2 downstream validation after the comment/string neutralization fix
1066
+ removed a compensating miscount.
1067
+
1068
+ - **An alias can no longer name a protected output header (CON-1).**
1069
+ `console_query` accepted `select: ["key", "value", "id AS value"]`; the
1070
+ duplicate header made the positional redactor mask the aliased cell and
1071
+ return the real EAV secret in cleartext (`id AS key` disarmed masking
1072
+ entirely). A fourth select refusal now rejects any `AS` alias whose name
1073
+ collides with a `console_redacted_columns` or `console_redacted_key_values`
1074
+ column, case-insensitively. Defense-in-depth: the positional redactor now
1075
+ treats a duplicated key or value header as ambiguous and masks every cell
1076
+ under a value-named header unconditionally instead of letting a
1077
+ last-index-wins lookup pick the shadow. The alias/aggregate refusals also
1078
+ compare configured column names case-insensitively (CON-3/R1-6), matching
1079
+ the predicate-side refusals. `console_sql` was already immune (its
1080
+ reference scan sees the alias token) and is unchanged.
1081
+ - **`woods:incremental` refuses to run without a baseline index (CORE-2).**
1082
+ Against an output directory with no published generation and no dependency
1083
+ graph — a failed CI cache restore, a typo'd `WOODS_OUTPUT`, a first run on
1084
+ a fresh runner — `extract_changed` computed an empty blast radius over the
1085
+ empty graph, dispatched only the diffed paths, and silently published a
1086
+ near-empty index as generation 1 that nothing self-heals until a full
1087
+ extraction. `prepare_incremental_run` now raises a typed
1088
+ `Woods::ExtractionError` naming `woods:extract` (the task exits non-zero);
1089
+ the watch daemon keeps its existing missing-generation → full-extraction
1090
+ posture, and an embedding caller that already holds a populated in-memory
1091
+ graph still runs. `woods:refresh` gets the same guard.
1092
+ - **Per-path prune skips by (identifier, type), not bare identifier
1093
+ (CORE-1).** Two dispatch rules can claim one path (`app/policies` is
1094
+ claimed by the policies and pundit_policies rules) and mint the same
1095
+ identifier for different unit types; when an edit made one of them stop
1096
+ producing, the surviving identifier shielded the stale sibling-type node
1097
+ from the prune — a permanent full/incremental divergence. The #225 typed
1098
+ discipline now covers `prune_path_leftovers` too.
1099
+
1100
+ - Trailing `#` comments no longer steer `SourceNesting`'s depth tracking. A comment ending in the
1101
+ word "end" (`module Api # rename at the end`) made the wrapper look self-terminated and dropped
1102
+ the namespace from the identifier; a comment containing `do`/`for` opened a phantom frame that
1103
+ swallowed a real `end` and leaked a closed wrapper onto a later top-level class (EXTA-1).
1104
+ - `SourceNesting` keeps non-ASCII constant names whole (`class Café` was captured as `Caf`) and
1105
+ skips declarations inside `=begin`/`=end` block comments (EXTA-14).
1106
+ - `RakeTaskExtractor` no longer counts block keywords found in comments, string literals, or
1107
+ heredoc bodies. A `# do not touch production` inside a task inflated depth with no matching
1108
+ `end`, so later namespaces inherited a stale prefix (`other:third` indexed as
1109
+ `cleanup:other:third`) and a task swallowed the next one's lines; a heredoc line reading
1110
+ `end of the road` truncated a task body and lost its dependency edges (EXTB-1).
1111
+ - `FactoryExtractor` no longer reads block keywords inside attribute strings. Two of them in one
1112
+ factory (`title { "things to do" }`, `note { "walk for a while" }`) left the factory unclosed at
1113
+ EOF and the unit was dropped from the index entirely (EXTB-2).
1114
+ - `SemanticChunker`'s line-depth heuristic no longer treats `=begin`/`=end` as a block opener. Each
1115
+ block comment added a permanent +1 to the depth count, so the enclosing method never closed and
1116
+ every later method was appended to its chunk (STO-3).
1117
+ - A blockless AASM/`state_machines` `event :noop` is emitted immediately instead of being replaced
1118
+ unseen by the next event, and event depth is clamped at zero so an unbalanced `end` can no longer
1119
+ disable parsing for every event that follows (EXTB-17).
1120
+ - Service, job/worker, and mailer dependency edges keep their namespace. Since G-1 a namespaced
1121
+ unit's identifier is fully qualified, so `Billing::ChargeService.call` recorded an edge to
1122
+ `ChargeService` — matching no node, invisible to `dependents`, PageRank, and the incremental
1123
+ blast radius (EXTA-2).
1124
+ - One shared enqueue pattern is now used by the dependency scanner, `JobExtractor`, and
1125
+ `CallbackAnalyzer`, so all three agree: Sidekiq `*Worker` classes and
1126
+ `SyncJob.set(wait: …).perform_later` chains produce `:job` edges everywhere (EXTA-4).
1127
+ - `CallbackAnalyzer` detects `self.col ||=`, `self.col +=` and the other operator-assignment forms
1128
+ as column writes — `self.token ||= SecureRandom.uuid` reported none — and no longer reads
1129
+ `self.col =~ /re/` as a write (EXTA-6).
1130
+ - `CallbackAnalyzer` neutralizes comments and string bodies before its regex scans, so
1131
+ `logger.info "self.status = pending"` and a commented-out assignment no longer report a column
1132
+ write (EXTA-8).
1133
+ - `EventExtractor` recognizes Wisper's canonical paren form `broadcast(:event, …)`; the
1134
+ whitespace-only regex registered no publisher, and with no subscriber naming the event the event
1135
+ unit did not exist at all (EXTB-3).
1136
+ - Quoted namespaced rake dependencies keep their namespace: `task deploy: 'assets:precompile'`
1137
+ recorded `precompile` and pointed the edge at a task that does not exist (EXTB-9).
1138
+ - Whenever commands in single quotes are recognized: `runner 'CleanupJob.perform_later'` yielded
1139
+ command type `:unknown`, no job class, and no `:job` edge (EXTB-12).
1140
+ - Environment-nested schedule YAML unwraps the section for the current `Rails.env` (falling back to
1141
+ the first) instead of whichever environment happens to be listed first (EXTB-19).
1142
+ - `retry_config[:retry_on]` keeps namespaced error classes: `retry_on Net::OpenTimeout, wait: …,
1143
+ attempts: …` recorded `Net` and lost both `wait` and `attempts` (EXTA-10).
1144
+ - A class-discovered job whose source cannot be resolved records a nil `file_path` instead of a
1145
+ fabricated `app/jobs/<name>.rb`. The fabricated path entered the graph's `file_map` and the next
1146
+ incremental run's safety-net sweep deleted a unit every full extraction emits — the B-070/#171
1147
+ GraphQL shape, reproduced for jobs (EXTA-3).
1148
+ - GraphQL per-field complexity is attributed to the field that declares it; the match crossed
1149
+ declarations, so a field with no complexity absorbed the next field's and the real owner lost it
1150
+ (EXTB-8).
1151
+ - Strong-params capture accepts the fluent chain style — `params.require(:post)` with `.permit(…)`
1152
+ on the following line, and the fully fluent `params` / `.require` / `.permit` form — which the
1153
+ multi-line (M2) fix did not reach (EXTA-5 / R1-2).
1154
+ - `permitted_params` now lists every top-level key, hash-form keys included, and excludes nested
1155
+ leaves: `permit(:title, tags: [], meta: {seo: [:keyword]})` yields `title, tags, meta` where it
1156
+ used to yield `title, keyword` (EXTA-12 / R1-3).
1157
+
1158
+ - **`unless` no longer inverts every flow document (EXTB-4).** `Prism::UnlessNode`
1159
+ shared `convert_prism_if` and landed its body in the *then* slot, so
1160
+ `woods:flow`, the precomputed `flows/*.json`, and the MCP flow tools stated the
1161
+ opposite of what the code does for every `unless` — including the modifier form.
1162
+ The parser now swaps the then/else slots for `unless`, which makes the emitted
1163
+ `if <predicate>` reading exact; the parser-gem branch already normalized it that
1164
+ way.
1165
+ - **Call arguments are walkable AST children (EXTB-5).** Arguments were flattened
1166
+ to source text, so anything nested in an argument list was invisible to every
1167
+ tree walker: `private def hidden; …; end` lost the whole method (no
1168
+ `ruby_method` unit, no `extract_method_source`, no dataflow), and `foo(Bar.baz)`
1169
+ never produced a `Bar.baz` call site. Argument nodes now become children,
1170
+ appended after the receiver; the `arguments` text field is unchanged.
1171
+ - **Call sites in a block call's receiver chain are recorded (EXTB-16).**
1172
+ `User.where(active: true).each { … }` recorded `each` but not `where`, while the
1173
+ same chain without a block recorded both. `CallSiteExtractor`'s `:block` branch
1174
+ now recurses into the send's own children, matching the non-block path.
1175
+ - **A predicate-less `case` keeps its first `when` branch (EXTB-15).** The
1176
+ predicate slot is now positional (a nil hole when absent), mirroring the L2 fix
1177
+ for `:if`. `handle_case`'s `drop(1)` used to consume the first branch and render
1178
+ its condition as the literal word "when".
1179
+ - **`MermaidRenderer#render_dependency_map` renders edges again (EXTB-6).**
1180
+ `DependencyGraph#to_h` has emitted `[{target:, via:}]` hashes since the via
1181
+ migration; the renderer iterated them as bare targets, so `nodes.key?(hash)` was
1182
+ always false and every edge was silently dropped — the committed
1183
+ `docs/self-analysis/dependency-map.md` shipped ~2,400 nodes and zero
1184
+ dependencies. Edges are normalized through `DependencyGraph.normalize_edges`
1185
+ (legacy bare strings still load) and now carry `|via|` labels. The
1186
+ `docs/self-analysis/` artifacts are regenerated.
1187
+ - **`GraphAnalyzer#domain_clusters` is order-free (EXTB-7).** Unnamespaced units
1188
+ were assigned to clusters one at a time, each assignment mutating the target
1189
+ before the next unit was scored, so a unit whose only connection was *another*
1190
+ unnamespaced unit joined a cluster only when that other unit was registered
1191
+ first — full and incremental runs register in different orders, so the MCP
1192
+ `domain_clusters` tool could answer differently for identical trees. Assignment
1193
+ now scores every pending unit against one pre-round membership snapshot and
1194
+ applies the round together, iterating to a fixed point within
1195
+ `ORPHAN_ASSIGNMENT_ROUNDS`. Closes the residual hole in the #216/B-103
1196
+ determinism claim.
1197
+ - **`pagerank` survives a graph carrying edges for a node-less source
1198
+ (EXTB-10).** `from_h` accepts (and `registered_types` relies on) that shape, but
1199
+ `pagerank_step` read `scores[src]` for every reverse source and raised
1200
+ `NoMethodError: undefined method '*' for nil` out of the incremental PageRank
1201
+ refresh. A phantom source now contributes 0.0.
1202
+ - **`DependencyGraph#to_h` no longer hands out the live edge arrays (EXTB-11).**
1203
+ The documented "returns a dup so callers can't pollute the cached hash" was
1204
+ shallow: `to_h[:edges][id]` was the very Array `@edges` holds, so appending to it
1205
+ changed what `dependencies_of` answered. Edge arrays are copied element-wise,
1206
+ and each snapshot is detached from the memo.
1207
+ - **`change_table` columns are extracted from migrations (EXTB-18).**
1208
+ `change_table :orders do |t| t.string :notes end` produced no `columns_added`
1209
+ entry and left `orders` out of `tables_affected` (so no `:table_name` model
1210
+ edge). `change_table` joins `TABLE_OPERATIONS` and the three block scanners
1211
+ accept both openers.
1212
+ - **STI models sharing a table no longer churn the Notion Columns database
1213
+ (EXP-1).** Column pages are titled by *table*, so two models emitted the same
1214
+ `users.id` title with a different `Table` relation and a different content hash,
1215
+ each run PATCHing the page back — two API calls per shared column, forever, with
1216
+ the relation pointing at whichever model synced last. Columns are now grouped by
1217
+ physical table before syncing: one page per physical column per run, validations
1218
+ unioned, and the `Table` relation lists every owning model's page in a
1219
+ deterministic order. The #149 distinct-table behavior is unchanged.
1220
+ - **Notion rich_text is truncated in UTF-16 code units (EXP-2).** Notion's 2000
1221
+ limit counts UTF-16 units, not Ruby characters, so text containing non-BMP
1222
+ characters (emoji in a header comment is enough) shipped payloads up to twice
1223
+ the limit and the unit failed with `Notion API error 400` on *every* run.
1224
+ Truncation now walks whole characters accumulating 1 or 2 units, never splitting
1225
+ a surrogate pair.
1226
+ - **Notion "Last Schema Change" is the migration's date, not the extraction's
1227
+ (EXP-3).** `latest_changes` keyed on `extracted_at`, which a full extraction
1228
+ re-stamps for every unit — so every table read "changed today" and every Data
1229
+ Models page was rewritten to say so. The migration's own `migration_version`
1230
+ stamp (`%Y%m%d%H%M%S`, already in the same metadata hash) is preferred;
1231
+ `extracted_at` remains the fallback when it is absent or unparseable.
1232
+ - **Exporters pin the index generation (EXP-5).** `IndexReader` self-refreshes on
1233
+ every public accessor when the published generation moves and assigns pinning
1234
+ responsibility to direct callers; none of the three exporters pinned, so an
1235
+ extraction publishing mid-export produced a mixed-generation export (silently:
1236
+ Notion column pages created with no `Table` relation, Obsidian's
1237
+ "byte-identical across runs" contract broken, sweep and purge sets computed
1238
+ against a mixture). `Notion::Exporter#sync_all`, `Unblocked::Exporter#sync_all`
1239
+ and `Obsidian::VaultExporter#export_all` now run inside
1240
+ `reader.with_pinned_generation` when the injected reader supports it.
1241
+ - **The Obsidian stale-note sweep survives glob metacharacters in the vault path
1242
+ (EXP-6).** The vault path was interpolated into the glob pattern, so `[`, `]`,
1243
+ `{`, `}`, `*` or `?` in a folder name (`my [work] vault`) made `managed_notes`
1244
+ match nothing — the sweep saw zero managed notes and deleted nothing, forever.
1245
+ The glob now runs with `base:` so the path is never pattern syntax.
1246
+ - **NameMapper re-checks its hashed candidate and sanitizes Windows device names
1247
+ (EXP-9).** The collision-hash suffix was inserted into the taken set without
1248
+ being re-checked, so a hash prefix colliding with an existing literal basename
1249
+ put two notes at one path (last writer wins, one id lost from the inverse map);
1250
+ the digest slice now widens until the basename is free. `CON`, `PRN`, `AUX`,
1251
+ `NUL`, `COM1-9` and `LPT1-9` (any case) are prefixed, so a class named `Aux` no
1252
+ longer produces a note Windows cannot create.
1253
+ - **A columns-only Notion configuration syncs its columns (EXP-11).** `sync_all`
1254
+ required *both* database ids before running the column sync, so a columns-only
1255
+ configuration returned all-zero stats in silence — indistinguishable from
1256
+ breakage, and contradicting the documented "the other sync is skipped
1257
+ gracefully". The sync now runs with no parent pages (`ColumnMapper` already
1258
+ tolerates that) and says on stderr why the `Table` relation is missing.
1259
+ - **`context_completeness` is no longer recall under a second name (EXP-10).**
1260
+ The evaluator passed `expected_units` as both the relevant and the required set,
1261
+ so `mean_context_completeness == mean_recall` always and a threshold keyed on it
1262
+ silently gated on recall. `QuerySet::Query` gains an optional `required_units`
1263
+ annotation (loaded, saved, and validated as a subset of `expected_units`);
1264
+ queries without it keep the previous value.
1265
+ - **`flow_document.rb` and `evaluation/report_generator.rb` load standalone
1266
+ (EXTB-13, EXP-8).** Both named `Time#iso8601` (and `FileUtils` in the report
1267
+ generator) without requiring them; only a transitive `require "woods"` masked it.
1268
+ Missing requires added, with a subprocess smoke spec.
1269
+
1270
+ - **`reload` no longer degrades an index that has never run `woods:embed` (MCP-1).**
1271
+ A retriever-wired server with no promoted dump used `required: true` against a
1272
+ nil dump, so `reload` aborted the transaction, answered `degraded_index`, and
1273
+ stamped `bootstrap.reload_failure` into `woods_status` — clearable only by a
1274
+ successful reload, which was impossible until an embed ran. Boot hydrates that
1275
+ shape with `load_or_empty`; reload now agrees and answers a zero-count success.
1276
+ A dump that IS promoted but incomplete still fails closed.
1277
+ - **`reload` refreshes the reader on every zero-count no-op (MCP-2).**
1278
+ `Bootstrapper.reload_stores!`'s early returns (no retriever, no swap target, no
1279
+ artifact, durable stores, no promoted dump) skipped `reader.reload!`. On a flat
1280
+ (pre-2.0) index the reader never self-refreshes, so `reload` is its only
1281
+ freshness path — and precisely there the tool reported `reloaded: true` with the
1282
+ retired manifest. Every early return now runs the reader's exclusive reload
1283
+ first. Both packaged executables always wire a reloader, so the tool's
1284
+ non-reloader fallback was never the path in a shipped process; the spec that
1285
+ covered it now exercises the real wiring.
1286
+ - **A raising pipeline-lock acquire no longer wedges `pipeline_extract` /
1287
+ `pipeline_embed` for the life of the process (MCP-3).** On an index directory
1288
+ the server cannot write (a read-only Docker mount), `PipelineLock#acquire`
1289
+ raises `SystemCallError` instead of returning false. The raise escaped between
1290
+ `pipeline_start` and the background hand-off, leaking the in-process in-flight
1291
+ flag — every later call answered `already_running` — and reached `ToolContract`
1292
+ as a nested `SystemCallError`, which relabeled it `corrupt_artifact` ("An Index
1293
+ artifact is unavailable or malformed"), the wrong diagnosis for a permissions
1294
+ failure. The window is now owned by one guarded region that answers a typed
1295
+ `lock_unwritable` error and releases both the on-disk lock and the in-process
1296
+ slot unless the run was handed off.
1297
+ - **An orphaned task from a producer this host cannot judge now expires (MCP-4).**
1298
+ A `working` record whose `producer_identity` names a foreign boot id or pid
1299
+ namespace was left alone forever — correct for a cross-machine producer over a
1300
+ shared filesystem, wrong for the far more common reading of a boot-id mismatch
1301
+ on the same store: this machine rebooted mid-run, so the producer is dead by
1302
+ construction and the client polled `working` with no TTL backstop. Foreign
1303
+ producers are now believed on age alone, up to
1304
+ `Tasks::Store::FOREIGN_PRODUCER_GRACE_SECONDS` (24h from `updated_at`), then
1305
+ resolved to `failed`. Younger foreign records are untouched.
1306
+ - **Bounded staleness under sustained overlapping requests (MCP-5).**
1307
+ `IndexReader` only attempted a refresh when a pin arrived at depth 0, so on a
1308
+ threaded transport with continuous traffic — several agents against one
1309
+ `woods-mcp-http`, the deployment stateless mode exists for — the depth never
1310
+ reached zero and a retired generation was served indefinitely, silently. A pin
1311
+ arriving after the generation moved while pins are held now registers as a
1312
+ refresh waiter: it gates new pin entries (the same way an exclusive reload
1313
+ does), waits for the held pins to drain, refreshes, and admits the queue.
1314
+ Nested pins and the reads of an already-held pin are exempt, so the drain
1315
+ always completes.
1316
+ - **Store failures inside the retrieval pipeline surface as the typed store
1317
+ error (MCP-6).** M8 wrapped the three lookups the Retriever performs itself,
1318
+ but the store reads carrying most query traffic happen inside the pipeline
1319
+ components (the ranker's and assembler's `find_batch`, every executor store
1320
+ call). Those raised raw, so the SDK reported "Internal error calling tool
1321
+ codebase_retrieve" or `ToolContract` relabeled an IO-flavored cause as
1322
+ `corrupt_artifact`. The pipeline components now read through facades that
1323
+ translate any store failure into `Woods::Retriever::StoreError` naming the
1324
+ failing store, which `codebase_retrieve` already maps to
1325
+ `degraded_index (phase: 'query')`. `build_structural_context` no longer
1326
+ swallows a store failure to `nil` — the last swallow-to-empty on the query
1327
+ path.
1328
+ - **A payload directory without its `manifest.json` degrades instead of hanging
1329
+ (CORE-4).** The reader's retention-pin loop retried on `ENOENT` by reloading
1330
+ the generation; with the pointer unchanged, every iteration took the identical
1331
+ path with no sleep and no cap, spinning the request thread at 100% CPU. A
1332
+ second consecutive `ENOENT` for the same expected directory now proceeds
1333
+ unpinned, matching the flat-index branch.
1334
+ - **`search` rejects an unknown `fields` value (MCP-9).** `fields: ["sourcecode"]`
1335
+ used to return a clean empty result with nothing saying the selector was
1336
+ meaningless. The schema now enumerates `identifier`, `metadata`, `source_code`
1337
+ and `ToolContract` enforces it.
1338
+ - **A whitespace-only `OPENAI_API_KEY` is treated as absent on the `woods.json`
1339
+ path (R1-5).** The resolver-default path already stripped before deciding; the
1340
+ stored-config path did not, so `OPENAI_API_KEY=" "` wired an OpenAI provider
1341
+ with a blank key and failed per request instead of raising the one-line
1342
+ `MissingCredential` message.
1343
+
1344
+ - **Console MCP `console_sql` is validated once, with the host adapter's dialect.**
1345
+ The registered tool handler pre-validated with a dialect-blind `SqlValidator.new`
1346
+ (the conservative MySQL+PostgreSQL union) before the executor ever ran, so on a
1347
+ MySQL host a statement like `WHERE body = 'customer\'s request for update'` was
1348
+ rejected as a row-lock clause and the PR-248 dialect-aware acceptance path was
1349
+ dead on both real transports. The handler now only builds the request; the
1350
+ executor still raises `SqlValidationError` for anything it refuses, so no gate is
1351
+ lost. (CON-2)
1352
+ - **Console audit log redacts before it truncates.** `AuditLogger` cut a >16 KiB
1353
+ field at `MAX_FIELD_CHARS` and *then* ran the credential scanner, so a secret
1354
+ straddling that boundary was split, no longer matched the scanner's
1355
+ word-boundary-anchored patterns, and its cleartext prefix landed in the JSONL.
1356
+ Redaction now runs over the whole value first. `#entries` also reads UTF-8
1357
+ explicitly (the truncation notice itself carries a multibyte ellipsis). (CON-5)
1358
+ - **Unexpected exceptions inside the Console dispatch pipeline render as sanitized
1359
+ tool errors.** `DispatchPipeline#call` rescued five known error classes; anything
1360
+ else (a renderer `NoMethodError`, an encoding oddity, a handler defect) escaped
1361
+ into the `mcp` gem's handling of a raising tool block, which can echo
1362
+ `Class: message` to the client. Such failures now answer with the executor's
1363
+ sanitized shape — the tool name only, details logged server-side via
1364
+ `console.dispatch.unexpected_error` — still routed through the credential scan.
1365
+ (prior-audit L9)
1366
+ - **`exe/woods-console` leases a database connection per request.** The stdio
1367
+ server passed `SafeContext.new(connection: ActiveRecord::Base.connection)`,
1368
+ pinning one connection for the process lifetime, so every tool call failed after
1369
+ a failover or a `wait_timeout` recycle until the client restarted. It now passes
1370
+ `pool:`, matching the HTTP path. (prior-audit L10)
1371
+ - **Console executor error text is credential-scanned before the `Rails.logger`
1372
+ write.** The client response for an unexpected execution error was already
1373
+ sanitized to the class name, but the log line carried the adapter's own message —
1374
+ and PG/Mysql2 errors embed the rejected SQL and constraint literals, so a secret
1375
+ in a WHERE clause reached the server log unscanned. (prior-audit L11)
1376
+ - **The missing-Console-token boot warning names the transport it applies to.**
1377
+ "Console MCP requests will be refused (401) until one is set" has no transport
1378
+ qualifier, but the 401 belongs to the HTTP stack; the stdio transport neither
1379
+ sends nor consumes a bearer token. The warning now says so, and that a stdio-only
1380
+ setup still works. The production raise is unchanged. (prior-audit G-3)
1381
+ - **Four more Woods JSONL/JSON readers no longer depend on the process locale.**
1382
+ `Feedback::Store#all_entries`, `SessionTracer::FileStore` (history append, read,
1383
+ and the legacy-file merge), and `Evaluation::QuerySet.load`/`#save` used bare
1384
+ reads, so under `LANG=C` the first non-ASCII entry raised
1385
+ `Encoding::CompatibilityError` and — because the poison line stays on disk while
1386
+ writes keep succeeding — permanently took down `retrieval_explain`,
1387
+ `retrieval_suggest`, GapDetector, and the `session_trace` MCP tool. All read
1388
+ UTF-8 explicitly now, per the `AtomicFile.read` contract. (INF-3, R1-1, EXP-7)
1389
+ - **`woods:embed`, `woods:embed_incremental` and `woods:notion_sync` exit 1 when
1390
+ they report errors.** They printed `Errors: N` and exited 0, so a revoked API
1391
+ key, a full vector store, or a Notion 401 on every page left CI green while the
1392
+ embedding index or the Notion database went stale. They now fail like their
1393
+ siblings `woods:unblocked_sync` and `woods:obsidian`. (INF-4 / EXP-4)
1394
+ - **A missing `git` binary is a decision, not a crash, in `woods:incremental`.**
1395
+ `Errno::ENOENT` out of `Open3.capture3` killed the task with a backtrace before
1396
+ the tail-M1 decision matrix ran, so a daemon-covered tree that should have stood
1397
+ down failed its hook instead. The helper now returns the same `[nil, failure]`
1398
+ shape with `git unavailable: …`. (INF-12)
1399
+ - **`woods/session_tracer/redis_store` loaded standalone raises the documented
1400
+ error.** Without `lib/woods.rb` loaded first, the missing-redis-gem guard raised
1401
+ `NameError: uninitialized constant …SessionTracerError` instead of the actionable
1402
+ "add `gem \"redis\"`" message. (INF-8)
1403
+ - **`woods/feedback/store` requires `time`.** `Time#iso8601` only exists after
1404
+ `require 'time'`; in the MCP server process unrelated requires masked it, so a
1405
+ narrow entry point got a `NoMethodError` from `record_rating`. (INF-9)
1406
+
1407
+ - **STO-1**: A genuine pre-rename `codebase_index` database no longer wedges `Db::Migrator#migrate!` permanently. The legacy gem recorded applied versions in `codebase_index_schema_migrations`, which nothing renamed, so `ensure_table!` created an empty ledger, 001-005 re-ran against a live legacy database and 006's `ALTER TABLE codebase_units RENAME TO woods_units` then failed against the table 001 had just created — and failed identically on every later run. `SchemaVersion#ensure_table!` now adopts the legacy ledger first (guarded, idempotent; when both tables exist the `woods_` one wins and the legacy table is left in place with a warning). The spec fixture that recorded legacy versions in a table the legacy gem never had is corrected.
1408
+ - **STO-2**: Durable-store reconciliation no longer deletes non-Woods Qdrant points. A collection shared with another writer had every foreign point read as "vanished" on each `woods:embed`, and silently deleted whenever the vanished fraction stayed under the 30% purge guard. `Qdrant#each_id` now skips points with no `woods_identifier` payload (they are unattributable to Woods), and `Indexer#vanished_durable_identifiers` additionally ignores ids shaped like a canonical UUID or a native integer point id — shapes Woods never mints as an identifier.
1409
+ - **STO-4**: `Snapshotter::Metadata` raises the typed `Woods::MCP::UnsupportedArtifact` for a truncated or malformed `metadata.msgpack` and for a header missing or mistyping `schema_version`/`record_count`, instead of a raw `EOFError`/`NoMethodError`. Mirrors the M3/M10 guards the Vector twin already carried.
1410
+ - **STO-5**: `woods/storage/qdrant`, `woods/resilience/circuit_breaker` and `woods/cache/cache_middleware` load in isolation again. The first two were missing the repo's `class Error < StandardError; end unless defined?` shim; the third included `Embedding::Provider::Interface` without requiring it. `spec/load_order_spec.rb` grew a require-in-isolation sweep over the storage/cache/embedding entry files.
1411
+ - **STO-6**: `Builder` no longer crashes with `NoMethodError` when an injected embedding provider implements only `#embed`/`#embed_batch` and a durable vector store is configured with an explicit `vector_store_options[:dimensions]`. `vector_dimensions` is now probed like `safe_max_input_tokens` (`respond_to?` plus `rescue NotImplementedError`), which makes the documented "built without an embedding provider" fallback reachable.
1412
+ - **STO-8**: `MetadataStore::InMemory` and `MetadataStore::SQLite` agree on non-string values. InMemory now normalises stored metadata through the same JSON round-trip SQLite performs (symbol keys *and* values become strings, all the way down) and builds field-scoped search haystacks the way `json_extract` does, instead of leaking Ruby `Hash#to_s` syntax such as `=>` into the haystack. The shared "hardened search" examples grew hash/array-valued field queries and value round-trips; the spec that pinned InMemory's divergent symbol round-trip under a parity title is flipped.
1413
+ - **STO-9**: `MetadataStore::SQLite#store` rejects a blank `type` (`''` or whitespace) as well as a missing one — it fabricated exactly the empty type column L22 was fixed to prevent.
1414
+ - **STO-11**: The Indexer's prune guards use the `implements_own?` ownership check rather than `respond_to?(:delete)`, which was always true because `VectorStore::Interface` defines `#delete` as a raising stub (B-108). `reconcilable?` now requires an own `#delete` too, so an adapter that can be enumerated but not deleted from completes the run instead of raising `NotImplementedError` mid-sweep.
1415
+ - **STO-13**: `Util::UUID5.name_bytes` hashes an `ASCII-8BIT`-tagged name by its bytes instead of raising `Encoding::UndefinedConversionError` on high bytes. BINARY is "already bytes", not an encoding to transcode from — the method's own contract.
1416
+ - **STO-15**: The OpenAI provider's in-adapter retry rescues `Net::ReadTimeout`, matching Ollama. `Net::ReadTimeout` descends from `Timeout::Error`, not `IOError`, so a read timeout escaped both the retry and the `RequestError` typing.
1417
+ - **INF-1**: The watch daemon's polling fallback rebuilds the watcher with `ignored: ignored_directories`. Dropping it re-armed the output-directory feedback loop for any `WOODS_OUTPUT` under the root but outside the default ignore set — a daemon that never idles and re-extracts forever, on precisely the path a large tree reaches when listen cannot start.
1418
+ - **INF-2**: Carried-forward work is retried on its own thread rather than inline on the heartbeat thread. A retried storm used to starve the `PipelineLock` touch and the status re-stamp for its whole duration: past `LOCK_STALE_TIMEOUT` (600 s) a waiting writer retires the live lock (the two-writer clobber the lock exists to prevent), and past `Status::STALE_AFTER` (900 s) `woods:incremental` stops standing down. `drain`'s `try_lock` still bounds it to one drain at a time.
1419
+ - **INF-7**: `PipelineLock#acquire` unlinks the lock file it just created if the write fails (a full or read-only disk). The 0-byte remnant was fresh, unparseable (`:unknown` ownership for everyone) and never released, blocking every writer for the whole stale window — the artifact `#touch` was already fixed never to create.
1420
+ - **INF-10**: The daemon's startup watermark treats a generation whose payload directory no longer resolves as no index at all, so the existing no-watermark → storm → full-extraction path recovers a gutted index. Previously the surviving marker read as "index is current at startup" while every reader resolved to a rootward fallback holding nothing.
1421
+ - **INF-11**: `Daemon#release_claim` verifies ownership before deleting the startup claim, mirroring `reclaim_if_stale`'s snapshot-compare and `PipelineLock#release`. A daemon whose claim had been replaced deleted its *successor's* live claim at shutdown, letting a third starter in while the successor ran.
1422
+ - **CORE-6**: `Generation#payload_dir` bounds the payload pointer with a `realpath` comparison against the index root, mirroring `IndexArtifact#validate_dump_dir!` (B-134). `expand_path` is textual, so a symlink planted inside `payloads/` and pointing outside the index passed the check and every payload reader followed it. Flat-index, missing-directory and textual-escape fallbacks are unchanged.
1423
+ - **Gem-owned classes keep their real source path instead of a synthesized
1424
+ app path.** `resolve_source_location` accepted only app-owned locations and
1425
+ otherwise returned the caller's convention fallback, so an engine model such
1426
+ as `ActiveStorage::Blob` indexed at `app/models/active_storage/blob.rb`, a
1427
+ file that does not exist, with no class body in its source and a
1428
+ `woods:validate` warning telling the operator to re-run extraction for a
1429
+ path no extraction could produce. When nothing in the app defines the class
1430
+ and its definition site exists on disk, that site is used. It stays absolute
1431
+ through path normalization, `woods:validate` reports it as gem-owned, and git
1432
+ enrichment skips paths outside `Rails.root` (git rejects a whole `log`
1433
+ invocation when any pathspec is outside the repository) as well as vendored
1434
+ `vendor/` and `node_modules/` paths under it (a bundle vendored inside the
1435
+ app root is gitignored, so asking git about it is wasted work). Applies to every
1436
+ class-based extractor sharing the helper: models, controllers, mailers, jobs,
1437
+ serializers.
1438
+
1439
+ - **Incremental runs no longer duplicate a class-discovered job under its
1440
+ enclosing class's identifier.** A job nested inside a non-job file (`class
1441
+ Billing::Invoicing::Reconciler; class RefreshJob < ApplicationJob`) is found
1442
+ on the full path by the `ApplicationJob` descendant walk, with the model
1443
+ file as its `file_path`. Blast-radius re-extraction only knew the unit's
1444
+ type, took the file-based entry point, and Zeitwerk-governed naming then
1445
+ correctly named the *file* for its outer constant — registering a second
1446
+ `job` unit under the PORO's identifier that no full extraction emits. The
1447
+ duplicate flipped the graph node's type, added a false `variants` entry,
1448
+ and made `woods:incremental` and `woods:extract` disagree about the same
1449
+ tree until the next full run. `Extractor#re_extracted_units` now falls back
1450
+ to the class-based entry point when the file does not reproduce the unit,
1451
+ and only for a class the extractor's own discovery would return
1452
+ (`JobExtractor#discoverable_classes`). The booted equivalence lane pins the
1453
+ shape with a nested-job fixture in `spec/dummy`.
1454
+
1455
+ - **Concerns join the Zeitwerk-governed naming contract.** `ConcernExtractor`
1456
+ was the one extractor still naming from the outer-module chain alone, which
1457
+ stops at the first non-module line. A concern under a mid-path `concerns/`
1458
+ segment (a real namespace, not an autoload root) with a one-line `class
1459
+ SomeError < StandardError; end` declared above it indexed as the wrapper
1460
+ (`Outer::Mid::Inner`) instead of `Outer::Mid::Inner::Concerns::Leaf`. The
1461
+ governed name is tried first; the module-chain scan remains the fallback.
1462
+
1463
+ - **The missing-token console warning names the transport it applies to.**
1464
+ It claimed every Console MCP request would be refused with 401, but only
1465
+ the HTTP transport carries the bearer check; an stdio console server lists
1466
+ and executes every Tier 1 tool without a token. The warning now says HTTP
1467
+ requests will be refused, that stdio does not check the token, and that
1468
+ production boot will raise.
1469
+
1470
+ - **`woods:validate` no longer tells you to re-run extraction for gem-owned
1471
+ paths.** Engine models and framework sources carry absolute paths outside
1472
+ the application tree, so under a different install prefix they resolve
1473
+ nowhere by design and re-extraction cannot change that. They now get their
1474
+ own warning without the no-op remedy; app-tree paths keep the original one.
1475
+
1476
+ - **Metadata searches with `fields: []` now return an empty result on every
1477
+ backend (B-133).** The SQLite adapter previously emitted an incomplete
1478
+ `WHERE` clause and exposed a raw `SQLite3::SQLException`; adapters now stop
1479
+ before touching their backing store when no fields are searchable.
1480
+
1481
+ - **Snapshot dump writers reject symlink escapes before writing (B-134).**
1482
+ Vector dumps, metadata dumps, and promotion now share the same realpath-aware
1483
+ `dumps_root` boundary check. A legitimate symlinked alias of the artifact
1484
+ root remains supported, while a child symlink targeting another directory
1485
+ cannot receive snapshot files.
1486
+
1487
+ - **Corrupt JSON temporal snapshots are consistently treated as absent
1488
+ (B-135).** Direct `find` now follows the existing list/history posture by
1489
+ warning and returning `nil`; `diff` warns and returns an empty result when a
1490
+ requested snapshot is truncated, rather than leaking `JSON::ParserError`.
1491
+
1492
+ - **Payload retention preserves generations pinned by readers in other
1493
+ processes.** `IndexReader#with_pinned_generation` previously coordinated
1494
+ only threads sharing one reader object. A long MCP request could remain on
1495
+ generation N while three quick extraction publishes advanced retention far
1496
+ enough to delete `payloads/gen-N/`; an artifact first opened later in the
1497
+ request then failed with `ENOENT`. Pinned readers now hold a shared advisory
1498
+ lock on that generation's manifest, and retention skips any payload whose
1499
+ manifest cannot immediately take the exclusive lock. The operating system
1500
+ releases the lock on normal exit or a crash, and a later publish reclaims
1501
+ the skipped payload.
1502
+
1503
+ - **One-shot extraction tasks fail when their generation marker cannot be
1504
+ published.** `woods:extract`, `woods:incremental`, `woods:refresh`, and the
1505
+ `woods:extract_framework` compatibility task no longer print success and
1506
+ exit 0 after writing a payload that readers cannot reach. They now raise a
1507
+ typed `Woods::ExtractionError` while the previous generation stays active.
1508
+ The resident watch daemon keeps its existing recoverable behavior: it
1509
+ reports degraded and carries the paths into a later cycle.
1510
+
1511
+ - **`woods:watch_status` resolves its conventional index beside the active
1512
+ Rakefile, not the caller's current directory.** Cheap hook checks still do
1513
+ not boot Rails, but `rake -f /app/Rakefile woods:watch_status` now reads
1514
+ `/app/tmp/woods/watch_status.json` even when a worktree manager launches it
1515
+ elsewhere. `WOODS_OUTPUT` continues to override the conventional path.
1516
+
1517
+ - **`console_sql` rejects `INSERT`, `UPDATE`, and `DELETE` written as bare
1518
+ keywords mid-statement.** The forbidden-body keyword scan anchored every
1519
+ keyword to statement-leader positions only (start of the SQL, or after
1520
+ `;`/a comment boundary), so a statement like `SELECT 1 UPDATE posts SET
1521
+ status = 10` passed validation and failed as an adapter-level syntax error
1522
+ instead of a typed refusal. Those three keywords are reserved words on every
1523
+ supported backend and can never be bare identifiers, so the validator now
1524
+ also rejects them as bare tokens anywhere in the noise-stripped statement
1525
+ body. `MERGE` is deliberately excluded from that scan: SQLite permits an
1526
+ unquoted `merge` column, so body-level MERGE scanning would reject ordinary
1527
+ selects like `SELECT merge FROM posts`; MERGE statements remain covered by
1528
+ the allowed-prefix rule and the WITH-attached-DML check. Literal content
1529
+ never triggers (`SELECT 'update' AS word`, `WHERE title = 'UPDATE me'` stay
1530
+ accepted), identifier-shaped column names stay accepted (`updated_at`,
1531
+ `last_update`, `merge`), and row-lock clauses keep their dedicated earlier
1532
+ check, so `SELECT 1 FOR UPDATE` still reports the lock-clause message.
1533
+ Non-DML keywords that are plausible column names (`do`, `lock`, `release`)
1534
+ keep the leader-anchored rule unchanged.
1535
+
1536
+ - **`console_query` placeholder scopes resolve table-qualified columns
1537
+ case-insensitively, exactly like the public path.** A `["posts.status = ?",
1538
+ 10]` scope passed the public schema but was refused at execution with
1539
+ `Unknown table 'posts'. Cannot validate qualified column 'posts.status'.`
1540
+ whenever the executor's ModelValidator had no model-to-table mapping to
1541
+ resolve the qualifier, and a case variant (`["Posts.status = ?", 10]`) was
1542
+ refused even with the mapping. The query scope path now resolves a
1543
+ `table.column` reference whose table matches the queried model's own table
1544
+ (case-insensitively, matching unquoted SQL identifier semantics) against
1545
+ that model's own columns, so own-table qualification behaves exactly like
1546
+ the bare-column form. Redaction stays strict: a redacted column referenced
1547
+ through any case variant (`Users.Password_Digest = ?`, `Orders.Amount = ?`,
1548
+ bare `AMOUNT`) refuses with the typed redaction message — the refusal now
1549
+ runs before column resolution and matches column names case-insensitively —
1550
+ and any other qualified table still resolves through the fail-closed
1551
+ table-column check.
1552
+
1553
+ - **The Index MCP `reload` tool no longer reports an empty success when the promoted dump's
1554
+ store configuration diverges from the live server (M2).** The live retriever was in-memory
1555
+ and the captured dump was complete and valid, but when the dump's embedded `woods.json`
1556
+ named a store type the live target cannot refresh (a re-embed ran with pgvector or Qdrant
1557
+ configured and promoted over the dump the server hydrated from), the reload-time resolver
1558
+ adopted the dump's store types, every candidate builder returned nil, and the tool answered
1559
+ `reloaded: true` with zero counts while nothing was swapped and no degraded condition was
1560
+ recorded. That divergence is now a degraded reload: the `reload` tool responds with the
1561
+ reload-phase `degraded_index` error naming both store types and the honest state (nothing
1562
+ was swapped, the previous generation is still served), and the condition surfaces additively
1563
+ through `woods_status` (`bootstrap.reload_failure`). A genuine empty dump still reloads
1564
+ successfully with zero counts.
1565
+
1566
+ - **Vector dump hydration fails closed on a truncated or mismatched `vectors.idx` (M3).** The
1567
+ idx parser read each record's length, id, and offset with no end-of-file guard: a dump
1568
+ truncated mid-record hydrated a garbage short id silently, an idx holding more records than
1569
+ the float blob crashed hydration with a bare `NoMethodError`, and an idx holding fewer
1570
+ silently hydrated fewer vectors than the dump header claims. Parsing now raises the same
1571
+ typed `UnsupportedArtifact` the bin side raises for a truncated float payload when a record
1572
+ would read past EOF, and the idx record count is cross-checked against the header's
1573
+ `vector_count` after parsing, naming both counts and prompting a re-run of `woods:embed` on
1574
+ mismatch.
1575
+
1576
+ - **Best-effort Git provenance and file-history probes are now quiet and rooted
1577
+ at the extracted application.** Expected failures in source copies without a
1578
+ `.git` directory no longer emit `fatal: not a git repository` on stderr, and
1579
+ extraction launched from another checkout can no longer attach that
1580
+ checkout's branch or file history to the Rails application.
1581
+
1582
+ - **Reloading the Index MCP server no longer opens an empty-store window, and a
1583
+ failed reload no longer leaves a misaligned index (M7).** The `reload` tool
1584
+ refreshed the live in-memory vector and metadata stores with `clear!` followed by
1585
+ `bulk_load`, so a concurrent `codebase_retrieve` could search an empty or
1586
+ half-loaded store (and the reader's caches were reloaded even when store
1587
+ hydration failed, pairing one generation's JSON index with another's vectors).
1588
+ The reload is now a transaction: candidate stores are built off-side against one
1589
+ captured generation marker and one captured promoted-dump identity, reading
1590
+ exclusively from those captured locations (config from the captured dump's
1591
+ embedded snapshot, vector/metadata from the captured dump directory, the graph
1592
+ from the captured payload), so a concurrent promotion can never mix vector and
1593
+ metadata halves from two dumps. Any candidate failure leaves the previous fully
1594
+ aligned generation untouched — the old retriever keeps answering and a distinct
1595
+ reload-phase `degraded_index` condition (with `phase: 'reload'` naming the
1596
+ generation still being served) is reported on the `reload` tool response and
1597
+ additively through `woods_status` (`bootstrap.reload_failure`), without flipping
1598
+ the boot degraded state. The commit acquires the same on-disk extraction
1599
+ PipelineLock every writer uses before rechecking both identities, so a writer
1600
+ cannot publish between the recheck and the one-assignment store-bundle swap; a
1601
+ promoted dump missing any required vector or metadata component also fails
1602
+ closed without replacing the healthy live bundle. Because the reload transaction
1603
+ takes the shared on-disk writer lock, the MCP process needs write access to the
1604
+ index directory when using `reload`. A
1605
+ generation movement fails the attempt with `ReloadGenerationMoved` and a
1606
+ promoted-dump movement (an embed promotes without bumping the generation file)
1607
+ with `ReloadDumpMoved` — the next `reload` is the recovery path. A successful
1608
+ reload clears the condition.
1609
+
1610
+ - **Incremental extraction no longer misses a class-based unit whose file
1611
+ moved with its constant unchanged (M1).** Moving `app/models/tag.rb` to
1612
+ `app/services/tag.rb` without renaming `Tag` pruned the model for the
1613
+ vanished old path, and the second reconciliation pass refused to re-add
1614
+ it, so one generation served an index with the unit missing until the
1615
+ next run. The pass now re-adds pruned class-based identifiers the active
1616
+ Zeitwerk loader still governs a changed file for — the constant path
1617
+ `cpath_expected_at` derives (its inflector, ignores, and root namespaces
1618
+ decide), gated on the file declaring it; a loader non-claim is
1619
+ authoritative, so an unmanaged path re-adds nothing. Another namespace's
1620
+ same-named file and a file that only mentions the class in a comment or
1621
+ string literal resurrect nothing; deletions (including deletions batched
1622
+ with unrelated additions) stay pruned exactly as before.
1623
+ - **Incremental runs now refresh the flow artifact family, and both paths
1624
+ fail closed (M3).** With `precompute_flows` enabled, a controller
1625
+ re-extracted incrementally lost `metadata[:flow_paths]`,
1626
+ `flow_index.json` kept describing pre-change routes, and `flows/`
1627
+ documents for deleted or renamed controllers persisted across every
1628
+ generation. Incremental runs now recompute the run's controller delta,
1629
+ carry untouched controllers' entries forward, and sweep `flows/`
1630
+ documents nothing references through a dedicated flow-artifact sweep
1631
+ (separate from the unit sweep). Full and incremental extractions of the
1632
+ same tree produce equivalent flow artifacts. A failure anywhere in the
1633
+ family on either path — assembly, index write, annotation rewrite, or
1634
+ sweep — now aborts before the generation publish, so a partial flow
1635
+ index, stale prior flow artifacts alongside a new graph, or
1636
+ half-rewritten annotations can never be published; the preceding
1637
+ generation stays resolved and readable.
1638
+ - **`woods:validate` no longer fails every flow-enabled index (G-2).** The
1639
+ validator treated `flows/` as a unit-type directory and demanded
1640
+ `_index.json` from it, so any index published with flow precomputation
1641
+ on reported "Missing _index.json in flows/". Type directories are now
1642
+ bounded by a shared allowlist derived from `Extractor::EXTRACTORS`, and
1643
+ the flow family is validated by its own rule: `flow_index.json` must
1644
+ parse and every entry must point at a flow document that exists and
1645
+ parses, with missing or malformed artifacts reported accurately.
1646
+ - **Multi-line `strong params` declarations are captured (M2).** The
1647
+ `permit(...)`/`expect(...)` capture regexes could not cross newlines, so
1648
+ the common multi-line style produced an empty `metadata[:permitted_params]`.
1649
+ - **A half-loaded model no longer aborts the models phase (L1).**
1650
+ `ModelExtractor.discoverable_classes` called `abstract_class?` unguarded;
1651
+ a descendant that raises on it (possible under the NameError fallback)
1652
+ escaped the scan and failed the whole extraction. The call is guarded the
1653
+ same way `ModelNameCache` already guarded its twin, keeping the class and
1654
+ letting per-class extraction handle failures.
1655
+ - **`if nil` no longer misattributes conditional branches in flow analysis
1656
+ (L2).** The AST normalized `if` children with a `compact` that dropped a
1657
+ literal `nil` condition, so the else body landed in `then_ops`. `if`
1658
+ children are positional now; missing slots stay nil.
1659
+ - **A hydration failure at boot no longer reports `:hydrated` over empty stores (M6).**
1660
+ A corrupt or unreadable dump left the in-memory vector/metadata stores empty behind
1661
+ only a stderr warning while `woods_status` reported a healthy `:hydrated` — a server
1662
+ that answered everything with nothing. The boot status is now derived from store
1663
+ health (`:degraded` plus a per-store `hydration_failures` report), and
1664
+ `codebase_retrieve` answers with a typed `degraded_index` error naming the affected
1665
+ stores instead of a clean empty result. Graph hydration reads through the
1666
+ encoding-safe atomic-file path, so a non-ASCII index stays healthy under `LANG=C`.
1667
+ - **A metadata-store failure no longer produces misleading retrieval answers (M8).**
1668
+ Store errors were swallowed at three call sites: `types:` queries reported `:absent`
1669
+ for types that exist, the rank-within-type fallback short-circuited to empty, and
1670
+ exclusion filtering silently no-op'd. All store accesses now raise the shared
1671
+ `Woods::Retriever::StoreError`, which `codebase_retrieve` maps to the same typed
1672
+ degraded metadata instead of raising through the tool boundary.
1673
+ - **A set-but-empty `OPENAI_API_KEY` behaves as absent (M9).** The truthiness check
1674
+ wired the OpenAI provider with a blank key, skipped the Ollama fallback a missing key
1675
+ gets, and then crashed boot with a raw backtrace. Blank keys now fall through to the
1676
+ Ollama probe (pattern-only when nothing is usable), and both executables catch
1677
+ `Woods::ConfigurationError` in their bootstrap rescue so an unusable embedding
1678
+ configuration prints the one-line operator message.
1679
+ - **A non-`SystemCallError` guard failure no longer leaks the pipeline lock (L6).**
1680
+ `pipeline_extract`/`pipeline_embed` released the on-disk lock only for
1681
+ `SystemCallError`/`IOError` from the task-durability guard; any other raise blocked
1682
+ every later writer until the stale window expired. The release is now `ensure`-based
1683
+ for every pre-handoff exit path.
1684
+ - **"find who calls X" routes to graph tracing (L8).** The query classifier's
1685
+ first-match ordering sent mixed locate/trace queries to keyword location handling;
1686
+ the `:trace` intent pattern now runs before `:locate`.
1687
+
1688
+ - **`woods_status` no longer reports a stale registry version alongside a newer
1689
+ install.** When the installed gem is ahead of RubyGems (for example while
1690
+ testing an unreleased release), `server.update.latest_version` now reports the
1691
+ newest known version — the installed one — instead of the raw published
1692
+ version, so the payload no longer pairs `current_version: 2.0.0` with
1693
+ `latest_version: 1.6.0` and `update_available: false`. `update_available`
1694
+ semantics and all key names are unchanged.
1695
+ - A wrong-dimension query vector now raises the typed `Woods::Error` before the
1696
+ request leaves the process on both the pgvector and Qdrant search paths
1697
+ (previously a server-side `PG::DataException` or Qdrant 400).
1698
+ - An OpenAI embedding request whose one retry also fails now raises the typed
1699
+ `RequestError` (as Ollama already did) instead of leaking a raw
1700
+ `Errno::ECONNRESET`. Persistent HTTP connections dropped on transport errors
1701
+ are closed promptly instead of waiting for GC.
1702
+
1703
+ - **A truncated vector dump now refuses to load instead of corrupting search (M10).**
1704
+ `vectors.bin` with a valid header but a short float payload used to unpack with nil
1705
+ padding: the nil-floated vectors loaded into the live store, crashed search with
1706
+ `TypeError`, and re-published as zeros on the next dump. Loading now raises
1707
+ `Woods::MCP::UnsupportedArtifact` pointing at the file; the remedy is a re-run of
1708
+ `woods:embed`.
1709
+ - **Every atomic dump write now fsyncs its directory (M11).** The vector and metadata
1710
+ snapshotters and the index artifact writer skipped the containing-directory fsync
1711
+ `AtomicFile` performs, so a crash after the rename could leave a directory entry that
1712
+ a reboot drops — a "complete" generation that vanishes. The class contract that the
1713
+ dump directory is fully fsynced before the `latest` pointer flips now holds on every
1714
+ write path.
1715
+ - **File permissions are explicit per artifact (O1).** `AtomicFile.write` takes a `mode:`
1716
+ parameter defaulting to the restrictive 0600 Tempfile already used. The one artifact
1717
+ with a cross-boundary consumer — the watch daemon's `watch_status.json`, read by
1718
+ host-side hooks through a bind mount — is written 0644 by design.
1719
+ - **A second writer on the metadata SQLite database no longer raises
1720
+ `SQLite3::BusyException` immediately (O2).** The connection now sets a busy timeout at
1721
+ open and retries a contended write a bounded number of times, mirroring the temporal
1722
+ snapshot store.
1723
+ - **Re-capturing an unchanged HEAD computes diff stats against real history (L20).**
1724
+ Both temporal stores resolved "previous" to the snapshot being captured, so the
1725
+ re-capture diffed against itself and zeroed every stat. Previous now excludes the SHA
1726
+ being captured.
1727
+ - **Storing metadata without a type key raises instead of writing an empty type (L22).**
1728
+ The SQLite metadata adapter coerced an absent key to `""` in the column that backs
1729
+ `find_by_type`; it now raises `ArgumentError`.
1730
+
1731
+ - **Select aliasing no longer defeats console redaction.** `console_query` accepted
1732
+ `select: ["password_digest AS note"]`; the positional redactor masks by output
1733
+ header name, so the aliased column returned plaintext. Three select shapes are
1734
+ now refused: an alias over a `console_redacted_columns` column, an aggregate over
1735
+ one (aliased or bare), and an alias over either column of a
1736
+ `console_redacted_key_values` pair. Direct, unaliased selection of a redacted
1737
+ column is unchanged and stays masked. Aggregates over either column of a
1738
+ `console_redacted_key_values` pair are also refused: an aggregate such as
1739
+ `MAX(amount)` over the rows a sensitive key selects reads the redacted EAV value
1740
+ itself. Selecting an EAV value column without its paired key column is refused
1741
+ as well — the positional rule needs both headers, so a lone value column
1742
+ returned plaintext.
1743
+ - **`console_query`'s having no longer leaks protected values.** `having` accepted
1744
+ aggregates over redacted or EAV-protected columns (`MAX(amount) > ?`) and bare
1745
+ predicates on redacted columns or EAV value columns; repeated guesses revealed
1746
+ the protected value from whether a row was returned. The same protected-column
1747
+ refusal used for `select` aggregates now runs on the having template and hash
1748
+ keys before any query executes. Structured scope predicates now apply the same
1749
+ rule to redacted columns and EAV value columns while preserving EAV key-column
1750
+ predicates.
1751
+ - **Tier 1 and raw-SQL redaction shapes now fail closed.** `console_sample`,
1752
+ `console_find`, `console_pluck`, and `console_recent` refuse an EAV value column
1753
+ unless its paired key column is selected too; `console_aggregate` refuses either
1754
+ EAV pair column. Structured order/group inputs and legacy multi-bind scope arrays
1755
+ now apply the protected-predicate guard. `console_sql` accepts a protected
1756
+ identifier only as a direct, unaliased outer select column; aliases, aggregates,
1757
+ predicates, CTE shapes, and unpaired EAV values are rejected before execution.
1758
+ - **A writable CTE past the first WITH entry no longer validates.** The writable-CTE
1759
+ check anchored its match to the statement leader, so
1760
+ `WITH a AS (SELECT 1), b AS (DELETE FROM users RETURNING *) SELECT * FROM b`
1761
+ passed validation and PostgreSQL executed the DELETE. Every `AS (...)` body in
1762
+ the statement is now inspected. A CTE list attached to top-level DML
1763
+ (`WITH a AS (SELECT 1) DELETE FROM users RETURNING *`) is also rejected; DELETE
1764
+ and UPDATE previously validated because the statement prefix is WITH and neither
1765
+ keyword is a body keyword (only the INSERT variant tripped a check, incidentally
1766
+ via INTO).
1767
+ - **Row-lock clauses are rejected.** `SELECT ... FOR UPDATE`, `FOR NO KEY UPDATE`,
1768
+ `FOR SHARE`, `FOR KEY SHARE` (with `NOWAIT`/`SKIP LOCKED`), and MySQL
1769
+ `LOCK IN SHARE MODE` validated as reads but took live row locks for the duration
1770
+ of the rolled-back transaction. The check is adapter-aware: `console_sql`
1771
+ validates with the active adapter's dialect (MySQL and PostgreSQL quote/comment
1772
+ grammars differ; MySQL double-quoted strings/backtick identifiers and PostgreSQL
1773
+ quoted identifiers/E-strings are tracked faithfully), while scanning
1774
+ both normalizations when the adapter is unknown, and every view is checked under
1775
+ both MySQL executable-comment (`/*!...*/`) semantics — `#` comments and
1776
+ version-guarded comments can no longer split a lock clause apart.
1777
+
1778
+ - **Index MCP reads no longer break under a C/US-ASCII host locale.** The
1779
+ Index Server read manifest.json, per-type `_index.json` files, and
1780
+ SUMMARY.md with bare `Pathname#read`, which tags the bytes with the host's
1781
+ default external encoding. Under a C locale that tag is US-ASCII, so any
1782
+ non-ASCII content in an index artifact (a branch like `feature/café`, a
1783
+ unit identifier, summary prose) made `JSON.parse` raise
1784
+ `Encoding::InvalidByteSequenceError`, surfacing search, lookup,
1785
+ dependencies, dependents, framework, and recent_changes results as
1786
+ misleading `corrupt_artifact` errors and degrading structure and
1787
+ `woods_status`. All `IndexReader` artifact reads now go through one
1788
+ UTF-8-forcing binary read (the mode unit loading already used), so an
1789
+ index is read correctly regardless of host locale. No re-index needed.
1790
+
1791
+ - **Console redaction-oracle refusals now fire on the real transports.**
1792
+ `Server.build_embedded` handed the executor the transport-provided
1793
+ SafeContext (connection/pool, statement timeout, rolled-back transaction)
1794
+ while building a separate, render-only SafeContext for the configured
1795
+ `console_redacted_columns`/`console_redacted_key_values`. The executor-side
1796
+ refusals (redacted scope/filter keys, find locators, order keys, aggregates
1797
+ and aliases over protected columns, unpaired EAV value selects, protected
1798
+ raw-SQL usage) all read the executor's context, so on the
1799
+ `exe/woods-console` and RackMiddleware wiring every one of them was dead: a
1800
+ comparison, aggregate, sort, or unpaired-EAV read executed against the
1801
+ database and returned plaintext before render-side redaction ever ran.
1802
+ `build_embedded` now derives a single policy-complete SafeContext from the
1803
+ transport context (`SafeContext#with_redaction_policy`), preserving its
1804
+ pool, timeout, and rolled-back transaction while applying the configured
1805
+ policy, and passes that one context to both the executor and the response
1806
+ renderer. The policy comes from the kwargs when supplied and otherwise from
1807
+ the lists the supplied context itself carries — a context that carries its
1808
+ own redaction lists now renders through the same policy-complete context
1809
+ instead of losing its renderer. When redaction is effectively configured
1810
+ but the supplied context cannot derive a policy-complete context,
1811
+ construction fails closed with a `ConfigurationError` rather than leaving
1812
+ the renderer disabled. Render-side masking behavior is unchanged.
1813
+
1814
+ - **TableGate catches blocked tables hidden in MySQL executable comments at
1815
+ FROM, JOIN, and subquery lead position.** The noise stripper deliberately
1816
+ preserves `/*! ... */` forms (MySQL executes their body), but the scanner's
1817
+ FROM/JOIN lead grammars cannot start on a comment marker, so
1818
+ `SELECT * FROM /*!authorizations*/`, `SELECT * FROM /*!99999*/
1819
+ authorizations`, `users JOIN /*!authorizations*/ a ...`, and
1820
+ `FROM (SELECT * FROM /*!authorizations*/) t` surfaced no identifier and the
1821
+ blocked table executed. The scanner now scans two additional views of each
1822
+ dialect's stripped text — every executable comment replaced by its body,
1823
+ and the whole form dropped — mirroring SqlValidator's dual
1824
+ executable-comment semantics for lock clauses. The preserved form is still
1825
+ scanned, so the post-comma shape keeps working; on PostgreSQL the `/*!`
1826
+ form is a syntax error, so extra detections there are over-detection by
1827
+ design.
1828
+
1829
+ - **A failed wholesale re-run can no longer publish a graph with phantom
1830
+ units (M8).** `replace_type_wholesale`'s rescue swallowed every failure.
1831
+ A unit's graph node is registered before its JSON is written, the removal
1832
+ half deletes the JSON before dropping the graph node, and registration
1833
+ itself mutates the graph before it can fail (a malformed dependency raises
1834
+ after the node is already inserted) — so a raise in any of those windows
1835
+ (a full disk, a serialization error, a malformed unit) left the in-memory
1836
+ graph and the payload directory disagreeing, and the run went on to
1837
+ publish a generation whose `dependency_graph.json` held nodes with no unit
1838
+ file, so `dependencies`/`dependents` reported `found: true` while lookup
1839
+ returned nothing. The rescue now re-raises (as `Woods::ExtractionError`)
1840
+ once the replacement has begun to register, write, or remove anything —
1841
+ the marker is placed before each mutation, so a failure inside one cannot
1842
+ slip past it — and the run aborts before publication, leaving the
1843
+ previous generation resolved. A failure that landed nothing is still
1844
+ swallowed, as before.
1845
+
1846
+ - **Incremental runs no longer ship the previous generation's SUMMARY.md
1847
+ (M4).** `write_structural_summary` returned early because an incremental
1848
+ run holds no units in memory, so the hardlinked summary of the last full
1849
+ extraction was served unchanged — its `Units:`/`Chunks:` totals went stale
1850
+ the first time a run added or removed a unit. The summary is now derived on
1851
+ the incremental path from the same persisted per-type `_index.json` files
1852
+ the manifest counts, so the two artifacts agree; the `Generated:` stamp
1853
+ still names the moment the summary was written. The equivalence oracle now
1854
+ also compares SUMMARY.md's totals against the manifest of the same index,
1855
+ so this drift can no longer hide.
1856
+
1857
+ - **`woods:incremental` no longer exits 0 over a git range it cannot resolve
1858
+ (M1).** The diff helper discarded git's exit status, so an unresolvable
1859
+ range — a GitLab zero-SHA, an unfetched GitHub base ref, garbage — read as
1860
+ "no relevant files changed" and the task exited 0 while the sync never ran;
1861
+ the degraded-daemon extract-anyway branch was unreachable. The helper now
1862
+ carries the failure out and the task decides in order: a resolvable range
1863
+ behaves as before; a failed range stands down with a printed reason (exit
1864
+ 0) only when a running watch daemon maintains the index, and otherwise
1865
+ fails with an actionable error naming the range (exit 1, like the
1866
+ lock-timeout abort). The diff is also rooted at the extracted application
1867
+ (`git -C Rails.root`), consistent with the provenance rooting, so it can no
1868
+ longer diff whatever checkout the process happened to start in.
1869
+
1870
+ ### Testing
1871
+
1872
+ - The verifying-double sweep: 102 string-named `instance_double`s — which verify nothing
1873
+ when the constant isn't loaded — now either reference the real constant (26) or are
1874
+ honest plain doubles (76) (#219). `PhlexExtractor` went from 3 examples/~38% line
1875
+ coverage to 39 examples/99% (#219). Every spec directory now passes standalone; nine
1876
+ spec files only passed in the company of the full suite because earlier files loaded
1877
+ their constants first (B-109). Two order-dependent flakes fixed: `wait_for_threads`
1878
+ now fails loudly on a hung thread, and `tasks_spec` no longer leaks a mutated global
1879
+ configuration (#215, #216).
1880
+ - **CI** (#220): workflows run on pushes to `main` as well as PRs (a bad merge previously
1881
+ went green by absence of a run); the unit axis adds Ruby 3.4 and the booted matrix adds
1882
+ Rails 8.1 rows; a new `live-backends` lane runs storage-adapter contract specs against
1883
+ real PostgreSQL+pgvector and Qdrant service containers plus an offline embed→retrieve
1884
+ round trip — every other storage spec drives doubles, which is how #181 shipped. The
1885
+ lane found B-108 on its first run.
1886
+
1887
+ - **The live-Redis session-tracer contract spec now runs in CI (M6).** The
1888
+ `spec/session_tracer/redis_store_live_spec.rb` suite from the P4 eviction
1889
+ rework was gated on `WOODS_RUN_LIVE_BACKENDS=1` but appeared in no CI
1890
+ job's rspec run, so its six examples (including the two-client Lua
1891
+ migration race) never executed anywhere. The `live-backends` job now
1892
+ lists it; that job already provides the ephemeral `redis` client install,
1893
+ the redis service, and `WOODS_REDIS_URL`.
1894
+
1895
+ ### Security
1896
+
1897
+ - Preserve Console blocked-table boundaries for MySQL adjacent subtraction and
1898
+ whitespace/comment-separated qualified table names. Validate every SQL safety
1899
+ check with the active dialect and exercise PostgreSQL/MySQL Console requests in CI.
1900
+ - Require MessagePack 1.8.2 or newer for the patched runtime buffer implementation.
1901
+ - Require patched JSON 2.x (>=2.19.9, <3) so older supported Rails encoders
1902
+ retain their `quirks_mode` compatibility. Activate installed Index executables
1903
+ before optional libraries can select conflicting dependency versions.
1904
+ - Update Inspector transitive dependencies `fast-uri` and `qs` to patched versions
1905
+ and audit the pinned Node dependency tree in CI.
1906
+
1907
+ ## [1.6.1] - 2026-07-22
1908
+
1909
+ ### Fixed
1910
+
1911
+ - **Incremental extraction crash on multi-unit files.** `woods:incremental` raised
1912
+ `NoMethodError: undefined method 'identifier' for an instance of Array` when re-extracting a
1913
+ unit whose extractor returns several units from one file (a `.rake` file defining multiple
1914
+ tasks; i18n, migration, and lib files are shaped the same way). `Extractor#re_extract_unit`
1915
+ now normalizes the extractor result to an array and registers and writes each unit, matching
1916
+ how full extraction already handles per-type results. Full extraction was unaffected.
1917
+
10
1918
  ## [1.6.0] - 2026-07-16
11
1919
 
12
1920
  ### Added