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
@@ -14,13 +14,15 @@ module Woods
14
14
  #
15
15
  # ## Reachability (v0.2)
16
16
  #
17
- # EvalGuard is the first of five controls on the embedded `console_eval`
18
- # opt-in path. `EmbeddedExecutor#handle_eval` calls `check!` before
19
- # anything else ahead of the Confirmation prompt, the SafeContext
20
- # rollback, the timeout, and the audit log. When the opt-in is off
21
- # (the default), `refusal_for('eval')` still short-circuits with the
22
- # `eval_disabled` payload and this guard is not reached. See
23
- # docs/CONSOLE_MCP_SETUP.md "console_eval opt-in" and backlog B-053.
17
+ # `EmbeddedExecutor#send_request` already wraps every dispatched tool
18
+ # `console_eval` included — inside `SafeContext#execute`'s rolled-back
19
+ # transaction before `handle_eval` runs at all. Within that,
20
+ # `handle_eval` calls `EvalGuard#check!` before anything else it
21
+ # controls: ahead of the Confirmation prompt, the timeout, and the audit
22
+ # log. When the opt-in is off (the default), `refusal_for('eval')` still
23
+ # short-circuits with the `eval_disabled` payload and this guard is not
24
+ # reached. See docs/CONSOLE_MCP_SETUP.md "console_eval opt-in" and
25
+ # backlog B-053.
24
26
  #
25
27
  # Bridge-process mode (in development) will call the same guard before
26
28
  # shipping the payload to the remote Rails worker.
@@ -98,24 +100,29 @@ module Woods
98
100
  # `remove_method` / `method_defined?` / `prepend` / `include_module`
99
101
  # reflection family.
100
102
  # - State mutation: `instance_variable_set` / `instance_variable_get`,
101
- # `class_variable_set` / `class_variable_get` / `freeze` / `taint`.
103
+ # `class_variable_set` / `class_variable_get`.
102
104
  # - Object-space escapes: `_id2ref`, `each_object`, `const_source_location`.
103
105
  # - System / process: `system`, `exec`, `spawn`, `fork`, `popen`, `%x{}`
104
106
  # (AST method name `backtick` / xstr) so they can't be invoked
105
107
  # implicitly.
106
- # - File / IO: `open` (bare Kernel#open the File-specific reader is
107
- # handled separately via CREDENTIAL_FILE_READERS, but the bare
108
- # `Kernel.open("|shell-command")` form is how most shellshock-style
109
- # escapes slip through).
110
- # - Network: `URI.open` (when called as `open` on URI, the AST method
111
- # name is `open` so the string match above catches it). HTTP / Socket
112
- # constants are denied separately via DENIED_CONSTANTS.
113
- # - Loader: `load`, `require`, `require_relative`, `autoload`.
108
+ # - Loader: `require`, `require_relative`, `autoload`. Bare `load` is
109
+ # NOT in this list (Marshal.load/YAML.load call sites are the ones
110
+ # worth denying, and those go through the constant gate below).
114
111
  # - Unsafe deserialization: `unsafe_load` / `_load` (Marshal.load and
