rails-ai-context 5.35.0 → 5.35.2

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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1024 -0
  3. data/CONTEXT.md +37 -7
  4. data/CONTRIBUTING.md +30 -1
  5. data/README.md +9 -9
  6. data/SECURITY.md +2 -2
  7. data/docs/ARCHITECTURE.md +12 -4
  8. data/docs/CLI.md +44 -15
  9. data/docs/COMPATIBILITY.md +34 -8
  10. data/docs/CONFIGURATION.md +28 -5
  11. data/docs/CUSTOM_TOOLS.md +8 -2
  12. data/docs/FAQ.md +5 -5
  13. data/docs/GUIDE.md +33 -65
  14. data/docs/INTROSPECTORS.md +4 -4
  15. data/docs/QUICKSTART.md +1 -1
  16. data/docs/RECIPES.md +5 -5
  17. data/docs/RELEASING.md +3 -1
  18. data/docs/SECURITY.md +255 -74
  19. data/docs/SETUP.md +1 -1
  20. data/docs/STANDALONE.md +2 -2
  21. data/docs/TOOLS.md +151 -90
  22. data/docs/TROUBLESHOOTING.md +123 -8
  23. data/docs/_config.yml +1 -1
  24. data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
  25. data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
  26. data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
  27. data/docs/index.md +2 -2
  28. data/exe/rails-ai-context +24 -9
  29. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
  30. data/lib/rails_ai_context/app_databases.rb +377 -0
  31. data/lib/rails_ai_context/app_kind.rb +2 -0
  32. data/lib/rails_ai_context/cli/app_root.rb +1 -0
  33. data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
  34. data/lib/rails_ai_context/configuration.rb +15 -8
  35. data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
  36. data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
  37. data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
  38. data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
  39. data/lib/rails_ai_context/database_family.rb +282 -0
  40. data/lib/rails_ai_context/database_file.rb +61 -7
  41. data/lib/rails_ai_context/database_yml.rb +7 -29
  42. data/lib/rails_ai_context/doctor.rb +15 -16
  43. data/lib/rails_ai_context/instrumentation.rb +1 -1
  44. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
  45. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
  46. data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
  47. data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
  48. data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
  49. data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
  50. data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
  51. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
  52. data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
  53. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
  54. data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
  55. data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
  56. data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
  57. data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
  58. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
  59. data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
  60. data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
  61. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
  62. data/lib/rails_ai_context/migration_status.rb +18 -46
  63. data/lib/rails_ai_context/payload.rb +48 -1
  64. data/lib/rails_ai_context/pending_migrations.rb +3 -4
  65. data/lib/rails_ai_context/run_cache.rb +5 -0
  66. data/lib/rails_ai_context/schema_adapter.rb +13 -18
  67. data/lib/rails_ai_context/secret_columns.rb +506 -0
  68. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
  69. data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
  70. data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
  71. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
  72. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
  73. data/lib/rails_ai_context/server.rb +143 -29
  74. data/lib/rails_ai_context/sql_gate.rb +1007 -0
  75. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
  76. data/lib/rails_ai_context/test_helper.rb +3 -1
  77. data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
  78. data/lib/rails_ai_context/tools/base_tool.rb +71 -48
  79. data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
  80. data/lib/rails_ai_context/tools/diagnose.rb +105 -42
  81. data/lib/rails_ai_context/tools/generate_test.rb +4 -6
  82. data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
  83. data/lib/rails_ai_context/tools/get_api.rb +1 -6
  84. data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
  85. data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
  86. data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
  87. data/lib/rails_ai_context/tools/get_concern.rb +6 -6
  88. data/lib/rails_ai_context/tools/get_config.rb +189 -12
  89. data/lib/rails_ai_context/tools/get_context.rb +111 -105
  90. data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
  91. data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
  92. data/lib/rails_ai_context/tools/get_engines.rb +1 -6
  93. data/lib/rails_ai_context/tools/get_env.rb +1 -6
  94. data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
  95. data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
  96. data/lib/rails_ai_context/tools/get_gems.rb +1 -6
  97. data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
  98. data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
  99. data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
  100. data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
  101. data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
  102. data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
  103. data/lib/rails_ai_context/tools/get_schema.rb +10 -8
  104. data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
  105. data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
  106. data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
  107. data/lib/rails_ai_context/tools/onboard.rb +24 -24
  108. data/lib/rails_ai_context/tools/performance_check.rb +15 -2
  109. data/lib/rails_ai_context/tools/query.rb +142 -1356
  110. data/lib/rails_ai_context/tools/read_logs.rb +1 -1
  111. data/lib/rails_ai_context/tools/review_changes.rb +6 -7
  112. data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
  113. data/lib/rails_ai_context/tools/safe_call.rb +70 -28
  114. data/lib/rails_ai_context/tools/search_code.rb +0 -4
  115. data/lib/rails_ai_context/tools/search_docs.rb +3 -1
  116. data/lib/rails_ai_context/tools/security_scan.rb +9 -5
  117. data/lib/rails_ai_context/tools/session_context.rb +18 -12
  118. data/lib/rails_ai_context/tools/validate.rb +1 -0
  119. data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
  120. data/lib/rails_ai_context/version.rb +1 -1
  121. data/lib/rails_ai_context.rb +4 -7
  122. data/server.json +2 -2
  123. metadata +14 -3
