reeve 0.6.0 → 0.8.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +293 -3
  3. data/README.md +276 -11
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +52 -5
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +10 -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_retention_to_reeve_audit_entries.rb.tt +37 -0
  8. data/lib/generators/reeve/upgrade/upgrade_generator.rb +19 -25
  9. data/lib/generators/reeve/upgrade/upgrade_notes.rb +62 -0
  10. data/lib/reeve/audit/chain/anchoring.rb +91 -0
  11. data/lib/reeve/audit/chain/canonical.rb +38 -7
  12. data/lib/reeve/audit/chain/checkpoint.rb +228 -0
  13. data/lib/reeve/audit/chain/checkpoint_check.rb +156 -0
  14. data/lib/reeve/audit/chain/entrypoints.rb +29 -0
  15. data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
  16. data/lib/reeve/audit/chain/epochs.rb +83 -0
  17. data/lib/reeve/audit/chain/head.rb +55 -2
  18. data/lib/reeve/audit/chain/keyring.rb +6 -0
  19. data/lib/reeve/audit/chain/report.rb +232 -32
  20. data/lib/reeve/audit/chain/retention/archive.rb +98 -0
  21. data/lib/reeve/audit/chain/retention/archive_check.rb +179 -0
  22. data/lib/reeve/audit/chain/retention/check.rb +94 -0
  23. data/lib/reeve/audit/chain/retention/connection.rb +65 -0
  24. data/lib/reeve/audit/chain/retention/digest.rb +29 -0
  25. data/lib/reeve/audit/chain/retention/plan.rb +100 -0
  26. data/lib/reeve/audit/chain/retention/preflight.rb +48 -0
  27. data/lib/reeve/audit/chain/retention/purge.rb +155 -0
  28. data/lib/reeve/audit/chain/retention/purge_result.rb +134 -0
  29. data/lib/reeve/audit/chain/retention/records.rb +23 -0
  30. data/lib/reeve/audit/chain/rotation.rb +124 -0
  31. data/lib/reeve/audit/chain/schema.rb +59 -0
  32. data/lib/reeve/audit/chain/verification.rb +53 -10
  33. data/lib/reeve/audit/chain/verifier.rb +75 -22
  34. data/lib/reeve/audit/chain/walk.rb +24 -4
  35. data/lib/reeve/audit/chain.rb +61 -13
  36. data/lib/reeve/audit/chain_epoch.rb +24 -0
  37. data/lib/reeve/audit/chain_record.rb +14 -8
  38. data/lib/reeve/audit/file_anchor.rb +49 -0
  39. data/lib/reeve/audit/retention_record.rb +24 -0
  40. data/lib/reeve/audit.rb +30 -3
  41. data/lib/reeve/configuration.rb +25 -0
  42. data/lib/reeve/errors.rb +15 -0
  43. data/lib/reeve/railtie.rb +2 -1
  44. data/lib/reeve/tasks/descriptions.rb +22 -0
  45. data/lib/reeve/tasks/purge_options.rb +54 -0
  46. data/lib/reeve/tasks.rb +104 -7
  47. data/lib/reeve/testing/checks/contract_version.rb +2 -2
  48. data/lib/reeve/testing/checks/ledger_chain.rb +56 -2
  49. data/lib/reeve/testing/ledger.rb +19 -3
  50. data/lib/reeve/version.rb +1 -1
  51. metadata +28 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03d0a3695140d0703272da744486caa3ab21530eddb0d48d275977e3b83047c0
4
- data.tar.gz: aa8988dd9d7fc1dfb40b5c7a67f1f0a9f70d5ba72350b7669ba32afb2121f140
3
+ metadata.gz: 5fec548cc5008459901b118b5bd79b3a3c47899554199c7a8a686ca3e9528cc8
4
+ data.tar.gz: 277700a1ad87207e90b693e96cfb2536ffec41459d420bd9d93d9fe08a1ebd68
5
5
  SHA512:
