reeve 0.5.0 → 0.7.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +281 -3
  3. data/README.md +151 -6
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +63 -3
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +20 -0
  6. data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
  7. data/lib/generators/reeve/upgrade/templates/add_ledger_chain_to_reeve_audit_entries.rb.tt +51 -0
  8. data/lib/generators/reeve/upgrade/upgrade_generator.rb +25 -2
  9. data/lib/generators/reeve/upgrade/upgrade_notes.rb +51 -0
  10. data/lib/reeve/audit/chain/canonical.rb +144 -0
  11. data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
  12. data/lib/reeve/audit/chain/epochs.rb +77 -0
  13. data/lib/reeve/audit/chain/head.rb +122 -0
  14. data/lib/reeve/audit/chain/keyring.rb +53 -0
  15. data/lib/reeve/audit/chain/normalizer.rb +88 -0
  16. data/lib/reeve/audit/chain/report.rb +177 -0
  17. data/lib/reeve/audit/chain/rotation.rb +124 -0
  18. data/lib/reeve/audit/chain/schema.rb +59 -0
  19. data/lib/reeve/audit/chain/verification.rb +131 -0
  20. data/lib/reeve/audit/chain/verifier.rb +168 -0
  21. data/lib/reeve/audit/chain/walk.rb +137 -0
  22. data/lib/reeve/audit/chain.rb +207 -0
  23. data/lib/reeve/audit/chain_epoch.rb +24 -0
  24. data/lib/reeve/audit/chain_record.rb +66 -0
  25. data/lib/reeve/audit/isolated_recorder.rb +5 -0
  26. data/lib/reeve/audit/recorder.rb +32 -19
  27. data/lib/reeve/audit.rb +37 -6
  28. data/lib/reeve/configuration.rb +66 -2
  29. data/lib/reeve/errors.rb +15 -0
  30. data/lib/reeve/ledger_key.rb +47 -0
  31. data/lib/reeve/railtie.rb +16 -0
  32. data/lib/reeve/tasks.rb +91 -0
  33. data/lib/reeve/testing/checks/contract_version.rb +2 -1
  34. data/lib/reeve/testing/checks/ledger_chain.rb +116 -0
  35. data/lib/reeve/testing/checks.rb +29 -4
  36. data/lib/reeve/testing/compliance_assertions.rb +3 -2
  37. data/lib/reeve/testing/compliance_suite.rb +2 -2
  38. data/lib/reeve/testing/ledger.rb +11 -1
  39. data/lib/reeve/testing/report.rb +8 -0
  40. data/lib/reeve/testing.rb +1 -1
  41. data/lib/reeve/version.rb +1 -1
  42. data/lib/reeve.rb +2 -0
  43. metadata +24 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 67bc82ca855e0385d021e54477cbcfcef697a1134d3c93e4fc073fea2ff3b748
4
- data.tar.gz: 0c11093e604b6a056a23bec7ebd419620d4dd1780f93ff7e30fc8a08bdbd92d3
3
+ metadata.gz: 4a50e8fe479452d3b685dcd79b3022881c76567651444c6204e8db3d601b07c8
4
+ data.tar.gz: fe9974e2c035ce7c958f75d44a847d4b951f9cd828820aba9468310e2a143acd
5
5
  SHA512:
6
- metadata.gz: 0524a6cd7376dc9cc8b7f2611d4a0a6a8b1fa81b6b5ff59ab662d3e5f5f313b4f47e7ad5b2d52d272da8bab1f1c19042ae937300beb382f57804f2fc72b77d3e
7
- data.tar.gz: 4ead6c668edcde8b543c1e4ead9817e08015ecbf42adfbc34d44c5fa31426206284efb3ffdd88dad4cd983033be7708e256ac9cc2e5eb4758a6fc92f2ac14a19
6
+ metadata.gz: aa5ff3ffce5221ee1b8baa64461865842faed31d432a0977051f6f3ec8be1846474174d27e19037a1a6b6901af5c6329fd38ac390e2da3fc664f7dfe9b7214ee
7
+ data.tar.gz: d986d9bd16574387f5529e983e932c1e740068a9b4a833a7220cf03dee30b3e134a5fd101a0ee41d2091bf3fa6660cc7ac67d52d720039bc149e32e6ec1dd33a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,283 @@ 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.7.0] - 2026-10-23
8
+
9
+ 0.6.0 let a ledger be verified across a key rotation, provided the old key was kept as a
10
+ retired key. It did not record the rotation itself: nothing in the ledger said which key had
11
+ signed which stretch of it. So an entry whose key id was rewritten to a fingerprint nobody
12
+ holds read as "signed by a key you don't have" (exit 2), not as tampering, and an operator
13
+ who replaced the key without keeping the old one learned it only when verification later
14
+ came back unverifiable. Now every key that signs the ledger opens a numbered **key epoch**,
15
+ every new entry records its epoch inside its digest, and `bin/rails reeve:rotate_ledger_key`
16
+ performs and checks a rotation.
17
+
18
+ **Audit-entry contract `3` → `4`, and a migration is required.** One nullable column,
19
+ `chain_epoch`, and one new table, `reeve_audit_chain_epochs`. The bump is MAJOR by the
20
+ contract's own rule, for two reasons: `chain_digest` on a contract-4 entry covers the epoch,
21
+ which a contract-3 digest does not, and a NULL epoch means "chained before key epochs" on a
22
+ contract-3 entry where a chained contract-4 entry always has one. The rule's wording now
23
+ states that exception instead of leaving it to precedent.
24
+
25
+ ### Upgrading from 0.6.0
26
+
27
+ 1. Update the gem, then generate and run the migration. Nothing in your initializer changes.
28
+
29
+ ```bash
30
+ bundle update reeve
31
+ bin/rails generate reeve:upgrade
32
+ bin/rails db:migrate
33
+ ```
34
+
35
+ Migrate before the new code serves traffic: until the table is migrated, every chained
36
+ write fails with a `Reeve::ConfigurationError` naming `reeve:upgrade`, so guarded calls fail
37
+ closed. (`bin/rails reeve:verify_ledger` keeps working on an unmigrated table and checks it
38
+ as 0.6.0 did.)
39
+
40
+ 2. Grant the application's database role `INSERT` and `SELECT` on the new table — never
41
+ `UPDATE`:
42
+
43
+ ```sql
44
+ GRANT INSERT, SELECT ON reeve_audit_chain_epochs TO app_role;
45
+ ```
46
+
47
+ 3. Deploy. The first chained write after the migration — normally the first guarded tool
48
+ call — opens epoch 0 with your configured `ledger_key`. Until then, verification shows no
49
+ epochs. Entries already in the ledger are not touched; verification lists them as "before
50
+ epoch 0" and checks them exactly as 0.6.0 did.
51
+
52
+ Servers still running 0.6.0 during the deploy keep writing contract-3 entries. They are never
53
+ reported as tampering: they verify wherever their key is configured, and are unverifiable
54
+ where it is not, exactly as in 0.6.0.
55
+
56
+ ### Rotating the ledger key
57
+
58
+ Do these in order. The old key stays configured throughout: it is what verifies the
59
+ entries it signed, for as long as you keep it.
60
+
61
+ 1. Configure the new key as `ledger_key` and move the old one to `retired_ledger_keys`.
62
+ Assign `ledger_key` first — a key may not be both the signing key and a retired one,
63
+ even for the moment between the two lines:
64
+
65
+ ```ruby
66
+ config.ledger_key = Rails.application.credentials.reeve_ledger_key_2026
67
+ config.retired_ledger_keys = [Rails.application.credentials.reeve_ledger_key_2025]
68
+ ```
69
+
70
+ If your `ledger_key` is the one the generated initializer derives from
71
+ `secret_key_base`, rotating `secret_key_base` is this step, and the old derived key must be
72
+ captured *before* you change `secret_key_base`, while the app can still derive it:
73
+
74
+ ```bash
75
+ bin/rails runner 'puts [Rails.application.key_generator.generate_key("reeve audit ledger", 32)].pack("m0")'
76
+ ```
77
+
78
+ Store the printed value in credentials (say as `reeve_ledger_key_previous`), rotate
79
+ `secret_key_base`, and keep the derived line as it is:
80
+
81
+ ```ruby
82
+ config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)
83
+ config.retired_ledger_keys = [Rails.application.credentials.reeve_ledger_key_previous.unpack1("m0")]
84
+ ```
85
+
86
+ 2. Deploy everywhere. From the first write a server makes with the new key, that key opens
87
+ the next epoch. Servers still on the old key during the deploy keep writing under the old
88
+ epoch; those entries verify. No write fails because a rotation is under way.
89
+
90
+ 3. Run the rotation task once the deploy has finished, on a host configured like your
91
+ servers (both keys):
92
+
93
+ ```bash
94
+ bin/rails reeve:rotate_ledger_key
95
+ ```
96
+
97
+ It prints one of three first lines and exits accordingly:
98
+
99
+ | First line | Exit | Meaning |
100
+ |---|---|---|
101
+ | `reeve ledger key: ROTATED — epoch N opened with key …` | 0 | The task opened the new epoch, starting one past the newest entry. |
102
+ | `reeve ledger key: CURRENT — epoch N already signs with the configured key …` | 0 | A write already opened it — the usual result on a ledger that was written to during the deploy — or you ran the task twice. Nothing written. |
103
+ | `reeve ledger key: REFUSED — …; nothing was written` | 1 | See below. Nothing written. |
104
+
105
+ It refuses when no `ledger_key` is configured; when the configured key already opened an
106
+ earlier epoch (a key opens one epoch, ever — configure a new one); and when the current
107
+ epoch's key is not configured on the host running the task, because that epoch's entries
108
+ would become unverifiable there. The last refusal names the fix: add the old key to
109
+ `retired_ledger_keys`. Only if that key is truly lost, run
110
+ `ACCEPT_UNVERIFIABLE=1 bin/rails reeve:rotate_ledger_key`: it reports ROTATED (exit 0) and
111
+ adds a line starting `warning: ` saying the earlier epoch is unverifiable on this host. The
112
+ same `warning: ` line on a CURRENT result means the same thing about the previous epoch.
113
+
114
+ Keys are never printed, only their 16-character fingerprints. The same result is
115
+ available from Ruby as a value: `Reeve::Audit::Chain.rotate_key!` returns a `Rotation`
116
+ with `status`, `reason`, `epoch`, `key_id`, `first_sequence`, `previous_epoch`,
117
+ `previous_key_id` and `warnings`.
118
+
119
+ 4. Verify:
120
+
121
+ ```bash
122
+ bin/rails reeve:verify_ledger
123
+ ```
124
+
125
+ With both keys configured it reports INTACT and, under the summary, one line per epoch
126
+ with its key fingerprint, the sequence it begins at, how many entries named it, and
127
+ `verified`. An epoch whose key is not configured where you run it is listed as
128
+ `key not configured`, and the run is UNVERIFIABLE (exit 2) — never INTACT.
129
+
130
+ 5. Keep the old key in `retired_ledger_keys` for as long as the entries it signed must
131
+ verify — in practice, for the life of the ledger. Removing it makes every run on that host
132
+ UNVERIFIABLE (exit 2) from then on; nothing re-signs those entries under a new key.
133
+
134
+ Behaviour changes worth reading before upgrading:
135
+
136
+ - **Entries name their epoch.** Every chained entry written at contract 4 records
137
+ `chain_epoch`. Query by it: `Reeve::Audit::Entry.where(contract_version: 4, chain_epoch: 1)`
138
+ (on a contract-3 entry the column is outside the digest and means nothing).
139
+ - **A rewritten key id or epoch is now tampering.** An entry whose `chain_key_id` or
140
+ `chain_epoch` no longer matches the epoch records, and an entry that names an epoch with no
141
+ record, are reported as BROKEN (exit 1), with the new break kinds `epoch_mismatch` and
142
+ `epoch_missing`. In 0.6.0 the first of these was UNVERIFIABLE. An epoch record that was
143
+ edited or renumbered is BROKEN too (`epoch_altered`); so is one deleted while entries name
144
+ it. An epoch record forged under a key the verifier does not hold reads as UNVERIFIABLE —
145
+ never INTACT — and deleting the newest epoch before anything was written under it is not
146
+ detected.
147
+ - **Entries without an epoch are unchanged.** Entries chained before epoch 0, and contract-3
148
+ entries written by 0.6.0 servers during the deploy, are checked exactly as in 0.6.0: a
149
+ rewritten key id on one of them still reads as unverifiable. The ledger has no record of
150
+ which keys signed them, and reeve does not invent one after the fact.
151
+ - **Two rotations with no write between them** open two epochs that begin at the same
152
+ sequence; the first never signs anything. Not a break.
153
+ - **The unverifiable count includes epoch records.** `unverifiable_count` (and the JSON
154
+ `totals`) count an epoch record whose key is not configured, as they already counted the
155
+ genesis.
156
+ - **JSON output** gains `epochs` — an array of `{ "epoch", "key_id", "first_sequence",
157
+ "entries", "status" }`, `status` one of `verified`, `key_not_configured`, `altered` — and
158
+ `before_first_epoch`, the integer count of entries chained before epoch 0 (null, like
159
+ `head_at_start`, when nothing was walked). Each break gains `epoch`. Every existing field is unchanged.
160
+ The text report is unchanged for a ledger with one verified epoch and nothing before it.
161
+ - **Custom recorders** that call `Reeve::Audit::Chain#append!` need no change: rows at
162
+ contract 4 are stamped with their epoch; rows passed at contract 3 are chained as before,
163
+ without one.
164
+ - **A ledger on its own database**: connect `Reeve::Audit::ChainEpoch` the same way as
165
+ `Reeve::Audit::ChainRecord` (for example with the same `connects_to`). Verification refuses,
166
+ naming the model, if either reads from another database.
167
+
168
+ What this adds to the guarantee, and what it does not, is in the README under
169
+ "Tamper-evident, within stated limits". In short: a key the verifier does not hold still
170
+ cannot be checked — epochs make that loss visible and attribute it to a named epoch, they do
171
+ not undo it — and a leaked key, retired or current, can still rewrite what it signed.
172
+
173
+ ### Added
174
+
175
+ - `bin/rails reeve:rotate_ledger_key` and `Reeve::Audit::Chain.rotate_key!`.
176
+ - Key epochs: `reeve_audit_chain_epochs`, `chain_epoch`, and their verification.
177
+ - The `reeve:upgrade` step to contract 4.
178
+
179
+ ## [0.6.0] - 2026-10-10
180
+
181
+ The ledger has been append-only at the library level since 0.1.0: no API updates or deletes
182
+ an entry, and the generated migration documents the grant that enforces the rest. Nothing
183
+ *detected* a change made outside the library. A DBA, a stolen application credential or a
184
+ well-meant data fix could edit, delete or reorder rows, and the ledger read exactly as
185
+ before. Now every row is chained to the one before it with a keyed digest, and
186
+ `bin/rails reeve:verify_ledger` reports every place the chain no longer holds.
187
+
188
+ **Audit-entry contract `2` → `3`, and a migration is required.** The new columns are
189
+ nullable, so the shape change is additive; the bump is MAJOR by the contract's own rule,
190
+ because what an absence means changed. On a contract 1–2 row a NULL digest means "written
191
+ before chaining"; on a contract-3 row it means the row was written unchained, which
192
+ verification reports. Upgrading is three steps:
193
+
194
+ ```bash
195
+ bin/rails generate reeve:upgrade
196
+ bin/rails db:migrate
197
+ ```
198
+
199
+ then add the line the generator prints to `config/initializers/reeve.rb`:
200
+
201
+ ```ruby
202
+ config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)
203
+ ```
204
+
205
+ Existing rows are not touched. They are reported as "not covered" from then on, and are
206
+ exactly as trustworthy as they were before.
207
+
208
+ What this detects and what it does not are set out in the README, under "Tamper-evident,
209
+ within stated limits". In short, it does **not** detect a change made by someone holding
210
+ the key; the newest rows deleted by someone who also rewinds the chain record's head; the
211
+ whole ledger deleted (reported as *no chain*, never as a pass); or anything about rows
212
+ written before the upgrade. It detects; it does not prevent.
213
+
214
+ Behaviour changes worth reading before upgrading:
215
+
216
+ - **A ledger key is required.** Without one, every guarded call fails with
217
+ `Reeve::AuditWriteError` (cause `Reeve::LedgerKeyError`, naming the setting). Under
218
+ `audit_failure_mode = :warn` the call goes through and the row is written *unchained*, a
219
+ warning is logged on every such write, and verification reports the row.
220
+ - **Rotating `secret_key_base` rotates the ledger key**, if you keep the generated line. Put
221
+ the old derived key in `config.retired_ledger_keys`, or entries it signed become
222
+ unverifiable — reported as such, never as tampered. A dedicated key avoids the coupling.
223
+ - **Writes serialize on the chain record.** About 0.5 ms more per ledger write on SQLite and
224
+ about 1 ms on PostgreSQL. Under the default recorder inside a transaction the host opened,
225
+ the lock is held until that transaction ends; `IsolatedRecorder` holds it for one insert.
226
+ - **Rails 7.0 with SQLite:** sqlite3 1.x waits for the write lock while holding Ruby's GVL,
227
+ so concurrent guarded calls in one process can fail closed after the busy timeout. Rails
228
+ 7.1+ or a server database avoids it. The limit predates this release; the chain's longer
229
+ write makes it likelier.
230
+ - **The compliance suites run one more check, `LedgerChain`.** It writes an entry of its own
231
+ and fails without a key, on a broken or unverifiable chain, or on unchained entries. It is
232
+ skipped, saying so, when there is no compliance tool to write with or no ledger loaded.
233
+ The test environment needs the key line too; the generated one works there unchanged.
234
+ - **Breaking for custom recorders** that write to `Reeve::Audit::Entry`: rows must be written
235
+ through `Reeve::Audit::Chain#append!` (or `#append_unchained!` to keep running without a
236
+ key), or verification reports them as unchained.
237
+ - **A table still at contract 2** now fails with `Reeve::ConfigurationError` naming
238
+ `reeve:upgrade`, instead of a column error from inside the write.
239
+ - **Known limit, MySQL:** a number in a tool's arguments beyond double precision is stored
240
+ rounded by MySQL's `JSON` type, and its row then fails to verify. A false alarm, never a
241
+ false pass.
242
+ - **During a rolling deploy**, servers still on 0.5.0 keep writing contract-2 rows. They
243
+ are counted as not covered, not reported as tampering.
244
+
245
+ ### Added
246
+
247
+ - **The ledger chain** (`Reeve::Audit::Chain`). Every entry carries a sequence number, the
248
+ 16-character fingerprint of the key that signed it, and an HMAC-SHA256 digest over its
249
+ canonical form and its predecessor's digest. The digest is computed over the values the
250
+ database stores — defaults, casts and JSON normalization included — so a row verifies
251
+ after a dump and restore with every primary key renumbered.
252
+ - **`Reeve::Audit::Chain.verify`** (optionally with `keys:`, for an auditor's own copy of the
253
+ key) and **`bin/rails reeve:verify_ledger`**, registered by a Railtie that loads only when
254
+ Rails is present. Exit status `0` intact, `1` broken, `2` could not verify; `LIMIT=` caps
255
+ the breaks listed, never the totals; `FORMAT=json` writes every break. Every line that
256
+ says *intact* or *pass* states the covered range and the uncovered count beside it.
257
+ `Reeve::Tasks.verify_ledger` is the same check without Rake.
258
+ - **`config.ledger_key`** and **`config.retired_ledger_keys`**, validated at assignment (32
259
+ bytes minimum) and shown only as fingerprints in `to_h`, `inspect`, logs and reports.
260
+ - **`Checks::LedgerChain`**, in both compliance suites.
261
+ - **`reeve:upgrade`**'s contract-3 step, which prints the initializer line, the rotation
262
+ warning and how to build the new index concurrently on a large PostgreSQL ledger. A step
263
+ now counts as applied only when its columns and its tables both exist.
264
+
265
+ ### Fixed
266
+
267
+ - **A per-tool compliance check with no tools to check is skipped, not passed.** With no
268
+ tool registered, `compliance_tools` set to `[]`, or `tools: []`, `Reeve::Checks.run`
269
+ returned an empty `Report` — which reads as passed — so the RSpec shared group and
270
+ `ComplianceAssertions` showed all six per-tool checks green over zero tools. Each now
271
+ returns one skipped result, "no compliance tools to check, so GuardDeclared established
272
+ nothing — the registry is empty and no endpoint inventory routes a tool" (or `the run was
273
+ given tools: []`, or `compliance_tools is []`), shown as pending in RSpec and as a skip
274
+ in Minitest. Over an empty registry `run_all` used to report only the ledger-wide checks
275
+ (two in 0.5.0); it now also reports the six per-tool skips. Nothing that ran over at
276
+ least one tool changes.
277
+ - **The framework-free gate fails when a check had nothing to check.** The README's
278
+ `abort report.to_s unless report.passed?` stayed green over zero tools, because a skip is
279
+ not a failure. The documented gate is now
280
+ `abort report.to_s if report.failed? || report.nothing_checked?`, using the new
281
+ `Report#nothing_checked?`. A skip for a missing inventory does not trip it.
282
+
283
+
7
284
  ## [0.5.0] - 2026-10-01