data/CHANGELOG.md CHANGED
@@ -5,6 +5,1030 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.35.2] - 2026-10-14
9
+
10
+ ### Security
11
+
12
+ - **On MySQL and MariaDB a quote character inside a string does not change
13
+ which databases a statement is read to name.** The secret column rule reads
14
+ the columns of every database a statement names. In 5.35.1, the first release
15
+ with that read, a quote character inside a string could keep the rule from
16
+ seeing that the statement named another database. A secret column of a table
17
+ or of a view in that database then came back. Each name before a dot is now
18
+ read backward from its dot, so nothing written before the name changes how it
19
+ is read. The refusals are the ones 5.35.1 gives. This gap is in 5.35.1
20
+ alone: earlier versions read no other database, which the 5.35.1 notes cover.
21
+ - **On MySQL and MariaDB a `[` outside a string or a quoted name is refused.**
22
+ MariaDB reads `[name]` as a quoted name when the session's sql_mode holds
23
+ `MSSQL`, and the statement checks read a bracket as an ordinary character. In
24
+ a session with that mode the checks and the server could read one statement
25
+ two ways. `rails_query` does not read the session's mode, so it now refuses
26
+ the bracket on both servers. 5.35.1 and every earlier version had the gap,
27
+ on an app whose connection runs in that mode.
28
+ - **On MySQL and MariaDB a query leaves no value behind on the app's
29
+ connection.** A user variable assignment (`:=`) and `LAST_INSERT_ID` called
30
+ with an argument each set a value that stayed on the pooled connection after
31
+ the query returned, which a read-only transaction does not stop. Both are now
32
+ refused. 5.35.1 and every earlier version ran them.
33
+ - **On PostgreSQL what a foreign scan sends to the remote server is read like
34
+ what the plan runs itself.** A condition, a sort or a group on a secret
35
+ column of a `postgres_fdw` foreign table ran on the remote server, where the
36
+ plan checks did not read it. So a statement could test the column's value,
37
+ also through a view that renames the column and under a `column_name`
38
+ option. The remote statement the plan prints is now read, and such a
39
+ statement answers "Blocked: the query's plan uses the secret column
40
+ `<column>`". Fetching the column returns `[FILTERED]` as before. A statement
41
+ whose plan holds a foreign scan costs one more catalog read. Only the remote
42
+ statement of `postgres_fdw` is read: another foreign data wrapper prints
43
+ keys of its own. 5.35.1 and every earlier version had the gap.
44
+ - **On PostgreSQL a join that the remote server runs is traced to its
45
+ tables.** When `postgres_fdw` sends a whole join to the remote server, the
46
+ plan names the tables with their aliases in one line of the scan, which the
47
+ plan checks did not read, so the secret column rule did not reach those
48
+ tables. The tables and aliases of that line are now read like any scan's: a
49
+ secret column is `[FILTERED]` in place and a whole row is refused, as for a
50
+ local table. 5.35.1 and every earlier version had the gap.
51
+ - **On PostgreSQL the check for a secret column passed through a subquery
52
+ reads a name with two spaces in a row or a dotted capital I.** In 5.35.1
53
+ that check missed a secret column whose name holds either one. A statement
54
+ that passes such a column through a subquery, a CTE or a set operation is
55
+ now refused as for any other secret column. 5.35.0 refused it, so the gap is
56
+ in 5.35.1 alone.
57
+ - **On SQLite a virtual table built on a relation that is not read is refused
58
+ however its definition is written.** 5.35.1 read a relation's name in the
59
+ `content=` option of a full-text table and in the table a vocabulary lists.
60
+ A virtual table that named the relation another way ran, and returned the
61
+ rows or words of a refused view, a shadow table or the page modules. The
62
+ module and every argument can now name a relation, except the columns a
63
+ full-text table (fts3, fts4, fts5) declares and the type an `fts5vocab`
64
+ table ends with (`row`, `col` or `instance`). An argument with `=` is read
65
+ whole, whatever its name, and comments are taken out first. The module is
66
+ read after the table's own name, so a name that spells a module changes
67
+ nothing: 5.35.1 read such a name as the module, and the rule for a virtual
68
+ table that holds a secret column did not reach that table. The answers are
69
+ the ones 5.35.1 gives. The gap needs a virtual table the app itself created.
70
+ 5.35.0 and earlier returned such a table's rows too.
71
+ - **On SQLite a relation whose name holds an apostrophe is matched where it
72
+ is written in single quotes.** SQLite reads a name in single quotes where a
73
+ table is expected, with the apostrophe doubled inside. The secret column
74
+ rule spelled a name in double quotes and in backticks and missed that
75
+ spelling, so a statement or a definition that used it was not held to the
76
+ rule for that relation. 5.35.1 and every earlier version had the gap, on an
77
+ app with a relation so named.
78
+
79
+ ### Changed
80
+
81
+ The three refusals below go out under a patch number. Each closes a guard gap,
82
+ and the maintainer chose 5.35.2 for them, outside step 1 of `docs/RELEASING.md`.
83
+
84
+ - **On MySQL and MariaDB a statement with a user variable assignment (`:=`) is
85
+ refused.** The answer is "Blocked: a user variable assignment (`:=`) is not
86
+ run. It leaves a value on the pooled connection after the query returns,
87
+ which a read-only transaction does not stop." A statement that numbers its
88
+ rows with `@n := @n + 1` ran before and is refused now. Use a window
89
+ function, `ROW_NUMBER() OVER (...)`, which MySQL 8 and MariaDB 10.2 and
90
+ later have. A `:=` inside a string or a quoted name is data and runs, with
91
+ one case apart: a statement with a backslash before a quote inside a string.
92
+ The second reading of a backslash ends the string at that quote, and where
93
+ that leaves the `:=` outside a string the statement is refused. Write the
94
+ quote doubled.
95
+ - **On MySQL and MariaDB `LAST_INSERT_ID` with an argument is refused.** The
96
+ answer is "Blocked: LAST_INSERT_ID with an argument is not run. It sets a
97
+ value that stays on the pooled connection after the query returns, which a
98
+ read-only transaction does not stop. LAST_INSERT_ID() with no argument
99
+ runs." The call is matched on the raw text, like the session functions, so
100
+ it is refused inside a string too.
101
+ - **On MySQL and MariaDB a statement with a `[` outside a string or a quoted
102
+ name is refused.** The answer is "Blocked: a `[` outside a string or a
103
+ quoted name is not run. MariaDB reads it as the start of a quoted name when
104
+ the session's sql_mode holds MSSQL, and rails_query does not read the
105
+ session's mode. Quote a name in backticks." MySQL, and MariaDB outside that
106
+ mode, run no statement that holds one. In a session with that mode, write
107
+ `` `name` `` for `[name]`. A `[` inside a string or a backticked name runs,
108
+ with one case apart: a statement with a backslash before a quote inside a
109
+ string, as in `'O\'Brien'` beside `REGEXP '[a-z]'`. The second reading of a
110
+ backslash ends the string at that quote, and where that leaves the `[`
111
+ outside a string the statement is refused. Such a statement ran before.
112
+ Write the quote doubled.
113
+
114
+ ### Fixed
115
+
116
+ - **A write that follows a `WITH` clause is refused as the write it is.**
117
+ `WITH ... INSERT INTO ...` answered "Blocked: SELECT INTO creates a table".
118
+ It now answers "Blocked: contains INSERT", and `REPLACE` and `MERGE` after a
119
+ `WITH` clause are named the same way. A real `SELECT ... INTO` keeps its
120
+ sentence.
121
+ - **A refusal names a view, a table and a relation as the database spells
122
+ them.** It named them case folded, with a run of spaces as one space, so a
123
+ view called `Hq Odd View` was named `hq odd view`, which no relation is
124
+ called. Two names that differ by a run of spaces alone are one name to the
125
+ rule, and the refusal names the first of them it read.
126
+ - **A view refused over a table that is not read is said to name it.** The
127
+ answer ended "its definition reads `<table>`, which is not read", and the
128
+ rule tests whether the definition names the table. It now ends "its
129
+ definition names `<table>`, which is not read".
130
+ - **The answer for a view that leads too deep drops its count.** It reads "The
131
+ view `<view>` leads through more databases than rails_query follows, so the
132
+ secret columns it could read cannot be told and no statement that names it
133
+ runs." The "(3 rounds)" after "follows" told a user nothing.
134
+ - **The note for a row limit of one says "1 row shown".** It said "1 rows
135
+ shown; the query returned more".
136
+ - **On PostgreSQL a sequence can be queried.** `SELECT last_value FROM
137
+ <sequence>` and `SELECT * FROM <sequence>` ran on 5.35.0, and 5.35.1 answered
138
+ that the sequence's columns could not be read. A sequence is now read like a
139
+ table that holds no secret column.
140
+ - **On PostgreSQL a whole row of a table with no secret column runs when
141
+ another table shares its alias or its name with a number after it.** 5.35.1
142
+ refused such a statement when a subquery used the same alias for a table
143
+ that holds a secret column, or when a table named like `name_2` stood beside
144
+ it, and the refusal named the other table. 5.35.0 ran it. The number the
145
+ planner adds to an alias is now taken off for the members of an Append or a
146
+ Merge Append alone, and a refusal over a whole row names the relation whose
147
+ row the statement reads.
148
+ - **On PostgreSQL a block comment inside a block comment is read as the server
149
+ reads it.** PostgreSQL nests block comments, and the gate ended one at the
150
+ first `*/`, so `SELECT 1 /* a /* b */ c */ AS n` answered PostgreSQL's
151
+ syntax error. It now runs.
152
+ - **On SQLite a statement that runs past `query_timeout` answers the timeout
153
+ when a writer takes the lock while it runs.** It answered that the database
154
+ was locked and the query did not run. The query process now takes its read
155
+ lock before it prepares the statement, so no lock stops the statement from
156
+ there on. The locked answer is given for a lock the query process is still
157
+ waiting on when it gives up or when the deadline passes.
158
+ - **On a SQLite file database a statement that names something the app's
159
+ connection alone holds runs on that connection.** While that connection
160
+ holds a temp object or an attachment, a statement runs there when it names a
161
+ temp object, `sqlite_temp_master` or `sqlite_temp_schema`, the `temp` schema
162
+ or an attached database, or calls a pragma function (`pragma_table_info(...)`,
163
+ `pragma_table_list` and the rest) or `dbstat`, since an argument can name any
164
+ of them.
165
+ The query process has none of them, so it answered an empty list, the rows of
166
+ a main table of the same name, or "unknown database". The answer now ends
167
+ with the note that the query ran without a time limit. A name is matched
168
+ anywhere in the statement, in any letter case and inside a string too.
169
+ Finding this out takes two more reads before a statement on a file database
170
+ where a query process can run: eight where there were six.
171
+ - **The full generated file lists the first database's recent migrations
172
+ under its own name.** With a first database not named `primary` and another
173
+ one that is, the heading read `### Recent Migrations: primary`. It now names
174
+ the first database, as in `### Recent Migrations: animals`.
175
+ - **The database marks and the first database's own name do not depend on the
176
+ preset.** On `preset: standard`, or any `introspectors` list without
177
+ `multi_database`, `rails_get_schema`, `rails_onboard`,
178
+ `rails_performance_check` and the generated files lost the mark of a
179
+ database another environment configures, such as `cache (production only)`,
180
+ and called a first database with another name `primary`. They now read the
181
+ app databases list there, as on `full`, and `rails_onboard` names a
182
+ secondary's adapter.
183
+ - **The model, controller, job, mailer and channel lists hold a class only
184
+ while its own constant names it.** A class whose constant was removed stays
185
+ loaded until the garbage collector takes it, so a list could hold it in one
186
+ run and lose it in the next. Such a class is now left out. A constant still
187
+ waiting to autoload counts as naming its class. The lists of a class's
188
+ children follow the same rule: a model's STI children, the classes a base job
189
+ or mailer is inherited by, and the abstract bases.
190
+ - **A model file that raises before its class opens costs that model alone.**
191
+ A failing `require` at the top of the file, or a raise above the class line,
192
+ made the whole models section answer an error. The file is now named with
193
+ what it raised, and the other models stay listed.
194
+ - **The refusal over a secret column says where `query_allowed_columns` is
195
+ set on either install.** It said to add the name "in an initializer", which
196
+ does nothing on a standalone install. It now ends "add "x" to
197
+ query_allowed_columns in .rails-ai-context.yml (or in
198
+ config/initializers/rails_ai_context.rb where the app bundles the gem)."
199
+ - **`rails_runtime_info` says when table sizes or index usage could not be
200
+ read.** A read that failed or ran out of time left its part of the database
201
+ section out with no word. The section now says "_Table sizes were not read:
202
+ the read failed or took longer than query_timeout._", and at `detail:
203
+ "full"` "_Index usage was not read: the read failed or took longer than
204
+ query_timeout._"
205
+
206
+ ## [5.35.1] - 2026-10-13
207
+
208
+ ### Security
209
+
210
+ - **`rails_query` sends no statement to an adapter with no database family.**
211
+ On SQL Server, Oracle, CockroachDB and any other adapter outside PostgreSQL,
212
+ MySQL and SQLite it ran the statement with the text checks alone, with no
213
+ read-only transaction and no timeout. It now answers with the adapter's
214
+ name, and it opens no connection to do so.
215
+ - **MySQL bounds a statement that starts with WITH or EXPLAIN ANALYZE.** The
216
+ `MAX_EXECUTION_TIME` hint bound only a statement that starts with SELECT, so
217
+ one that starts with WITH or EXPLAIN ANALYZE ran with no time limit. The
218
+ session's `max_execution_time` now bounds those too and is put back after the
219
+ statement, also when the statement fails. It does not cover `EXPLAIN` or
220
+ `SHOW`, where a wait on a lock ends at the session's `lock_wait_timeout`.
221
+ - **`rails_query` keeps a column secret when `Redaction`'s secret words name
222
+ it.** Columns such as `passwd`, `salt`, `credentials` and `pepper` were
223
+ returned by `rails_query` while log and config redaction filtered those
224
+ words. They are now refused by name and filtered in results, so a column
225
+ name is secret in every tool or in none.
226
+ - **An `encrypts` column stays secret when `query_allowed_columns` lists it.**
227
+ The statement check let an allowed name through, and on MySQL and SQLite a
228
+ query that gave the column an alias returned the stored value. The check now
229
+ refuses the name on every database family, and the refusal says the allowed
230
+ list does not release it.
231
+ - **On PostgreSQL a statement whose plan cannot be read does not run.** The
232
+ checks that keep a secret column out of a result read the statement's plan,
233
+ and a statement ran without them when planning timed out or failed. Every
234
+ statement except `SHOW` now runs only with a plan. An `EXPLAIN` is planned
235
+ as the statement it explains. A planning timeout is answered as the timeout,
236
+ and a planning error as the SQL error.
237
+ - **`rails_query` refuses `LOCK IN SHARE MODE` and `FOR KEY SHARE`.** MySQL
238
+ and MariaDB ran `LOCK IN SHARE MODE` inside the read-only transaction, where
239
+ it locks the rows it reads. The gate now refuses it wherever it refuses
240
+ `FOR SHARE`, with `NOWAIT`, `SKIP LOCKED`, `OF` or `WAIT` after it too.
241
+ - **A view cannot rename a secret column past `rails_query`.** On MySQL,
242
+ MariaDB and SQLite a view that selects a secret column under another name
243
+ returned its values. A statement that names such a view is now refused with
244
+ the view and the column. So is a view built on it, a view whose definition
245
+ has a wildcard and names a table that holds a secret column, and a view
246
+ whose definition cannot be read on a database that holds secret columns. A
247
+ definition is read with its comments taken out, and a view's name is matched
248
+ whatever characters it holds. `query_allowed_columns` releases a column by
249
+ name as before, and an `encrypts` column stays secret. On PostgreSQL a plain
250
+ view keeps the filter on the one column. A materialized view stores its
251
+ rows, so no result column of it is traced to a table column, and one whose
252
+ definition names a secret column returned it under another name. A
253
+ statement that reads such a materialized view is now refused, through a
254
+ view over it too.
255
+ - **A generated column that reads a secret column is secret.** On every database
256
+ family its values were returned under its own name. A statement that names it
257
+ is now refused and its values come back `[FILTERED]`. Two tables can each have
258
+ a generated column of one name. That name is secret when either column reads a
259
+ secret column, and it is held as an `encrypts` column when either reads one.
260
+ - **The secret column rule knows every relation a statement reads.** It read the
261
+ columns of the connection's own database, or of the search path, and no
262
+ others. So a secret column of a table anywhere else came back under an alias
263
+ or through an expression. The rule now follows what a statement reaches. On
264
+ MySQL and MariaDB it reads every database the statement's text names. On
265
+ PostgreSQL it reads materialized views and foreign tables, every schema the
266
+ statement's text names, and every relation the plan names. On SQLite it reads
267
+ the temp schema and every attached database. Then it reads the databases or
268
+ schemas that the definitions of the views in the statement name, and follows
269
+ those views three reads deep. On PostgreSQL only a materialized view's
270
+ definition is followed, since the plan reads through a plain view. A statement
271
+ that names a view that leads deeper is refused with the view's name. A
272
+ relation whose columns cannot be read is not queried.
273
+ - **On PostgreSQL two aliases that differ only by case are two relations.**
274
+ The plan checks folded an alias to lower case, so a whole-row read of one
275
+ relation was taken for a read of the other. An alias is now read as the plan
276
+ prints it.
277
+ - **On SQLite the database file's raw pages are not read.** `sqlite_dbpage`,
278
+ `sqlite_dbdata` and `sqlite_dbptr` return the file's pages and cells, and
279
+ every stored value is in them, secret columns included. A statement that
280
+ names one is now refused.
281
+ - **On SQLite a shadow table of a virtual table is not read.** A virtual
282
+ table, such as a full-text one, keeps what it stores in shadow tables under
283
+ other column names, so a secret column's values came back from there. A
284
+ statement that names a shadow table is now refused, and the refusal names
285
+ the virtual table to query. Before SQLite 3.37 a table whose name starts
286
+ with a virtual table's name and an underscore is read as a shadow table.
287
+ - **On SQLite a virtual table that holds a secret column is refused whole.** A
288
+ search or a function over a virtual table reads a column by its position, with
289
+ no name written, so it returned or confirmed a secret column's values. A
290
+ statement that names such a table is now refused. So is one that names an
291
+ `fts5vocab` or `fts4aux` table over it, which lists its words, and one that
292
+ names a view over any of these tables. Only the columns a virtual table
293
+ declares decide this, so a full-text table whose own name reads like a secret
294
+ name runs. A virtual table whose definition names a refused view, or a table
295
+ that is not read, is refused with its `fts5vocab` or `fts4aux` table too.
296
+ - **`rails_query` also keeps nonce and initialization vector columns secret.**
297
+ A column whose name holds `nonce`, or is `iv` or ends in `_iv`, is refused
298
+ by name and filtered in results. `query_allowed_columns` releases one by
299
+ name, as for any name the heuristic flags.
300
+ - **`authentication_string` is a built-in secret name.** MySQL keeps password
301
+ hashes in a column of that name, and `rails_query` returned it. A statement
302
+ that names it is now refused, and its values come back `[FILTERED]`.
303
+ - **A column's name is compared the way the database compares it.** MySQL
304
+ folds a letter that Ruby's `downcase` leaves as it is, so a secret column's
305
+ name written with that letter passed the name check. Names in a statement
306
+ and in the schema are now folded the same way before they are compared.
307
+ - **A column-alias list is refused in every spelling.** `rails_query` refuses
308
+ a list such as `t(a, b, c)`, since it renames the columns a wildcard
309
+ returns. The check read one spelling of the list, so a list written another
310
+ way passed it and returned secret columns under other names. The gate now
311
+ reads the list by its shape on every family, whatever the quoting of its
312
+ names, with or without `AS`, and with `MATERIALIZED` or `NOT MATERIALIZED`
313
+ before its body. One name after a function call, such as
314
+ `generate_series(1, 5) AS g(n)`, still runs, and so does a list over
315
+ `VALUES` rows.
316
+ - **On MySQL, MariaDB and SQLite a `*` inside a subquery is refused when the
317
+ statement names a table that holds a secret column.** Such a statement names
318
+ no secret column, so it ran, and a comparison with the subquery or a new name
319
+ around it could tell or return a secret value. It is now refused with the
320
+ table and the column, and the refusal says to name the columns in the
321
+ subquery. `EXISTS (SELECT * ...)` and `count(*)` still run, and MySQL's
322
+ `(TABLE t)` counts as a `*`. PostgreSQL keeps its plan checks. SQLite reads a
323
+ string as a table's name wherever a name can stand, so there a string equal to
324
+ such a table's name counts as that name. SQLite's `x IN t` is its shorthand
325
+ for `x IN (SELECT * FROM t)`. It ran with no `*` written, and it is now held
326
+ to the same rule.
327
+ - **On MySQL and MariaDB the statement text checks hold under both readings of a
328
+ backslash in a string.** MySQL reads a backslash in a string as an escape,
329
+ unless the session's sql_mode holds `NO_BACKSLASH_ESCAPES`. The checks read it
330
+ as an escape alone. In a session with that mode a string ended earlier than
331
+ the checks took it to, and the text after it ran unchecked. Each check now
332
+ reads the statement both ways, and a statement that either reading refuses is
333
+ refused. A refusal that the second reading alone gives says so. `rails_query`
334
+ does not read the session's mode.
335
+ - **On SQLite a result column named `name:1` is filtered.** SQLite gives a
336
+ second result column of the same name the name `name:1`. The result filter
337
+ read that whole name, so a secret column that came back twice was returned
338
+ once in clear. The filter now reads the name without the suffix.
339
+ - **On SQLite a dollar-quoted span is read as code.** SQLite has no dollar
340
+ quoting, and the checks skipped the text between two `$tag$` marks as if it
341
+ were a string. They now read it.
342
+ - **`SHOW` with a `WHERE` clause is refused.** MySQL's statement timeout holds
343
+ `SELECT` only, so a `SHOW ... WHERE` filter ran past `query_timeout`. It is
344
+ now refused on every family and `SHOW ... LIKE` still runs. The refusal
345
+ names the `information_schema` query to use.
346
+ - **More `SHOW` forms are refused.** `SHOW FULL PROCESSLIST`,
347
+ `SHOW REPLICA STATUS`, `SHOW ALL SLAVES STATUS`, `SHOW ALL REPLICAS STATUS`,
348
+ `SHOW BINARY LOGS`, `SHOW BINARY LOG STATUS`, `SHOW RELAYLOG EVENTS`,
349
+ `SHOW ENGINE ... STATUS`, `SHOW PROFILE`, `SHOW PROFILES`,
350
+ `SHOW OPEN TABLES`, `SHOW EXPLAIN FOR`, `SHOW ANALYZE FOR`,
351
+ `SHOW USER_STATISTICS`, `SHOW CLIENT_STATISTICS` and the `SHOW WSREP_` forms
352
+ ran on MySQL and MariaDB. Most of them tell of other sessions' statements,
353
+ of replication hosts and users, or of the rows a log holds. They now get the
354
+ refusal `SHOW PROCESSLIST` gets.
355
+ - **A function that returns a whole table as one value is refused.**
356
+ PostgreSQL's `table_to_xml`, `schema_to_xml`, `database_to_xml` and the rest
357
+ of the `_to_xml` family returned a table, its secret columns included, as
358
+ one XML value. A call to one is now refused by name.
359
+ - **Relations that hold sampled values, other sessions' statements or stored
360
+ credentials are not read.** No check on a column's name applies to what they
361
+ hold, so a query on one ran and returned it. A statement that names one is now
362
+ refused. On PostgreSQL they include `pg_stats`, `pg_stats_ext`,
363
+ `pg_statistic`, `pg_statistic_ext`, `pg_largeobject`, `pg_stat_activity` and
364
+ the functions it reads through, `pg_stat_statements`,
365
+ `pg_prepared_statements`, `pg_cursors`, `pg_authid`, `pg_shadow`,
366
+ `pg_db_role_setting`, `pg_user_mapping`, `pg_user_mappings`,
367
+ `pg_subscription`, `pg_foreign_server`, `pg_foreign_data_wrapper`,
368
+ `pg_foreign_table`, `pg_settings`, `pg_show_all_settings()`,
369
+ `pg_show_all_file_settings()`, `pg_hba_file_rules` and
370
+ `pg_ident_file_mappings`. There the plan is checked too, so a view over one of
371
+ them is refused, the `information_schema` views of user mappings and foreign
372
+ servers among them. On MySQL and MariaDB they include the whole `mysql`
373
+ schema, `performance_schema`, `sys`, `processlist`, `general_log`, `slow_log`,
374
+ `column_statistics`, `column_stats`, `global_priv`, and in
375
+ `information_schema` `INNODB_TRX`, `INNODB_LOCKS`, `INNODB_LOCK_WAITS`, the
376
+ `INNODB_FT_` tables, `USER_STATISTICS`, `CLIENT_STATISTICS`, `PROFILING` and
377
+ `OPTIMIZER_TRACE`. On SQLite they include `sqlite_stat1`, `sqlite_stat3`,
378
+ `sqlite_stat4` and `sqlite_stmt`, which lists the connection's own
379
+ statements with their literals. docs/SECURITY.md lists every name. Each name
380
+ is refused on the database family that has it, wherever it stands in the
381
+ statement. So an app table called `slow_log` or `processlist` is refused on
382
+ MySQL and MariaDB.
383
+ - **On PostgreSQL a role's password hash and its stored settings are secret
384
+ columns.** `pg_authid.rolpassword` and `pg_db_role_setting.setconfig` were
385
+ returned, also through the views PostgreSQL builds on them. Both names are
386
+ now built-in secret names. A view that reads neither column still runs, so
387
+ `pg_roles` and `pg_user` answer, and `pg_roles.rolconfig` comes back
388
+ `[FILTERED]`.
389
+ - **PostgreSQL settings that can hold a password are not read.**
390
+ `SHOW primary_conninfo` and `current_setting('primary_conninfo')` returned a
391
+ connection string with its password, and `SHOW ALL` listed it.
392
+ `primary_conninfo`, `ssl_passphrase_command`, `archive_command`,
393
+ `restore_command`, `archive_cleanup_command` and `recovery_end_command` are
394
+ now refused, and so is a setting whose own name reads as a secret, such as
395
+ `app.jwt_secret`. On PostgreSQL `SHOW ALL` is refused and `SHOW` takes a
396
+ setting's plain name. `current_setting` runs only with the name written as
397
+ one plain string, so the gate can read which setting it returns.
398
+ - **`EXPLAIN FOR CONNECTION` is refused.** On MySQL and MariaDB it showed the
399
+ plan of the statement another session was running. It is now refused there,
400
+ also when it is written with `DESCRIBE` or `DESC`.
401
+ - **A function called by a quoted name is refused.** The checks that read a
402
+ function's name read it bare, so a function whose name was written in
403
+ quotes, backticks or brackets passed them. A quoted name followed by a
404
+ parenthesis is now refused on every family. On PostgreSQL the functions a
405
+ statement's text names are checked beside those its plan prints, since a
406
+ plan prints no row of a `VALUES` list and no `LIMIT` expression.
407
+ - **A database whose columns cannot be read is not queried.** When the schema
408
+ read failed, `rails_query` ran the statement with no column of the schema to
409
+ check its text against. It now answers "The columns of the <name> database
410
+ could not be read, so its secret columns cannot be told and no query runs on
411
+ it." The primary alone falls back to the schema in the cached context.
412
+ - **On PostgreSQL a result column is filtered when its table's columns cannot
413
+ be read and that table holds a secret column.** The trace from a result
414
+ column to its table column skipped a table whose column names it could not
415
+ read, so a secret column of that table could come back under another name.
416
+ Such a column is now filtered when its table holds a secret column. When the
417
+ table cannot be told either, it is filtered when any table does.
418
+ - **The reads a `rails_query` call makes beside the query have a time limit on
419
+ PostgreSQL, MySQL and MariaDB.** Those reads, such as the schema read behind
420
+ the secret column rule, had no time limit, so a call against a table another
421
+ session had locked waited until the lock went. Each family now bounds them the
422
+ way it bounds the query, a wait on a lock included. The query has
423
+ `query_timeout` to itself. The schema read, the PostgreSQL plan with its
424
+ function lookup, and the column trace share a second `query_timeout`, and each
425
+ is charged the time it ran. A read that runs out of time ends the call with an
426
+ error that names those reads, so the statements of a call take about twice
427
+ `query_timeout` at most.
428
+ On MySQL and MariaDB a wait on a lock ends at `query_timeout` rounded up to a
429
+ whole second, and `explain: true` is bounded while it waits on a lock too.
430
+ `SHOW`, `DESCRIBE` and a plain `EXPLAIN` have that lock wait limit alone
431
+ there, since the statement limit does not cover them. On SQLite those reads
432
+ run on the app's own connection with no statement limit, and a wait on another
433
+ writer ends at that connection's busy timeout.
434
+ - **`rails_runtime_info` reads table sizes, index usage and pending migrations
435
+ under `query_timeout` on PostgreSQL, MySQL and MariaDB.** They had no time
436
+ limit, so a locked table held the answer until the lock went. Each read now
437
+ ends at `query_timeout`, a wait on a lock included.
438
+ - **A `query_timeout` under a millisecond still bounds the statement.** On
439
+ PostgreSQL and MySQL it rounded to zero, which turns the statement limit
440
+ off. PostgreSQL, MySQL and MariaDB now bound the statement at one
441
+ millisecond.
442
+ - **SQLite no longer chooses the app's own connection from an error message.**
443
+ `rails_query` runs a SQLite statement in a child process that it ends at
444
+ `query_timeout`. It ran the statement again on the app's own connection, with
445
+ no time limit, when the child's error message read like a missing function,
446
+ module or collation, and text from the statement could reach that message. The
447
+ choice is now made from which step failed in the child. A statement runs on
448
+ the app's own connection in four cases: an in-memory database, a platform
449
+ without `fork`, a statement the child could not prepare, and a transaction
450
+ open on the app's connection. A statement that fails while it runs in the
451
+ child is answered with its SQL error and is not run again. On
452
+ the app's own connection a statement that returns rows slowly now ends a few
453
+ rows after `query_timeout` passes, since an interrupt lands only between two
454
+ rows. One long step still cannot be ended there, so the answer keeps its
455
+ "without a time limit" note.
456
+ - **On MySQL and MariaDB no statement runs on a connection Rails replaced.**
457
+ Rails reconnects a connection it lost before the transaction begins, and the
458
+ new session has no read-only transaction and no time limit. The statement is
459
+ now sent only on the session that holds both. On any other the answer is an
460
+ error that starts "The <name> database stopped answering during the query:"
461
+ and says the statement was not sent.
462
+ - **`rails_query` holds `LIMIT ALL`, `LIMIT NULL` and a negative limit to the
463
+ row cap.** The three mean no limit, and the query fetched every row before
464
+ the cap cut the answer. They now read as no limit of the query's own and
465
+ fetch one row past the cap, with the note that rows were held back.
466
+ - **A SQLite database whose file is not there is refused by name, and no file
467
+ is created.** In 5.35.0 the connection created an empty database file.
468
+ `rails_query` now answers with an error that names the database and
469
+ `bin/rails db:prepare`, with or without `database`. `rails_runtime_info`
470
+ does the same for a named database. With none, its Database section says the
471
+ primary does not exist yet and the other sections still answer. A database
472
+ written as a `file:` URI is checked at the path inside the URI, or at the
473
+ whole name on a SQLite build that reads no URIs.
474
+ - **`rails_runtime_info` creates no Solid Cache database.** Under an
475
+ environment whose Solid Cache SQLite file was not there, the cache section's
476
+ read created an empty one. The section now says the database does not exist
477
+ yet.
478
+ - **`skip_tools` holds inside composite answers.**
479
+ `rails_get_context(include:)` ran `rails_security_scan` and `rails_get_env`
480
+ when `skip_tools` listed them. An include whose tool is turned off is now
481
+ answered by saying so, and that tool does not run. `rails_get_context`,
482
+ `rails_diagnose` and `rails_review_changes` build every other section the
483
+ same way: from a built-in tool only when the app serves it. A section left
484
+ out is named in one line at the end of the answer.
485
+
486
+ ### Removed
487
+
488
+ Ten tools that each answered one section of the app's configuration leave
489
+ `tools/list`, and `rails_get_config` answers all of them through a new
490
+ `section` parameter (ADR-0010). The tool count is now **35** (was 45).
491
+
492
+ - **Ten tools are now sections of `rails_get_config`.** They are
493
+ `rails_get_gems` (`section:"gems"`), `rails_get_frontend_stack`
494
+ (`"frontend"`), `rails_get_api` (`"api"`), `rails_get_engines`
495
+ (`"engines"`), `rails_get_conventions` (`"conventions"`),
496
+ `rails_get_autoload` (`"autoload"`), `rails_get_active_support`
497
+ (`"active_support"`), `rails_get_env` (`"env"`), `rails_get_env_config`
498
+ (`"env_config"`) and `rails_get_i18n` (`"i18n"`). `rails_get_config()` with
499
+ no `section` answers `app`, what it answered before.
500
+ - **The sections' parameters ride on `rails_get_config`.** They are `detail`
501
+ (read by `frontend`, `api` and `env`), `category` (`gems`), `environment`
502
+ (`env_config`), `locale` (`i18n`), and `offset` and `limit` (`gems`,
503
+ `env_config`, `i18n`). A parameter the asked section does not read is named
504
+ in the answer as ignored.
505
+ - **`rails_onboard` drops `quick`.** It takes `summary`, `standard` and
506
+ `full`, the levels every other tool with `detail` takes, and `summary` is
507
+ the one-paragraph overview `quick` gave.
508
+ - **`tools/list` is smaller.** Measured as the JSON of every tool's
509
+ definition, it was 47,802 characters (about 11,950 tokens at four characters
510
+ per token) and is now 41,275 characters (about 10,320 tokens).
511
+
512
+ ### Migration notes
513
+
514
+ - **Old names keep working where a name is typed.** The CLI resolves `gems`,
515
+ `rails_get_gems`, `frontend_stack` and every other old short and full name to
516
+ its section, and `--json` reports `rails_get_config`. A call to a retired
517
+ name, from the CLI or over MCP, is answered from its section and ends with a
518
+ line naming the call to make instead. That line is the command under
519
+ `tool_mode: :cli` and the MCP call under `:mcp`, and it carries only the
520
+ arguments the section reads. A value goes into the call when it is a number, a
521
+ boolean, or at most 80 characters of letters, digits, `_`, `.` and `-`. Any
522
+ other value is named after the call, as in "_rails_get_i18n is now
523
+ `rails_get_config(section:"i18n")`, with the same `locale`; call that
524
+ instead._" `rails_session_context(mark:)` takes a retired name too, and
525
+ records it as `rails_get_config` with its section. Update saved prompts to the
526
+ new call: `rails_get_gems(category:"auth")` becomes
527
+ `rails_get_config(section:"gems", category:"auth")`.
528
+ - **`skip_tools` keeps its meaning.** An old tool name turns its section off,
529
+ so an app that skipped `rails_get_env` still keeps the `env` section off.
530
+ `rails_get_config` turns off the `app` section only. A section that is off
531
+ leaves the published `section` list, the tool description, the parameters
532
+ only it reads and the guide row. A call that names it anyway is told it is
533
+ turned off. The tool leaves `tools/list` only when all eleven names are
534
+ listed.
535
+ - **With the `app` section off, `section` is required.** `app` is the default,
536
+ so once `skip_tools` lists `rails_get_config` the published schema marks
537
+ `section` required and the description says so. A call with no `section` is
538
+ answered with the sections that are on, and the guide row shows a call to
539
+ the first of them.
540
+ - **A custom `rails_get_config` still replaces the built-in.** An app that
541
+ lists `rails_get_config` in `skip_tools` and registers a custom tool with
542
+ that name gets the custom tool in the built-in's place. The ten sections go
543
+ with the built-in, so a retired name such as `rails_get_gems` is then an
544
+ unknown tool, and no answer points at a section.
545
+ - **A custom tool that reuses a retired name is served as its own tool.** A
546
+ custom `rails_get_gems` answers a call to that name. The gem's own hints
547
+ still mean the built-in section, and they print it as
548
+ `rails_get_config(section:"gems")`.
549
+ - **Ruby callers are unchanged.** `RailsAiContext::Tools::GetGems.call` and
550
+ the other nine classes still answer. They are out of the MCP registry, so
551
+ `Tools::BaseTool.registered_tools` no longer lists them. The test helper's
552
+ `execute_tool("rails_get_gems")` still finds the class.
553
+ - **`rails_get_context(include:)` takes the same keys.** `config`, `gems`,
554
+ `env` and `conventions` now run as `rails_get_config` sections.
555
+ - **The session context names `rails_get_config`.** A section call is recorded
556
+ as `rails_get_config` with its section, also when a retired name made the
557
+ call. A section call answered with an error is not recorded.
558
+ - **The CLI's `--environment` is a section's filter only after `env_config`.**
559
+ After `config --section env_config`, in any capitalisation, it is the
560
+ section's filter and leaves `RAILS_ENV` alone, as it does after
561
+ `env_config`. After any other section it sets `RAILS_ENV`.
562
+ - **`rails_onboard(detail:"quick")` becomes `detail:"summary"`.** Over MCP a
563
+ `quick` call is refused with the three valid levels. The CLI's
564
+ `--detail quick` warns with them and answers at `standard`.
565
+ - **Apps on SQL Server, Oracle or CockroachDB lose `rails_query`.** It answers
566
+ with the adapter's name and runs nothing (ADR-0008). It ran there with no
567
+ read-only transaction and no timeout. On SQL Server and Oracle the `LIMIT`
568
+ its row cap appended is a syntax error, so a query without its own row limit
569
+ failed. Add `rails_query` to `skip_tools` to take it out of `tools/list`.
570
+ `rails_runtime_info` still answers, and says table sizes and index usage are
571
+ not available for the adapter.
572
+ - **`query_allowed_columns` no longer releases an `encrypts` column.** A query
573
+ that names one is refused, and the refusal says the allowed list does not
574
+ release it. Its values come back `[FILTERED]` under `SELECT *`. Take the
575
+ column out of the query.
576
+ - **`rails_query` refuses more column names.** A column named `salt`, `passwd`,
577
+ `credentials`, `pepper`, `authentication_string`, `rolpassword` or
578
+ `setconfig`, one whose name holds `nonce`, and one named `iv` or ending in
579
+ `_iv` is refused by name and comes back `[FILTERED]` under `SELECT *`. Each of
580
+ `salt`, `passwd`, `credentials` and `pepper` counts as a whole name and as one
581
+ part of a name split at its underscores. So `salt_rounds`, `aws_credentials`
582
+ and `pepper_spray` are refused, and `salty`, `basalt` and `peppermint` are
583
+ not. To read one that holds no secret, list its name in
584
+ `query_allowed_columns`.
585
+ - **On MySQL, MariaDB and SQLite a query over a view that reads a secret
586
+ column is refused.** It ran on 5.35.0 and returned what the view selected.
587
+ The refusal names the view and the column. Query the tables the view reads,
588
+ or list the column in `query_allowed_columns` when it holds no secret. An
589
+ `encrypts` column is not released that way. On PostgreSQL the same holds for
590
+ a materialized view.
591
+ - **A secret column in another database or schema is refused by name.** A
592
+ statement that names a schema or database beside the connection's own, such as
593
+ `SELECT api_token AS t FROM other_db.users`, ran on 5.35.0. The rule now reads
594
+ the columns there, so the statement is refused. Name the other columns, or
595
+ list the name in `query_allowed_columns` when it holds no secret. A statement
596
+ that names a view that leads through more than three reads of further
597
+ databases or schemas is not run, and the answer names the view.
598
+ - **On SQLite a virtual table with a secret column, a shadow table and the
599
+ page modules are refused.** A query on a full-text table that holds a secret
600
+ column ran on 5.35.0. So did one on a shadow table of a virtual table, on an
601
+ `fts5vocab` table over it and on `sqlite_dbpage`. Keep the secret column out
602
+ of the virtual table, or query the table it was copied from. A shadow
603
+ table's refusal names the virtual table to query.
604
+ - **On SQLite only the pragma functions that describe the schema or the file
605
+ run.** They are `pragma_table_info`, `pragma_table_xinfo`,
606
+ `pragma_table_list`, `pragma_index_list`, `pragma_index_info`,
607
+ `pragma_index_xinfo`, `pragma_foreign_key_list`, `pragma_foreign_key_check`,
608
+ `pragma_integrity_check`, `pragma_quick_check`, `pragma_user_version`,
609
+ `pragma_application_id`, `pragma_schema_version`, `pragma_page_size`,
610
+ `pragma_page_count`, `pragma_freelist_count`, `pragma_encoding`,
611
+ `pragma_auto_vacuum`, `pragma_journal_mode`, `pragma_compile_options` and
612
+ `pragma_pragma_list`. Every other `pragma_*` function ran on 5.35.0 and is
613
+ refused now, and so are `changes()`, `total_changes()` and
614
+ `last_insert_rowid()`. Read those values on the app's own connection, in
615
+ `bin/rails runner` or the console.
616
+ - **A statement over 1,000,000 bytes is refused.** 5.35.0 took a statement of
617
+ any length. Every text check reads the whole statement, so its length is
618
+ checked before any of them. Send a shorter statement: 100,000 ids in an `IN`
619
+ list are about 700,000 bytes. The text checks also give up when they have run
620
+ longer than `query_timeout`, with a refusal that says so.
621
+ - **A generated column that reads a secret column is refused by name.** A
622
+ query that names it ran on 5.35.0. Take it out of the query, or list its
623
+ name in `query_allowed_columns` when it holds no secret.
624
+ - **On MySQL, MariaDB and SQLite `SELECT *` inside a subquery is refused over a
625
+ table with a secret column.**
626
+ `WITH x AS (SELECT * FROM users) SELECT * FROM x` ran on 5.35.0, and is
627
+ refused now when `users` holds a secret column. Name the columns in the
628
+ subquery. On SQLite `WHERE id IN users` ran too, and is
629
+ refused the same way: write `IN (SELECT id FROM users)`. There a string equal
630
+ to the name of such a table, as in `WHERE role = 'users'`, refuses a statement
631
+ with a `*` inside a subquery or with that `IN` form.
632
+ - **On MySQL and MariaDB a statement has to pass under both readings of a
633
+ backslash.** A statement with a backslash before a quote inside a string, such
634
+ as `'it\'s'`, ran on 5.35.0 when the checks passed it with the backslash read
635
+ as an escape. It is refused now when the checks refuse it with the backslash
636
+ read as an ordinary character, as MySQL reads it under `NO_BACKSLASH_ESCAPES`.
637
+ Write a quote inside a string doubled, as in `'it''s'`, so that both readings
638
+ agree.
639
+ - **`SHOW ... WHERE` is refused.** Use `SHOW ... LIKE`, or select from
640
+ `information_schema`: `SELECT table_name FROM information_schema.tables
641
+ WHERE table_schema = DATABASE()`.
642
+ - **`rails_query` no longer reads statistics, session and credential
643
+ relations.** A query on one ran on 5.35.0 and is refused now. Each name is
644
+ refused on the database family that has it. On PostgreSQL they are
645
+ `pg_stat_activity`, `pg_stats`, `pg_settings` and the other catalog names
646
+ docs/SECURITY.md lists. On MySQL and MariaDB they are `performance_schema`,
647
+ `processlist`, `general_log`, `slow_log`, `column_statistics`,
648
+ `column_stats`, `global_priv`, the listed `information_schema` tables and
649
+ anything under `mysql.` or `sys.`. On SQLite they are `sqlite_stat1`,
650
+ `sqlite_stat3`, `sqlite_stat4` and `sqlite_stmt`. A name is matched as a
651
+ whole word wherever it stands, inside a string literal too, so an app table
652
+ named `slow_log` is refused on MySQL and MariaDB. The `mysql` and `sys`
653
+ schemas are matched as a name before a dot, with string literals masked, so
654
+ `'mysql.com'` runs. An alias named `sys` or `mysql` before a dot is refused
655
+ there, and so is a double-quoted string that holds `mysql.`. Give such an
656
+ alias another name, and write such a string in single quotes. Read the
657
+ refused relations with the database's own client.
658
+ - **On PostgreSQL `SHOW ALL` and six settings are refused.** `SHOW ALL`,
659
+ `SHOW "work_mem"` with quotes, and `SHOW` or `current_setting` of
660
+ `primary_conninfo`, `ssl_passphrase_command`, `archive_command`,
661
+ `restore_command`, `archive_cleanup_command` or `recovery_end_command` ran
662
+ on 5.35.0. Show one setting by its plain name: `SHOW work_mem`, or
663
+ `current_setting('work_mem')`. On PostgreSQL a statement that holds one of
664
+ the six names anywhere is refused.
665
+ - **A function whose name is written in quotes cannot be called.**
666
+ `SELECT "upper"('x')` ran on 5.35.0 and is refused now. Write the name
667
+ without quotes. A function whose name needs quotes, such as a mixed-case one
668
+ on PostgreSQL, cannot be called through `rails_query`.
669
+ - **The `rails_query` refusals say "secret column".** They said "sensitive
670
+ column", and now use the glossary's word. A script that matches on the old
671
+ words in a refusal has to match on the new ones. The config keys keep their
672
+ names.
673
+ - **`query_timeout` takes a positive number only.** Zero, a negative value or
674
+ a value that is no number raises `ArgumentError` in an initializer, as
675
+ `query_row_limit` does. In `.rails-ai-context.yml` it is reported and the
676
+ default of 5 seconds is kept. In 5.35.0 zero turned the timeout off on
677
+ PostgreSQL and MySQL. Set a larger number for a slow query. A fraction of a
678
+ second is allowed.
679
+ - **A CLI call with a value its parameter cannot take exits 1.** `--format xml`
680
+ on `schema`, `--limit abc` and `--no-limit` exited 0 in 5.35.0, the first two
681
+ with a warning on stderr and the parameter's default. A script that leaned on
682
+ the fallback has to pass a value the parameter takes. The Fixed entry "The CLI
683
+ refuses a value a parameter does not take" gives the answers. `--detail` alone
684
+ still warns and falls back.
685
+ - **Some CLI calls that exited 0 now exit 1.** Beside the refused values above,
686
+ they are a `rails_query` timeout, a `rails_query` call the production guard
687
+ refuses, a database that cannot be reached, a failing `explain: true`, a
688
+ failed Brakeman scan in the bundle or outside it, a crash inside a section
689
+ of `rails_get_context`, `rails_diagnose` or
690
+ `rails_review_changes`, and an unexpected failure in `rails_onboard`,
691
+ `rails_diagnose`, `rails_generate_test`, `rails_review_changes`,
692
+ `rails_runtime_info`, `rails_session_context`, `rails_get_context` or
693
+ `rails_search_code`. Over MCP the same answers carry `isError`.
694
+ - **Regenerate the context files.** Run `rails ai:context` or
695
+ `rails-ai-context context` so the tool guide and tables name the new call.
696
+
697
+ ### Added
698
+
699
+ - **`rails_query` and `rails_runtime_info` take `database`.** It is an app
700
+ database's name from `config/database.yml`, checked when the tool runs. With
701
+ none they read the primary, the database `ActiveRecord::Base` is connected
702
+ to. A misspelled name is answered with the closest one. A database only
703
+ other environments configure, a replica and one with `database_tasks` off
704
+ included, is answered with the environments that configure it. One only a
705
+ dump names is refused with the reason. On the static tier both tools are
706
+ unavailable as a whole, with or without `database`.
707
+ - **Another database is reached through a connection handler of its own.** The
708
+ handler lasts for the call, so the app's pools are untouched and two calls
709
+ at once never share a pool. The database's secret columns are read from its
710
+ own schema. It has `query_timeout`, rounded up to whole seconds, to connect
711
+ in unless its entry sets `connect_timeout`. On MySQL and MariaDB a read on
712
+ it ends after twice `query_timeout` plus 5 seconds unless its entry sets
713
+ `read_timeout`, so a server that goes silent ends the call. PostgreSQL's
714
+ driver has no read timeout, so a PostgreSQL server that goes silent after it
715
+ connects is not ended by the client.
716
+ - **A query that misses a table another database holds names that database.**
717
+ The SQL error ends with the database that holds the table and the `database`
718
+ value to use. A failing `explain: true` carries the same pointer.
719
+ - **`rails_runtime_info` checks `database` whatever the `section`.** A name it
720
+ cannot reach is an error for every section, and a section that does not read
721
+ `database` says so.
722
+ - **`rails_runtime_info` names a database no connection reaches.** It says what
723
+ stopped the connection and what to do, as the `rails_query` answer under Fixed
724
+ does. For a named database that is an error for the call. With none, the
725
+ Database section says so about the primary and the other sections still
726
+ answer.
727
+ - **A database whose adapter gem is not in the bundle is named with the gem to
728
+ add.** The answer reads "The <name> database cannot be reached: its
729
+ <adapter> adapter did not load. Add the <gem> gem to the Gemfile and run
730
+ `bundle install`." `rails ai:doctor` gives the same reason and fix, and
731
+ `rails_runtime_info` with no `database` still answers its other sections.
732
+ - **Under a named database `rails_runtime_info` shows that database's own
733
+ pending migrations.** For a replica it says Rails runs no migrations on it.
734
+ For a database whose entry turns `database_tasks` off it says "_Rails runs
735
+ no migrations on <name>: its database.yml entry turns `database_tasks`
736
+ off._"
737
+ - **A SQLite database other than the primary that lives in memory is refused
738
+ by name.** That is `:memory:`, or a `file:` URI with `mode=memory`. It
739
+ exists only inside the process that created it, and a new connection would
740
+ open an empty one. `rails ai:doctor` and `rails_runtime_info` leave such a
741
+ database out of their pending migrations check.
742
+ - **`.ai-context.json` carries `primary_name` where the primary has another
743
+ name.** The key sits in the `multi_database` section and holds the primary's
744
+ `config/database.yml` name. An app whose primary is named `primary` has no
745
+ such key.
746
+
747
+ ### Changed
748
+
749
+ - **Statistics say when an adapter has none.** On an adapter with no database
750
+ family, `rails_runtime_info` says table sizes and index usage are not
751
+ available for that adapter, and the database stats say the same of row
752
+ estimates.
753
+ - **`rails_query` reads the schema in a few statements, whatever the number of
754
+ tables.** The secret column rule read each table's columns with a statement of
755
+ its own on every call, about 1.5 seconds on a 300-table PostgreSQL schema and
756
+ 2 seconds on MySQL. One catalogue read per family now lists every table's
757
+ columns, and one each reads the view definitions and the generated columns.
758
+ With the lookups beside them that is four statements at most on MySQL and
759
+ MariaDB, five on PostgreSQL and six on SQLite. A statement that names another
760
+ schema or database, or a view that names one, adds the reads of that one. Any
761
+ other statement adds none.
762
+ - **`rails_get_service_pattern` says which files its count leaves out.** A
763
+ listing that counts fewer services than the service directories hold Ruby
764
+ files adds one line with the number left out and the kinds: base classes,
765
+ concerns, modules with no entry point that other code mixes in, mailers and
766
+ files too large to read. It names up to five of each kind, and the base
767
+ classes are named above the listing as before.
768
+
769
+ ### Fixed
770
+
771
+ - **Every file requires the standard library it names.** Twelve files used
772
+ `JSON`, `YAML`, `URI`, `Pathname`, `FileUtils`, `RbConfig`, `DidYouMean`,
773
+ `Prism`, `Time.parse` or `iso8601` and worked only when another library had
774
+ loaded it first. On a Ruby that does not load the library by itself that
775
+ showed as "uninitialized constant" or "undefined method".
776
+ - **`rails_onboard` answers on the static tier for an app with Solid
777
+ databases.** On a default Rails 8 app `tool onboard --no-boot` answered
778
+ "Onboard error: undefined method 'to_sentence'". The gem called an
779
+ ActiveSupport extension that only a booted app had loaded. It now loads the
780
+ extension itself.
781
+ - **A tool that fails unexpectedly is an error result.** `rails_onboard`,
782
+ `rails_diagnose`, `rails_generate_test`, `rails_review_changes`,
783
+ `rails_runtime_info`, `rails_session_context`, `rails_get_context` and
784
+ `rails_search_code` answered an exception they did not plan for as ordinary
785
+ text, so the CLI exited 0. They now answer as every other tool does:
786
+ `isError: true` over MCP, exit 1 from the CLI, and a text that starts "Tool
787
+ <name> failed:".
788
+ - **A crash inside a section of a composite answer makes the answer an error.**
789
+ `rails_get_context`, `rails_diagnose` and `rails_review_changes` build their
790
+ sections from other tools. When one of those raised an exception, the answer
791
+ was ordinary text and the CLI exited 0. It is now an error response on both
792
+ surfaces. The text keeps every section, and the crashed one reads "Tool <name>
793
+ failed: <Class>: <message>". A section that answers an error for another
794
+ reason, such as a refusal or a failed Brakeman run, stays a section of an
795
+ ordinary answer.
796
+ - **A stack overflow or a `SecurityError` in a tool is an error response.**
797
+ Either one ended the stdio server and printed a backtrace on the CLI. The
798
+ answer is now an error such as "Tool <name> failed: SystemStackError: stack
799
+ level too deep", and the server keeps running.
800
+ - **A failed Brakeman scan is an error result.** `rails_security_scan` answered
801
+ "Brakeman scan failed: ..." as an ordinary answer, an unknown check name
802
+ included. The text is the same, and it now carries `isError` over MCP and
803
+ exits 1 from the CLI. A brakeman run from outside the bundle that started and
804
+ failed is an error too, with the text it had. One that could not start stays
805
+ an ordinary answer.
806
+ - **`serve --transport http` starts on rack 2 with rackup 1.x.** It stopped
807
+ with "uninitialized constant Rackup::Handler" on an app that pins rack below
808
+ 3 and bundles the rackup gem.
809
+ - **The `rails_get_config` app section names the database family.** Its
810
+ Database line showed `mysql2` where every other surface says "MySQL". It now
811
+ reads "MySQL", "PostgreSQL" or "SQLite", and an adapter with no family as
812
+ database.yml writes it. The JSON keeps adapter names as written.
813
+ - **`rails_onboard` words one Solid database in the singular.** For one
814
+ framework with one database it said "keeps their tables in databases of
815
+ their own". It now says "Solid Cache keeps its tables in a database of its
816
+ own: cache."
817
+ - **`rails_runtime_info` names a Solid Cache store "SolidCache::Store".** The
818
+ cache section's Store line read "Store".
819
+ - **`rails_runtime_info` says when a database family has no index usage.** At
820
+ full detail on MySQL, MariaDB and SQLite it printed nothing where PostgreSQL
821
+ lists index usage. It now says "_Index usage is not available for the MySQL
822
+ database family._", or SQLite.
823
+ - **`rails_runtime_info` reads pending migrations in every database configured
824
+ here.** It said "all up to date" while another database had pending
825
+ migrations. The line now names the databases it read, as in "**Migrations:**
826
+ all up to date in primary, queue". A database it could not read gets a line
827
+ of its own with the reason, and the databases it did read are still
828
+ reported.
829
+ - **A booted MySQL app reads "MySQL".** A connection on mysql2 or Trilogy
830
+ showed its driver name in the generated context and in `rails_runtime_info`.
831
+ It now reads "MySQL" there, as the static tier already said. PostGIS reads
832
+ "PostgreSQL".
833
+ - **Migration advice for an undecided database names no database.** When the
834
+ app's database is one of two candidates, `rails_migration_advisor` no longer
835
+ gives PostgreSQL and MySQL advice at once.
836
+ - **Migration advice follows the database that holds the table.** Index and
837
+ lock advice took the primary's database family for a table another database
838
+ holds. It now takes the family of the database that holds the table.
839
+ - **Every surface names the same databases.** On a default Rails 8 app, where
840
+ the running environment configures only the primary and production configures
841
+ queue, cache and cable, the schema section, the multi-database section, the
842
+ stack overview and the model listing now all name the four databases. The
843
+ migrations section names each one whose migrations directories hold files. A
844
+ database only other environments configure is marked with every one of them,
845
+ "(production only)" or "(staging and production)", and one only a committed
846
+ dump names is marked "(committed dump only)". The mark is on every line that
847
+ names such a database: the `rails_get_schema` Secondary databases line, each
848
+ database `rails_onboard` names, the performance check's "Not checked" line,
849
+ the Database, Migrations and Databases lines of the compact generated files,
850
+ and the headings of the full generated file and of the schema rules file, as
851
+ in "## cache (production only, SQLite, 1 table)".
852
+ - **An app whose first database is not named `primary` is read by its own
853
+ names.** Where another database is the one named `primary`, the generated
854
+ files called both of them primary. They now name the first database by its own
855
+ name, as in "## Database Schema: animals (2 tables)". Migration advice for a
856
+ table the database named `primary` holds gave no `--database`, and now carries
857
+ `--database primary`.
858
+ - **A `replica` entry is read as Rails reads it.** `replica: "yes"`, or any
859
+ other value Ruby counts as true, marks a replica. The static tier took `true`
860
+ alone.
861
+ - **The static tier reads an environment whose whole value is a URL.** A
862
+ `config/database.yml` environment written as one URL string was left out. It
863
+ is now read as that environment's one database.
864
+ - **A secondary dump too large to read is named.** It keeps an entry in the
865
+ schema section that gives the reason, and a database whose migrations are
866
+ replayed instead no longer has its existing dump called missing.
867
+ - **`rails_onboard` says when a database's dump was not read.** For a secondary
868
+ whose dump is too large to read it said "(0 tables)" and counted the database
869
+ in its summary. It now says "plus the warehouse database (tables not read)",
870
+ and the summary reads "5 tables in 2 databases, the warehouse database not
871
+ read". The unread database is left out of both counts.
872
+ - **The performance check covers every database.** Missing indexes and counter
873
+ caches are checked in each database's dump, a finding outside the primary
874
+ names its database, and a database it could not read is listed under "Not
875
+ checked" with the reason.
876
+ - **Migration advice counts rows only on the primary.** The row estimate comes
877
+ from the primary database, so a table on another database is no longer given
878
+ the primary's count.
879
+ - **A `.rails-ai-context.yml` that holds no settings is reported and
880
+ ignored.** A file that holds a bare string, a list or a number crashed the
881
+ loader with a `NoMethodError`. It now warns on stderr that the file holds no
882
+ `key: value` settings, and reads nothing from it.
883
+ - **Of two tools under a built-in's name the server keeps the built-in.** With
884
+ no `skip_tools` entry the warning said it kept the built-in, while MCP could
885
+ serve an app's `BaseTool` subclass under that name. The server now keeps the
886
+ gem's own tool, as the warning says and as the CLI runs.
887
+ - **`tool --list` shows the tools the MCP server serves.** A custom tool that
888
+ shares a built-in's name is listed once, and the CLI's single-tool lookup
889
+ picks the tool the server serves while still loading only that tool's class.
890
+ - **No answer points at a tool the app turned off.** Every pointer to another
891
+ tool, in `_Next:` hints, fix steps, recovery lines and lists, goes through
892
+ one helper, and a tool `skip_tools` names is left out with its hint. A
893
+ custom tool in a built-in's place is left out the same way, since a hint
894
+ carries the built-in's arguments.
895
+ - **The CLI refuses a value a parameter does not take.**
896
+ `rails-ai-context tool schema --format xml` warned on stderr and answered in
897
+ the default format with exit 0, and so did `--limit abc` on a parameter that
898
+ takes an integer. Each now exits 1 and names what the parameter takes: the
899
+ listed values, "an integer", "a string", "an array", or "true or false" for a
900
+ boolean. An empty `--limit=` is refused too. So is `--no-<param>` on a
901
+ parameter that is no boolean, which passed `false` to the tool: `--no-limit`
902
+ answers "**Error:** `--no-limit` is not a valid `limit`. Valid: an integer".
903
+ A `--detail` level a tool does not have still falls back to the default with
904
+ a warning, since every level answers the same question.
905
+ - **`tool <name> --help` describes the tool the app serves.** Help read no
906
+ configuration, so `tool config --help` said "Default: app." in an app that
907
+ turned the `app` section off. Inside an app, or with `--app-path`, help now
908
+ reads `.rails-ai-context.yml` without booting. A tool or section `skip_tools`
909
+ turned off is answered as turned off at exit 1, and `tool config --help` says
910
+ "Required: the app section is turned off in this app." when `app` is off.
911
+ Outside an app the output is unchanged. A `skip_tools` set in an initializer
912
+ is not seen without a boot.
913
+ - **A call to a skipped tool shows in instrumentation.** Over MCP it left the
914
+ `rails_ai_context.tools.call` payload with no tool name and no error. The
915
+ payload now carries `tool_name` and `error: :tool_skipped`.
916
+ - **A model class in a `concerns` directory is listed.** A file in a `concerns`
917
+ directory of a model root holds a model when the class its path names has a
918
+ model superclass. Such a class was missing from the static tier's models, and
919
+ from the booted listing until something loaded it. It is now listed under the
920
+ constant its file declares. A concern module that holds an inner class stays a
921
+ concern, the mixins beside it stay out, and `rails_validate` gives the model's
922
+ file the model checks. On the static tier a class whose superclass is a gem's
923
+ own record base is not listed.
924
+ - **`rails_get_context(include:)` lists all twelve keys it takes.** It also
925
+ names a key it does not take, and a key given twice, in any capitalisation,
926
+ runs once.
927
+ - **`rails_get_context` says what is not there with a tool turned off.** With
928
+ `rails_get_controllers` in `skip_tools`, an `action` the controller does not
929
+ have is answered "Action 'nope' not found in PostsController. Available:
930
+ create, destroy, ...". With `rails_analyze_feature` in `skip_tools`, a
931
+ `feature` keyword that only model names hold is answered "The model names that
932
+ hold 'comment': Comment."
933
+ - **The performance check finds a `has_paper_trail` model's unindexed versions
934
+ table on the static tier.** paper_trail adds `has_many :versions, as: :item`
935
+ when the app boots, so only the booted tier reported
936
+ `add_index :versions, [:item_type, :item_id]`. The static tier now reads the
937
+ macro, and the versions class its options name (`versions: { class_name: ...
938
+ }`), so both tiers give the same finding.
939
+ - **A database `rails_query` cannot reach is an error that names it.** A server
940
+ that was down, or a database its server does not hold, came back as an
941
+ ordinary answer headed "Database unavailable" or "Database not found", with no
942
+ database named and advice to try `RAILS_ENV=test`. With or without `database`,
943
+ the answer is now an error that names the database, says what stopped the
944
+ connection and says what to do: start its server or fix its
945
+ `config/database.yml` entry, or run `bin/rails db:create`.
946
+ - **A connection lost while a statement runs is answered as one.** On Rails
947
+ 7.1 and later the answer is an error that starts "The <name> database
948
+ stopped answering during the query:".
949
+ - **`rails_query` refused in production is an error.** With
950
+ `allow_query_in_production` off, the refusal came back as an ordinary answer
951
+ and the CLI exited 0. The text is the same, and it is now an error response
952
+ like the tool's other refusals.
953
+ - **A locked SQLite database is answered as locked.** With another connection
954
+ holding the database, `rails_query` answered "Query exceeded 5 second timeout"
955
+ although nothing ran. It now answers "The database is locked by another
956
+ connection, so the query did not run. Run it again when the other connection
957
+ has finished." The query process waits on a lock for 80% of `query_timeout`
958
+ first. The process tells its parent the moment it meets a lock, so the answer
959
+ is the locked one however slow the machine is. A lock met while the connection
960
+ opens, as on the CLI or on a server's first call, gets the same answer.
961
+ - **On SQLite a statement sent inside an open transaction reads that
962
+ transaction's rows.** It ran in the query process, which opens the file
963
+ itself, so it answered from committed rows alone. With a transaction open on
964
+ the app's connection the statement now runs on that connection, and the
965
+ answer carries the "ran without a time limit" note.
966
+ - **On SQLite a pragma function no longer answers for the wrong connection.**
967
+ `pragma_foreign_keys`, `changes()` and their like answered for the
968
+ connection `rails_query` runs on, which is its own and has values the tool
969
+ set. They are now refused, and the Migration note lists the pragma functions
970
+ that run.
971
+ - **On PostgreSQL a wildcard over a partitioned table is filtered in place.**
972
+ `SELECT *` over a partitioned table, or over an inheritance parent, was
973
+ refused with a reason about a CTE or subquery when the table holds a secret
974
+ column. It now runs with that column `[FILTERED]`, also with an expression
975
+ beside the wildcard, as in `SELECT *, id + 1 AS next FROM events`. A whole-row
976
+ read of such a table stays refused when any of its partitions holds a secret
977
+ column.
978
+ - **A `rails_query` timeout is an error.** A query that ran past
979
+ `query_timeout` came back as an ordinary answer. With the same text it is
980
+ now an error response, for a query and for `explain: true`: the CLI exits
981
+ non-zero and an MCP client sees `isError`.
982
+ - **A guarded read leaves an open transaction as it found it.** On a
983
+ connection already inside a transaction, as in a transactional test,
984
+ PostgreSQL's `SET TRANSACTION READ ONLY` left the caller's transaction
985
+ read-only. PostgreSQL now guards inside a savepoint, and the outer
986
+ transaction stays writable. MySQL and MariaDB cannot make an open
987
+ transaction read-only, so there the statement is not sent: "Query failed:
988
+ MySQL cannot make a transaction that is already open read-only, so the
989
+ statement was not sent. Run it outside the transaction."
990
+ - **`rails_query` runs `JOIN ... USING (x, y)` and a row comparison.** The
991
+ column-alias check read `JOIN b USING (x, y)` and `WHERE (a, b) IN (...)` as
992
+ an alias list and refused them.
993
+ - **On PostgreSQL a subquery or join that reads no secret column runs.**
994
+ `SELECT (SELECT count(*) FROM users) AS n` was refused when `users` holds a
995
+ secret column, although `count(*)` reads none. It runs now, and so do
996
+ `count(id)`, `max(id)` and `EXISTS (SELECT 1 ...)` in its place. An
997
+ aggregate over a secret column, a whole row and `count(users)` stay refused.
998
+ - **On PostgreSQL a quoted mixed-case secret column is filtered in place.**
999
+ `SELECT *` over a table with a secret column such as `"ApiToken"` was refused
1000
+ whole, where a lowercase one is filtered.
1001
+ - **CSV output with no rows is the header row.** `format: "csv"` answered a
1002
+ query that returned no rows with the sentence the table format prints.
1003
+ - **EXPLAIN on MySQL leaves a row with no table out of the scan summary.**
1004
+ `explain: true` on a statement that reads no table, such as `SELECT 1`,
1005
+ listed a scan of `?`.
1006
+ - **EXPLAIN on SQLite reads a constant row and an alias for what they are.**
1007
+ `explain: true` on `SELECT 1` listed a full table scan of `CONSTANT` with a
1008
+ warning, and now shows no scan summary. A table the statement gives another
1009
+ name was listed as a table named by its alias, and now reads
1010
+ `u (a name the statement gives)`.
1011
+ - **Only a timeout is reported as a timeout.** `rails_query` read any SQL
1012
+ error that mentioned "timeout" as a timeout, so an unknown column named
1013
+ `timeout` came back as one. A timeout is now read from the error's class or
1014
+ the database's native code, and MariaDB's 1969 counts.
1015
+ - **MySQL table sizes count base tables only.** The row estimates already did.
1016
+ - **An `explain: true` failure is an error.** A failing EXPLAIN, or one asked
1017
+ of a statement other than SELECT or WITH, came back as an ordinary answer.
1018
+ The CLI now exits non-zero and an MCP client sees `isError`. Asked of
1019
+ another statement it answered "EXPLAIN only supports SELECT queries." and
1020
+ now answers "`explain: true` takes a SELECT or WITH statement and explains
1021
+ it.", with "The statement is already an EXPLAIN." in front when it is one.
1022
+ - **An SQL error no longer ends in a colon.** Rails ends an error's first line
1023
+ with a colon where the statement follows, and `rails_query` printed it.
1024
+ - **`rails_query` runs a string literal that only reads like a refused
1025
+ phrase.** The `OR 1=1` check is gone: the caller writes the whole statement,
1026
+ so it protected nothing and refused `LIKE '%OR 1=1%'`. On PostgreSQL a
1027
+ locking clause is read with literals masked, since its read-only transaction
1028
+ refuses a locking read, so `'%ready for update%'` runs there. MySQL's
1029
+ read-only transaction allows `FOR SHARE`, so there and on SQLite the raw
1030
+ text still decides.
1031
+
8
1032
  ## [5.35.0] - 2026-10-12
9
1033
 
10
1034
  ### Added