6
- metadata.gz: 116dbf0359f8666ffa50cb70a9a85aa7a037d76390305b3315fc4ecc37233fe83a5faabb14103c0ae999ae69f5cc5fa020813cd7da3ade8ae86f837639604c98
7
- data.tar.gz: 4ae861908807ec1bc3b3363442edda7cf6936d60d01e39b5aa03f6ae91cd7e0daad49ac8a154a53db277894c866cf9adfdb2e8776d99071b20d6d4704647f4b0
6
+ metadata.gz: 077330b44a698212477ea31db93ade9d0242c8c1a85ac546363a85a296ca5faaadb3865b058720ac74fb9e1a71e2e0afa72c42eb0687b78707cd5588cbad3394
7
+ data.tar.gz: f6b915ea4c1fb5ade7a2d07c71cc199b5446eca9dcfb8beecbdc233f1d7574e754b026de3a6ecd309fa82f9205af99b65a65c0182e8e5ba5fe31a7c57585fab8
data/CHANGELOG.md CHANGED
@@ -4,6 +4,293 @@ 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.8.0] - 2026-10-25
8
+
9
+ Two additions to the tamper-evident ledger: **anchoring**, checkpoints of the chain kept
10
+ outside the database, so deleting the newest entries or the whole ledger is caught; and
11
+ **retention**, purging the oldest entries up to a checkpoint without breaking verification.
12
+ No audit-entry contract change: it stays `4`.
13
+
14
+ ### Upgrading from 0.7.0
15
+
16
+ 1. `bundle update reeve`, then `bin/rails generate reeve:upgrade && bin/rails db:migrate`. It
17
+ adds one table, `reeve_audit_retentions`; nothing in the ledger table changes.
18
+ 2. Grant the application's database role `SELECT` on `reeve_audit_retentions`.
19
+ 3. If anything reads the text output of `bin/rails reeve:verify_ledger`, switch it to
20
+ `FORMAT=json`: every summary line now ends with its anchoring (`; not anchored` until you
21
+ set one up). Existing JSON keys are unchanged.
22
+
23
+ Anchoring and retention are both opt-in; the README shows how to set each up.
24
+
25
+ ### Anchoring
26
+
27
+ The chain's own records live in the same database as the entries, so two kinds of tampering
28
+ went unseen: deleting the newest entries and rewinding the head to match, and deleting the
29
+ whole ledger, which read as "no chain". A new epoch nobody had written under yet could also
30
+ be deleted without trace. Now reeve can make a **checkpoint**, one signed line saying how far
31
+ the chain reached, and hand it to an **anchor**: storage you choose, outside the database.
32
+ Verification checks the ledger against every checkpoint.
33
+
34
+ **No contract change.** The audit-entry contract stays `4`. Nothing changes for a ledger
35
+ write. (Retention, below, adds one table.)
36
+
37
+ #### Added
38
+
39
+ - `bin/rails reeve:checkpoint_ledger` (`Reeve::Tasks.checkpoint_ledger`,
40
+ `Reeve::Audit::Chain.checkpoint!`): makes a checkpoint, publishes it, and prints it. Exit `0`
41
+ made, `1` refused (no key, no chain yet) or the anchor failed; the line is printed either way
42
+ so it can be stored by hand.
43
+ - `config.ledger_anchor`: any object with `publish(line)` and `checkpoints`. reeve ships
44
+ `Reeve::Audit::FileAnchor`, a file it only ever appends to. It is a building block: on the
45
+ database host it protects nothing. Point it at storage the database's users cannot change,
46
+ such as a mount backed by an object-locked bucket, and use one per environment.
47
+ - `reeve:verify_ledger` checks against the anchor's checkpoints when one is configured.
48
+ `CHECKPOINTS=path` reads a file of checkpoint lines instead, `CHECKPOINTS=` (empty) uses
49
+ none, and `FROM=latest` walks only what came after the newest checkpoint. A checkpoint line
50
+ that does not read is exit `2`, naming its position.
51
+ - New breaks, all exit `1`: `checkpoint_not_reached`, `checkpoint_mismatch`,
52
+ `checkpoint_altered`. A checkpoint signed by a key not configured is unverifiable (exit `2`).
53
+ With a checkpoint, an emptied ledger is BROKEN, not NO CHAIN.
54
+ - `reeve:rotate_ledger_key` makes a checkpoint after opening an epoch when an anchor is
55
+ configured, so the new epoch is pinned before anything is written under it.
56
+ - `Reeve::Checks::LedgerChain.new(anchored: true)`: also makes a checkpoint, reads it back
57
+ from the anchor and verifies against it. Run it as a test of its own (the README shows how);
58
+ the suite's own `LedgerChain` never reads the anchor, since checkpoints earlier runs left
59
+ name test databases that are gone.
60
+
61
+ #### Changed
62
+
63
+ - **Every summary line that follows a walk now ends with its anchoring**: `; not anchored`,
64
+ or `; anchored at #48,000 (checkpoint 2026-10-25 14:00 UTC), 211 entries after it`. This
65
+ applies whether or not you configure an anchor. If something reads the text report, read
66
+ `FORMAT=json` instead; its existing keys are unchanged, and it gains `checkpoints`,
67
+ `anchored_at`, `beyond_anchor`, `walked_from` and `breaks[].checkpoint`.
68
+
69
+ #### Limits, stated plainly
70
+
71
+ - Entries written after the latest checkpoint can still be removed without trace. The summary
72
+ line counts the entries still there after it, not ones deleted; how often you checkpoint is
73
+ the size of that window.
74
+ - A checkpoint is only as safe as its store. One an attacker can delete or overwrite protects
75
+ nothing, and reeve cannot make a store append-only.
76
+ - A leaked key can still rewrite the chain from any point, but not a checkpoint made before
77
+ the leak: rewriting an entry it saw changes the digest it names.
78
+ - Restoring a backup older than a checkpoint reads as BROKEN, because the restored ledger lost
79
+ entries that checkpoint saw. A database reset on purpose needs a new anchor file.
80
+ - A checkpoint's time is the producing host's clock, for people. Verification never uses it.
81
+
82
+ ### Retention
83
+
84
+ The ledger could only grow, and deleting old rows by hand broke the chain and read as
85
+ tampering. Now `bin/rails reeve:purge_ledger` removes the oldest entries up to a checkpoint
86
+ your anchor holds, writes an archive of them first, and leaves a signed **retention record**, so
87
+ the rest still verifies and the purge reads as a purge.
88
+
89
+ **One migration, no contract change.** `bin/rails generate reeve:upgrade` adds the table
90
+ `reeve_audit_retentions`; the audit-entry contract stays `4`. Grant the application's role
91
+ `SELECT` on it. Nothing is purged until you run the task.
92
+
93
+ **The constitution's append-only rule now has one exception** (1.1.0): application code still
94
+ cannot update or delete an entry; only this operator-run purge can, and only behind a verified,
95
+ anchored checkpoint, on a database role the application does not use, after writing a signed
96
+ record.
97
+
98
+ - `config.ledger_retention_connection`: an abstract ActiveRecord class connected to the ledger's
99
+ database as a role with `DELETE`; the purge refuses another database, or the application's
100
+ own user (SQLite has no roles, so there it warns).
101
+ - `bin/rails reeve:purge_ledger`: `OLDER_THAN=365d` or `THROUGH=sequence`; exactly one of
102
+ `ARCHIVE=path` (a new file each run) and `NO_ARCHIVE=1`; nothing removed without `CONFIRM=1`.
103
+ Exit `0` for a dry run, a purge, or nothing to purge (the first line says which); `1` refused,
104
+ with the reason and the fix. It requires the whole ledger to verify INTACT first. A purge
105
+ stopped midway leaves rows "awaiting purge" (INTACT, not a break); any rerun finishes it first.
106
+ The README shows the role, the grants and the `database.yml` entry.
107
+ - `bin/rails reeve:verify_archive ARCHIVE=path`: verifies an archive on its own, from its start to
108
+ its boundary checkpoint. It needs the application (for the table's column types), not the
109
+ purged rows.
110
+ - Verification starts after the latest purge, names the purged range on every summary line,
111
+ reports checkpoints older than it as purged, and gains `retention_altered` (BROKEN) and the JSON
112
+ keys `retention` and `awaiting_purge`.
113
+
114
+ Keep every ledger key: epoch and retention records stay in the ledger and are checked under
115
+ their own keys, and archives need the keys of the rows they hold.
116
+
117
+ Limits, in plain words: what a purge removed without an archive is gone for good, and the record
118
+ proves only that it existed and verified. An archive is a file you must keep safe, as you do the
119
+ anchor. A row edited inside the purged range while the purge runs is not reported by the ledger,
120
+ because it is being removed; the archive holds what was read.
121
+
122
+ ## [0.7.0] - 2026-10-23
123
+
124
+ 0.6.0 let a ledger be verified across a key rotation, provided the old key was kept as a
125
+ retired key. It did not record the rotation itself: nothing in the ledger said which key had
126
+ signed which stretch of it. So an entry whose key id was rewritten to a fingerprint nobody
127
+ holds read as "signed by a key you don't have" (exit 2), not as tampering, and an operator
128
+ who replaced the key without keeping the old one learned it only when verification later
129
+ came back unverifiable. Now every key that signs the ledger opens a numbered **key epoch**,
130
+ every new entry records its epoch inside its digest, and `bin/rails reeve:rotate_ledger_key`
131
+ performs and checks a rotation.
132
+
133
+ **Audit-entry contract `3` → `4`, and a migration is required.** One nullable column,
134
+ `chain_epoch`, and one new table, `reeve_audit_chain_epochs`. The bump is MAJOR by the
135
+ contract's own rule, for two reasons: `chain_digest` on a contract-4 entry covers the epoch,
136
+ which a contract-3 digest does not, and a NULL epoch means "chained before key epochs" on a
137
+ contract-3 entry where a chained contract-4 entry always has one. The rule's wording now
138
+ states that exception instead of leaving it to precedent.
139
+
140
+ ### Upgrading from 0.6.0
141
+
142
+ 1. Update the gem, then generate and run the migration. Nothing in your initializer changes.
143
+
144
+ ```bash
145
+ bundle update reeve
146
+ bin/rails generate reeve:upgrade
147
+ bin/rails db:migrate
148
+ ```
149
+
150
+ Migrate before the new code serves traffic: until the table is migrated, every chained
151
+ write fails with a `Reeve::ConfigurationError` naming `reeve:upgrade`, so guarded calls fail
152
+ closed. (`bin/rails reeve:verify_ledger` keeps working on an unmigrated table and checks it
153
+ as 0.6.0 did.)
154
+
155
+ 2. Grant the application's database role `INSERT` and `SELECT` on the new table — never
156
+ `UPDATE`:
157
+
158
+ ```sql
159
+ GRANT INSERT, SELECT ON reeve_audit_chain_epochs TO app_role;
160
+ ```
161
+
162
+ 3. Deploy. The first chained write after the migration — normally the first guarded tool
163
+ call — opens epoch 0 with your configured `ledger_key`. Until then, verification shows no
164
+ epochs. Entries already in the ledger are not touched; verification lists them as "before
165
+ epoch 0" and checks them exactly as 0.6.0 did.
166
+
167
+ Servers still running 0.6.0 during the deploy keep writing contract-3 entries. They are never
168
+ reported as tampering: they verify wherever their key is configured, and are unverifiable
169
+ where it is not, exactly as in 0.6.0.
170
+
171
+ ### Rotating the ledger key
172
+
173
+ Do these in order. The old key stays configured throughout: it is what verifies the
174
+ entries it signed, for as long as you keep it.
175
+
176
+ 1. Configure the new key as `ledger_key` and move the old one to `retired_ledger_keys`.
177
+ Assign `ledger_key` first — a key may not be both the signing key and a retired one,
178
+ even for the moment between the two lines:
179
+
180
+ ```ruby
181
+ config.ledger_key = Rails.application.credentials.reeve_ledger_key_2026
182
+ config.retired_ledger_keys = [Rails.application.credentials.reeve_ledger_key_2025]
183
+ ```
184
+
185
+ If your `ledger_key` is the one the generated initializer derives from
186
+ `secret_key_base`, rotating `secret_key_base` is this step, and the old derived key must be
187
+ captured *before* you change `secret_key_base`, while the app can still derive it:
188
+
189
+ ```bash
190
+ bin/rails runner 'puts [Rails.application.key_generator.generate_key("reeve audit ledger", 32)].pack("m0")'
191
+ ```
192
+
193
+ Store the printed value in credentials (say as `reeve_ledger_key_previous`), rotate
194
+ `secret_key_base`, and keep the derived line as it is:
195
+
196
+ ```ruby
197
+ config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)
198
+ config.retired_ledger_keys = [Rails.application.credentials.reeve_ledger_key_previous.unpack1("m0")]
199
+ ```
200
+
201
+ 2. Deploy everywhere. From the first write a server makes with the new key, that key opens
202
+ the next epoch. Servers still on the old key during the deploy keep writing under the old
203
+ epoch; those entries verify. No write fails because a rotation is under way.
204
+
205
+ 3. Run the rotation task once the deploy has finished, on a host configured like your
206
+ servers (both keys):
207
+
208
+ ```bash
209
+ bin/rails reeve:rotate_ledger_key
210
+ ```
211
+
212
+ It prints one of three first lines and exits accordingly:
213
+
214
+ | First line | Exit | Meaning |
215
+ |---|---|---|
216
+ | `reeve ledger key: ROTATED — epoch N opened with key …` | 0 | The task opened the new epoch, starting one past the newest entry. |
217
+ | `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. |
218
+ | `reeve ledger key: REFUSED — …; nothing was written` | 1 | See below. Nothing written. |
219
+
220
+ It refuses when no `ledger_key` is configured; when the configured key already opened an
221
+ earlier epoch (a key opens one epoch, ever — configure a new one); and when the current
222
+ epoch's key is not configured on the host running the task, because that epoch's entries
223
+ would become unverifiable there. The last refusal names the fix: add the old key to
224
+ `retired_ledger_keys`. Only if that key is truly lost, run
225
+ `ACCEPT_UNVERIFIABLE=1 bin/rails reeve:rotate_ledger_key`: it reports ROTATED (exit 0) and
226
+ adds a line starting `warning: ` saying the earlier epoch is unverifiable on this host. The
227
+ same `warning: ` line on a CURRENT result means the same thing about the previous epoch.
228
+
229
+ Keys are never printed, only their 16-character fingerprints. The same result is
230
+ available from Ruby as a value: `Reeve::Audit::Chain.rotate_key!` returns a `Rotation`
231
+ with `status`, `reason`, `epoch`, `key_id`, `first_sequence`, `previous_epoch`,
232
+ `previous_key_id` and `warnings`.
233
+
234
+ 4. Verify:
235
+
236
+ ```bash
237
+ bin/rails reeve:verify_ledger
238
+ ```
239
+
240
+ With both keys configured it reports INTACT and, under the summary, one line per epoch
241
+ with its key fingerprint, the sequence it begins at, how many entries named it, and
242
+ `verified`. An epoch whose key is not configured where you run it is listed as
243
+ `key not configured`, and the run is UNVERIFIABLE (exit 2) — never INTACT.
244
+
245
+ 5. Keep the old key in `retired_ledger_keys` for as long as the entries it signed must
246
+ verify — in practice, for the life of the ledger. Removing it makes every run on that host
247
+ UNVERIFIABLE (exit 2) from then on; nothing re-signs those entries under a new key.
248
+
249
+ Behaviour changes worth reading before upgrading:
250
+
251
+ - **Entries name their epoch.** Every chained entry written at contract 4 records
252
+ `chain_epoch`. Query by it: `Reeve::Audit::Entry.where(contract_version: 4, chain_epoch: 1)`
253
+ (on a contract-3 entry the column is outside the digest and means nothing).
254
+ - **A rewritten key id or epoch is now tampering.** An entry whose `chain_key_id` or
255
+ `chain_epoch` no longer matches the epoch records, and an entry that names an epoch with no
256
+ record, are reported as BROKEN (exit 1), with the new break kinds `epoch_mismatch` and
257
+ `epoch_missing`. In 0.6.0 the first of these was UNVERIFIABLE. An epoch record that was
258
+ edited or renumbered is BROKEN too (`epoch_altered`); so is one deleted while entries name
259
+ it. An epoch record forged under a key the verifier does not hold reads as UNVERIFIABLE —
260
+ never INTACT — and deleting the newest epoch before anything was written under it is not
261
+ detected.
262
+ - **Entries without an epoch are unchanged.** Entries chained before epoch 0, and contract-3
263
+ entries written by 0.6.0 servers during the deploy, are checked exactly as in 0.6.0: a
264
+ rewritten key id on one of them still reads as unverifiable. The ledger has no record of
265
+ which keys signed them, and reeve does not invent one after the fact.
266
+ - **Two rotations with no write between them** open two epochs that begin at the same
267
+ sequence; the first never signs anything. Not a break.
268
+ - **The unverifiable count includes epoch records.** `unverifiable_count` (and the JSON
269
+ `totals`) count an epoch record whose key is not configured, as they already counted the
270
+ genesis.
271
+ - **JSON output** gains `epochs` — an array of `{ "epoch", "key_id", "first_sequence",
272
+ "entries", "status" }`, `status` one of `verified`, `key_not_configured`, `altered` — and
273
+ `before_first_epoch`, the integer count of entries chained before epoch 0 (null, like
274
+ `head_at_start`, when nothing was walked). Each break gains `epoch`. Every existing field is unchanged.
275
+ The text report is unchanged for a ledger with one verified epoch and nothing before it.
276
+ - **Custom recorders** that call `Reeve::Audit::Chain#append!` need no change: rows at
277
+ contract 4 are stamped with their epoch; rows passed at contract 3 are chained as before,
278
+ without one.
279
+ - **A ledger on its own database**: connect `Reeve::Audit::ChainEpoch` the same way as
280
+ `Reeve::Audit::ChainRecord` (for example with the same `connects_to`). Verification refuses,
281
+ naming the model, if either reads from another database.
282
+
283
+ What this adds to the guarantee, and what it does not, is in the README under
284
+ "Tamper-evident, within stated limits". In short: a key the verifier does not hold still
285
+ cannot be checked — epochs make that loss visible and attribute it to a named epoch, they do
286
+ not undo it — and a leaked key, retired or current, can still rewrite what it signed.
287
+
288
+ ### Added
289
+
290
+ - `bin/rails reeve:rotate_ledger_key` and `Reeve::Audit::Chain.rotate_key!`.
291
+ - Key epochs: `reeve_audit_chain_epochs`, `chain_epoch`, and their verification.
292
+ - The `reeve:upgrade` step to contract 4.
293
+
7
294
  ## [0.6.0] - 2026-10-10
8
295
 
9
296
  The ledger has been append-only at the library level since 0.1.0: no API updates or deletes
@@ -515,6 +802,9 @@ is for:
515
802
  - **A change that widens access is breaking by definition** — major version, plus a
516
803
  migration note saying exactly what became visible that was not visible before.
517
804
  - Adding a nullable ledger column is minor and leaves the audit-entry contract version
518
- alone. Removing or renaming a column, or changing what a value means, is major and bumps
519
- it.
520
- - The audit-entry contract version is `3`.
805
+ alone — unless what its absence means, or what an existing value covers, differs between
806
+ rows written before and after it, which is major (contracts 3 and 4 are both examples).
807
+ Removing or renaming a column, or changing what a value means, is major and bumps it.
808
+ - A new table beside the ledger that leaves its rows unchanged is minor and leaves the
809
+ contract alone (`reeve_audit_retentions`, 0.8.0).
810
+ - 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.6.0.** Read the known limitations in
10
+ > **Status: 0.8.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
 
@@ -200,7 +200,7 @@ bin/rails reeve:verify_ledger
200
200
  ```
201
201
 
202
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)
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); not anchored
204
204
  ```
205
205
 
206
206
  It exits `0` when the chain is intact, `1` when it is broken, and `2` when it could not
@@ -218,7 +218,8 @@ puts result # the report above, with every break listed under it
218
218
 
219
219
  In CI the compliance suite you already run includes `LedgerChain`, in both frameworks:
220
220
  it writes entries of its own and fails on a broken chain, a missing key, or entries that
221
- were not chained.
221
+ were not chained. "Not anchored" on the summary line is explained under **Anchoring the
222
+ chain** below.
222
223
 
223
224
  **The key.** The generated initializer derives it from `secret_key_base`:
224
225
 
@@ -230,9 +231,242 @@ Rotating `secret_key_base` therefore rotates the ledger key. Keep the old one, o
230
231
  entries it signed become *unverifiable* — reported as such, never as tampered:
231
232
 
232
233
  ```ruby
233
- config.retired_ledger_keys = [old_key] # verify with these; never sign with them
234
+ config.retired_ledger_keys = [old_key] # verify with these; new epochs never open with them
234
235
  ```
235
236
 
237
+ **Rotating the key.** Every key that signs the ledger opens a numbered *key epoch*, and every
238
+ entry records its epoch inside its digest. To rotate: set the new key as `ledger_key` and the
239
+ old one in `retired_ledger_keys` (in that order), deploy, then run
240
+
241
+ ```bash
242
+ bin/rails reeve:rotate_ledger_key
243
+ ```
244
+
245
+ It reports `ROTATED` or `CURRENT` (exit `0`), or `REFUSED` (exit `1`, nothing written) — for
246
+ instance when the current epoch's key is not configured where it runs, since that epoch's
247
+ entries would become unverifiable there. Writes never wait for it: the first write with the
248
+ new key opens its epoch, and servers still on the old key during the deploy keep writing
249
+ under theirs. From Ruby:
250
+
251
+ ```ruby
252
+ rotation = Reeve::Audit::Chain.rotate_key!
253
+ rotation.status # => :rotated, :current or :refused
254
+ rotation.epoch # => 1
255
+ ```
256
+
257
+ Verification then lists each epoch under its summary line, with the key fingerprint, where
258
+ it begins, and whether it verified. The CHANGELOG has the full procedure.
259
+
260
+ **Anchoring the chain.** The chain's own records live in the same database as the entries, so
261
+ someone who deletes the newest entries and rewinds the head to match, or deletes the whole
262
+ ledger, leaves nothing behind that verification can see. A *checkpoint* is a second witness:
263
+ one signed line saying how far the chain reached (its genesis, its head, its latest key
264
+ epoch), kept where the database's users cannot change it. It holds digests and counts only,
265
+ never entry content or a key.
266
+
267
+ Point an anchor at that storage. The line goes at the bottom of
268
+ `config/initializers/reeve.rb`, after `require "reeve/audit"` (which defines `FileAnchor`),
269
+ outside the `Reeve.configure` block, hence `Reeve.config`:
270
+
271
+ ```ruby
272
+ require "reeve/audit"
273
+
274
+ Reeve.config.ledger_anchor =
275
+ Reeve::Audit::FileAnchor.new("/mnt/ledger-anchor/#{Rails.env}.checkpoints")
276
+ ```
277
+
278
+ Use one anchor per environment, on storage outside the database host, such as a mount backed
279
+ by an object-locked bucket; a file next to the database protects nothing. The hosts that make
280
+ checkpoints need to write the file (it is created on the first one), and the hosts that verify
281
+ need to read it. Any object with `publish(line)` and `checkpoints` works in its place.
282
+
283
+ Then make a checkpoint on a schedule, from one host (hourly, say), and keep verifying as
284
+ before. Alert on anything but `0` from either task. `reeve:checkpoint_ledger` exits `1` when
285
+ it refused (no key, or no chain until the first guarded call) or the anchor failed, and still
286
+ prints the line so it can be stored by hand. Two runs at once are safe.
287
+
288
+ ```bash
289
+ bin/rails reeve:checkpoint_ledger # exit 0 made and published
290
+ bin/rails reeve:verify_ledger # checks the ledger against every checkpoint
291
+ ```
292
+
293
+ ```text
294
+ 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); anchored at #48,000 (checkpoint 2026-10-25 14:00 UTC), 211 entries after it
295
+ ```
296
+
297
+ Every checkpoint is checked: a ledger that no longer reaches one, holds another entry where it
298
+ saw one, or lost an epoch record it named, is BROKEN (exit `1`), and so is an emptied ledger,
299
+ which without a checkpoint reads only as "no chain". The newest entries deleted with the head
300
+ rewound read like this:
301
+
302
+ ```text
303
+ reeve ledger: BROKEN — 1 break in sequences 1–46,211; 5,000 earlier entries not covered (5,000 when the chain started); anchored at #44,000 (checkpoint 2026-10-25 13:00 UTC), 2,211 entries after it
304
+ totals: checkpoint_not_reached 1
305
+ checkpoint #48,000 checkpoint_not_reached the head is at 46,211; entries the checkpoint saw were removed
306
+ ```
307
+
308
+ What it does not cover: entries written after the latest checkpoint can still be removed
309
+ without trace. "211 entries after it" counts the entries still there past the checkpoint; it
310
+ cannot see ones that were deleted, so how often you checkpoint is the size of that window.
311
+ `reeve:rotate_ledger_key` makes a checkpoint itself after opening an epoch. Writes never touch
312
+ the anchor.
313
+
314
+ *Restores and resets.* Restoring a backup older than a checkpoint reads as BROKEN, truthfully:
315
+ the restored ledger lost entries that checkpoint saw. Resetting a database on purpose (a
316
+ staging rebuild) is the same, so start a new anchor file for the new ledger: change the path
317
+ (`staging-2026-10-25.checkpoints`, say), keep the old file as the record of the old ledger, and
318
+ make a checkpoint. Until then the run reads "not anchored". `CHECKPOINTS=` (empty) verifies
319
+ without any checkpoint, for a one-off check, never as the fix.
320
+
321
+ *Other sources, large ledgers.* `CHECKPOINTS=path` verifies against a file of checkpoint lines
322
+ instead of the anchor, say an auditor's copy. `FROM=latest` walks only what came after the
323
+ newest checkpoint: it first checks that checkpoint (its signature, the genesis and epoch it
324
+ names, and that its entry still has the digest it saw), then verifies every entry after it.
325
+ Entries before it are not re-verified by that run, and the summary says how many; keep a full
326
+ run on a slower schedule. With no checkpoint to start from it exits `2`. From Ruby:
327
+
328
+ ```ruby
329
+ Reeve::Audit::Chain.checkpoint!.checkpoint.to_line # => "{\"epoch\":1,…}"
330
+ Reeve::Audit::Chain.verify(checkpoints: lines).anchored_at # the highest checkpoint that held
331
+ ```
332
+
333
+ *In CI.* The `LedgerChain` check the compliance suite runs never reads the anchor, so
334
+ configuring one does not change it: a test database is emptied between runs, and old
335
+ checkpoints name ledgers that are gone. To prove the anchor works, run the anchored variant as
336
+ a test of its own, with the test environment's anchor pointed at a scratch file such as
337
+ `Rails.root.join("tmp/ledger.checkpoints")`. It makes a checkpoint, reads it back through the
338
+ anchor, and verifies against that one alone:
339
+
340
+ ```ruby
341
+ # RSpec
342
+ it "anchors the ledger" do
343
+ result = Reeve::Checks::LedgerChain.new(anchored: true).call
344
+ expect(result).to be_passed, result.message
345
+ end
346
+
347
+ # Minitest
348
+ def test_anchors_the_ledger
349
+ result = Reeve::Checks::LedgerChain.new(anchored: true).call
350
+ assert result.passed?, result.message
351
+ end
352
+ ```
353
+
354
+ **Purging old entries.** The ledger grows until you purge it. Deleting rows by hand breaks
355
+ the chain and is reported as tampering, so reeve purges for you: the oldest entries, up to a
356
+ checkpoint your anchor holds, after verifying the whole ledger INTACT, leaving a signed
357
+ *retention record* so the rest still verifies and the purge reads as a purge. It also removes
358
+ rows written before the chain began, and nothing else. It stops at a checkpoint, so with
359
+ `OLDER_THAN=365d` a few entries just over a year old can remain until the next run.
360
+
361
+ *Setting it up, once.* Run `bin/rails generate reeve:upgrade && bin/rails db:migrate` (it adds
362
+ `reeve_audit_retentions`), then create a database role only the purge uses; the application's
363
+ role never gains `DELETE`. On PostgreSQL (the migration's header shows the same grants):
364
+
365
+ ```sql
366
+ CREATE ROLE reeve_retention LOGIN PASSWORD '…';
367
+ GRANT SELECT ON reeve_audit_entries, reeve_audit_chains, reeve_audit_chain_epochs,
368
+ reeve_audit_retentions TO reeve_retention;
369
+ GRANT DELETE ON reeve_audit_entries TO reeve_retention;
370
+ GRANT INSERT ON reeve_audit_retentions TO reeve_retention;
371
+ GRANT USAGE ON SEQUENCE reeve_audit_retentions_id_seq TO reeve_retention;
372
+ GRANT SELECT ON reeve_audit_retentions TO app_role; -- the application only reads it
373
+ ```
374
+
375
+ Add the role to `config/database.yml` as a second database entry pointing at the same
376
+ database, and a class that connects through it:
377
+
378
+ ```yaml
379
+ production:
380
+ primary:
381
+ <<: *default
382
+ database: app_production
383
+ ledger_retention:
384
+ <<: *default
385
+ database: app_production # the same database
386
+ username: reeve_retention
387
+ password: <%= ENV["REEVE_RETENTION_PASSWORD"] %>
388
+ database_tasks: false # never migrated through this entry
389
+ ```
390
+
391
+ ```ruby
392
+ # app/models/ledger_retention_record.rb
393
+ class LedgerRetentionRecord < ActiveRecord::Base
394
+ self.abstract_class = true
395
+ connects_to database: { writing: :ledger_retention }
396
+ end
397
+
398
+ # config/initializers/reeve.rb, after require "reeve/audit"
399
+ Reeve.config.ledger_retention_connection = LedgerRetentionRecord
400
+ ```
401
+
402
+ The purge refuses a connection to another database, or one that logs in as the application's
403
+ own user. On SQLite, which has no roles, it can only warn. The task needs the ledger key, as
404
+ every chain task does.
405
+
406
+ *Running it.* Monthly, from one host, a dry run first (no `CONFIRM`), then the purge. The
407
+ archive path must be new each run: an existing archive is never overwritten.
408
+
409
+ ```bash
410
+ bin/rails reeve:purge_ledger OLDER_THAN=365d ARCHIVE=/mnt/archive/ledger-2026-10.jsonl # dry run
411
+ bin/rails reeve:purge_ledger OLDER_THAN=365d ARCHIVE=/mnt/archive/ledger-2026-10.jsonl CONFIRM=1
412
+ ```
413
+
414
+ ```text
415
+ reeve ledger purge: WOULD PURGE — sequences 1–6,000 through checkpoint #6,000 (2025-10-01 14:00 UTC): 6,000 chained entries and 5,000 pre-chain rows; archive to /mnt/archive/ledger-2026-10.jsonl; run again with CONFIRM=1
416
+ ```
417
+
418
+ In crontab, escape the date's `%`: `ledger-$(date +\%Y-\%m).jsonl`. `THROUGH=6000` names a
419
+ checkpoint's sequence instead of an age. Exactly one of `ARCHIVE=path` and `NO_ARCHIVE=1` is
420
+ required; with neither the task refuses, with both it raises. Exit `0` covers a dry run, a
421
+ purge, and nothing old enough (the first line says which: `WOULD PURGE`, `PURGED`, `NOTHING TO
422
+ PURGE`); exit `1` is a refusal, which removes nothing and names the fix:
423
+
424
+ | Refusal says | Fix |
425
+ |---|---|
426
+ | no anchor configured | set `config.ledger_anchor` and schedule checkpoints (above) |
427
+ | no retention connection / another database / the application's own database user | `config.ledger_retention_connection` and the role above |
428
+ | the ledger does not verify intact | read `bin/rails reeve:verify_ledger`; nothing is purged while anything is broken or unverifiable |
429
+ | no checkpoint the anchor holds is at that sequence | use one of the two it names, or `OLDER_THAN` |
430
+ | the archive … already exists, or cannot be written | a new path, in a directory the task can write |
431
+
432
+ Any other failure (a lost connection, a full disk) raises, exit `1` from Rake, like any task.
433
+
434
+ *If it stops midway.* The archive is written in full and the retention record committed
435
+ before any row is deleted. If the host dies during the deletes, verification reads INTACT,
436
+ exit `0`, with `; 120 entries awaiting purge` beside the purged range, which is not a break.
437
+ Run the purge again, the same command or any other: it first finishes the interrupted purge
438
+ (`PURGED — … (resumed; 120 rows removed this run)`), whatever boundary the new run names, and
439
+ skips the archive, which already exists. Run it once more to purge further.
440
+
441
+ *The archive.* Every purged row with every column, so the range can be checked without the
442
+ ledger's rows:
443
+
444
+ ```bash
445
+ bin/rails reeve:verify_archive ARCHIVE=/mnt/archive/ledger-2026-10.jsonl # reeve archive: INTACT — sequences 1–6,000 verified, …
446
+ ```
447
+
448
+ It runs inside the application (it needs the entries table's columns, from any database with
449
+ the schema, not the purged rows), with the keys that signed the archived rows configured as
450
+ `ledger_key` or `retired_ledger_keys`; the anchor is not needed. Each archive covers the range
451
+ its purge removed, named in its first line, so name files by the month they were made. Keep
452
+ archives where they cannot be changed, as you do the anchor. `NO_ARCHIVE=1` deletes without
453
+ one, for data you must not keep; the record and the output then say no archive was written.
454
+
455
+ *Keys.* Keep every key you ever signed with. A purge removes entries, not epoch records or
456
+ retention records, and verification still checks those under their own keys; archives need
457
+ the keys of the rows they hold.
458
+
459
+ After a purge, every summary line names what was purged before its coverage; with
460
+ `FORMAT=json`, `retention` and `awaiting_purge` hold the same:
461
+
462
+ ```text
463
+ reeve ledger: INTACT — sequences 6,001–10,000 verified (head at start: 10,000); 1–6,000 purged (retention record 0, 2026-10-25); 0 earlier entries not covered (5,000 when the chain started, 5,000 purged); anchored at #10,000 (checkpoint 2026-10-25 14:00 UTC), 0 entries after it
464
+ ```
465
+
466
+ Checkpoints older than the boundary read as purged. Deleting entries a retention record does
467
+ not cover, or editing or deleting a record, is BROKEN (`retention_altered` or `missing`), as
468
+ before.
469
+
236
470
  A dedicated key decouples the two: `config.ledger_key = Rails.application.credentials.reeve_ledger_key`.
237
471
  Keys must be at least 32 bytes and are only ever printed as 16-character fingerprints.
238
472
  Without a key, every guarded call fails closed; under `audit_failure_mode = :warn` it is
@@ -245,18 +479,35 @@ recorded unchained instead, logged each time, and reported by verification.
245
479
  - chained rows swapped, renumbered or moved, or a row inserted that Reeve did not write —
246
480
  including one numbered past the head;
247
481
  - the newest rows removed while the chain record was left alone;
248
- - the chain record removed while chained rows remain.
482
+ - the chain record removed while chained rows remain;
483
+ - an entry whose key id or key epoch was changed, or that names an epoch with no record, and
484
+ an epoch record that was changed, or removed while entries name it (one forged under a key
485
+ you do not hold reads as unverifiable, never intact);
486
+ - with checkpoints: any entry the latest checkpoint saw removed, even with the head rewound;
487
+ the whole ledger deleted or replaced; an epoch record a checkpoint named, removed or changed;
488
+ a checkpoint edited, or forged without a key you hold.
249
489
 
250
490
  **Not detected** — stated plainly, because these are the limits of the guarantee:
251
491
 
252
492
  1. **A leaked key.** Anyone holding it can rewrite the chain from any point forward. By
253
- default that is anyone holding `secret_key_base`.
254
- 2. **The newest rows deleted by someone who also rewinds the chain record's head.** Only a
255
- checkpoint held outside the database could catch that; Reeve does not ship one.
256
- 3. **The whole ledger deleted, chain record included.** Reported as *no chain*, never as a
257
- pass — but indistinguishable from a ledger that was never chained.
493
+ default that is anyone holding `secret_key_base`. Not past a checkpoint made before the
494
+ leak, though: rewriting an entry it saw changes the digest it names.
495
+ 2. **The newest rows deleted by someone who also rewinds the chain record's head**, unless a
496
+ checkpoint saw them. Rows after the latest checkpoint stay exposed; the summary line counts
497
+ them.
498
+ 3. **The whole ledger deleted, chain record included**, without a checkpoint. Reported as
499
+ *no chain*, never as a pass, but indistinguishable from a ledger that was never chained.
500
+ And checkpoints are only as safe as their store: one an attacker can delete protects nothing.
258
501
  4. **Rows written before you upgraded.** They are counted as "not covered" and are exactly
259
- as trustworthy as they were before.
502
+ as trustworthy as they were before. Likewise, entries without a key epoch — chained by
503
+ 0.6.0, before or during the upgrade — are checked as 0.6.0 checked them: a rewritten key id
504
+ on one of them reads as unverifiable, not as tampering.
505
+ 5. **A key you no longer hold.** Entries it signed cannot be checked, by anyone. Epochs make
506
+ that visible and name the epoch; they do not undo it.
507
+ 6. **What a purge removed.** Without an archive, nobody can read it again; the retention record
508
+ proves only that it existed, verified, and was removed on purpose. A row edited inside the
509
+ range while the purge runs is not reported by the ledger, since it is being removed; the
510
+ archive holds what was read.
260
511
 
261
512
  And it prevents nothing: it detects a change after the fact. The `GRANT INSERT, SELECT` in
262
513
  the generated migration is what prevents one. The chain record needs `UPDATE` as well,
@@ -280,6 +531,16 @@ same door the built-in ones use, or verification reports them as unchained:
280
531
  Reeve::Audit::Chain.new(entry_class: Reeve::Audit::Entry).append!(row)
281
532
  ```
282
533
 
534
+ **Upgrading from 0.7.0:** `bin/rails generate reeve:upgrade && bin/rails db:migrate` adds
535
+ `reeve_audit_retentions`; grant the application's role `SELECT` on it. Nothing is purged until
536
+ you run the task, and anchoring is opt-in. Every summary line now ends with its anchoring
537
+ (`; not anchored` until you set one up), so anything that matches the whole text line should
538
+ read `FORMAT=json` instead, whose existing keys are unchanged.
539
+
540
+ **Upgrading from 0.6.0:** `bin/rails generate reeve:upgrade && bin/rails db:migrate` before
541
+ the new code serves traffic, and grant `INSERT, SELECT` on `reeve_audit_chain_epochs`. The next guarded call opens epoch 0;
542
+ earlier entries are listed as "before epoch 0" and checked as before.
543
+
283
544
  **Upgrading from 0.5.0:**
284
545
 
285
546
  ```bash
@@ -583,6 +844,10 @@ touched*. Keep your existing auth.
583
844
  Ruby 3.0+, Rails 7.0+. No runtime dependencies. The fast-mcp adapter needs Ruby 3.1+,
584
845
  because fast-mcp does.
585
846
 
847
+ ## Who built this
848
+
849
+ 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.
850
+
586
851
  ## License
587
852
 
588
853
  MIT. See [LICENSE.txt](LICENSE.txt).