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.
- checksums.yaml +4 -4
- data/README.md +62 -28
- data/docs/plans/2026-06-18-001-feat-connection-wiring-and-enabled-flag-plan.md +363 -0
- data/docs/plugin_authoring.md +21 -10
- data/docs/read_only_connections.md +12 -6
- data/docs/solutions/runtime-errors/sidekiq-adapter-requires-sidekiq-api-2026-06-16.md +4 -4
- data/lib/generators/talk_to_your_app/install/templates/initializer.rb.tt +42 -15
- data/lib/talk_to_your_app/configuration.rb +53 -9
- data/lib/talk_to_your_app/plugin.rb +27 -10
- data/lib/talk_to_your_app/plugin_registry.rb +13 -0
- data/lib/talk_to_your_app/plugins/db/plugin.rb +41 -16
- data/lib/talk_to_your_app/plugins/db/tools/query.rb +17 -13
- data/lib/talk_to_your_app/plugins/db/tools/schema.rb +0 -3
- data/lib/talk_to_your_app/plugins/db/tools/tables.rb +4 -6
- data/lib/talk_to_your_app/plugins/flipper/plugin.rb +21 -3
- data/lib/talk_to_your_app/plugins/flipper/tools/disable_flag.rb +0 -1
- data/lib/talk_to_your_app/plugins/flipper/tools/enable_flag.rb +0 -1
- data/lib/talk_to_your_app/plugins/flipper/tools/enabled_flags.rb +0 -1
- data/lib/talk_to_your_app/plugins/flipper/tools/list_flags.rb +0 -1
- data/lib/talk_to_your_app/plugins/flipper/tools/read_flag.rb +0 -1
- data/lib/talk_to_your_app/plugins/jobs/plugin.rb +53 -28
- data/lib/talk_to_your_app/plugins/jobs/tools/base.rb +28 -0
- data/lib/talk_to_your_app/plugins/jobs/tools/failed_jobs.rb +3 -4
- data/lib/talk_to_your_app/plugins/jobs/tools/queue_sizes.rb +4 -4
- data/lib/talk_to_your_app/plugins/jobs/tools/rate_metrics.rb +3 -4
- data/lib/talk_to_your_app/plugins/jobs/tools/recent_jobs.rb +3 -4
- data/lib/talk_to_your_app/railtie.rb +6 -2
- data/lib/talk_to_your_app/tool.rb +63 -14
- data/lib/talk_to_your_app/transport/rails_mount.rb +25 -2
- data/lib/talk_to_your_app/version.rb +1 -1
- data/lib/talk_to_your_app.rb +26 -5
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 968cb02f102a1c4e238a3305a9b67f9b00642e3da99c30ccf9ece00799251547
|
|
4
|
+
data.tar.gz: 8d87ffd983498a0636b3e3d88b465262fa7781e0e534325cd96ef3c06ee15c28
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
[](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
|
-
- 🧰 **
|
|
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 :
|
|
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
|
|
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.
|
|
108
|
-
| `config.
|
|
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
|
|
161
|
-
| [
|
|
162
|
-
| [
|
|
163
|
-
| [
|
|
164
|
-
| [
|
|
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
|
|
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 :
|
|
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. **
|
|
178
|
-
- **`db.tables`** — lists the table names in the
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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 :
|
|
251
|
+
config.plugin :sidekiq, connection: false
|
|
252
|
+
config.plugin :solid_queue, connection: false # both may run at once
|
|
225
253
|
```
|
|
226
254
|
|
|
227
|
-
|
|
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
|
-
|
|
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`
|