8
285
 
9
286
  Reeve protects the tools routed through it, and only those. A server that registers its
@@ -410,6 +687,7 @@ is for:
410
687
  - **A change that widens access is breaking by definition** — major version, plus a
411
688
  migration note saying exactly what became visible that was not visible before.
412
689
  - Adding a nullable ledger column is minor and leaves the audit-entry contract version
413
- alone. Removing or renaming a column, or changing what a value means, is major and bumps
414
- it.
415
- - The audit-entry contract version is `2`.
690
+ alone — unless what its absence means, or what an existing value covers, differs between
691
+ rows written before and after it, which is major (contracts 3 and 4 are both examples).
692
+ Removing or renaming a column, or changing what a value means, is major and bumps it.
693
+ - The audit-entry contract version is `4`.
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.5.0.** Read the known limitations in
10
+ > **Status: 0.7.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
 
@@ -52,7 +52,10 @@ end
52
52
  ```
53
53
 
54
54
  Until that resolver is set, every guarded call denies with `no_principal`. That is the
55
- intended behaviour: the library is safe before it is configured.
55
+ intended behaviour: the library is safe before it is configured. The initializer also
56
+ derives the key that chains the audit ledger from `secret_key_base`, so that works in
57
+ every environment with nothing to set up — see [Tamper-evident, within stated
58
+ limits](#tamper-evident-within-stated-limits).
56
59
 
57
60
  **2. Guard a tool.**
58
61
 
@@ -186,6 +189,142 @@ transaction holds the write lock; `IsolatedRecorder.available?` lets you branch
186
189
  develop on one and deploy on the other. The default recorder warns when it detects the
187
190
  case rather than failing silently.
188
191
 
192
+ ## Tamper-evident, within stated limits
193
+
194
+ Every row Reeve writes is linked to the one before it by a keyed digest (HMAC-SHA256), with
195
+ a key the database does not hold. Someone who can write to the ledger table but does not
196
+ hold the key can still change it, but the change shows when the chain is checked:
197
+
198
+ ```bash
199
+ bin/rails reeve:verify_ledger
200
+ ```
201
+
202
+ ```text
203
+ reeve ledger: INTACT — sequences 1–48,211 verified (head at start: 48,211); 5,000 earlier entries not covered (5,000 when the chain started)
204
+ ```
205
+
206
+ It exits `0` when the chain is intact, `1` when it is broken, and `2` when it could not
207
+ verify (no chain, no key, or rows signed by a key it does not hold). Run it on a schedule
208
+ and alert on anything but `0`. `LIMIT=` caps the breaks it lists, never the totals;
209
+ `FORMAT=json` writes every break. The same answer from Ruby, or with an auditor's own copy
210
+ of the key:
211
+
212
+ ```ruby
213
+ result = Reeve::Audit::Chain.verify
214
+ result.status # => :intact, :broken, :unverifiable or :no_chain
215
+ result.covered # => 1..48211
216
+ puts result # the report above, with every break listed under it
217
+ ```
218
+
219
+ In CI the compliance suite you already run includes `LedgerChain`, in both frameworks:
220
+ it writes entries of its own and fails on a broken chain, a missing key, or entries that
221
+ were not chained.
222
+
223
+ **The key.** The generated initializer derives it from `secret_key_base`:
224
+
225
+ ```ruby
226
+ config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)
227
+ ```
228
+
229
+ Rotating `secret_key_base` therefore rotates the ledger key. Keep the old one, or the
230
+ entries it signed become *unverifiable* — reported as such, never as tampered:
231
+
232
+ ```ruby
233
+ config.retired_ledger_keys = [old_key] # verify with these; new epochs never open with them
234
+ ```
235
+
236
+ **Rotating the key.** Every key that signs the ledger opens a numbered *key epoch*, and every
237
+ entry records its epoch inside its digest. To rotate: set the new key as `ledger_key` and the
238
+ old one in `retired_ledger_keys` (in that order), deploy, then run
239
+
240
+ ```bash
241
+ bin/rails reeve:rotate_ledger_key
242
+ ```
243
+
244
+ It reports `ROTATED` or `CURRENT` (exit `0`), or `REFUSED` (exit `1`, nothing written) — for
245
+ instance when the current epoch's key is not configured where it runs, since that epoch's
246
+ entries would become unverifiable there. Writes never wait for it: the first write with the
247
+ new key opens its epoch, and servers still on the old key during the deploy keep writing
248
+ under theirs. From Ruby:
249
+
250
+ ```ruby
251
+ rotation = Reeve::Audit::Chain.rotate_key!
252
+ rotation.status # => :rotated, :current or :refused
253
+ rotation.epoch # => 1
254
+ ```
255
+
256
+ Verification then lists each epoch under its summary line, with the key fingerprint, where
257
+ it begins, and whether it verified. The CHANGELOG has the full procedure.
258
+
259
+ A dedicated key decouples the two: `config.ledger_key = Rails.application.credentials.reeve_ledger_key`.
260
+ Keys must be at least 32 bytes and are only ever printed as 16-character fingerprints.
261
+ Without a key, every guarded call fails closed; under `audit_failure_mode = :warn` it is
262
+ recorded unchained instead, logged each time, and reported by verification.
263
+
264
+ **Detected**, provided the key has not leaked:
265
+
266
+ - a chained row whose recorded content was changed;
267
+ - a chained row removed from anywhere before the newest one;
268
+ - chained rows swapped, renumbered or moved, or a row inserted that Reeve did not write —
269
+ including one numbered past the head;
270
+ - the newest rows removed while the chain record was left alone;
271
+ - the chain record removed while chained rows remain;
272
+ - an entry whose key id or key epoch was changed, or that names an epoch with no record, and
273
+ an epoch record that was changed, or removed while entries name it (one forged under a key
274
+ you do not hold reads as unverifiable, never intact).
275
+
276
+ **Not detected** — stated plainly, because these are the limits of the guarantee:
277
+
278
+ 1. **A leaked key.** Anyone holding it can rewrite the chain from any point forward. By
279
+ default that is anyone holding `secret_key_base`.
280
+ 2. **The newest rows deleted by someone who also rewinds the chain record's head.** Only a
281
+ checkpoint held outside the database could catch that; Reeve does not ship one.
282
+ 3. **The whole ledger deleted, chain record included.** Reported as *no chain*, never as a
283
+ pass — but indistinguishable from a ledger that was never chained.
284
+ 4. **Rows written before you upgraded.** They are counted as "not covered" and are exactly
285
+ as trustworthy as they were before. Likewise, entries without a key epoch — chained by
286
+ 0.6.0, before or during the upgrade — are checked as 0.6.0 checked them: a rewritten key id
287
+ on one of them reads as unverifiable, not as tampering.
288
+ 5. **A key you no longer hold.** Entries it signed cannot be checked, by anyone. Epochs make
289
+ that visible and name the epoch; they do not undo it.
290
+
291
+ And it prevents nothing: it detects a change after the fact. The `GRANT INSERT, SELECT` in
292
+ the generated migration is what prevents one. The chain record needs `UPDATE` as well,
293
+ which is why a role allowed to rewind it is limit 2.
294
+
295
+ **What it costs.** About half a millisecond per ledger write on SQLite and about one on
296
+ PostgreSQL. Every chained write locks the chain record, so writes serialize. Under the
297
+ default recorder inside a transaction your application opened, that lock is held until the
298
+ transaction ends, and every other guarded call waits behind it — up to the database's own
299
+ lock wait: indefinitely on PostgreSQL (`lock_timeout` is 0 by default), 50 seconds on MySQL
300
+ (`innodb_lock_wait_timeout`), and ActiveRecord's `timeout` on SQLite (5 seconds in a
301
+ generated `database.yml`). `IsolatedRecorder` holds it for the length of one insert. On
302
+ **Rails 7.0 with SQLite**, a thread waiting for SQLite's write lock holds Ruby's GVL
303
+ (sqlite3 1.x), so concurrent guarded calls in one process can fail closed after the busy
304
+ timeout. Rails 7.1+ or a server database avoids it.
305
+
306
+ **Your own recorder.** A recorder that writes to Reeve's table chains its rows through the
307
+ same door the built-in ones use, or verification reports them as unchained:
308
+
309
+ ```ruby
310
+ Reeve::Audit::Chain.new(entry_class: Reeve::Audit::Entry).append!(row)
311
+ ```
312
+
313
+ **Upgrading from 0.6.0:** `bin/rails generate reeve:upgrade && bin/rails db:migrate` before
314
+ the new code serves traffic, and grant `INSERT, SELECT` on `reeve_audit_chain_epochs`. The next guarded call opens epoch 0;
315
+ earlier entries are listed as "before epoch 0" and checked as before.
316
+
317
+ **Upgrading from 0.5.0:**
318
+
319
+ ```bash
320
+ bin/rails generate reeve:upgrade
321
+ bin/rails db:migrate
322
+ ```
323
+
324
+ Then add the `config.ledger_key` line the generator prints. Existing rows are not touched;
325
+ the next guarded call starts the chain. Servers still on 0.5.0 during a rolling deploy keep
326
+ writing unchained rows, which are counted as not covered rather than reported as tampering.
327
+
189
328
  ## Provable in CI, in whichever framework you already use
190
329
 
191
330
  All the logic lives in framework-neutral checks. RSpec and Minitest are thin front-ends
@@ -223,11 +362,13 @@ script, or a boot-time assertion in staging:
223
362
  require "reeve/testing"
224
363
 
225
364
  report = Reeve::Checks.run_all(principals: [alice, bob])
226
- abort report.to_s unless report.passed?
365
+ abort report.to_s if report.failed? || report.nothing_checked?
227
366
  ```
