reeve 0.2.0 → 0.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aaf976c70abfbb4d56236acface7e1ef95a4e8c177452e4ef918ca22f45933d0
4
- data.tar.gz: 9076bbad0582db6c4cda3c38dc89f97840dc3a7e2dbb99ca98a16c5b642ece57
3
+ metadata.gz: 329fc869bdb15c7e60eb1923221aeccb1725821535c25186dafe77bda001cc44
4
+ data.tar.gz: 0f0781268bc7e90b1fad27a74bef611aa6aa65df3da37fb1351eb3f28524da15
5
5
  SHA512:
6
- metadata.gz: 7957d35a7e9df67acaa1c6babcd18b1d2340ffd63595fc88a11e16bd5c81cfa78ddb6b6cf17742f74b1f300d5e35058442d124fe77d6cc7051005e3ed4d5b837
7
- data.tar.gz: 543bb727d1abf718053d21a72fe3d3eb62b9e82ebe5a3658046b7d80eb8c897c91e76da7d6c55842f1ee0545b76af94a2d1127ffa2fc814a88bbadab3c827a01
6
+ metadata.gz: b5d6286339f5f034e76f74052e3d19f9560c55b45e9432e6583b50931ddf4425a1aa5bc5c0ea1200646a9085f972521a3d1e2c38d8cf654e8aa8004fa2be0e3f
7
+ data.tar.gz: 32c28f2c34294f7d2454b772a1fd7bd52af690c3bc6d46793e2ec23021c6c3bd5d0d4bc6ee5f04b990a36a5f8ada40e03677bd9efa5c2a099b08c6bcf4cac147
data/CHANGELOG.md CHANGED
@@ -4,6 +4,155 @@ All notable changes are recorded here. This project follows [Semantic
4
4
  Versioning](https://semver.org), with one rule specific to what it does — see
5
5
  [Versioning policy](#versioning-policy).
6
6
 
7
+ ## [0.4.0] - 2026-09-03
8
+
9
+ Everything here came out of a second application integrating the gem — one that had put a
10
+ handful of its MCP tools behind reeve and was about to guard its write tools next. Two
11
+ defects surfaced from reviewing that work, and both were failures of the same kind: the
12
+ gem reporting a guarantee it had not actually established.
13
+
14
+ **The audit-entry contract is unchanged at `2`, so no migration is required.**
15
+
16
+ Behaviour changes worth reading before upgrading:
17
+
18
+ - A denied invocation now rolls the tool's work back. A guarded tool that writes and is
19
+ then refused used to leave the write behind.
20
+ - A guarded tool that raises after writing now has that write rolled back too.
21
+ - The compliance suite now reports tools that never declared a guard, so a suite that
22
+ passed before may legitimately fail now. That is the fix, not a regression — set
23
+ `config.compliance_tools` to what you have certified so far.
24
+
25
+ ### Fixed
26
+
27
+ - **The compliance suite can see a tool that has no guard.** It walked the guard registry,
28
+ which holds one entry per `guard_with` — so every subject it checked had a guard by
29
+ construction, and `GuardDeclared`, the check whose whole job is naming tools that lack
30
+ one, could not fail. An application with three guarded tools and one that was forgotten
31
+ reported `4 checks, 4 passed`. The gem's own quickstart demonstrated this: it ships a
32
+ deliberately unguarded tool and then asserted that the compliance run passed.
33
+
34
+ Tools are now recorded when they include `Reeve::Guard`, declared or not — through
35
+ inheritance too, so an adapter that includes the DSL into a base class once (fast-mcp)
36
+ no longer hides every tool built on it. `Reeve.registry.unguarded_tool_classes` is the
37
+ worklist. A base class that carries the DSL for its subclasses declares itself with
38
+ `reeve_abstract!` rather than being guessed at: inferring "anything with a subclass is a
39
+ base" would silently drop a real tool from the run the moment someone subclassed it.
40
+
41
+ **Reeve can only see tools that reached it.** An MCP server dispatching tools reeve was
42
+ never told about is still invisible to it — pass `tools:` to certify the real inventory.
43
+
44
+ - **A denied invocation no longer leaves the host's data changed.** The envelope
45
+ authorizes before the tool runs, with the model class as the subject because no record
46
+ exists yet, and scopes the return value afterwards. A write tool lives between those two
47
+ points: it fetched a record, changed it, and only then was refused. The caller saw
48
+ `out_of_scope_record`, the ledger recorded a denial — and the row in the database had
49
+ already been rewritten by a principal who could not see it. A `deny` that follows a
50
+ committed write is not a partial guarantee, it is a false statement in the artifact the
51
+ ledger exists to be.
52
+
53
+ The tool body now runs in a transaction and a scope denial rolls it back. `requires_new`
54
+ makes it a savepoint, so a transaction the host opened around the invocation is left
55
+ alone — the rollback reaches the tool's work and stops there. Measured overhead is at or
56
+ below noise on a read-only call.
57
+
58
+ Two consequences worth knowing:
59
+
60
+ - A tool that **writes and then raises** now has that write rolled back too. It was
61
+ already recorded as `tool_error`; the data now matches what the ledger said.
62
+ - A host with no ActiveRecord, or with the library loaded but no connection, keeps the
63
+ previous behaviour — there is nothing to roll back, and neither should be made to fail.
64
+
65
+ ### Added
66
+
67
+ - **`config.compliance_tools`** — which tools the suite certifies, as an array or a
68
+ callable, defaulting to every tool reeve knows about. A retrofit is the normal state of
69
+ an application adopting this, and now that unguarded tools are reported, one that has
70
+ guarded three of thirty has a red build it cannot honestly turn green. A build expected
71
+ to be red is a build nobody reads. Narrowing the list states what has been certified —
72
+ a claim that can go green and stay green as the list grows.
73
+
74
+ - **`authorize!(record)`** inside a tool body — asks the declared policy about one record
75
+ and returns it, raising `DeniedError` if the policy says no. For the part of a tool a
76
+ rollback cannot reach: a sent email, a webhook, a written file. A denial raised this way
77
+ carries the policy's own rule into the ledger rather than being filed as `tool_error`,
78
+ and names no record, so a refusal and a record that does not exist still read the same
79
+ (FR-006).
80
+
81
+ ## [0.3.0] - 2026-08-17
82
+
83
+ Three of the limitations 0.1.0 shipped knowingly are now closed, and the audit-entry
84
+ contract is unchanged — **no migration is required to take this release**.
85
+
86
+ Two of the "Known limitations" under 0.1.0 below no longer hold, and are left in place
87
+ because that section records what was true of that release:
88
+
89
+ - *"A transaction the host wraps around an invocation takes the ledger row with it"* —
90
+ `IsolatedRecorder` closes it where the database has concurrent writers.
91
+ - *"CI runs SQLite only"* — the suite now runs on PostgreSQL and MySQL too.
92
+
93
+ Still open from that list: an unscoped fetch by id disclosing existence, a scope-less type
94
+ needing to be tied to its policy, `AuditCoverage` proving one invocation rather than every
95
+ one, and the fast-mcp adapter needing Ruby 3.1+.
96
+
97
+ ### Added
98
+
99
+ - **`Reeve::Audit::IsolatedRecorder`** — the ledger row on a connection of its own, which
100
+ closes the R5 limitation the first release documented rather than fixed. The default
101
+ recorder writes in a savepoint: the trace survives a rollback by the *tool*, but a
102
+ transaction the *host* wrapped around the whole invocation takes the row with it, and
103
+ the calls most worth auditing are exactly the ones that rolled back. A transaction on
104
+ one connection cannot roll back an INSERT committed on another.
105
+
106
+ ```ruby
107
+ config.audit_recorder = Reeve::Audit::IsolatedRecorder
108
+ ```
109
+
110
+ Opt-in rather than the default, because it cannot work everywhere the default does: on
111
+ SQLite an open transaction holds the database write lock, so a second connection would
112
+ block until it timed out and fail every guarded call. It raises `ConfigurationError`
113
+ there rather than degrading quietly, and `IsolatedRecorder.available?` answers the
114
+ question in an initializer for a host that develops on SQLite and deploys on PostgreSQL.
115
+ The default recorder's warning now names it.
116
+
117
+ Worth deciding knowingly: this also keeps the row when the host rolls back an
118
+ invocation it meant to undo entirely. A ledger of what was *attempted* is the intended
119
+ reading of Constitution II, but it is a choice.
120
+
121
+ - **CI runs the suite on PostgreSQL and MySQL as well as SQLite.** The gem claims to work
122
+ on every database ActiveRecord supports — the ledger stores `invocation_id` as a string
123
+ rather than a native `uuid` precisely so that holds — and until now that claim had only
124
+ ever been executed against SQLite. The three engines disagree about exactly what this
125
+ gem leans on: what `t.json` becomes, what a boolean literal is, what
126
+ `change_column_default` does to a NOT NULL column, and how a savepoint behaves inside a
127
+ transaction the caller opened. Select an engine with `DB=postgresql` or `DB=mysql2`
128
+ (`DB=postgresql bundle install` first — the drivers are `install_if`-gated so the
129
+ default install needs no native client libraries).
130
+
131
+ All 545 examples pass on all three. The one portability defect it found was in the
132
+ `reeve:upgrade` spec rather than the library: a fixture row inserted `0` into boolean
133
+ columns, which is a boolean on SQLite and MySQL and a type error on PostgreSQL. The
134
+ `change_column_default` the upgrade migration depends on behaves identically on all
135
+ three, which was the specific thing worth knowing after shipping it against SQLite only.
136
+
137
+ - **`bin/rails generate reeve:upgrade`** — brings an existing ledger up to the current
138
+ audit-entry contract. It asks the table which columns it has and emits only the steps
139
+ that are missing, so it is a no-op on a current ledger and safe to run twice before
140
+ migrating. Replaces the hand-written `add_column` the 0.2.0 notes had to give.
141
+ ([#11](https://github.com/vicmaster/reeve/issues/11))
142
+ - `contracts/audit-entry.md` states the rule the upgrade path depends on: **changes to the
143
+ table must be additive**, and a new column must be nullable or carry a default that is
144
+ true of the rows already written. An append-only ledger has no honest value to backfill
145
+ into a historical row. A change that cannot be expressed additively is a new table, not
146
+ a new version of this one.
147
+
148
+ ### Fixed
149
+
150
+ - `Checks::ContractVersion` names `reeve:upgrade` when the table is missing a column. It
151
+ said `reeve:install`, which is wrong for exactly the case it fires in: the table exists,
152
+ so Rails resolves that migration by name and either emits nothing or offers to overwrite
153
+ one that has already run — which does not touch the database and destroys the record of
154
+ what was applied. The install generator now says the same thing on its way out.
155
+
7
156
  ## [0.2.0] - 2026-08-12
8
157
 
9
158
  Everything here came out of running 0.1.0 against a real Rails 8.1 application rather than
data/README.md CHANGED
@@ -7,7 +7,7 @@ A Ruby gem that makes it safe for a Rails application to expose MCP tools to AI
7
7
  declarative per-record authorization, an append-only audit ledger, and a testing kit that
8
8
  proves both hold.
9
9
 
10
- > **Status: 0.2.0.** Read the known limitations in
10
+ > **Status: 0.4.0.** Read the known limitations in
11
11
  > [CHANGELOG.md](CHANGELOG.md) before adopting it — particularly the one about a
12
12
  > transaction wrapped around an invocation, if your application wraps requests in one.
13
13
 
@@ -119,6 +119,42 @@ end
119
119
  Reaching for `Invoice.sum(:cents)` there is denied with `unscoped_derived_result`. The
120
120
  guarantee is structural, not a matter of remembering.
121
121
 
122
+ ## Tools that write
123
+
124
+ A denial means nothing happened. Authorization runs before the tool, but with the model
125
+ class as its subject — for an index-style check there is no record yet — so the per-record
126
+ answer only arrives once the tool has returned something to scope. A write tool sits
127
+ between those two points:
128
+
129
+ ```ruby
130
+ def call(id:, number:)
131
+ invoice = Invoice.find(id) # unscoped fetch
132
+ invoice.update!(number: number) # ...then a write
133
+ invoice
134
+ end
135
+ ```
136
+
137
+ The tool body runs in a transaction, and a scope denial rolls it back. Someone else's
138
+ record is refused *and* unchanged, and the ledger's `deny` is a true statement about the
139
+ database. A transaction the host opened around the invocation is untouched — the rollback
140
+ reaches the tool's work and stops there.
141
+
142
+ What a rollback cannot reach is anything that was never in the transaction. A tool that
143
+ sends an email, calls a webhook or writes a file before it knows whether it is allowed to
144
+ must ask first:
145
+
146
+ ```ruby
147
+ def call(id:, to:)
148
+ invoice = authorize!(Invoice.find(id)) # raises DeniedError if the policy says no
149
+ InvoiceMailer.reminder(invoice, to).deliver_now
150
+ invoice
151
+ end
152
+ ```
153
+
154
+ `authorize!` asks the declared policy about one record and returns it, so it reads inline.
155
+ The denial carries the policy's own rule into the ledger, and names no record — a refusal
156
+ and a record that does not exist have to read the same.
157
+
122
158
  ## Every call leaves a trace
123
159
 
124
160
  One append-only row per invocation, allowed or denied, naming the agent, the principal, the
@@ -136,6 +172,20 @@ A call whose tool raised is still recorded — that trace is the one most worth
136
172
  call that cannot be recorded fails, unless the host has explicitly opted into
137
173
  `audit_failure_mode = :warn`.
138
174
 
175
+ If your application wraps invocations in a transaction — a controller that opens one per
176
+ request, a job runner — a rollback takes the ledger row with it, and exactly the calls
177
+ worth auditing are the ones that rolled back. Write on a connection the rollback cannot
178
+ reach:
179
+
180
+ ```ruby
181
+ config.audit_recorder = Reeve::Audit::IsolatedRecorder
182
+ ```
183
+
184
+ It needs a database with concurrent writers and refuses on SQLite, where an open
185
+ transaction holds the write lock; `IsolatedRecorder.available?` lets you branch if you
186
+ develop on one and deploy on the other. The default recorder warns when it detects the
187
+ case rather than failing silently.
188
+
139
189
  ## Provable in CI, in whichever framework you already use
140
190
 
141
191
  All the logic lives in framework-neutral checks. RSpec and Minitest are thin front-ends
@@ -177,7 +227,29 @@ abort report.to_s unless report.passed?
177
227
  ```
178
228
 
179
229
  `alice` and `bob` are two fixture principals with disjoint records — that disjointness is
180
- what makes a shared record identifier proof of a leak. As a Rails rake task:
230
+ what makes a shared record identifier proof of a leak.
231
+
232
+ **The suite walks every tool that included `Reeve::Guard`, not only the ones that declared
233
+ a guard.** A tool that forgot `guard_with` is the one worth finding, and it is absent from
234
+ the guard registry by definition — so a run that only inspected guarded tools reported
235
+ all-green on precisely the application that had a problem.
236
+
237
+ Mid-retrofit that means a red build, which is honest but unreadable if it stays red for
238
+ weeks. Say what you have certified so far, and grow the list:
239
+
240
+ ```ruby
241
+ config.compliance_tools = -> { [InvoiceSearchTool, InvoiceShowTool] }
242
+ ```
243
+
244
+ Reeve can only see tools that reached it. If your MCP server dispatches tools Reeve has
245
+ never been told about — a custom controller with its own registry — hand it the real
246
+ inventory, or it will certify the subset it happens to know:
247
+
248
+ ```ruby
249
+ Reeve::Checks.run_all(principals: [alice, bob], tools: McpToolRegistry.tool_classes)
250
+ ```
251
+
252
+ As a Rails rake task:
181
253
 
182
254
  ```ruby
183
255
  # lib/tasks/reeve.rake
@@ -39,6 +39,10 @@ module Reeve
39
39
  2. Run `bin/rails db:migrate` to create the audit ledger.
40
40
 
41
41
  Then add `guard_with SomePolicy` to a tool.
42
+
43
+ Upgrading an existing ledger later? Use `rails g reeve:upgrade`, not this
44
+ generator — Rails resolves a migration by name, so re-running install cannot
45
+ deliver a shape change to a table that already exists.
42
46
  NEXT
43
47
  end
44
48
  end
@@ -53,6 +53,25 @@ Reeve.configure do |config|
53
53
  # guarantee for availability.
54
54
  config.audit_failure_mode = :fail
55
55
 
56
+ # ---------------------------------------------------------------------------
57
+ # Does your application wrap the invocation in a transaction?
58
+ #
59
+ # A controller that opens one per request, a job runner, a test suite with
60
+ # transactional fixtures — if that transaction rolls back, it takes the ledger
61
+ # row with it, and the default recorder can only warn about it.
62
+ #
63
+ # config.audit_recorder = Reeve::Audit::IsolatedRecorder
64
+ #
65
+ # writes on a connection of its own, which a rollback on yours cannot reach.
66
+ # It needs a database with concurrent writers and refuses to run on SQLite, so
67
+ # a host that develops on SQLite and deploys on PostgreSQL branches:
68
+ #
69
+ # config.audit_recorder =
70
+ # Reeve::Audit::IsolatedRecorder.available? ? Reeve::Audit::IsolatedRecorder
71
+ # : Reeve::Audit::Recorder
72
+ #
73
+ # Left unset, which resolves to Reeve::Audit::Recorder.
74
+
56
75
  # Cap on how many record identifiers one entry stores. The true count is
57
76
  # always recorded, and the entry says when the list was truncated.
58
77
  config.max_recorded_ids = 1000
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Audit-entry contract 1 → 2. Generated by `bin/rails generate reeve:upgrade`.
4
+ #
5
+ # Adds the column that lets a row name the shape it was written under. Contract 1 wrote
6
+ # `metadata` NULL whatever the caller passed, and contract 2 writes what was passed, so
7
+ # without this column a NULL `metadata` is ambiguous: "never recorded" and "the caller
8
+ # passed none" look identical, separable only by knowing when the gem was deployed.
9
+ #
10
+ # `default: 1` is not a fallback, it is the truth about the rows already in the table:
11
+ # every one of them was written under contract 1. The default is then dropped, so a row
12
+ # inserted from here on has to say what it is — which matches the shape a fresh
13
+ # `reeve:install` creates, and keeps the two paths converged.
14
+ class AddContractVersionToReeveAuditEntries < ActiveRecord::Migration[7.0]
15
+ def change
16
+ add_column :reeve_audit_entries, :contract_version, :integer, null: false, default: 1
17
+ change_column_default :reeve_audit_entries, :contract_version, from: 1, to: nil
18
+ end
19
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Reeve
7
+ module Generators
8
+ # `bin/rails generate reeve:upgrade` — brings an existing ledger up to the audit-entry
9
+ # contract this version of the gem writes.
10
+ #
11
+ # Why this exists as a second generator rather than as a re-run of `reeve:install`:
12
+ # Rails resolves a migration by name, so `install` on a host that already has
13
+ # `create_reeve_audit_entries` reports `identical` and emits nothing, or — once the
14
+ # template has changed, which is exactly the upgrade case — offers to overwrite a
15
+ # migration that has already run. Overwriting an applied migration does not touch the
16
+ # database and destroys the record of what was applied, so the one thing a host must
17
+ # never be told to do about an out-of-date ledger is run the install generator again.
18
+ #
19
+ # What it emits is decided by the table, not by a version number the host might have
20
+ # recorded wrongly or not at all: each step declares the columns it adds, and a step
21
+ # whose columns are all present has already been applied. That makes running this on a
22
+ # current ledger a no-op it can report rather than a duplicate migration.
23
+ class UpgradeGenerator < Rails::Generators::Base
24
+ include ActiveRecord::Generators::Migration
25
+
26
+ TABLE = "reeve_audit_entries"
27
+
28
+ # The ladder, oldest first. One entry per contract bump that touches the table.
29
+ #
30
+ # `adds` is what makes a step detectable, so it must name every column the step
31
+ # creates. A step that changes a column without adding one cannot be detected this
32
+ # way and needs its own predicate — see the additive-only rule in
33
+ # contracts/audit-entry.md, which exists so that stays hypothetical: an append-only
34
+ # ledger cannot be backfilled, so a column that is not additive has no honest value
35
+ # to give the rows already written.
36
+ STEPS = [
37
+ {
38
+ contract: 2,
39
+ adds: %w[contract_version],
40
+ template: "add_contract_version_to_reeve_audit_entries.rb.tt",
41
+ destination: "add_contract_version_to_reeve_audit_entries.rb"
42
+ }
43
+ ].freeze
44
+
45
+ source_root File.expand_path("templates", __dir__)
46
+
47
+ desc "Brings an existing reeve audit ledger up to the current audit-entry contract."
48
+
49
+ def verify_ledger_exists
50
+ return if table_exists?
51
+
52
+ say <<~MISSING
53
+
54
+ There is no #{TABLE} table to upgrade.
55
+
56
+ This generator is for a ledger that already exists. For a new one:
57
+
58
+ bin/rails generate reeve:install
59
+ bin/rails db:migrate
60
+ MISSING
61
+
62
+ # Not an exception: "you wanted the other generator" is a normal thing to get
63
+ # wrong, and a backtrace would suggest reeve broke.
64
+ @halted = true
65
+ end
66
+
67
+ def create_upgrade_migrations
68
+ return if @halted
69
+
70
+ pending = STEPS.reject { |step| applied?(step) }
71
+ return @nothing_to_do = true if pending.empty?
72
+
73
+ pending.each do |step|
74
+ migration_template(step[:template], "db/migrate/#{step[:destination]}")
75
+ end
76
+
77
+ @emitted = pending
78
+ end
79
+
80
+ def report_next_step
81
+ return if @halted
82
+
83
+ say(@nothing_to_do ? up_to_date_message : pending_message)
84
+ end
85
+
86
+ private
87
+
88
+ def up_to_date_message
89
+ <<~CURRENT
90
+
91
+ #{TABLE} is already at audit-entry contract #{STEPS.last[:contract]}. Nothing to do.
92
+ CURRENT
93
+ end
94
+
95
+ def pending_message
96
+ <<~NEXT
97
+
98
+ #{@emitted.size} migration#{'s' if @emitted.size != 1} written, taking the ledger
99
+ to audit-entry contract #{@emitted.last[:contract]}. Run:
100
+
101
+ bin/rails db:migrate
102
+
103
+ Existing rows are stamped with the contract they were actually written under,
104
+ not with the current one.
105
+ NEXT
106
+ end
107
+
108
+ # Asked of the table rather than of a version the host recorded: the table is the
109
+ # thing being changed, and it is the only party that cannot be out of date about
110
+ # itself.
111
+ def applied?(step)
112
+ (step[:adds] - existing_columns).empty?
113
+ end
114
+
115
+ def existing_columns
116
+ @existing_columns ||= connection.columns(TABLE).map(&:name)
117
+ end
118
+
119
+ def table_exists?
120
+ connection.table_exists?(TABLE)
121
+ rescue StandardError
122
+ false
123
+ end
124
+
125
+ def connection
126
+ ActiveRecord::Base.connection
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ # The ledger row, written on a connection of its own.
6
+ #
7
+ # `Recorder` writes in a savepoint, which protects the trace from a transaction the
8
+ # *tool* opens and rolls back. It cannot protect the trace from a transaction the
9
+ # *host* opened around the whole invocation — a controller that wraps each request in
10
+ # one, a job runner, a test suite with transactional fixtures. A savepoint released
11
+ # into that transaction goes down with it, and the envelope has no way to learn
12
+ # otherwise: the write reported success, the invocation returned records, and no row
13
+ # survives to say so. Constitution II says every call leaves a trace; on one
14
+ # connection that is not achievable, only detectable, which is why `Recorder` warns.
15
+ #
16
+ # A second connection is the only thing that actually closes it. A transaction on
17
+ # connection A cannot roll back an INSERT committed on connection B, so the row lands
18
+ # whatever the host's transaction does next.
19
+ #
20
+ # config.audit_recorder = Reeve::Audit::IsolatedRecorder
21
+ #
22
+ # == Why this is not the default
23
+ #
24
+ # It cannot be. On SQLite the host's open transaction holds the database write lock,
25
+ # so the second connection blocks until it times out and every guarded call fails —
26
+ # the isolation is not merely unavailable there, it is actively worse than the
27
+ # savepoint. Rather than degrade quietly on the database most hosts develop against,
28
+ # this refuses to run on SQLite and says why. `Recorder` remains the default, and a
29
+ # host that needs durability under a wrapping transaction opts in on a database where
30
+ # opting in means something.
31
+ #
32
+ # It also costs a connection per pool, and moves the ledger write outside the host's
33
+ # transaction in both directions: the row survives a rollback, which is the point,
34
+ # and it also survives a rollback of an invocation the host *meant* to undo entirely.
35
+ # A ledger of what was attempted is the intended reading of Constitution II, but it is
36
+ # a choice, and it should be made rather than inherited.
37
+ class IsolatedRecorder < Recorder
38
+ UNSUPPORTED_ADAPTERS = %w[sqlite sqlite3].freeze
39
+
40
+ class << self
41
+ def record(attributes)
42
+ new.record(attributes)
43
+ end
44
+
45
+ # True when a second connection can actually be used here. Lets a host branch in
46
+ # an initializer — SQLite in development, PostgreSQL in production — rather than
47
+ # discovering the answer on the first guarded call.
48
+ def available?
49
+ verify_supported!
50
+ true
51
+ rescue Reeve::Error
52
+ false
53
+ end
54
+
55
+ def verify_supported!
56
+ adapter = IsolatedEntry.adapter_name
57
+ return unless UNSUPPORTED_ADAPTERS.include?(adapter.to_s.downcase)
58
+
59
+ raise ConfigurationError,
60
+ "Reeve::Audit::IsolatedRecorder cannot be used on #{adapter}: an open " \
61
+ "transaction holds the database write lock, so a second connection " \
62
+ "blocks until it times out and every guarded call fails. Use " \
63
+ "Reeve::Audit::Recorder here — it warns when a host transaction is " \
64
+ "wrapped around an invocation — and configure this recorder only where " \
65
+ "the database supports concurrent writers."
66
+ end
67
+ end
68
+
69
+ def initialize(config: nil)
70
+ super(entry_class: IsolatedEntry, config: config)
71
+ end
72
+
73
+ # Checked out and returned around each write rather than held: the pool exists to
74
+ # keep the ledger off the host's connection, not to keep a connection per thread
75
+ # alive for the life of the process.
76
+ def record(attributes)
77
+ self.class.verify_supported!
78
+ IsolatedEntry.connection_pool.with_connection { super }
79
+ end
80
+
81
+ private
82
+
83
+ # The savepoint `Recorder` uses is what makes the trace survive the *tool's* own
84
+ # rollback, and that is still worth having here: this connection is not in a
85
+ # transaction the host controls, but the tool body may have opened one on the main
86
+ # connection, and a single INSERT wants the same all-or-nothing treatment either
87
+ # way. Nothing about it is inherited from the host's transaction.
88
+ #
89
+ # The warning the parent emits is correctly silent here: it asks whether *this*
90
+ # connection has a transaction open, and this one never does.
91
+ def enclosing_transaction?
92
+ false
93
+ end
94
+ end
95
+
96
+ # The same table and the same rules, on a pool of its own.
97
+ #
98
+ # A subclass rather than a second model so that validations, readonly-once-persisted
99
+ # and the destroy guard are defined in exactly one place. The table has no
100
+ # inheritance column, so ActiveRecord adds no type condition and this reads and writes
101
+ # the same rows `Entry` does.
102
+ class IsolatedEntry < Entry
103
+ class << self
104
+ def adapter_name
105
+ establish_own_connection!
106
+ connection_pool.db_config.adapter
107
+ rescue StandardError => e
108
+ raise Error, "could not reach the audit ledger on its own connection: " \
109
+ "#{e.class}: #{e.message}"
110
+ end
111
+
112
+ def connection_pool
113
+ establish_own_connection!
114
+ super
115
+ end
116
+
117
+ private
118
+
119
+ # Idempotent, and deliberately lazy: a Rails initializer naming this recorder runs
120
+ # before the database is necessarily reachable, so the pool is built on first use
121
+ # rather than at configure time.
122
+ def establish_own_connection!
123
+ return if @own_connection
124
+
125
+ establish_connection(::ActiveRecord::Base.connection_db_config)
126
+ @own_connection = true
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -26,10 +26,14 @@ module Reeve
26
26
  # A genuinely independent write needs a second connection, and that is not portable:
27
27
  # on SQLite the enclosing transaction holds the write lock, so a second connection
28
28
  # blocks until it times out. Rather than fail every call on the databases where
29
- # isolation is impossible, the recorder detects the enclosing transaction and warns
30
- # that the guarantee is suspended for that call. A host that needs durability under a
31
- # wrapping transaction supplies its own `audit_recorder` — writing to a separate
32
- # connection, a queue, or an append-only log which is what that setting is for.
29
+ # isolation is impossible, this recorder detects the enclosing transaction and warns
30
+ # that the guarantee is suspended for that call.
31
+ #
32
+ # `Reeve::Audit::IsolatedRecorder` is the answer where the database has concurrent
33
+ # writers: same rows, same redaction, on a pool of its own, so a rollback on the
34
+ # host's connection cannot reach it. It is opt-in rather than the default precisely
35
+ # because it cannot work everywhere this one does. A host with a different answer
36
+ # again — a queue, an append-only log — still supplies its own `audit_recorder`.
33
37
  #
34
38
  # The other known limit, unchanged: on a single connection there is a narrow window
35
39
  # where the tool's data commits and the ledger write then fails. The caller learns by
@@ -161,8 +165,9 @@ module Reeve
161
165
 
162
166
  message = "reeve: invocation #{invocation_id} was recorded inside a transaction " \
163
167
  "the host opened around it, so the ledger row will be rolled back with " \
164
- "it. Configure Reeve.config.audit_recorder with a recorder that writes " \
165
- "outside this transaction if the trace must survive."
168
+ "it. Set Reeve.config.audit_recorder = Reeve::Audit::IsolatedRecorder " \
169
+ "to write on a connection the transaction cannot reach (needs a " \
170
+ "database with concurrent writers; not SQLite)."
166
171
  logger = config.logger
167
172
  logger ? logger.warn(message) : Kernel.warn(message)
168
173
  end
data/lib/reeve/audit.rb CHANGED
@@ -92,6 +92,7 @@ end
92
92
  require_relative "audit/redactor"
93
93
  require_relative "audit/entry"
94
94
  require_relative "audit/recorder"
95
+ require_relative "audit/isolated_recorder"
95
96
  require_relative "audit/query"
96
97
 
97
98
  Reeve::Audit.install!
@@ -16,10 +16,22 @@ module Reeve
16
16
  module Guard
17
17
  def self.included(base)
18
18
  base.extend(ClassMethods)
19
+ Reeve.registry.note(base)
19
20
  end
20
21
 
21
22
  # Class-level DSL. See contracts/tool-dsl.md.
22
23
  module ClassMethods
24
+ # Declares this class a base rather than a tool: it carries the DSL so its
25
+ # subclasses have it, and the compliance suite should not ask whether it is guarded.
26
+ #
27
+ # class ApplicationTool
28
+ # include Reeve::Guard
29
+ # reeve_abstract!
30
+ # end
31
+ def reeve_abstract!
32
+ Reeve.registry.mark_abstract(self)
33
+ end
34
+
23
35
  # Declares which policy governs this tool. Absence is not neutral: a tool with no
24
36
  # declaration is denied by the envelope (FR-002, FR-004).
25
37
  def guard_with(policy, action: nil)
@@ -59,8 +71,16 @@ module Reeve
59
71
 
60
72
  # A subclass of a guarded tool is itself guarded, and is registered under its own
61
73
  # name so the envelope — which only ever has a name — can find it.
74
+ #
75
+ # The note comes first and unconditionally, because a subclass of an *unguarded*
76
+ # base is the case worth catching: subclasses acquire the DSL without ever running
77
+ # `included`, so without this every fast-mcp tool would be invisible to the
78
+ # compliance suite — that adapter includes Guard into `FastMcp::Tool` once and lets
79
+ # inheritance do the rest.
62
80
  def inherited(subclass)
63
81
  super
82
+ Reeve.registry.note(subclass)
83
+
64
84
  declaration = reeve_guard
65
85
  # An anonymous subclass has no name to be looked up by; it still inherits the
66
86
  # declaration through the ancestry walk in Registry#for_class.
@@ -93,5 +113,38 @@ module Reeve
93
113
 
94
114
  Authorization::Scoper.scoped_relation(state, model_or_relation)
95
115
  end
116
+
117
+ # Asks the declared policy about one record, before the tool acts on it. Returns the
118
+ # record so it reads inline; raises +DeniedError+ if the policy says no.
119
+ #
120
+ # def call(id:, to:)
121
+ # invoice = authorize!(Invoice.find(id))
122
+ # InvoiceMailer.reminder(invoice, to).deliver_now
123
+ # invoice
124
+ # end
125
+ #
126
+ # The envelope authorizes before the tool runs and scopes what it returns, and between
127
+ # those two points a tool can do things neither of them can reach. A rolled-back write
128
+ # leaves no trace; a sent email does. `scoped(...)` is the answer when a tool can work
129
+ # from a relation; this is the answer when it cannot — when the tool holds one record
130
+ # and is about to do something to the world with it.
131
+ #
132
+ # The denial carries the policy's own rule, so the ledger names what refused rather
133
+ # than reporting the tool as broken. It names no record: an out-of-scope record and a
134
+ # record that does not exist have to be indistinguishable (FR-006), and a tool that
135
+ # reached this line has already fetched the record it must not talk about.
136
+ def authorize!(record)
137
+ state = Authorization::Current.state
138
+ raise Error, "authorize!(...) may only be called inside a guarded invocation" if state.nil?
139
+
140
+ decision = state.adapter.authorize(
141
+ principal: state.context.principal, policy: state.declaration.policy,
142
+ action: state.declaration.action, record: record
143
+ )
144
+ return record if decision.allowed?
145
+
146
+ raise DeniedError.from(decision, tool_name: state.context.tool_name,
147
+ principal_id: state.context.principal_id)
148
+ end
96
149
  end
97
150
  end
@@ -13,10 +13,49 @@ module Reeve
13
13
 
14
14
  def initialize
15
15
  @declarations = {}
16
+ @known = {} # every class that included Guard, declared or not
17
+ @abstract = {} # bases others inherit from; not tools themselves
16
18
  @name_index = nil # invalidated on every add; see #name_index
17
19
  @mutex = Mutex.new
18
20
  end
19
21
 
22
+ # A class that included the DSL. Recorded whether or not it goes on to declare
23
+ # anything, because a tool that forgot `guard_with` is precisely the one worth
24
+ # finding, and it is absent from `@declarations` by definition.
25
+ def note(tool_class)
26
+ @mutex.synchronize { @known[tool_class] = true }
27
+ tool_class
28
+ end
29
+
30
+ # A base others inherit from — `FastMcp::Tool`, or a host's own `ApplicationTool`.
31
+ # It acquires the DSL so its subclasses have it, and it is not itself a tool, so
32
+ # asking whether it declared a guard is a question with no useful answer.
33
+ #
34
+ # Explicit rather than inferred. The obvious heuristic — "a class something else
35
+ # inherits from is a base" — silently drops a real tool from the compliance run the
36
+ # moment someone subclasses it, and a check that quietly stops checking is the
37
+ # failure mode this whole file exists to prevent.
38
+ def mark_abstract(tool_class)
39
+ @mutex.synchronize { @abstract[tool_class] = true }
40
+ tool_class
41
+ end
42
+
43
+ def abstract?(tool_class)
44
+ @abstract.key?(tool_class)
45
+ end
46
+
47
+ # Every tool reeve knows about: the ones that declared a guard and the ones that
48
+ # only included the DSL. This is what the compliance suite walks, so that "all
49
+ # checks passed" cannot mean "we only looked at the tools that were already safe".
50
+ def tool_classes
51
+ (@known.keys | @declarations.keys).reject { |klass| abstract?(klass) }
52
+ end
53
+
54
+ # The worklist.
55
+ def unguarded_tool_classes
56
+ tool_classes.reject { |klass| @declarations.key?(klass) }
57
+ end
58
+
20
59
  # The DSL's `guard_with`. Declaring twice on one class is a mistake worth naming;
21
60
  # `add` is the quiet path used by `redact` and by inheritance, which refine an
22
61
  # existing declaration rather than compete with it.
@@ -74,6 +113,8 @@ module Reeve
74
113
  def remove(tool_class)
75
114
  @mutex.synchronize do
76
115
  @declarations.delete(tool_class)
116
+ @known.delete(tool_class)
117
+ @abstract.delete(tool_class)
77
118
  @name_index = nil
78
119
  end
79
120
  end
@@ -81,6 +122,8 @@ module Reeve
81
122
  def reset!
82
123
  @mutex.synchronize do
83
124
  @declarations = {}
125
+ @known = {}
126
+ @abstract = {}
84
127
  @name_index = nil
85
128
  end
86
129
  end
@@ -24,7 +24,7 @@ module Reeve
24
24
  SETTINGS = %i[
25
25
  principal_resolver unguarded_tools audit_failure_mode redact_arguments
26
26
  max_recorded_ids policy_adapter default_action audit_recorder logger
27
- compliance_principals
27
+ compliance_principals compliance_tools
28
28
  ].freeze
29
29
 
30
30
  attr_reader(*SETTINGS)
@@ -40,11 +40,33 @@ module Reeve
40
40
  @audit_recorder = nil
41
41
  @logger = nil
42
42
  @compliance_principals = nil
43
+ @compliance_tools = nil
43
44
  end
44
45
 
45
46
  # Two fixture principals with disjoint records — the only host setup the compliance
46
47
  # suite needs (contracts/testing-kit.md). A callable rather than a value, because in a
47
48
  # Rails test suite the fixtures do not exist yet when the helper is loaded.
49
+ # Which tools the compliance suite certifies. Defaults to every tool reeve knows
50
+ # about, which is the answer a finished application wants.
51
+ #
52
+ # A host part-way through a retrofit needs the other answer. Once the suite reports
53
+ # unguarded tools — which is the whole point of it — an application with thirty tools
54
+ # and three guarded ones has a red build it cannot honestly turn green, and a red
55
+ # build that is expected to be red stops being read. Narrowing the list says "these
56
+ # are the ones I have certified", which is a claim that can go green and then stay
57
+ # green as the list grows.
58
+ #
59
+ # config.compliance_tools = -> { [InvoiceSearchTool, InvoiceShowTool] }
60
+ def compliance_tools=(tools)
61
+ unless tools.nil? || tools.respond_to?(:call) || tools.is_a?(Array)
62
+ raise ArgumentError,
63
+ "compliance_tools must be an Array or a callable returning one, " \
64
+ "got #{tools.inspect}"
65
+ end
66
+
67
+ @compliance_tools = tools
68
+ end
69
+
48
70
  def compliance_principals=(principals)
49
71
  unless principals.nil? || principals.respond_to?(:call) || principals.is_a?(Array)
50
72
  raise ArgumentError,
@@ -37,7 +37,11 @@ module Reeve
37
37
  # Idempotent: requiring "reeve/fast_mcp" twice must not stack two envelopes around
38
38
  # the same call.
39
39
  def install!(tool_base = ::FastMcp::Tool)
40
- tool_base.include(Reeve::Guard) unless tool_base.include?(Reeve::Guard)
40
+ unless tool_base.include?(Reeve::Guard)
41
+ tool_base.include(Reeve::Guard)
42
+ # FastMcp::Tool carries the DSL for its subclasses; it is not itself a tool.
43
+ tool_base.reeve_abstract!
44
+ end
41
45
 
42
46
  unless tool_base.singleton_class.include?(Inheritance)
43
47
  tool_base.singleton_class.prepend(Inheritance)
@@ -152,13 +152,51 @@ module Reeve
152
152
  Decision.deny(rule: Decision::POLICY_ERROR, detail: "policy raised #{e.class}: #{e.message}")
153
153
  end
154
154
 
155
+ # A denial has to mean nothing happened.
156
+ #
157
+ # Authorization runs before the tool, but with the *class* as its subject — for an
158
+ # index-style check there is no record yet. The per-record answer only exists once the
159
+ # tool has returned something to scope. So a write tool used to mutate the record and
160
+ # then be refused its response: the caller saw `out_of_scope_record`, the ledger
161
+ # recorded a denial, and the row in the host's database had already changed. A denial
162
+ # in a compliance artifact that follows a committed write is not a partial guarantee,
163
+ # it is a false statement.
164
+ #
165
+ # Running the tool inside a transaction closes that. `requires_new` matters: under a
166
+ # transaction the host opened around the invocation this is a savepoint, so rolling
167
+ # back takes the tool's work and leaves the host's alone.
168
+ #
169
+ # What it cannot undo is anything that was never in the transaction — a sent email, a
170
+ # webhook, a file. A tool that reaches outside the database before it knows whether it
171
+ # is allowed to must ask first; `authorize!` in the tool body is for exactly that.
155
172
  def run_guarded(guard, decision, &tool)
173
+ return run_and_narrow(guard, decision, &tool) unless rollback_available?
174
+
175
+ ::ActiveRecord::Base.transaction(requires_new: true) do
176
+ outcome = run_and_narrow(guard, decision, &tool)
177
+ raise ::ActiveRecord::Rollback if @scope_result.denied?
178
+
179
+ outcome
180
+ end || @scope_result.decision
181
+ end
182
+
183
+ def run_and_narrow(guard, decision, &tool)
156
184
  result = execute(&tool)
157
185
 
158
186
  @scope_result = narrow(guard, result)
159
187
  @scope_result.denied? ? @scope_result.decision : decision
160
188
  end
161
189
 
190
+ # The core runs with no ActiveRecord at all (SC-008), and a host may have the library
191
+ # loaded without a connection. Neither can roll anything back, and neither should be
192
+ # made to fail here — they get the behaviour they had before, which the ledger still
193
+ # records truthfully because it records what the scoper decided either way.
194
+ def rollback_available?
195
+ defined?(::ActiveRecord::Base) && ::ActiveRecord::Base.connected?
196
+ rescue StandardError
197
+ false
198
+ end
199
+
162
200
  # The degraded mode a host opts into while retrofitting guards onto existing tools
163
201
  # (FR-023). The call is unscoped — that is the whole point of the warning.
164
202
  def run_unguarded(&tool)
@@ -171,6 +209,14 @@ module Reeve
171
209
 
172
210
  def execute(&tool)
173
211
  tool.call
212
+ rescue DeniedError => e
213
+ # A denial the tool raised on itself through `authorize!`. It is an authorization
214
+ # outcome, not a crash, and recording it as `tool_error` would file a policy saying
215
+ # no under the same rule as a NoMethodError. The rule the policy gave is carried
216
+ # through to the ledger unchanged.
217
+ @decision = Decision.deny(rule: e.rule, detail: e.detail)
218
+ @scope_result = ScopeResult.deny(rule: e.rule, detail: e.detail)
219
+ raise
174
220
  rescue StandardError => e
175
221
  # The tool's own failure propagates untouched, but not before the ensure block
176
222
  # records it: the invocations most worth having a trace of are the ones that broke.
@@ -79,10 +79,14 @@ module Reeve
79
79
  )
80
80
  end
81
81
 
82
+ # Names the generator that actually fixes this. It used to say `reeve:install`,
83
+ # which is wrong for the case this failure describes: the table exists, so Rails
84
+ # resolves that migration by name and either emits nothing or offers to overwrite
85
+ # one that has already run.
82
86
  def missing_columns(missing)
83
87
  failed(
84
88
  "expected the ledger to implement audit-entry contract version #{@expected}, " \
85
- "but #{TABLE} is missing: #{missing.join(', ')} — run `rails g reeve:install` " \
89
+ "but #{TABLE} is missing: #{missing.join(', ')} — run `rails g reeve:upgrade` " \
86
90
  "and migrate",
87
91
  version: @expected, missing: missing
88
92
  )
@@ -55,7 +55,7 @@ module Reeve
55
55
  def self.run(check, principals:, tools: nil, arguments: {}, invoke: nil, ledger: nil)
56
56
  return Report.new([check.new(ledger: ledger).call]) if GLOBAL.include?(check)
57
57
 
58
- subjects = tools || Reeve.registry.map(&:tool_class)
58
+ subjects = tools || Testing.compliance_tools
59
59
  Report.new(
60
60
  subjects.map do |tool|
61
61
  build(check, tool: tool, principals: principals, arguments: arguments,
data/lib/reeve/testing.rb CHANGED
@@ -40,12 +40,25 @@ module Reeve
40
40
  principals
41
41
  end
42
42
 
43
+ # The tools the suite walks. Unset means every tool reeve knows about — the classes
44
+ # that included the DSL, guarded or not. That default is what makes an unguarded
45
+ # tool visible rather than merely absent.
46
+ attr_writer :compliance_tools
47
+
48
+ def compliance_tools
49
+ source = @compliance_tools || Reeve.config.compliance_tools
50
+ return Reeve.registry.tool_classes if source.nil?
51
+
52
+ Array(source.respond_to?(:call) ? source.call : source)
53
+ end
54
+
43
55
  def compliance_principals?
44
56
  !(@compliance_principals || Reeve.config.compliance_principals).nil?
45
57
  end
46
58
 
47
59
  def reset!
48
60
  @compliance_principals = nil
61
+ @compliance_tools = nil
49
62
  end
50
63
 
51
64
  private
data/lib/reeve/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Reeve
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reeve
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Victor Velazquez
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-12 00:00:00.000000000 Z
11
+ date: 2026-09-03 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Reeve makes it safe for a Rails application to expose MCP (Model Context Protocol)
@@ -30,9 +30,12 @@ files:
30
30
  - lib/generators/reeve/install/install_generator.rb
31
31
  - lib/generators/reeve/install/templates/create_audit_entries.rb.tt
32
32
  - lib/generators/reeve/install/templates/initializer.rb.tt
33
+ - lib/generators/reeve/upgrade/templates/add_contract_version_to_reeve_audit_entries.rb.tt
34
+ - lib/generators/reeve/upgrade/upgrade_generator.rb
33
35
  - lib/reeve.rb
34
36
  - lib/reeve/audit.rb
35
37
  - lib/reeve/audit/entry.rb
38
+ - lib/reeve/audit/isolated_recorder.rb
36
39
  - lib/reeve/audit/query.rb
37
40
  - lib/reeve/audit/recorder.rb
38
41
  - lib/reeve/audit/redactor.rb