talk_to_your_app 0.1.0.pre.3 → 0.1.0.pre.4

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +62 -28
  3. data/docs/plans/2026-06-18-001-feat-connection-wiring-and-enabled-flag-plan.md +363 -0
  4. data/docs/plugin_authoring.md +21 -10
  5. data/docs/read_only_connections.md +12 -6
  6. data/docs/solutions/runtime-errors/sidekiq-adapter-requires-sidekiq-api-2026-06-16.md +4 -4
  7. data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +42 -15
  8. data/lib/talk_to_your_app/configuration.rb +53 -9
  9. data/lib/talk_to_your_app/plugin.rb +27 -10
  10. data/lib/talk_to_your_app/plugin_registry.rb +13 -0
  11. data/lib/talk_to_your_app/plugins/db/plugin.rb +41 -16
  12. data/lib/talk_to_your_app/plugins/db/tools/query.rb +17 -13
  13. data/lib/talk_to_your_app/plugins/db/tools/schema.rb +0 -3
  14. data/lib/talk_to_your_app/plugins/db/tools/tables.rb +4 -6
  15. data/lib/talk_to_your_app/plugins/flipper/plugin.rb +21 -3
  16. data/lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb +0 -1
  17. data/lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb +0 -1
  18. data/lib/talk_to_your_app/plugins/flipper/tools/enabled_flags.rb +0 -1
  19. data/lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb +0 -1
  20. data/lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb +0 -1
  21. data/lib/talk_to_your_app/plugins/jobs/plugin.rb +53 -28
  22. data/lib/talk_to_your_app/plugins/jobs/tools/base.rb +28 -0
  23. data/lib/talk_to_your_app/plugins/jobs/tools/failed_jobs.rb +3 -4
  24. data/lib/talk_to_your_app/plugins/jobs/tools/queue_sizes.rb +4 -4
  25. data/lib/talk_to_your_app/plugins/jobs/tools/rate_metrics.rb +3 -4
  26. data/lib/talk_to_your_app/plugins/jobs/tools/recent_jobs.rb +3 -4
  27. data/lib/talk_to_your_app/railtie.rb +6 -2
  28. data/lib/talk_to_your_app/tool.rb +63 -14
  29. data/lib/talk_to_your_app/transport/rails_mount.rb +25 -2
  30. data/lib/talk_to_your_app/version.rb +1 -1
  31. data/lib/talk_to_your_app.rb +26 -5
  32. metadata +5 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 593eb7d10a1e55f810002c8890fe4eb9e650d5001c2f252c17c79c78c70f9b8d
4
- data.tar.gz: 73bfc284f8ce0081c3af5d40a1b30e7ed14a644d0340e1c7603f98d07d76ecb7
3
+ metadata.gz: 968cb02f102a1c4e238a3305a9b67f9b00642e3da99c30ccf9ece00799251547
4
+ data.tar.gz: 8d87ffd983498a0636b3e3d88b465262fa7781e0e534325cd96ef3c06ee15c28
5
5
  SHA512:
6
- metadata.gz: d537fc75c1a3d5e59591829de61792610089e8fb4c55c3327abe8ceb228d26fe27bd8802895785940f84fec29d33e1677c3af85ce078e4ff160e14185cfa4766
7
- data.tar.gz: '091db0b41b51ea0dfa3862b3fdade99ae30980c7b80d8c03e323e77be8f957fb7cc34129c79e4e2766f39e056d2f6fac3cecd85287df61ebf4c2dc7861c52dad'
6
+ metadata.gz: e6dae56486a9660bc76769312aeb9c7a0f42de81b15c0b409d084962f4e3ea20600c9b8eb2670d267c6621885c3aa0e3c5ab02b471747662dbc74f9bc179571a
7
+ data.tar.gz: 7c13b0d2eb1110fd16ff11e224b5be5fb73a8cb5330913d3310d561064efe8af537d0d03e5a0997bf52b27628f53c0d0a1775fbd7c24a26a7d690cd97b312910
data/README.md CHANGED
@@ -2,13 +2,17 @@
2
2
 
