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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +293 -3
- data/README.md +276 -11
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +52 -5
- data/lib/generators/reeve/install/templates/initializer.rb.tt +10 -0
- data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
- data/lib/generators/reeve/upgrade/templates/add_ledger_retention_to_reeve_audit_entries.rb.tt +37 -0
- data/lib/generators/reeve/upgrade/upgrade_generator.rb +19 -25
- data/lib/generators/reeve/upgrade/upgrade_notes.rb +62 -0
- data/lib/reeve/audit/chain/anchoring.rb +91 -0
- data/lib/reeve/audit/chain/canonical.rb +38 -7
- data/lib/reeve/audit/chain/checkpoint.rb +228 -0
- data/lib/reeve/audit/chain/checkpoint_check.rb +156 -0
- data/lib/reeve/audit/chain/entrypoints.rb +29 -0
- data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
- data/lib/reeve/audit/chain/epochs.rb +83 -0
- data/lib/reeve/audit/chain/head.rb +55 -2
- data/lib/reeve/audit/chain/keyring.rb +6 -0
- data/lib/reeve/audit/chain/report.rb +232 -32
- data/lib/reeve/audit/chain/retention/archive.rb +98 -0
- data/lib/reeve/audit/chain/retention/archive_check.rb +179 -0
- data/lib/reeve/audit/chain/retention/check.rb +94 -0
- data/lib/reeve/audit/chain/retention/connection.rb +65 -0
- data/lib/reeve/audit/chain/retention/digest.rb +29 -0
- data/lib/reeve/audit/chain/retention/plan.rb +100 -0
- data/lib/reeve/audit/chain/retention/preflight.rb +48 -0
- data/lib/reeve/audit/chain/retention/purge.rb +155 -0
- data/lib/reeve/audit/chain/retention/purge_result.rb +134 -0
- data/lib/reeve/audit/chain/retention/records.rb +23 -0
- data/lib/reeve/audit/chain/rotation.rb +124 -0
- data/lib/reeve/audit/chain/schema.rb +59 -0
- data/lib/reeve/audit/chain/verification.rb +53 -10
- data/lib/reeve/audit/chain/verifier.rb +75 -22
- data/lib/reeve/audit/chain/walk.rb +24 -4
- data/lib/reeve/audit/chain.rb +61 -13
- data/lib/reeve/audit/chain_epoch.rb +24 -0
- data/lib/reeve/audit/chain_record.rb +14 -8
- data/lib/reeve/audit/file_anchor.rb +49 -0
- data/lib/reeve/audit/retention_record.rb +24 -0
- data/lib/reeve/audit.rb +30 -3
- data/lib/reeve/configuration.rb +25 -0
- data/lib/reeve/errors.rb +15 -0
- data/lib/reeve/railtie.rb +2 -1
- data/lib/reeve/tasks/descriptions.rb +22 -0
- data/lib/reeve/tasks/purge_options.rb +54 -0
- data/lib/reeve/tasks.rb +104 -7
- data/lib/reeve/testing/checks/contract_version.rb +2 -2
- data/lib/reeve/testing/checks/ledger_chain.rb +56 -2
- data/lib/reeve/testing/ledger.rb +19 -3
- data/lib/reeve/version.rb +1 -1
- metadata +28 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5fec548cc5008459901b118b5bd79b3a3c47899554199c7a8a686ca3e9528cc8
|
|
4
|
+
data.tar.gz: 277700a1ad87207e90b693e96cfb2536ffec41459d420bd9d93d9fe08a1ebd68
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
519
|
-
it.
|
|
520
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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).
|