115
- # YAML.load are denied via DENIED_CONSTANTS + method gate below).
116
- # - Threading escapes from SafeContext's rollback: `new` on Thread /
117
- # Fiber / Process is denied via DENIED_CONSTANTS so the
118
- # {Kernel.fork, Thread.new} pair can't slip past.
112
+ # YAML.load are denied via DENIED_CONSTANTS on the `Marshal`/`YAML`
113
+ # receiver, not through this list).
114
+ # - Legacy taint-mode escapes: `taint` / `untaint`.
115
+ #
116
+ # Bare `open` (Kernel#open, e.g. `open("|shell-command")`) is NOT in
117
+ # this list and is NOT denied — a receiver-less call has no receiver
118
+ # for {#refuse_credential_file_read!} to match either. `File.open` /
119
+ # `IO.open` / `Pathname.new(...).open` are caught only when the
120
+ # argument looks like a credential path, via CREDENTIAL_FILE_READERS
121
+ # below. `URI.open` is denied, but via `URI` being a denied receiver
122
+ # in DENIED_CONSTANTS ({#refuse_denied_constant_receiver!}), not via an
123
+ # `open` entry here. Threading escapes from SafeContext's rollback
124
+ # (`Thread.new`, `Fiber.new`, `Process.fork`, ...) are denied the same
125
+ # way, via their receiver constants.
119
126
  DENIED_REFLECTION = %w[
120
127
  eval instance_eval class_eval module_eval binding
121
128
  instance_exec class_exec module_exec
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Console
5
+ # Normalizes integer inputs and enforces the bounds declared by a ToolSpec.
6
+ module InputContract
7
+ class ValidationError < StandardError; end
8
+
9
+ DECIMAL_INTEGER = /\A-?(?:0|[1-9]\d*)\z/
10
+ private_constant :DECIMAL_INTEGER
11
+
12
+ module_function
13
+
14
+ def normalize!(arguments, properties)
15
+ properties.each do |property_name, definition|
16
+ next unless definition[:type] == 'integer'
17
+
18
+ key = argument_key(arguments, property_name)
19
+ next unless key
20
+
21
+ value = parse_integer(arguments[key], property_name)
22
+ enforce_bounds!(value, property_name, definition)
23
+ arguments[key] = value
24
+ end
25
+
26
+ arguments
27
+ end
28
+
29
+ # Reject a String value for any integer-typed property, without
30
+ # attempting to parse it. Used ahead of {normalize!} on paths that skip
31
+ # full JSON-Schema validation (e.g. an unregistered tool spec): without
32
+ # this, a well-formed decimal string like `"15"` sails past
33
+ # {parse_integer} untouched (it's valid input to that method) even
34
+ # though the declared schema's `type: integer` would reject any String
35
+ # outright — silently coercing input the public contract disallows.
36
+ #
37
+ # @param arguments [Hash] Mutated arguments hash (string or symbol keys)
38
+ # @param properties [Hash] ToolSpec JSON Schema property definitions
39
+ # @raise [ValidationError] when an integer-typed property holds a String
40
+ def reject_string_typed_integers!(arguments, properties)
41
+ properties.each do |property_name, definition|
42
+ next unless definition[:type] == 'integer'
43
+
44
+ key = argument_key(arguments, property_name)
45
+ next unless key
46
+ next unless arguments[key].is_a?(String)
47
+
48
+ raise ValidationError, "#{property_name} must be an integer"
49
+ end
50
+ end
51
+
52
+ def argument_key(arguments, property_name)
53
+ return property_name if arguments.key?(property_name)
54
+
55
+ string_name = property_name.to_s
56
+ string_name if arguments.key?(string_name)
57
+ end
58
+ private_class_method :argument_key
59
+
60
+ def parse_integer(value, property_name)
61
+ return value if value.is_a?(Integer)
62
+ return Integer(value, 10) if value.is_a?(String) && DECIMAL_INTEGER.match?(value)
63
+
64
+ raise ValidationError, "#{property_name} must be an integer"
65
+ end
66
+ private_class_method :parse_integer
67
+
68
+ def enforce_bounds!(value, property_name, definition)
69
+ minimum = definition.fetch(:minimum)
70
+ maximum = definition.fetch(:maximum)
71
+ return if value.between?(minimum, maximum)
72
+
73
+ raise ValidationError, "#{property_name} must be between #{minimum} and #{maximum}"
74
+ end
75
+ private_class_method :enforce_bounds!
76
+ end
77
+ end
78
+ end
@@ -20,8 +20,14 @@ module Woods
20
20
  #
21
21
  class ModelValidator
22
22
  # @param registry [Hash<String, Array<String>>] Model name => column names mapping
23
- def initialize(registry:)
23
+ # @param table_names [Hash<String, String>] Model name => table name mapping, used to
24
+ # resolve a qualified `table.column` reference back to the model that owns it. Optional
25
+ # and defaults to empty — callers that don't supply it get every qualified column
26
+ # reference rejected by {#validate_table_column!} (fail closed: an unmapped table can't
27
+ # be proven safe).
28
+ def initialize(registry:, table_names: {})
24
29
  @registry = registry
30
+ @model_by_table = table_names.each_with_object({}) { |(model, table), acc| acc[table.to_s] = model }
25
31
  end
26
32
 
27
33
  # Validate that a model name is known.
@@ -59,6 +65,28 @@ module Woods
59
65
  column_names.each { |col| validate_column!(model_name, col) }
60
66
  end
61
67
 
68
+ # Validate a qualified `table.column` reference against the real
69
+ # columns of the model that owns `table_name`. Table gating (TableGate)
70
+ # only checks whether a table is *blocked*; it never confirms the
71
+ # column exists on that table at all, so a syntactically valid but
72
+ # nonexistent qualified column previously reached SQL execution and
73
+ # failed as a generic adapter error instead of a typed one.
74
+ #
75
+ # @param table_name [String]
76
+ # @param column_name [String]
77
+ # @return [true]
78
+ # @raise [ValidationError] if the table is unmapped or the column is unknown on it
79
+ def validate_table_column!(table_name, column_name)
80
+ model_name = @model_by_table[table_name.to_s]
81
+ unless model_name
82
+ raise ValidationError,
83
+ "Unknown table '#{table_name}'. Cannot validate qualified column " \
84
+ "'#{table_name}.#{column_name}'."
85
+ end
86
+
87
+ validate_column!(model_name, column_name)
88
+ end
89
+
62
90
  # List all known model names.
63
91
  #
64
92
  # @return [Array<String>]
@@ -17,9 +17,8 @@ module Woods
17
17
  #
18
18
  # config.middleware.use Woods::Console::RackMiddleware, path: '/mcp/console'
19
19
  #
20
- # This mounts 31 console tools at /mcp/console. By default, console_sql and
21
- # console_query are blocked in embedded mode and return an "unsupported" error
22
- # pointing users to enable the flag.
20
+ # This mounts the 9 executable Tier 1 tools at /mcp/console. Explicit
21
+ # read-tool mode registers console_sql and console_query as well.
23
22
  #
24
23
  # == Enabling the feature
25
24
  #
@@ -31,10 +30,12 @@ module Woods
31
30
  # config.console_redacted_columns = %w[api_token password_digest]
32
31
  # end
33
32
  #
34
- # With the flag off, requests to the mounted path return 410 Gone so
35
- # operators can see the endpoint exists but is gated. See
36
- # docs/CONSOLE_MCP_SETUP.md for the full security posture (blocked tables,
37
- # credential scanner, column/EAV redaction, SafeContext rollback).
33
+ # With the flag off, requests including those at the mounted path
34
+ # pass through to the app untouched. The Woods railtie mounts this
35
+ # middleware unconditionally (#183), so a host that has not opted in
36
+ # must be completely unaffected. See docs/CONSOLE_MCP_SETUP.md for the
37
+ # full security posture (blocked tables, credential scanner, column/EAV
38
+ # redaction, SafeContext rollback).
38
39
  #
39
40
  # == Enabling read tools (console_sql + console_query)
40
41
  #
@@ -48,9 +49,11 @@ module Woods
48
49
  #
49
50
  # Security posture with embedded_read_tools: true:
50
51
  #
51
- # 1. SqlValidator denylist — console_sql rejects INSERT/UPDATE/DELETE/DROP/TRUNCATE/
52
- # ALTER/CREATE/REPLACE and similar DML/DDL at the string level before any database
53
- # interaction. Only SELECT and WITH...SELECT are allowed.
52
+ # 1. SqlValidator — console_sql rejects INSERT/UPDATE/DELETE/DROP/TRUNCATE/ALTER/CREATE
53
+ # and similar DML/DDL keywords via a denylist at the string level before any database
54
+ # interaction. REPLACE is rejected too, but by the separate allowed-prefix check
55
+ # (only SELECT, WITH, and EXPLAIN may lead a statement), not the denylist — it isn't
56
+ # in SqlValidator::FORBIDDEN_KEYWORDS.
54
57
  #
55
58
  # 2. SafeContext rollback — every request (including console_query) runs inside
56
59
  # a database transaction that is always rolled back on completion. Even if a
@@ -61,59 +64,65 @@ module Woods
61
64
  # ActiveRecord::Base's pool and returns it after the response. No shared
62
65
  # mutable state leaks between requests.
63
66
  #
64
- # These three layers make embedded_read_tools: true safe for read-only workloads.
65
- # If your threat model requires stricter isolation, use the bridge mode instead
66
- # (docs/CONSOLE_MCP_SETUP.md) which runs the executor in a separate process.
67
+ # These three layers define the supported read-tool posture. Keep read tools
68
+ # disabled when the host requires a narrower database capability.
67
69
  #
68
- class RackMiddleware
70
+ class RackMiddleware # rubocop:disable Metrics/ClassLength
69
71
  # @param app [#call] The next Rack app in the middleware stack
70
72
  # @param path [String] URL path to mount the MCP endpoint (default: '/mcp/console')
71
- # @param embedded_read_tools [Boolean] Enable sql/query tools in embedded mode (default: false)
72
- # @param unsafe_eval_confirmation [Confirmation, nil] Approval callback for the
73
- # `console_eval` opt-in. Required when `WOODS_CONSOLE_UNSAFE_EVAL=true` (or
74
- # `config.console_unsafe_eval_enabled = true`); the server refuses to boot
75
- # without it. Takes precedence over `config.console_unsafe_eval_confirmation`.
76
- # @param unsafe_eval_audit_log_path [String, Pathname, nil] JSONL audit log
77
- # path for every `console_eval` run. Required on the opt-in path. Takes
78
- # precedence over `config.console_unsafe_eval_audit_log_path`.
79
- def initialize(app, path: '/mcp/console', embedded_read_tools: false,
80
- unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil)
73
+ # @param embedded_read_tools [Boolean, #call] Enable sql/query tools in
74
+ # embedded mode (default: false). May be a callable resolved when the
75
+ # server is first built the railtie passes one because middleware
76
+ # arguments are captured before config/initializers run (#183).
77
+ # @param stateless [Boolean, #call] Use modern stateless Streamable HTTP
78
+ # semantics (default: true). Set false only for compatibility with a
79
+ # client that still requires MCP session IDs.
80
+ # @param unsafe_eval_confirmation [Confirmation, nil] Legacy option retained
81
+ # for compatibility. Passing it fails closed because console_eval is unavailable.
82
+ # @param unsafe_eval_audit_log_path [String, Pathname, nil] Legacy option retained
83
+ # for compatibility. Passing it fails closed because console_eval is unavailable.
84
+ def initialize(app, path: '/mcp/console', embedded_read_tools: false, # rubocop:disable Metrics/ParameterLists
85
+ unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil,
86
+ stateless: true)
81
87
  @app = app
82
88
  @path = path
83
89
  @embedded_read_tools = embedded_read_tools
84
90
  @unsafe_eval_confirmation = unsafe_eval_confirmation
85
91
  @unsafe_eval_audit_log_path = unsafe_eval_audit_log_path
92
+ @stateless = stateless
86
93
  @mutex = Mutex.new
87
94
  @transport = nil
88
95
  end
89
96
 
90
- DISABLED_BODY = JSON.generate(
91
- error: 'woods_console_disabled',
92
- message: 'Woods Console MCP is disabled. Set ' \
93
- 'Woods.configuration.console_mcp_enabled = true to enable. ' \
94
- 'See docs/CONSOLE_MCP_SETUP.md for the full security posture.'
95
- ).freeze
96
-
97
97
  # Rack interface — intercepts requests at the configured path.
98
98
  #
99
- # Returns 410 Gone when Woods.configuration.console_mcp_enabled is false
100
- # (the default). This keeps the middleware inert on hosts that have
101
- # mounted it but not yet opted into the feature. All other requests at
102
- # non-matching paths pass through to the wrapped app unchanged.
99
+ # The enabled flag is read from Woods.configuration on every request,
100
+ # never captured at construction: the railtie inserts this middleware
101
+ # before config/initializers have run (#183). While
102
+ # `console_mcp_enabled` is false (the default), requests even at the
103
+ # mounted path — pass through to the wrapped app unchanged, so a host
104
+ # that never opted in is completely unaffected. Requests at
105
+ # non-matching paths always pass through.
103
106
  #
104
107
  # @param env [Hash] Rack environment
105
108
  # @return [Array] Rack response triple
106
109
  def call(env)
107
- return @app.call(env) unless env['PATH_INFO'].start_with?(@path)
108
- return [410, { 'content-type' => 'application/json' }, [DISABLED_BODY]] unless enabled?
110
+ return @app.call(env) unless env['PATH_INFO'].to_s.start_with?(@path)
111
+ return @app.call(env) unless enabled?
109
112
 
110
- ensure_transport.handle_request(Rack::Request.new(env))
113
+ transport = ensure_transport
114
+ request_env = env.dup
115
+ request_env.delete('HTTP_MCP_SESSION_ID') if @stateless_mode
116
+ transport.handle_request(Rack::Request.new(request_env))
111
117
  end
112
118
 
113
119
  private
114
120
 
121
+ # Whether the console is enabled, read from the live configuration on
122
+ # every request. Nil-safe: the railtie mounts this middleware in every
123
+ # app (#183), including hosts that never called Woods.configure.
115
124
  def enabled?
116
- Woods.configuration.console_mcp_enabled
125
+ Woods.configuration&.console_mcp_enabled
117
126
  end
118
127
 
119
128
  # Thread-safe lazy initialization of the MCP server and transport.
@@ -131,7 +140,10 @@ module Woods
131
140
  Rails.application.eager_load!
132
141
 
133
142
  server = build_embedded_server
134
- @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(server)
143
+ @stateless_mode = resolve_deferred(@stateless)
144
+ @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
145
+ server, stateless: @stateless_mode
146
+ )
135
147
  server.transport = @transport