228
367
 
229
368
  `alice` and `bob` are two fixture principals with disjoint records — that disjointness is
230
- what makes a shared record identifier proof of a leak.
369
+ what makes a shared record identifier proof of a leak. `passed?` only means nothing failed,
370
+ so a run over no tools at all passes; `nothing_checked?` is what stops this gate when the
371
+ registry is empty, since a script has no pending test to show it.
231
372
 
232
373
  **The suite walks every tool that included `Reeve::Guard`, not only the ones that declared
233
374
  a guard.** A tool that forgot `guard_with` is the one worth finding, and it is absent from
@@ -257,7 +398,7 @@ namespace :reeve do
257
398
  require "reeve/testing"
258
399
 
259
400
  report = Reeve::Checks.run_all(principals: Reeve::Testing.compliance_principals)
260
- abort report.to_s unless report.passed?
401
+ abort report.to_s if report.failed? || report.nothing_checked?
261
402
  puts report
262
403
  end
263
404
  end
@@ -470,12 +611,16 @@ ActiveRecord unless they are not — a plain object with an `id` works.
470
611
  ## What you have not gained
471
612
 
472
613
  Rate limiting, prompt-injection defence, cost control, and connection authentication are
473
- out of scope. This library governs *what an authenticated agent may touch* and *what it
614
+ out of scope, and the ledger's chain detects tampering rather than preventing it. This library governs *what an authenticated agent may touch* and *what it
474
615
  touched*. Keep your existing auth.
