woods 1.6.1 → 2.0.0.beta2

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