3
3
  **Let an AI agent talk to your running Rails app — safely.** `talk_to_your_app` mounts a [Model Context Protocol](https://modelcontextprotocol.io) endpoint on your app, so a tool like Claude can query your database, inspect background jobs, flip feature flags, and call tools you write yourself — all behind your auth, all audit-logged, and read-only unless you opt into a write-capable plugin.
4
4
 
5
+ [![Watch the 20-second demo](docs/demo_poster.png)](docs/demo.mp4)
6
+
7
+ > ▶️ **[Watch the 20-second demo](docs/demo.mp4)** — setup, live `db.query`, and read-only enforcement.
8
+
5
9
  It's a thin, Rails-native layer over the official [MCP Ruby SDK](https://github.com/modelcontextprotocol/ruby-sdk): the SDK handles the wire protocol; this gem adds everything Rails — your replicas, your jobs backend, your feature flags — plus the guardrails that make pointing an agent at your app something you can actually ship.
6
10
 
7
11
  **What you get**
8
12
 
9
13
  - 🔌 **A Streamable HTTP MCP endpoint in two lines** — `mount TalkToYourApp.rack_app`, and you're live.
10
14
  - 🔒 **Fail-closed by design** — API-key or HTTP Basic auth required, optional per-tool authorization, and a read-only DB role the app *refuses to boot without*. Misconfiguration fails at deploy, never on the first request.
11
- - 🧰 **Five batteries-included plugins** — `db` (read-only SQL + schema introspection), `jobs` (Sidekiq / Solid Queue metrics), `flipper` (feature flags), `rake` (allow-listed tasks), and `custom_tools` (your own tools, with a generator).
15
+ - 🧰 **Batteries-included plugins** — `db` (read-only SQL + schema introspection), `sidekiq` and `solid_queue` (background-job metrics), `flipper` (feature flags), `rake` (allow-listed tasks), and `custom_tools` (your own tools, with a generator).
12
16
  - 📝 **Every call audit-logged** — principal, IP, params, outcome, duration. Subscribe to persist your own trail.
13
17
  - ✍️ **A Ruby DSL + generators** for writing first-class tools of your own in a few lines.
14
18
 
@@ -39,16 +43,16 @@ mount TalkToYourApp.rack_app, at: TalkToYourApp.configuration.mount_at
39
43
  ```ruby
40
44
  TalkToYourApp.configure do |config|
41
45
  config.api_keys = { "my-agent" => ENV.fetch("TTYA_KEY") }
42
- config.connection :replica_readonly, database: "primary", role: :reading
43
- config.plugin :db
46
+ config.connection :readonly, database: "primary" # role: defaults to :reading
47
+ config.plugin :db, connection: :readonly
44
48
  end
45
49
  ```
46
50
 
47
51
  `"my-agent"` is the **principal** — the name this token authenticates as, recorded on every audit line. The endpoint is **secure by default**: no tool responds until auth is configured, and the app refuses to boot with a plugin enabled and no auth.
48
52
 
49
- In production, point `database:` at a genuinely read-only replica (or a Postgres role with `GRANT SELECT` only). The gem enforces read-only at the Rails layer too, but the database role is the real backstop. See [docs/read_only_connections.md](docs/read_only_connections.md) for step-by-step setup on PostgreSQL, MySQL, and SQLite.
53
+ You declare connections by name and **wire one into each plugin** that needs a database (`connection: :readonly`). `role:` defaults to `:reading`. In production, point `database:` at a genuinely read-only replica (or a Postgres role with `GRANT SELECT` only). The gem enforces read-only at the Rails layer too, but the database role is the real backstop. See [docs/read_only_connections.md](docs/read_only_connections.md) for step-by-step setup on PostgreSQL, MySQL, and SQLite.
50
54
 
51
- 2. **Mount it** (see above) and boot the app. If the `:replica_readonly` connection were missing, boot would fail with a clear error.
55
+ 2. **Mount it** (see above) and boot the app. If you enable `:db` without wiring a `connection:`, or name one you never declared, boot fails with a clear error.
52
56
 
53
57
  3. **Point your MCP client** (e.g. Claude Code) at `http://localhost:3000/mcp` with the bearer token `TTYA_KEY`.
54
58
 
@@ -94,6 +98,7 @@ Every option lives inside `TalkToYourApp.configure`:
94
98
 
95
99
  | Option | Description |
96
100
  | --- | --- |
101
+ | `config.enabled` | Global on/off switch. When `false`, the endpoint serves `503` and boot validation is skipped, so you can ship the initializer and disable per-environment. Default `true`. |
97
102
  | `config.mount_at` | Path the endpoint serves from. Default `"/mcp"`. |
98
103
  | `config.server_name` | Server name in the `initialize` handshake. Default `"talk_to_your_app"`. |
99
104
  | `config.server_title` | Human-friendly server title shown to clients (optional). |
@@ -104,8 +109,9 @@ Every option lives inside `TalkToYourApp.configure`:
104
109
  | `config.basic_auth { \|user, pass\| ... }` | HTTP Basic callable returning truthy to authenticate. Wire it to your own user model. |
105
110
  | `config.authorize { \|principal, tool\| ... }` | Optional per-principal tool authorization. Returns truthy to allow. Without it, any authenticated principal may call any enabled tool. |
106
111
  | `config.allowed_origins` | Origins permitted for browser requests (DNS-rebinding protection). Empty allows non-browser clients. |
107
- | `config.connection :name, database:, role:, replica:, statement_timeout:` | Declares a named connection. `role:` is `:reading` or `:writing`. |
108
- | `config.plugin :name, **opts` | Enables a plugin (off by default). |
112
+ | `config.allowed_hosts` | Extra `Host` header values accepted by the transport (DNS-rebinding protection), beyond the always-allowed loopback defaults (`127.0.0.1`, `::1`, `localhost`). A non-loopback deployment **must** list its host here or every request is rejected with "Forbidden: Invalid Host header". Each entry matches a bare host (any port) or full `host:port`. The generated initializer defaults this to `TalkToYourApp.rails_hosts` (your app's own `config.hosts`, literal strings only — add wildcard/regexp hosts explicitly). |
113
+ | `config.connection :name, database:, role:, replica:, statement_timeout:` | Declares a named connection. `role:` defaults to `:reading`; pass `:writing` for a writer. |
114
+ | `config.plugin :name, connection: :conn, **opts` | Enables a plugin (off by default). **Every plugin must declare `connection:`** — a declared connection name, or `connection: false` to opt out (e.g. `:sidekiq`, `:rake`, `:custom_tools` that don't read a wired SQL connection). |
109
115
  | `config.logger` | Audit logger. Defaults to `Rails.logger`. Swappable to any Logger-compatible object. |
110
116
  | `config.log_level` | Global audit level (default `:info`); overridable per plugin. |
111
117
 
@@ -157,25 +163,26 @@ All plugins are **off by default** — enable them explicitly, and an agent can
157
163
 
158
164
  | Plugin | What an agent can do | Enable with |
159
165
  | --- | --- | --- |
160
- | [DB](#db) | Run read-only SQL; introspect tables, columns, indexes, FKs | `config.plugin :db` |
161
- | [Jobs](#jobs-read-only) | Read queue sizes, recent/failed jobs, rates | `config.plugin :jobs, adapter: :sidekiq` |
162
- | [Flipper](#flipper) | Read and toggle feature flags (global, actor, group, %) | `config.plugin :flipper` |
163
- | [Rake](#rake-allow-listed-task-runner) | Run allow-listed rake tasks and read their output | `config.plugin :rake, allowed: [...]` |
164
- | [Custom Tools](#custom-tools) | Call tools you write yourself (writes allowed) | `config.plugin :custom_tools` |
166
+ | [DB](#db) | Run SQL (read-only by default); introspect tables, columns, indexes, FKs | `config.plugin :db, connection: :readonly` |
167
+ | [Sidekiq](#jobs-read-only) | Read Sidekiq queue sizes, recent/failed jobs, rates | `config.plugin :sidekiq, connection: false` |
168
+ | [Solid Queue](#jobs-read-only) | Read Solid Queue queue sizes, recent/failed jobs, rates | `config.plugin :solid_queue, connection: false` |
169
+ | [Flipper](#flipper) | Read and toggle feature flags (global, actor, group, %) | `config.plugin :flipper, connection: :writer` |
170
+ | [Rake](#rake-allow-listed-task-runner) | Run allow-listed rake tasks and read their output | `config.plugin :rake, connection: false, allowed: [...]` |
171
+ | [Custom Tools](#custom-tools) | Call tools you write yourself (writes allowed) | `config.plugin :custom_tools, connection: false` |
165
172
 
166
173
  ### DB
167
174
 
168
- A single read-only SQL tool.
175
+ A single SQL tool — read-only by default.
169
176
 
170
177
  ```ruby
171
178
  # `database:` must map to a SELECT-only DB user or a replica — not your
172
179
  # writable primary. That read-only role is the security boundary.
173
- config.connection :replica_readonly, database: "readonly", role: :reading
174
- config.plugin :db
180
+ config.connection :readonly, database: "readonly" # role: defaults to :reading
181
+ config.plugin :db, connection: :readonly
175
182
  ```
176
183
 
177
- - **`db.query`** — `sql` (required), `format` (`json` | `text` | `html`, default `json`). Runs inside a transaction with a per-query statement timeout (default 30s, override with `statement_timeout:` on the connection). The timeout is enforced on PostgreSQL (`statement_timeout`) and MySQL (`max_execution_time`); SQLite has no per-statement timeout. **Writes are rejected by the read-only DB role** — the gem does not parse SQL (see [Read-only is enforced by the database](#read-only-is-enforced-by-the-database)). Results are capped at **2000 rows by default** — raise or lower it with `config.plugin :db, max_rows: 5000`, or remove the cap with `max_rows: nil` (also accepts `false` or `:unlimited`); when a query exceeds the cap the response is truncated and flagged (`"truncated": true, "max_rows": N`). **Invalid SQL** comes back as a tool error (`isError`) carrying the database's message — it never crashes the request or leaks a stack trace.
178
- - **`db.tables`** — lists the table names in the read-only database.
184
+ - **`db.query`** — `sql` (required), `format` (`json` | `text` | `html`, default `json`). Runs inside a transaction with a per-query statement timeout (default 30s, override with `statement_timeout:` on the connection). The timeout is enforced on PostgreSQL (`statement_timeout`) and MySQL (`max_execution_time`); SQLite has no per-statement timeout. **On a `:reading` connection (the default) writes are rejected by the read-only DB role** — the gem does not parse SQL (see [Read-only is enforced by the database](#read-only-is-enforced-by-the-database)). Results are capped at **2000 rows by default** — raise or lower it with `config.plugin :db, connection: :readonly, max_rows: 5000`, or remove the cap with `max_rows: nil` (also accepts `false` or `:unlimited`); when a query exceeds the cap the response is truncated and flagged (`"truncated": true, "max_rows": N`). **Invalid SQL** comes back as a tool error (`isError`) carrying the database's message — it never crashes the request or leaks a stack trace.
185
+ - **`db.tables`** — lists the table names in the database.
179
186
  - **`db.schema`** — `table` (required): the table's columns, primary key, indexes, and foreign keys.
180
187
 
181
188
  > **Discovering the schema.** Point the model at your `db/schema.rb` or `db/structure.sql` so it knows the tables and columns before querying — or let it call `db.tables` / `db.schema` to introspect the live database directly.
@@ -210,23 +217,47 @@ Then declare that account as the reader and add the `db` plugin:
210
217
 
211
218
  ```ruby
212
219
  # database.yml has a `readonly` entry using the SELECT-only credentials above
213
- config.connection :replica_readonly, database: "readonly", role: :reading
214
- config.plugin :db
220
+ config.connection :readonly, database: "readonly" # role: defaults to :reading
221
+ config.plugin :db, connection: :readonly
222
+ ```
223
+
224
+ > ⚠️ Do **not** point a `:reading` connection at your writable primary. If the account can write, a crafted CTE or stacked statement will write — the gem cannot prevent it. The read-only role is the boundary; everything else is convenience.
225
+
226
+ #### Running `:db` against a writable connection
227
+
228
+ `db.query` runs read-only as shipped. If you deliberately wire a **`:writing`** connection, `db.query` can execute arbitrary writes — `UPDATE`, `INSERT`, `DELETE`, even DDL:
229
+
230
+ ```ruby
231
+ config.connection :writer, database: "primary", role: :writing
232
+ config.plugin :db, connection: :writer # db.query can now write — logs a loud boot warning
215
233
  ```
216
234
 
217
- > ⚠️ Do **not** point `:reading` at your writable primary. If the account can write, a crafted CTE or stacked statement will write the gem cannot prevent it. The read-only role is the boundary; everything else is convenience.
235
+ This is a deliberate, two-step opt-in (declare a `role: :writing` connection **and** wire it in); the gem logs a warning at boot but does not stop you. Before enabling it, understand what it does **not** give you:
236
+
237
+ - **`config.authorize` cannot distinguish reads from writes.** The tool is always `"db.query"`, so an authorizer like `tool.start_with?("db.")` permits `SELECT` and `DELETE` identically. The real access control is the **database user's privileges** — grant only what the writes you intend require.
238
+ - **Full SQL, including literal values, is written to the audit log.** Write statements with sensitive literals (`UPDATE users SET token = '…'`) appear in every audit destination. Scope or filter your log sinks accordingly.
239
+ - The gem still does **not** parse SQL. Nothing inspects or restricts the statement beyond the connection's role.
240
+
241
+ Prefer a read-only connection unless you specifically need writes.
218
242
 
219
243
  ### Jobs (read-only)
220
244
 
221
- Common metrics across job backends. Declare which adapter you run:
245
+ Background-job metrics, as one plugin per backend `:sidekiq` and
246
+ `:solid_queue`. Enable whichever you run (or both, e.g. mid-migration). The
247
+ backends read Redis / their own tables, not a wired SQL connection, so enable
248
+ them with `connection: false`:
222
249
 
223
250
  ```ruby
224
- config.plugin :jobs, adapter: :sidekiq # or :solid_queue
251
+ config.plugin :sidekiq, connection: false
252
+ config.plugin :solid_queue, connection: false # both may run at once
225
253
  ```
226
254
 
227
- - **`jobs.queue_sizes`**, **`jobs.recent_jobs`** (`limit`, ≤500), **`jobs.failed_jobs`** (`limit`, ≤500), **`jobs.rate_metrics`** (`window` seconds, default 1800).
255
+ Each plugin exposes four adapter-namespaced tools:
256
+
257
+ - **Sidekiq** — `sidekiq.queue_sizes`, `sidekiq.recent_jobs` (`limit`, ≤500), `sidekiq.failed_jobs` (`limit`, ≤500), `sidekiq.rate_metrics` (`window` seconds, default 1800).
258
+ - **Solid Queue** — `solid_queue.queue_sizes`, `solid_queue.recent_jobs`, `solid_queue.failed_jobs`, `solid_queue.rate_metrics` (same arguments).
228
259
 
229
- Adapters return a stable shape regardless of backend. Boot fails if the adapter is unset, unknown, or its gem is missing.
260
+ Both backends return the same response shape. Boot fails if the backend's gem is missing.
230
261
 
231
262
  ### Flipper
232
263
 
@@ -234,9 +265,11 @@ Read and toggle feature flags, globally or per actor.
234
265
 
235
266
  ```ruby
236
267
  config.connection :flipper_writer, database: "primary", role: :writing
237
- config.plugin :flipper
268
+ config.plugin :flipper, connection: :flipper_writer
238
269
  ```
239
270
 
271
+ Flipper writes flag state, so it requires a connection declared `role: :writing` — boot fails on a `:reading` connection.
272
+
240
273
  - **`flipper.list_flags`** — names of all configured flags.
241
274
  - **`flipper.read_flag`** — a flag's effective state plus its full per-gate configuration. Optional `actor_class` + `actor_id` reads the state for that actor.
242
275
  - **`flipper.enable_flag`** / **`flipper.disable_flag`** — toggle a flag across a gate: global (default), an actor (`actor_class` + `actor_id`), a registered `group`, or a `percentage` rollout (`percentage_type`: `actors` or `time`). Each returns `{ name, enabled, gate_type, gates }` — `gate_type` is the targeted gate (`boolean`, `actor`, `group`, `percentage_of_actors`, `percentage_of_time`) and `gates` is the flag's full per-gate configuration.
@@ -249,8 +282,8 @@ Declaring `:flipper_writer` is required (the gem refuses to boot without it) and
249
282
  Runs operator-approved rake tasks and returns their status and output.
250
283
 
251
284
  ```ruby
252
- config.plugin :rake, allowed: ["stats", "report:generate"]
253
- config.plugin :rake, allowed: [...], timeout: 60 # per-task seconds, default 20
285
+ config.plugin :rake, connection: false, allowed: ["stats", "report:generate"]
286
+ config.plugin :rake, connection: false, allowed: [...], timeout: 60 # per-task seconds, default 20
254
287
  ```
255
288
 
256
289
  - **`rake.run`** — `task` (required, must be on the `allowed:` list), `args` (optional array of positional arguments → `task[arg1,arg2]`). Returns JSON `{ task, status, exit_code, output, error }`. The task runs in a subprocess (`bundle exec rake`), so arguments cannot inject shell commands. A task that runs longer than `timeout:` (default 20s) is hard-killed and returned as a tool error, so a hung task can't pin the web thread.
@@ -262,7 +295,8 @@ config.plugin :rake, allowed: [...], timeout: 60 # per-task seconds, default 2
262
295
  Write your own tools by subclassing `TalkToYourApp::Tool` — the same base class the bundled tools use, with typed arguments and **writes allowed** (unlike the read-only DB plugin). Drop one per file in `app/talk_to_your_app/custom_tools/` and it's exposed automatically; no explicit registration.
263
296
 
264
297
  ```ruby
265
- config.plugin :custom_tools
298
+ config.plugin :custom_tools, connection: false # tools call ctx.connection(:name) explicitly
299
+ config.plugin :custom_tools, connection: :read # ...or wire a default; ctx.connection (no arg) uses it
266
300
  ```
267
301
 
268
302
  Scaffold one with the generator (creates `app/talk_to_your_app/custom_tools/<name>.rb`, exposed as `custom.<name>`):
@@ -0,0 +1,363 @@
1
+ ---
2
+ title: "feat: Pluggable connection wiring, global enabled flag, opt-in DB writes"
3
+ type: feat
4
+ status: completed
5
+ date: 2026-06-18
6
+ deepened: 2026-06-18
7
+ ---
8
+
9
+ # feat: Pluggable connection wiring, global enabled flag, opt-in DB writes
10
+
11
+ ## Summary
12
+
13
+ Three changes to the operator-facing configuration of `talk_to_your_app`: (1) a global `config.enabled = false` switch that makes the whole gem inert without removing the initializer; (2) replacing each plugin's hardcoded connection name with an explicit `config.plugin :db, connection: :read` wiring so operators define connections once and route them to plugins by name (with `role:` now defaulting to `:reading`); and (3) letting the DB plugin run against a `:writing` connection — opt-in, loudly warned — so `db.query` can execute writes when an operator deliberately wires one in. The connection-wiring change is a deliberate breaking change, accepted pre-1.0.
14
+
15
+ ---
16
+
17
+ ## Problem Frame
18
+
19
+ The connection model couples plugins to magic connection names: the DB plugin hardcodes `:replica_readonly`, the Flipper plugin hardcodes `:flipper_writer`, in both the plugin's `requires_connection` declaration and every tool's `CONNECTION` constant (`lib/talk_to_your_app/plugins/db/tools/query.rb:18`). An operator cannot name their own connections, cannot point the DB plugin at a writable database even deliberately (boot hard-fails on a `:writing` role — `lib/talk_to_your_app/plugins/db/plugin.rb:42`), and cannot disable the gem in one environment without deleting configuration. The connection DSL also forces three keyword arguments (`name`, `database:`, `role:`) where `role:` is almost always `:reading`.
20
+
21
+ ---
22
+
23
+ ## Requirements
24
+
25
+ - R1. `config.enabled = true/false` (default `true`) toggles the whole gem. When `false`, the mounted endpoint serves nothing and boot validation is skipped, so a partially-configured-but-disabled gem still boots.
26
+ - R2. `config.connection` defaults `role:` to `:reading`; `database:` stays required. Existing explicit-`role:` calls keep working.
27
+ - R3. Plugins that need a database connection receive it explicitly via `config.plugin <name>, connection: <conn_name>`. Hardcoded connection names are removed.
28
+ - R4. A plugin that requires a connection but is enabled without `connection:` fails at boot with a clear, actionable error (breaking change — existing `config.plugin :db` / `config.plugin :flipper` now require the arg).
29
+ - R5. The DB plugin wired to a `:reading` connection stays read-only (write prevention on, as today). Wired to a `:writing` connection, `db.query` may execute writes — a loud warning is logged at boot and the risk is documented.
30
+ - R6. The Flipper plugin requires a `:writing` connection (it toggles flags); wiring a `:reading` connection fails at boot.
31
+ - R7. Documentation (README, `docs/read_only_connections.md`), the dummy app initializer, and the install generator reflect the new wiring, including a worked "run `:db` with a write DB" example.
32
+
33
+ ---
34
+
35
+ ## Scope Boundaries
36
+
37
+ - Not adding `enabled:` per-plugin or per-connection — global only (per the decision: `config.enabled`). Plugins remain individually off-by-default by not being listed.
38
+ - Not adding SQL parsing or statement-type filtering to the writable DB path — writes are gated solely by the wired connection's role plus operator intent. The read-only guarantee for `:reading` connections is unchanged.
39
+ - Not changing the auth, audit-logging, transport, or rake/jobs/custom_tools plugins beyond what the connection-resolution threading requires.
40
+ - Not adding a deprecation/back-compat shim for the old hardcoded names — explicit `connection:` is required (breaking change accepted pre-1.0).
41
+
42
+ ---
43
+
44
+ ## Context & Research
45
+
46
+ ### Relevant Code and Patterns
47
+
48
+ - `lib/talk_to_your_app/configuration.rb:108` — `#plugin(name, **options)` stores per-plugin options in `@enabled_plugins`; `#connection(name, database:, role:, ...)` at `:120` builds the `ConnectionSpec`.
49
+ - `lib/talk_to_your_app/plugin.rb:19` — `requires_connection(*names)` class DSL (currently stores names); role policy enforced in subclass `validate_enablement!`.
50
+ - `lib/talk_to_your_app.rb:73` — `TalkToYourApp.required_connections` gathers `[name, requester]` pairs from `plugin_class.required_connections` **and** each `tool_class.connection`, feeding `ConnectionRegistry.validate!`.
51
+ - `lib/talk_to_your_app/tool.rb:54` — `Context#connection(name=nil)` falls back to the tool's static `connection` DSL; `:160`/`:179` already thread `plugin_name` into `to_mcp_tool` → `dispatch` (but not yet into `invoke`/`Context`).
52
+ - `lib/talk_to_your_app/plugins/db/plugin.rb:33-52` — `requires_connection :replica_readonly`, `validate_enablement!` hard-rejects non-`:reading`. `Db.max_rows` (`:20`) is the established pattern for a plugin reading its own enabled options.
53
+ - `lib/talk_to_your_app/plugins/db/tools/query.rb:18,53` — `CONNECTION = :replica_readonly`; `timeout_ms` does `ConnectionRegistry.fetch(CONNECTION)`. Same `CONNECTION` constant in `tables.rb:12` and `schema.rb:13`.
54
+ - `lib/talk_to_your_app/plugins/flipper/plugin.rb:134` — `requires_connection :flipper_writer`; tools declare `connection :flipper_writer`.
55
+ - `lib/talk_to_your_app/transport/rails_mount.rb:17` — `RailsMount.build` assembles the Rack app; `lib/talk_to_your_app/railtie.rb:35` — `validate_boot!` runs all fail-closed checks.
56
+ - `lib/talk_to_your_app/connection_registry.rb:72` — `with(name)` switches role via `connected_to`; a `:writing` role does not set `prevent_writes`, so writes already flow once a writer is wired and validation permits it.
57
+
58
+ ### Institutional Learnings
59
+
60
+ - `docs/read_only_connections.md` is the canonical operator guide for the read-only contract; it must stay consistent with R5/R6 and the new wiring.
61
+ - The gem is fail-closed by design (`configuration.rb:4-7`): every misconfiguration surfaces at boot, never at first request. New validation (R4/R6) must follow this — raise in the boot path, not at call time.
62
+
63
+ ---
64
+
65
+ ## Key Technical Decisions
66
+
67
+ - **Connection name flows from operator config, resolved at call time** (not from a tool/plugin constant): thread the already-available `plugin_name` from `dispatch` into `invoke` → `Context`, and have `ctx.connection` resolve the wired name from `enabled_plugins[plugin_name][:connection]`. Rationale: reuses existing `plugin_name` plumbing; one source of truth (the operator's `connection:` option); removes the hardcoded constants cleanly. Resolution precedence: **explicit `ctx.connection(:name)` arg → plugin-wired `connection:` → tool's static `connection` DSL → ConfigurationError.** The static-DSL fallback preserves custom-tool behavior.
68
+ - **`requires_connection` becomes a no-arg marker.** A plugin declares it needs exactly one wired connection; the *name* is supplied by the operator. Role policy stays in each plugin's `validate_enablement!`. Rationale: decouples "needs a connection" (framework concern, enforced generically) from "needs a *reading/writing* connection" (plugin concern).
69
+ - **`role:` defaults to `:reading`.** Matches the read-biased design and the safe default; writers opt in explicitly. `database:` stays required because connection name and `database.yml` key are genuinely distinct (the README examples already differ, e.g. name `:replica_readonly` → database `"primary"`).
70
+ - **DB writes are gated by the deliberate `:writing`-role wiring, not by SQL inspection, and not by the boot warning.** The genuine safeguard is that the operator must *declare* a `role: :writing` connection **and** wire it into `:db` — two explicit, documented steps. The boot warning is **detection/audit only**, not risk mitigation: it does not gate, requires no acknowledgement, and fires once into a stream that may be unmonitored, so it cannot prevent the "wired the wrong connection" mistake — it only leaves a forensic trail. Rationale: the gem already does not parse SQL; layering a parser would be false security. (Per the locked decision, no second `allow_writes:` token is added — the role wiring is the opt-in.)
71
+ - **Boot-validation ordering is load-bearing and must be specified, not left to method-call order.** `validate_boot!` runs `PluginRegistry.validate_enabled!` (which invokes each plugin's `validate_enablement!`) **before** `ConnectionRegistry.validate!` (`railtie.rb:36-37`). So the generic "plugin requires a `connection:` but none/typo'd was wired" check must own that error: each plugin's `validate_enablement!` must `return` early when `opts[:connection]` is nil and must guard with `ConnectionRegistry.registered?` before calling `fetch` (which raises on a missing/`nil` name — `connection_registry.rb:41-45`). Otherwise the promised "clear, actionable" R4 error is pre-empted by `fetch`'s "not registered" message, or crashes on `nil.to_sym`. Rationale: keep one authoritative message per failure and preserve fail-closed-at-boot.
72
+ - **`config.authorize` cannot distinguish reads from writes on `db.query`** — the tool name is always `"db.query"` (`configuration.rb:88`), so an authorizer like `tool.start_with?("db.")` permits SELECT and UPDATE identically. The access control for the write path is **database-user privilege scoping**, not the authorizer. This is documented as an explicit limitation (U4/U6) rather than changing the authorize signature, consistent with "not changing auth."
73
+ - **`config.enabled` is resolved per-request inside the Rack app, not only at `build` time.** `rack_app` is memoized (`talk_to_your_app.rb:51`) and the host mounts it in `routes.rb`, which can be evaluated before the operator's initializer sets `enabled`. To avoid a stale-`enabled` app being captured at build time, the mounted Rack app checks `configuration.enabled` on each call and short-circuits to a disabled response when false; `validate_boot!` also returns early when disabled (skipping connection/plugin checks). **Auth validation is independent of `enabled`** — it is not "made invalid-when-enabled"; re-enabling a gem that was shipped without auth correctly fails closed at boot. Rationale: an operator must be able to ship the initializer and disable per-environment without the gem refusing to boot, without memoization/route-load ordering silently serving tools.
74
+ - **The disabled endpoint returns `503` (not `404`).** `503` is the machine-readable "temporarily unavailable" signal and distinguishes "gem disabled" from "route missing" for monitoring and for host catch-all routes that would swallow a `404`.
75
+
76
+ ---
77
+
78
+ ## Open Questions
79
+
80
+ ### Resolved During Planning
81
+
82
+ - Scope of `enabled:` → global `config.enabled` only (user decision).
83
+ - DB + writing connection → opt-in writes allowed, warned, documented (user decision).
84
+ - Back-compat for hardcoded names → none; explicit `connection:` required (user decision).
85
+ - How tools learn their operator-wired connection → thread `plugin_name` into `Context` (see Key Technical Decisions).
86
+
87
+ ### Deferred to Implementation
88
+
89
+ - Exact disabled-response body and whether to emit a one-line "gem disabled" log at boot — settle when wiring `RailsMount`. (Status code is decided: `503`.)
90
+
91
+ > **Resolved (was deferred):** `Context` exposes a public `connection_name` reader returning the *single* name resolved by `Context#connection`'s precedence, and `query.rb`'s `timeout_ms` reads that. This is a correctness requirement, not a style choice: a `Db.connection_name`-via-options helper would only see the plugin-wired name and would silently fetch the timeout from a different spec than the one a `ctx.connection(:explicit)` override actually ran on. Resolve the name once, in one place.
92
+
93
+ ---
94
+
95
+ ## High-Level Technical Design
96
+
97
+ > *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
98
+
99
+ Operator-facing DSL, before → after:
100
+
101
+ ```ruby
102
+ # BEFORE — magic names, hardcoded in the plugins
103
+ config.connection :replica_readonly, database: "primary", role: :reading
104
+ config.connection :flipper_writer, database: "primary", role: :writing
105
+ config.plugin :db
106
+ config.plugin :flipper
107
+
108
+ # AFTER — define connections, wire them by name; role defaults to :reading
109
+ config.enabled = true # global kill-switch (default true)
110
+ config.connection :read, database: "primary" # role: :reading implied
111
+ config.connection :write, database: "primary", role: :writing
112
+ config.plugin :db, connection: :read # read-only (write prevention on)
113
+ config.plugin :flipper, connection: :write # requires a :writing connection
114
+
115
+ # AFTER — opt-in DB writes (deliberate, warned, documented)
116
+ config.plugin :db, connection: :write # db.query may execute writes
117
+ ```
118
+
119
+ DB plugin role behavior (decision matrix):
120
+
121
+ | Wired connection role | DB plugin at boot | `db.query` behavior |
122
+ |---|---|---|
123
+ | `:reading` (default) | boots silently | reads only; writes raise `ActiveRecord::ReadOnlyError` (unchanged) |
124
+ | `:writing` (opt-in) | boots **with a loud warning** | reads **and writes** execute |
125
+ | none wired | **ConfigurationError** at boot | n/a |
126
+
127
+ Connection resolution at call time:
128
+
129
+ ```
130
+ ctx.connection(:explicit) → use :explicit
131
+ ctx.connection → enabled_plugins[plugin_name][:connection] (operator-wired)
132
+ → tool's static `connection` DSL (custom tools)
133
+ → ConfigurationError
134
+ ```
135
+
136
+ ---
137
+
138
+ ## Implementation Units
139
+
140
+ ### U1. Global `config.enabled` switch
141
+
142
+ **Goal:** A `config.enabled` boolean (default `true`) that makes the gem inert — skips boot validation and serves nothing — without removing the initializer.
143
+
144
+ **Requirements:** R1
145
+
146
+ **Dependencies:** None
147
+
148
+ **Files:**
149
+ - Modify: `lib/talk_to_your_app/configuration.rb` (add `attr_accessor :enabled`; default `true` in `initialize`)
150
+ - Modify: `lib/talk_to_your_app/railtie.rb` (`validate_boot!` early-returns the *connection/plugin* checks when disabled)
151
+ - Modify: `lib/talk_to_your_app/transport/rails_mount.rb` (the mounted Rack app checks `configuration.enabled` **per request** and returns a `503` disabled response when false)
152
+ - Test: `test/talk_to_your_app/configuration_test.rb`; a new `test/integration/` test that boots the dummy app with `config.enabled = false` and drives a real request
153
+
154
+ **Approach:**
155
+ - Default `@enabled = true`. `validate_boot!` checks `return unless configuration.enabled` before the plugin/connection validation. **Do not** skip `validate_auth!` in a way that lets a re-enabled-without-auth gem boot — auth config is independent of `enabled`; the simplest correct shape is for the disabled early-return to skip only the connection/plugin checks (those are what an operator can't satisfy while intentionally disabled), or to accept that re-enabling without auth fails closed at boot (the desired behavior).
156
+ - The **disabled check must be evaluated per request**, not captured at `build` time: because `rack_app` is memoized (`talk_to_your_app.rb:51`) and `routes.rb` may load before the initializer runs, branching only inside `build` risks capturing a stale `enabled`. The built Rack app reads `configuration.enabled` on each call and returns a `503` (`{"content-type"=>"text/plain"}`, body `"disabled"`) when false, so the host's `mount TalkToYourApp.rack_app` always resolves and toggling `enabled` never serves a stale app. The `503` short-circuit sits in front of the auth middleware (nothing to protect when disabled).
157
+
158
+ **Patterns to follow:** `railtie.rb:44` `validate_auth!`'s early-return style; the `Auth::Middleware`-wrapping shape in `rails_mount.rb:32`.
159
+
160
+ **Test scenarios:**
161
+ - Happy path: `config.enabled` defaults to `true`; gem boots and serves normally as today.
162
+ - Edge case: `config.enabled = false` with a plugin enabled but **no connection configured** → boots without raising (connection/plugin validation skipped).
163
+ - Integration (real boot, not a direct `build` call): boot the dummy app with `config.enabled = false`, then drive a request to the mount path → `503`, no tool served, no audit line written. This specifically guards the route-load-before-initializer / memoization ordering that a unit test calling `build` cannot.
164
+ - Edge case: flip `enabled` from `false` to `true` on the *same* memoized `rack_app` (no `reset_configuration!`) → the endpoint now serves (proves the per-request check, not build-time capture).
165
+ - Edge case: toggling back to `true` after `reset_configuration!` restores normal boot validation.
166
+
167
+ **Verification:** A disabled gem boots on intentionally-incomplete config and serves `503`; flipping `enabled` is honored without a process restart or stale-app surprise; an enabled gem behaves exactly as before.
168
+
169
+ ---
170
+
171
+ ### U2. Default `role:` to `:reading` in `config.connection`
172
+
173
+ **Goal:** Make `role:` optional on `config.connection`, defaulting to `:reading`; keep `database:` required.
174
+
175
+ **Requirements:** R2
176
+
177
+ **Dependencies:** None
178
+
179
+ **Files:**
180
+ - Modify: `lib/talk_to_your_app/configuration.rb` (`#connection` — `role:` becomes a keyword with default `:reading`)
181
+ - Test: `test/talk_to_your_app/configuration_test.rb` (or `connection_registry_test.rb`)
182
+
183
+ **Approach:** Change the signature to `def connection(name, database:, role: :reading, replica: false, statement_timeout: nil)` — `role:` keeps a keyword default (not made positional), so existing explicit-`role:` calls keep working. Keep the existing `:reading`/`:writing` validation and the `replica: true` + `:writing` rejection (`configuration.rb:121-128`) unchanged.
184
+
185
+ **Patterns to follow:** existing keyword-default + validation in `configuration.rb:120`.
186
+
187
+ **Test scenarios:**
188
+ - Happy path: `config.connection :read, database: "primary"` produces a spec with `role: :reading`.
189
+ - Happy path: `config.connection :write, database: "primary", role: :writing` preserves `:writing`.
190
+ - Error path: `role: :nonsense` still raises `ConfigurationError`.
191
+ - Edge case: `replica: true` with explicit `role: :writing` still raises (unchanged); `replica: true` with defaulted role resolves to `:reading` and is accepted.
192
+
193
+ **Verification:** A read connection needs only name + `database:`; writer connections and all prior validations are unaffected.
194
+
195
+ ---
196
+
197
+ ### U3. Pluggable connection wiring (framework mechanism)
198
+
199
+ **Goal:** Route the operator's `config.plugin <name>, connection: <conn>` to tools at call time, replacing hardcoded connection names; fail closed when a connection-requiring plugin is wired none.
200
+
201
+ **Requirements:** R3, R4
202
+
203
+ **Dependencies:** None (U4/U5 adopt this)
204
+
205
+ **Files:**
206
+ - Modify: `lib/talk_to_your_app/plugin.rb` (`requires_connection` → no-arg marker; add `requires_connection?`; **remove the now-vestigial `required_connections` alias at `plugin.rb:24`** — after the rename it would return the marker's value, not connection names)
207
+ - Modify: `lib/talk_to_your_app.rb` (`required_connections` reads `opts[:connection]` for connection-requiring plugins instead of static names; **skip plugins whose `opts[:connection]` is nil** — `PluginRegistry` owns that error — so a `nil` name never reaches `ConnectionRegistry.registered?`/`reject`, which would `nil.to_sym`-crash)
208
+ - Modify: `lib/talk_to_your_app/plugin_registry.rb` (`validate_enabled!` raises when a `requires_connection?` plugin has no `connection:` opt — **before** any plugin's `validate_enablement!` body runs)
209
+ - Modify: `lib/talk_to_your_app/tool.rb` (thread `plugin_name` `dispatch → invoke → Context`; `Context#connection` resolution precedence; expose `Context#connection_name` returning the single resolved name)
210
+ - Test: `test/talk_to_your_app/plugin_test.rb`, `test/talk_to_your_app/plugin_registry_test.rb`, `test/talk_to_your_app/tool_test.rb`, `test/talk_to_your_app/connection_registry_test.rb`
211
+
212
+ **Approach:**
213
+ - `requires_connection` (no args) sets a flag; `requires_connection?` reads it. Drop the name-collecting behavior **and remove the `required_connections` alias** (`plugin.rb:24`) — the only caller, `TalkToYourApp.required_connections`, is rewritten here.
214
+ - `TalkToYourApp.required_connections`: for each enabled plugin where `plugin_class.requires_connection?` **and `opts[:connection]` is non-nil**, push `[opts[:connection], "Plugin #{name.inspect}"]`. Nil connections are *not* pushed — the missing-`connection:` error is owned by `PluginRegistry.validate_enabled!` and a `nil` must never reach `registered?(nil)`/`reject`. Remove the per-tool `tool_class.connection` gathering for bundled plugins.
215
+ - **Validation ordering (load-bearing — see Key Technical Decisions):** `PluginRegistry.validate_enabled!` raises `ConfigurationError` for a `requires_connection?` plugin with `opts[:connection].nil?` — message names the plugin and shows `config.plugin :name, connection: :your_connection` — and this missing-`connection:` check must execute *before* it calls any plugin's `validate_enablement!` (so the actionable R4 message wins, not a downstream `fetch` "not registered"). A typo'd-but-present name still flows to `ConnectionRegistry.validate!`, which emits the requester-naming "not registered" message; the per-plugin `validate_enablement!` (U4/U5) must guard with `registered?` before `fetch` so it doesn't pre-empt that.
216
+ - `Tool.dispatch` passes `plugin_name:` to `invoke`; `Context.new(..., plugin_name:)`. `Context#connection` resolves via the precedence in Key Technical Decisions, reading the plugin-wired name as `configuration.enabled_plugins[@plugin_name]&.dig(:connection)` so a `nil` `plugin_name` (a tool dispatched outside a plugin — `tool.rb:179` defaults `plugin_name: nil`) falls through to the static-DSL branch instead of raising `NoMethodError`. `Context#connection_name` returns the single resolved name for `timeout_ms` to reuse.
217
+
218
+ **Patterns to follow:** `Db.max_rows` (`plugins/db/plugin.rb:20`) for reading a plugin's own options; existing `plugin_name` threading at `tool.rb:160,172,180` (verified: `to_mcp_tool` → `dispatch` carries it; `invoke` at `tool.rb:192` does not yet — that gap is what this unit closes).
219
+
220
+ **Test scenarios:**
221
+ - Happy path: a tool calling `ctx.connection` (no arg) resolves to the connection wired via `config.plugin :p, connection: :c`.
222
+ - Happy path: `ctx.connection(:explicit)` overrides the wired connection, and `ctx.connection_name` returns `:explicit` (so a co-derived timeout reads the same spec).
223
+ - Edge case: a custom tool with a static `connection :foo` DSL and no plugin-wired connection still resolves to `:foo`.
224
+ - Edge case: a tool dispatched with `plugin_name: nil` and no static DSL falls through to the terminal `ConfigurationError` without raising `NoMethodError` (the `&.dig` guard).
225
+ - Edge case: a bundled tool that still carried a residual static `connection` DSL resolves to the *plugin-wired* connection, not the static one (precedence: plugin-wired beats static DSL — guards the U4/U5 constant removal).
226
+ - Error path: a `requires_connection?` plugin enabled **without** `connection:` raises `ConfigurationError` at boot from `PluginRegistry` (not a `fetch`/`nil.to_sym` error), naming the plugin and the fix.
227
+ - Error path: a plugin wired a **typo'd/undeclared** name raises the `ConnectionRegistry.validate!` "not registered (required by Plugin :x)" message — distinct from the missing-`connection:` case above.
228
+ - Error path: `ctx.connection` with no explicit arg, no wired connection, and no static DSL raises `ConfigurationError` (message in spirit of `tool.rb:57`).
229
+ - Integration: `required_connections` returns the operator-wired name for an enabled connection-requiring plugin; `ConnectionRegistry.validate!` still catches a missing `database.yml` key.
230
+
231
+ **Verification:** Tools run against the operator-wired connection; the two distinct misconfigurations (no `connection:` vs. wrong name) each produce their own clear boot error in the right order; no resolution path crashes on a `nil` connection or `nil` `plugin_name`.
232
+
233
+ ---
234
+
235
+ ### U4. DB plugin: use wired connection, allow opt-in writes
236
+
237
+ **Goal:** The DB plugin reads its connection from `connection:`; a `:reading` connection stays read-only, a `:writing` connection enables writes with a loud boot warning.
238
+
239
+ **Requirements:** R3, R5
240
+
241
+ **Dependencies:** U3
242
+
243
+ **Files:**
244
+ - Modify: `lib/talk_to_your_app/plugins/db/plugin.rb` (`requires_connection` marker; `validate_enablement!` — `:reading` ok silently, `:writing` ok + warn, drop the hard reject; add a `Db.connection_name` helper if chosen for `timeout_ms`)
245
+ - Modify: `lib/talk_to_your_app/plugins/db/tools/query.rb` (remove `CONNECTION`/static `connection`; `ctx.connection`; `timeout_ms` resolves the wired connection)
246
+ - Modify: `lib/talk_to_your_app/plugins/db/tools/tables.rb` (remove `CONNECTION`/static `connection`; `ctx.connection`)
247
+ - Modify: `lib/talk_to_your_app/plugins/db/tools/schema.rb` (same)
248
+ - Test: `test/talk_to_your_app/plugins/db/query_test.rb`, `test/integration/db_plugin_integration_test.rb`
249
+
250
+ **Approach:**
251
+ - `validate_enablement!(opts)`: **guard `return unless ConnectionRegistry.registered?(opts[:connection])`** first (mirroring the current `db/plugin.rb:43` guard) so the dedicated connection validation owns the missing/typo'd-name message; only once the spec is known to exist, branch on role: `spec.reading?` → return (silent); `:writing` → log a warning through `configuration.logger` (e.g. "DB plugin wired to a writable connection — db.query can execute writes"). No raise either way. The missing-`connection:` (nil) case is caught generically in U3, before this body runs.
252
+ - `timeout_ms` reads `ctx.connection_name` (the single name resolved by `Context#connection`, per U3) — **not** a `Db.connection_name`-via-options helper, which couldn't observe an explicit `ctx.connection(:other)` override and would fetch the timeout from a different spec than the query ran on.
253
+ - The write-execution path itself needs no new code: `ConnectionRegistry.with(name)` on a `:writing` role does not set `prevent_writes`, so writes flow; the `ActiveRecord::ReadOnlyError` rescue in `query.rb:44` simply won't trigger.
254
+ - **Security notes to carry into U6 docs (no code change here unless decided otherwise):** (a) `db.query`'s `:sql` argument is *not* `redact: true` (`query.rb:23`), so on the writable path full write statements **including literal values** are written to every audit destination (`audit_logger.rb:70`) — the README danger note must say so and recommend a log filter or subscriber scoping. (b) `config.authorize` receives only the tool name `"db.query"` (`configuration.rb:88`) and cannot distinguish reads from writes — database-user privilege scoping is the only write-level access control; document this explicitly so operators don't assume authorizer granularity they don't have.
255
+
256
+ **Patterns to follow:** existing `validate_enablement!` shape and its `registered?` guard (`db/plugin.rb:42-43`); `Db.max_rows` for option reading.
257
+
258
+ **Test scenarios:**
259
+ - Happy path (read): `config.plugin :db, connection: :read` (a `:reading` connection) → `SELECT` returns rows.
260
+ - Error path (read): same config → `UPDATE` is rejected with `ActiveRecord::ReadOnlyError` surfaced as a tool error (unchanged behavior).
261
+ - Happy path (write, opt-in): `config.plugin :db, connection: :write` (a `:writing` connection) → an `UPDATE`/`INSERT` executes and the change is visible on a follow-up `SELECT`.
262
+ - Edge case: enabling `:db` with a `:writing` connection logs the warning at boot (assert by capturing on a test logger / `ActiveSupport::Notifications`-style array logger — see `test/support/array_logger.rb`), and the gem boots (no raise).
263
+ - Error path: enabling `:db` with no `connection:` → `ConfigurationError` at boot from `PluginRegistry` (covered by U3, asserted here for the DB plugin specifically).
264
+ - Error path: enabling `:db` with a `connection:` naming an undeclared connection → the `ConnectionRegistry.validate!` "not registered" message, **not** a `fetch` error raised from inside the DB plugin's `validate_enablement!` (proves the `registered?` guard preserves error ordering).
265
+ - Integration: the per-query statement timeout still applies on the wired connection, resolved via `ctx.connection_name` (not the removed constant), including when a tool uses an explicit `ctx.connection(:other)` override.
266
+
267
+ **Verification:** DB plugin honors the wired connection's role — read-only by default, writable by deliberate opt-in with a logged warning.
268
+
269
+ ---
270
+
271
+ ### U5. Flipper plugin: use wired connection, require `:writing`
272
+
273
+ **Goal:** The Flipper plugin reads its connection from `connection:` and requires a `:writing` role (it toggles flags).
274
+
275
+ **Requirements:** R3, R6
276
+
277
+ **Dependencies:** U3
278
+
279
+ **Files:**
280
+ - Modify: `lib/talk_to_your_app/plugins/flipper/plugin.rb` (`requires_connection` marker; `validate_enablement!` requires wired role `:writing`, raises on `:reading`)
281
+ - Modify: `lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb`, `read_flag.rb`, `enable_flag.rb`, `disable_flag.rb`, `enabled_flags.rb` (remove static `connection :flipper_writer`; use `ctx.connection`)
282
+ - Test: `test/talk_to_your_app/plugins/flipper/flipper_test.rb`, `test/integration/flipper_plugin_integration_test.rb`
283
+
284
+ **Approach:** Flipper currently has **no** `validate_enablement!` (only `requires_connection` + `requires_gem` — `flipper/plugin.rb:133-137`), so the registry checks the connection *exists* but never its role. This unit *adds* a role check: `validate_enablement!(opts)` guards `return unless ConnectionRegistry.registered?(opts[:connection])` (let U3/registry own the missing/typo'd-name error), then raises `ConfigurationError` ("Flipper plugin requires a connection with role: :writing") if the existing spec is not `:writing`. Tools switch to `ctx.connection`.
285
+
286
+ > **Behavior change (not preservation):** today a `:flipper_writer` declared with `role: :reading` boots and only fails later at write time. After this unit it fails *at boot*. This is a deliberate tightening — see the System-Wide Impact note; it is not an unchanged invariant.
287
+
288
+ **Patterns to follow:** U4's `validate_enablement!` + `registered?` guard; the DB plugin's prior role-enforcement shape (`db/plugin.rb:42-51`), inverted to require `:writing`.
289
+
290
+ **Test scenarios:**
291
+ - Happy path: `config.plugin :flipper, connection: :write` (a `:writing` connection) → `enable_flag`/`disable_flag` toggle and `read_flag`/`list_flags`/`enabled_flags` work.
292
+ - Error path (new behavior): `config.plugin :flipper, connection: :read` (a `:reading` connection) → `ConfigurationError` **at boot** (previously this would have booted and failed only on the first write — assert the boot-time failure explicitly).
293
+ - Error path: `config.plugin :flipper` with no `connection:` → `ConfigurationError` at boot (U3, asserted for Flipper).
294
+
295
+ **Verification:** Flipper runs against an operator-wired writer and refuses a read-only connection at boot.
296
+
297
+ ---
298
+
299
+ ### U6. Docs, dummy app, and install generator
300
+
301
+ **Goal:** Bring all operator-facing docs and templates in line with the new wiring, the `config.enabled` flag, and the opt-in DB-write example.
302
+
303
+ **Requirements:** R7
304
+
305
+ **Dependencies:** U1, U2, U3, U4, U5
306
+
307
+ **Files:**
308
+ - Modify: `README.md` (config table: `config.enabled` row; `config.connection` row → `role:` optional/default `:reading`; `config.plugin` row → `connection:` required for db/flipper; DB section → "running `:db` with a write DB" opt-in example + warning; Flipper section → `connection:` wiring)
309
+ - Modify: `docs/read_only_connections.md` (all examples → `config.plugin :db, connection: :replica_readonly`; note the writable opt-in points back to the README)
310
+ - Modify: `test/dummy/config/initializers/talk_to_your_app.rb` (`config.plugin :db, connection: :replica_readonly`; `config.plugin :flipper, connection: :flipper_writer`)
311
+ - Modify: install generator initializer template under `lib/generators/talk_to_your_app/install/` (generated initializer uses `connection:` wiring and shows `config.enabled`)
312
+ - Modify: `CHANGELOG.md` (breaking change note: explicit `connection:` now required; new `config.enabled`; opt-in DB writes)
313
+ - Test: `test/generators/install_generator_test.rb` (assert the generated initializer contains the new wiring)
314
+
315
+ **Approach:** Mechanical doc/template edits matching U1–U5. The README's DB write example must carry an explicit danger note covering all three plan-level security facts surfaced in U4: (1) `db.query` executes arbitrary writes — scope the DB user's privileges as the real control; (2) `config.authorize` cannot distinguish reads from writes (same tool name) — do not rely on it to gate writes; (3) full SQL **including literal values** appears in audit logs on the writable path — filter or scope log destinations accordingly. Also note that a custom tool using a static `connection :writer` DSL does **not** receive the DB plugin's boot warning — recommend custom tools rely on plugin wiring. The dummy app already declares `:replica_readonly` and `:flipper_writer` connections; add `connection:` to its `config.plugin :db`/`:flipper` lines, and **drop the now-redundant `role: :reading`** from its `:replica_readonly` declaration so the demo exercises the new default and stays consistent with the README "AFTER" examples (keep `role: :writing` on `:flipper_writer`, which is non-default).
316
+
317
+ **Execution note:** Update the install-generator template and its test together so the generated initializer stays assertion-covered.
318
+
319
+ **Test scenarios:**
320
+ - Integration: `install_generator_test` asserts the generated initializer wires `connection:` and references `config.enabled`.
321
+ - Test expectation: none for README/`read_only_connections.md`/CHANGELOG/dummy-initializer edits — documentation and demo config, no behavior of their own (the dummy initializer is exercised indirectly by the integration suite).
322
+
323
+ **Verification:** A fresh `rails g talk_to_your_app:install` produces a working initializer under the new API; docs match shipped behavior; the dummy app boots under the new wiring.
324
+
325
+ ---
326
+
327
+ ## System-Wide Impact
328
+
329
+ - **Interaction graph:** `Tool.dispatch → invoke → Context#connection` is the new connection-resolution path; `plugin_name` (already threaded to `dispatch`) now also reaches `Context`. `RailsMount.build` and `Railtie.validate_boot!` both gain an `enabled` gate.
330
+ - **Error propagation:** New *configuration* failures are boot-time `ConfigurationError`s (fail-closed) for correctly-shaped-but-wrong configs. One exception by construction: `Context#connection`'s terminal "no connection resolvable" error is call-time (`tool.rb:57`) — it cannot fire for bundled tools (their `plugin_name` is always set in `collect_tools`, `rails_mount.rb:42`) but is reachable for a misconfigured custom tool. The DB-write warning is a log line, not an error.
331
+ - **State lifecycle risks:** `config.enabled` interacts with the memoized `@rack_app` (`lib/talk_to_your_app.rb:51`) — `reset_configuration!` already clears it; tests toggling `enabled` must reset to avoid a stale app.
332
+ - **API surface parity:** This is a **breaking** operator-config change. Every initializer enabling `:db` or `:flipper` must add `connection:`. The dummy app, install generator, README, and `docs/read_only_connections.md` are the surfaces that must move together (U6).
333
+ - **Integration coverage:** The read-only-enforced vs writable DB distinction (U4) and Flipper's writer requirement (U5) are only proven by integration tests that actually switch roles and attempt writes — unit tests with mocked connections won't prove the `connected_to(role:)` behavior.
334
+ - **Unchanged invariants:** Auth *mechanics*, the transport, statement-timeout mechanics, `max_rows`, and the jobs/rake/custom_tools plugins keep their current behavior. The read-only guarantee for `:reading` connections (Rails write prevention + DB role) is unchanged. **Two deliberate behavior changes, not invariants:** (1) Flipper now enforces a `:writing` role *at boot* (previously a misrole'd connection failed only at write time — U5); (2) audit logs now carry write-statement SQL when the DB plugin is wired writable (new data flow, not a logging change — U4). Auth's *coverage* is unchanged but its *granularity* is a known limit on the writable path (authorize can't see read-vs-write).
335
+
336
+ ---
337
+
338
+ ## Risks & Dependencies
339
+
340
+ | Risk | Mitigation |
341
+ |------|------------|
342
+ | Operators silently get a writable `db.query` by wiring the wrong connection | The real guard is the **deliberate two-step opt-in**: declare a `role: :writing` connection *and* wire it into `:db`. The boot warning is **detection/audit only** — it does not prevent the mistake, it records it. Read-only stays the default. Per the locked decision, no second `allow_writes:` token is added. |
343
+ | A writable `db.query` is under-controlled by `config.authorize` and over-exposed in audit logs | Documented as explicit limitations (U4/U6): authorize can't distinguish read vs write on one tool name, so **database-user privilege scoping** is the write-level control; full SQL incl. literals appears in audit logs, so scope/filter log destinations. No SQL parsing is added (false security). |
344
+ | Breaking change strands existing initializers on upgrade | Two distinct, ordered boot errors (missing `connection:` vs. unknown name); CHANGELOG breaking-change note. **Premise:** acceptable because the gem is pre-1.0 (`0.1.0.pre`) with no known external installs and Rails apps hit boot before serving — stated as an assumption, not a guarantee. No upgrade generator is provided. |
345
+ | `config.enabled` honored late / stale due to memoized `rack_app` | The disabled check is evaluated **per request** inside the Rack app (not captured at `build`), so route-load-before-initializer ordering and memoization can't serve a stale-`enabled` app; covered by a real-boot integration test in U1. Returns `503` (distinguishable from a missing route). |
346
+ | Connection-resolution precedence regresses custom tools, or terminal error fires at call time | Static `connection` DSL retained as a fallback for custom tools (plugin-wired beats static for bundled tools); `plugin_name`-nil guarded with `&.dig`. The terminal `ConfigurationError` is inherently call-time — bundled tools avoid it because `plugin_name` is always set in `collect_tools` (`rails_mount.rb:42`); the "fails at boot" claim covers correctly-wired configs, not the resolution fallthrough. |
347
+
348
+ ---
349
+
350
+ ## Documentation / Operational Notes
351
+
352
+ - README, `docs/read_only_connections.md`, the install generator template, and the dummy initializer all move in U6; treat them as one consistency set.
353
+ - CHANGELOG must call out the breaking `connection:` requirement prominently so upgraders see it.
354
+
355
+ ---
356
+
357
+ ## Sources & References
358
+
359
+ - Connection DSL & validation: `lib/talk_to_your_app/configuration.rb`, `lib/talk_to_your_app/connection_registry.rb`
360
+ - Plugin/tool wiring: `lib/talk_to_your_app/plugin.rb`, `lib/talk_to_your_app.rb`, `lib/talk_to_your_app/tool.rb`, `lib/talk_to_your_app/plugin_registry.rb`
361
+ - DB / Flipper plugins: `lib/talk_to_your_app/plugins/db/`, `lib/talk_to_your_app/plugins/flipper/`
362
+ - Boot & transport: `lib/talk_to_your_app/railtie.rb`, `lib/talk_to_your_app/transport/rails_mount.rb`
363
+ - Operator docs: `README.md`, `docs/read_only_connections.md`, `test/dummy/config/initializers/talk_to_your_app.rb`