475
616
 
476
617
  Ruby 3.0+, Rails 7.0+. No runtime dependencies. The fast-mcp adapter needs Ruby 3.1+,
477
618
  because fast-mcp does.
478
619
 
620
+ ## Who built this
621
+
622
+ Built by [Victor Velázquez](https://victorvelazquez.dev) — COO at [MagmaLabs](https://www.magmalabs.io) and author of [*The Agentic Engineer*](https://leanpub.com/theagenticengineer). Reeve guards MCP tools in production at MagmaLabs.
623
+
479
624
  ## License
480
625
 
481
626
  MIT. See [LICENSE.txt](LICENSE.txt).
@@ -13,12 +13,27 @@
13
13
  # REVOKE ALL ON reeve_audit_entries FROM app_role;
14
14
  # GRANT INSERT, SELECT ON reeve_audit_entries TO app_role;
15
15
  # GRANT USAGE ON SEQUENCE reeve_audit_entries_id_seq TO app_role;
16
+ # -- the chain record: its head moves on every write
17
+ # GRANT INSERT, SELECT, UPDATE ON reeve_audit_chains TO app_role;
18
+ # GRANT USAGE ON SEQUENCE reeve_audit_chains_id_seq TO app_role;
19
+ # -- the key epochs: written once each, never updated
20
+ # GRANT INSERT, SELECT ON reeve_audit_chain_epochs TO app_role;
21
+ # GRANT USAGE ON SEQUENCE reeve_audit_chain_epochs_id_seq TO app_role;
22
+ #
23
+ # Every row is chained to the one before it with a keyed digest, and names the key epoch
24
+ # that signed it (audit-entry contract 4),
25
+ # so a row changed, removed or reordered outside reeve is reported by
26
+ # `bin/rails reeve:verify_ledger`. That is tamper-evidence, not tamper-proofing: it
27
+ # detects a change after the fact and prevents nothing — the grant above is what
28
+ # prevents. It does not detect a change made by someone holding the ledger key, nor the
29
+ # newest rows being deleted by a role that can also rewind the head in
30
+ # reeve_audit_chains; that role needs UPDATE there, which is why the chain table is
31
+ # granted separately from the ledger.
16
32
  #
17
33
  # Also explicitly out of scope for now, so you can plan around it:
18
34
  # * no retention, rotation or archival — this table grows until you prune it, and
19
- # pruning is your policy, run by a role that is allowed to delete;
20
- # * no cryptographic chaining or tamper-evidence. If that lands later it arrives as a
21
- # nullable column, which the audit-entry contract's versioning already permits.
35
+ # pruning is your policy, run by a role that is allowed to delete. Deleting rows
36
+ # breaks the chain by design.
22
37
  #
23
38
  # `invocation_id` is a UUID stored as a string rather than a native `uuid` column,
24
39
  # because reeve supports every database ActiveRecord does and only PostgreSQL has the
@@ -61,6 +76,17 @@ class CreateReeveAuditEntries < ActiveRecord::Migration[7.0]
61
76
  # it means "the caller passed none". Stamping each row keeps an export readable
62
77
  # across an upgrade instead of collapsing that distinction.
63
78
  t.integer :contract_version, null: false
79
+
80
+ # The chain (contract 3). Nullable: a row written before the chain began has no
81
+ # honest value for these, and nothing is backfilled into an append-only table.
82
+ # `chain_key_id` is the key's fingerprint, never the key.
83
+ t.bigint :chain_sequence
84
+ t.string :chain_key_id, limit: 16
85
+ t.string :chain_digest, limit: 64
86
+
87
+ # The key epoch whose key signed the row (contract 4), inside the digest. Nullable for
88
+ # the same reason as the chain columns, and NULL on an unchained row.
89
+ t.integer :chain_epoch
64
90
  end
65
91
 
66
92
  add_index :reeve_audit_entries, :invocation_id, unique: true
@@ -73,5 +99,39 @@ class CreateReeveAuditEntries < ActiveRecord::Migration[7.0]
73
99
  add_index :reeve_audit_entries, %i[tool_name occurred_at]
74
100
  add_index :reeve_audit_entries, %i[agent_id occurred_at]
75
101
  add_index :reeve_audit_entries, %i[outcome occurred_at]
102
+
103
+ # One position, one row. NULLs are distinct, so unchained rows never collide; the
104
+ # index also orders the walk `reeve:verify_ledger` makes.
105
+ add_index :reeve_audit_entries, :chain_sequence, unique: true
106
+
107
+ # The chain record: one row. The genesis columns are written once, when the first
108
+ # chained entry is; the head moves with every chained write, in the same transaction
109
+ # as the row it points at.
110
+ create_table :reeve_audit_chains do |t|
111
+ t.string :name, null: false
112
+ t.datetime :genesis_at, null: false, precision: 6
113
+ t.bigint :pre_chain_count, null: false
114
+ t.bigint :pre_chain_unchained_count, null: false
115
+ t.string :genesis_key_id, null: false, limit: 16
116
+ t.string :genesis_digest, null: false, limit: 64
117
+ t.bigint :head_sequence, null: false
118
+ t.string :head_digest, null: false, limit: 64
119
+ end
120
+
121
+ add_index :reeve_audit_chains, :name, unique: true
122
+
123
+ # One row per key that has signed the ledger (contract 4): written when the key first
124
+ # signs, or by `bin/rails reeve:rotate_ledger_key`, and never updated. A key opens one
125
+ # epoch only, hence the unique key_id.
126
+ create_table :reeve_audit_chain_epochs do |t|
127
+ t.integer :epoch, null: false
128
+ t.string :key_id, null: false, limit: 16
129
+ t.bigint :first_sequence, null: false
130
+ t.datetime :opened_at, null: false, precision: 6
131
+ t.string :epoch_digest, null: false, limit: 64
132
+ end
133
+
134
+ add_index :reeve_audit_chain_epochs, :epoch, unique: true
135
+ add_index :reeve_audit_chain_epochs, :key_id, unique: true
76
136
  end
77
137
  end
@@ -72,6 +72,26 @@ Reeve.configure do |config|
72
72
  #
73
73
  # Left unset, which resolves to Reeve::Audit::Recorder.
74
74
 
75
+ # ---------------------------------------------------------------------------
76
+ # The key that chains the audit ledger, so a row changed, removed or reordered
77
+ # outside reeve is reported by `bin/rails reeve:verify_ledger`. It must live
78
+ # outside the database: someone who can write the table but does not hold the
79
+ # key cannot forge the chain.
80
+ #
81
+ # Derived from secret_key_base, it works in every environment — test included —
82
+ # with no new secret to provision. Without a key, every guarded call fails
83
+ # closed (or, under audit_failure_mode :warn, is recorded unchained).
84
+ config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)
85
+
86
+ # Rotating secret_key_base rotates this key too. Keep the old one here, or the
87
+ # entries it signed become unverifiable (reported as such, never as tampered):
88
+ #
89
+ # config.retired_ledger_keys = [old_key]
90
+ #
91
+ # Or use a dedicated key, so secret_key_base can rotate on its own schedule:
92
+ #
93
+ # config.ledger_key = Rails.application.credentials.reeve_ledger_key
94
+
75
95
  # Cap on how many record identifiers one entry stores. The true count is