136
148
  @transport
137
149
  end
@@ -176,11 +188,11 @@ module Woods
176
188
  config = Woods.configuration
177
189
  introspection = build_model_introspection
178
190
  Server.build_embedded(
179
- model_validator: ModelValidator.new(registry: introspection[:registry]),
191
+ model_validator: ModelValidator.new(registry: introspection[:registry], table_names: introspection[:tables]),
180
192
  safe_context: SafeContext.new(pool: ActiveRecord::Base.connection_pool),
181
193
  redacted_columns: Array(config&.console_redacted_columns),
182
194
  redacted_key_values: Array(config&.console_redacted_key_values),
183
- read_tools_enabled: @embedded_read_tools,
195
+ read_tools_enabled: resolve_deferred(@embedded_read_tools),
184
196
  model_tables: introspection[:tables],
185
197
  model_reflections: introspection[:reflections],
186
198
  unsafe_eval_confirmation: @unsafe_eval_confirmation,
@@ -235,6 +247,17 @@ module Woods
235
247
  end
236
248
  end
237
249
 
250
+ # Resolve a constructor option that may have been passed as a callable.
251
+ # The railtie defers option values because middleware arguments are
252
+ # captured at railtie-initializer time — before config/initializers
253
+ # have run (#183).
254
+ #
255
+ # @param value [Object, #call]
256
+ # @return [Object]
257
+ def resolve_deferred(value)
258
+ value.respond_to?(:call) ? value.call : value
259
+ end
260
+
238
261
  def structured_logger
239
262
  @structured_logger ||= Woods::Observability::StructuredLogger.new
240
263
  end
@@ -112,17 +112,30 @@ module Woods
112
112
  # Resolve EAV patterns against a `columns` header into concrete index
113
113
  # pairs. A rule only fires when both key_column and value_column are
114
114
  # present in the header, and costs nothing per row otherwise.
115
+ #
116
+ # A duplicated key or value header (an `AS` alias can shadow the real
117
+ # column — CON-1) makes index attribution ambiguous: a last-index-wins
118
+ # lookup would let the shadow steal the mask from the secret. The
119
+ # executor refuses those selects up front; here, defense-in-depth
120
+ # fails toward masking — every cell under a value-named header is
121
+ # redacted unconditionally.
115
122
  def positional_kv_rules(columns, ctx)
116
123
  return [] unless columns.is_a?(Array)
117
124
 
118
- index = columns.each_with_index.to_h { |name, idx| [name.to_s, idx] }
119
- ctx.redacted_key_values.filter_map do |pattern|
120
- key_idx = index[pattern['key_column']]
121
- val_idx = index[pattern['value_column']]
122
- next unless key_idx && val_idx
125
+ names = columns.map(&:to_s)
126
+ ctx.redacted_key_values.filter_map { |pattern| positional_kv_rule(names, pattern) }
127
+ end
128
+
129
+ # One resolved rule for one EAV pattern, or nil when the header lacks
130
+ # either column. Unambiguous headers get the key/value index pair;
131
+ # duplicated headers get the unconditional mask list.
132
+ def positional_kv_rule(names, pattern)
133
+ key_idxs = names.each_index.select { |i| names[i] == pattern['key_column'] }
134
+ val_idxs = names.each_index.select { |i| names[i] == pattern['value_column'] }
135
+ return nil if key_idxs.empty? || val_idxs.empty?
136
+ return { mask_idxs: val_idxs } unless key_idxs.one? && val_idxs.one?
123
137
 
124
- { key_idx: key_idx, val_idx: val_idx, sensitive: pattern['sensitive_keys'] }
125
- end
138
+ { key_idx: key_idxs.first, val_idx: val_idxs.first, sensitive: pattern['sensitive_keys'] }
126
139
  end
127
140
 
128
141
  # Redact positional row data using a precomputed plan. Handles both
@@ -140,7 +153,12 @@ module Woods
140
153
  def redact_row(row, plan)
141
154
  result = apply_mask(row, plan[:mask])
142
155
  plan[:kv_rules].each do |rule|
143
- result[rule[:val_idx]] = '[REDACTED]' if rule[:sensitive].include?(row[rule[:key_idx]].to_s)
156
+ if rule[:mask_idxs]
157
+ # Ambiguous (duplicated) headers: mask every value-named cell.
158
+ rule[:mask_idxs].each { |idx| result[idx] = '[REDACTED]' }
159
+ elsif rule[:sensitive].include?(row[rule[:key_idx]].to_s)
160
+ result[rule[:val_idx]] = '[REDACTED]'
161
+ end
144
162
  end
145
163
  result
146
164
  end
@@ -13,8 +13,10 @@ module Woods
13
13
  #
14
14
  # Safety layers:
15
15
  # - Every query runs inside a transaction that is always rolled back
16
- # - Statement timeout uses `SET LOCAL` so it cannot leak to the next
17
- # pool consumer
16
+ # - Statement timeout cannot leak to the next pool consumer: PostgreSQL
17
+ # uses `SET LOCAL`, which self-discards at transaction end; MySQL has
18
+ # no LOCAL equivalent, so its session-scoped `max_execution_time` is
19
+ # read before the override and restored in an `ensure` instead
18
20
  # - Column redaction replaces sensitive values with "[REDACTED]"
19
21
  #
20
22
  # == What SafeContext does NOT cover
@@ -86,7 +88,7 @@ module Woods
86
88
  # ]
87
89
  # )
88
90
  #
89
- class SafeContext
91
+ class SafeContext # rubocop:disable Metrics/ClassLength
90
92
  # Thread-local key that exposes the connection currently leased for
91
93
  # the in-flight #execute block. Handlers should prefer this over
92
94
  # acquiring their own connection so every request stays on a single
@@ -183,6 +185,24 @@ module Woods
183
185
  apply_key_value_redaction(redacted)
184
186
  end
185
187
 
188
+ # Return a SafeContext that keeps THIS context's execution behavior —
189
+ # the same pool (or wrapped connection), statement timeout, and
190
+ # rolled-back transaction — with the given redaction policy applied,
191
+ # so the executor and the renderer can share one policy-complete
192
+ # context. A non-empty kwarg defines that list (the documented
193
+ # configuration surface); an empty kwarg preserves the list this
194
+ # context already carries, so spec-style wiring that configures
195
+ # lists on the context keeps working. See {initialize} for the
196
+ # kwarg shapes.
197
+ #
198
+ # @return [SafeContext]
199
+ def with_redaction_policy(redacted_columns: [], redacted_key_values: [])
200
+ policy_columns = redacted_columns.empty? ? @redacted_columns : redacted_columns
201
+ policy_key_values = redacted_key_values.empty? ? @redacted_key_values : redacted_key_values
202
+ SafeContext.new(pool: @pool, timeout_ms: @timeout_ms,
203
+ redacted_columns: policy_columns, redacted_key_values: policy_key_values)
204
+ end
205
+
186
206
  private
187
207
 
188
208
  # Wrap one connection in a rolled-back transaction with timeout, and
@@ -190,17 +210,21 @@ module Woods
190
210
  # clears the thread-local in ensure so a raise mid-block cannot leak
191
211
  # a stale connection reference into the next request on this thread.
192
212
  def run_with_timeout(connection)
193
- previous = Thread.current[LEASED_CONNECTION_KEY]
213
+ previous_lease = Thread.current[LEASED_CONNECTION_KEY]
194
214
  Thread.current[LEASED_CONNECTION_KEY] = connection
195
215
  result = nil
196
216
  connection.transaction do
197
- set_timeout(connection)
198
- result = yield(connection)
217
+ restore_timeout = set_timeout(connection)
218
+ begin
219
+ result = yield(connection)
220
+ ensure
221
+ restore_timeout&.call
222
+ end
199
223
  raise ActiveRecord::Rollback
200
224
  end
201
225
  result
202
226
  ensure
203
- Thread.current[LEASED_CONNECTION_KEY] = previous
227
+ Thread.current[LEASED_CONNECTION_KEY] = previous_lease
204
228
  end
205
229
 
206
230
  def normalize_key_value_patterns(patterns)
@@ -241,14 +265,25 @@ module Woods
241
265
  # request, background job, etc.). Safe here because every #execute
242
266
  # is wrapped in a transaction.
243
267
  #
244
- # MySQL uses `SET max_execution_time` (applies to SELECT only — DDL
245
- # and DML statements cannot be time-limited via this variable).
268
+ # MySQL has no per-statement equivalent of `SET LOCAL`: `SET
269
+ # max_execution_time` (applies to SELECT only DDL and DML statements
270
+ # cannot be time-limited via this variable) is SESSION scope and
271
+ # survives ROLLBACK, so left alone it would leak onto the next request
272
+ # served from the same pooled connection. {#set_mysql_timeout} reads
273
+ # the session's current value first and returns a Proc that restores
274
+ # it; the caller runs that Proc in an +ensure+ around the yielded
275
+ # block.
276
+ #
277
+ # @return [Proc, nil] a restore callback for MySQL, or nil when no
278
+ # restoration is needed (PostgreSQL's LOCAL scope self-discards; an
279
+ # unsupported adapter sets nothing to restore).
246
280
  def set_timeout(connection, timeout_ms = @timeout_ms)
247
281
  adapter = connection.adapter_name.downcase
248
282
  if adapter.include?('mysql')
249
- connection.execute("SET max_execution_time = #{timeout_ms.to_i}")
283
+ set_mysql_timeout(connection, timeout_ms)
250
284
  else
251
285
  connection.execute("SET LOCAL statement_timeout = '#{timeout_ms.to_i}ms'")
286
+ nil
252
287
  end
253
288
  rescue StandardError => e
254
289
  # Unsupported adapter (SQLite, Trilogy on unsupported version, Oracle) —
@@ -259,6 +294,19 @@ module Woods
259
294
  nil
260
295
  end
261
296
 
297
+ # Read MySQL's current session-scoped `max_execution_time`, override
298
+ # it, and return a Proc that restores the value read here. There is no
299
+ # per-statement `SET LOCAL` on MySQL, so the override otherwise
300
+ # outlives this transaction's rollback and bleeds onto whatever the
301
+ # pooled connection serves next.
302
+ #
303
+ # @return [Proc] restores the previous session value
304
+ def set_mysql_timeout(connection, timeout_ms)
305
+ previous_value = connection.select_value('SELECT @@SESSION.max_execution_time').to_i
306
+ connection.execute("SET max_execution_time = #{timeout_ms.to_i}")
307
+ -> { connection.execute("SET max_execution_time = #{previous_value}") }
308
+ end
309
+
262
310
  def warn_timeout_unsupported(adapter, error)
263
311
  return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
264
312
 
@@ -39,6 +39,15 @@ module Woods
39
39
  # Suffix pattern — longest suffix match wins because we scan the full list.
40
40
  SUFFIX_PATTERN = /(_eq|_not_eq|_gteq|_lteq|_gt|_lt|_not_in|_not_null|_in|_null|_present|_blank|_matches)\z/
41
41
 
42
+ # Suffix classes, each with its own required value type. Ruby truthiness
43
+ # treats any non-nil, non-false value as true — so without a strict type
44
+ # check, `{status_present: "false"}` (a JSON string) inverts the caller's
45
+ # intent instead of raising. `_eq`/`_not_eq`/`_matches` accept any scalar
46
+ # and are intentionally not classified here.
47
+ EXISTENCE_SUFFIXES = %w[_null _not_null _present _blank].freeze
48
+ COMPARISON_SUFFIXES = %w[_gt _gteq _lt _lteq].freeze
49
+ SET_SUFFIXES = %w[_in _not_in].freeze
50
+
42
51
  SUFFIX_HINT = "Supported suffixes: #{SUPPORTED_SUFFIXES.join(', ')}.".freeze
43
52
 
44
53
  # @param model_name [String] ActiveRecord model name (e.g. 'Order')
@@ -70,8 +79,10 @@ module Woods
70
79
  suffix = match[1]
71
80
  column = key.delete_suffix(suffix)
72
81
  @model_validator.validate_column!(@model_name, column)
82
+ validate_suffix_value_type!(suffix, value)
73
83
  arel_nodes << build_node(relation, column, suffix, value)
74
84
  else
85
+ @model_validator.validate_column!(@model_name, key)
75
86
  equality[raw_key] = value
76
87
  end
77
88
  end
@@ -83,6 +94,36 @@ module Woods
83
94
 
84
95
  private
85
96
 
97
+ # Enforce the suffix-dependent value type before any Arel node is
98
+ # built. `true`/`false` are checked by identity (`==`), not truthiness,
99
+ # so a JSON string like `"false"` is rejected rather than silently
100
+ # treated as truthy.
101
+ #
102
+ # @param suffix [String] One of SUPPORTED_SUFFIXES
103
+ # @param value [Object] The predicate value
104
+ # @raise [ValidationError] when the value's type doesn't match the suffix class
105
+ def validate_suffix_value_type!(suffix, value)
106
+ case suffix
107
+ when *EXISTENCE_SUFFIXES
108
+ return if [true, false].include?(value)
109
+
110
+ raise ValidationError,
111
+ "Predicate suffix '#{suffix}' requires a strict boolean value " \
112
+ "(got #{value.class}: #{value.inspect}). #{SUFFIX_HINT}"
113
+ when *COMPARISON_SUFFIXES
114
+ return if value.is_a?(String) || value.is_a?(Numeric)
115
+
116
+ raise ValidationError,
117
+ "Predicate suffix '#{suffix}' requires a scalar (String or Numeric) value " \
118
+ "(got #{value.class}: #{value.inspect}). #{SUFFIX_HINT}"
119
+ end
120
+ # _in/_not_in are intentionally not type-checked here: build_node
121
+ # already wraps a scalar in a single-element Array via `Array(value)`,
122
+ # a pre-existing, separately-tested convenience for callers building
123
+ # scope hashes programmatically. The public JSON Schema still
124
+ # requires a real array for _in/_not_in at the MCP boundary.
125
+ end
126
+
86
127
  # Build an Arel predicate node for a validated column + suffix.
87
128
  #
88
129
  # @param relation [ActiveRecord::Relation, Class] Used to get the arel_table