76
96
  # always recorded, and the entry says when the list was truncated.
77
97
  config.max_recorded_ids = 1000
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Audit-entry contract 3 → 4. Generated by `bin/rails generate reeve:upgrade`.
4
+ #
5
+ # Records which key signed which stretch of the chain. Each key that signs the ledger opens
6
+ # a numbered key epoch, recorded once in reeve_audit_chain_epochs, and every row reeve chains
7
+ # from here on names its epoch in `chain_epoch`, inside its digest. Verification then reports
8
+ # a row whose key or epoch was changed as tampering, where before it could only say the row
9
+ # was signed by a key it did not have.
10
+ #
11
+ # Rows already in the table are not touched. Rows chained before the first epoch opens keep
12
+ # exactly the verification they had; the column is nullable for that reason. The first
13
+ # chained write after this migration opens epoch 0 with the configured ledger key.
14
+ #
15
+ # Rotate the key with `bin/rails reeve:rotate_ledger_key` (see the CHANGELOG).
16
+ #
17
+ # The application role needs INSERT and SELECT on the new table, never UPDATE:
18
+ #
19
+ # GRANT INSERT, SELECT ON reeve_audit_chain_epochs TO app_role;
20
+ class AddKeyEpochsToReeveAuditEntries < ActiveRecord::Migration[7.0]
21
+ def change
22
+ add_column :reeve_audit_entries, :chain_epoch, :integer
23
+
24
+ create_table :reeve_audit_chain_epochs do |t|
25
+ t.integer :epoch, null: false
26
+ t.string :key_id, null: false, limit: 16
27
+ t.bigint :first_sequence, null: false
28
+ t.datetime :opened_at, null: false, precision: 6
29
+ t.string :epoch_digest, null: false, limit: 64
30
+ end
31
+
32
+ add_index :reeve_audit_chain_epochs, :epoch, unique: true
33
+ add_index :reeve_audit_chain_epochs, :key_id, unique: true
34
+ end
35
+ end