reeve 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03d0a3695140d0703272da744486caa3ab21530eddb0d48d275977e3b83047c0
4
- data.tar.gz: aa8988dd9d7fc1dfb40b5c7a67f1f0a9f70d5ba72350b7669ba32afb2121f140
3
+ metadata.gz: 4a50e8fe479452d3b685dcd79b3022881c76567651444c6204e8db3d601b07c8
4
+ data.tar.gz: fe9974e2c035ce7c958f75d44a847d4b951f9cd828820aba9468310e2a143acd
5
5
  SHA512:
6
- metadata.gz: 116dbf0359f8666ffa50cb70a9a85aa7a037d76390305b3315fc4ecc37233fe83a5faabb14103c0ae999ae69f5cc5fa020813cd7da3ade8ae86f837639604c98
7
- data.tar.gz: 4ae861908807ec1bc3b3363442edda7cf6936d60d01e39b5aa03f6ae91cd7e0daad49ac8a154a53db277894c866cf9adfdb2e8776d99071b20d6d4704647f4b0
6
+ metadata.gz: aa5ff3ffce5221ee1b8baa64461865842faed31d432a0977051f6f3ec8be1846474174d27e19037a1a6b6901af5c6329fd38ac390e2da3fc664f7dfe9b7214ee
7
+ data.tar.gz: d986d9bd16574387f5529e983e932c1e740068a9b4a833a7220cf03dee30b3e134a5fd101a0ee41d2091bf3fa6660cc7ac67d52d720039bc149e32e6ec1dd33a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,178 @@ 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
+
7
179
  ## [0.6.0] - 2026-10-10
8
180
 
9
181
  The ledger has been append-only at the library level since 0.1.0: no API updates or deletes
@@ -515,6 +687,7 @@ is for:
515
687
  - **A change that widens access is breaking by definition** — major version, plus a
516
688
  migration note saying exactly what became visible that was not visible before.
517
689
  - 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`.
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.6.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
 
@@ -230,9 +230,32 @@ Rotating `secret_key_base` therefore rotates the ledger key. Keep the old one, o
230
230
  entries it signed become *unverifiable* — reported as such, never as tampered:
231
231
 
232
232
  ```ruby
233
- config.retired_ledger_keys = [old_key] # verify with these; never sign with them
233
+ config.retired_ledger_keys = [old_key] # verify with these; new epochs never open with them
234
234
  ```
235
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
+
236
259
  A dedicated key decouples the two: `config.ledger_key = Rails.application.credentials.reeve_ledger_key`.
237
260
  Keys must be at least 32 bytes and are only ever printed as 16-character fingerprints.
238
261
  Without a key, every guarded call fails closed; under `audit_failure_mode = :warn` it is
@@ -245,7 +268,10 @@ recorded unchained instead, logged each time, and reported by verification.
245
268
  - chained rows swapped, renumbered or moved, or a row inserted that Reeve did not write —
246
269
  including one numbered past the head;
247
270
  - the newest rows removed while the chain record was left alone;
248
- - the chain record removed while chained rows remain.
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).
249
275
 
250
276
  **Not detected** — stated plainly, because these are the limits of the guarantee:
251
277
 
@@ -256,7 +282,11 @@ recorded unchained instead, logged each time, and reported by verification.
256
282
  3. **The whole ledger deleted, chain record included.** Reported as *no chain*, never as a
257
283
  pass — but indistinguishable from a ledger that was never chained.
258
284
  4. **Rows written before you upgraded.** They are counted as "not covered" and are exactly
259
- as trustworthy as they were before.
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.
260
290
 
261
291
  And it prevents nothing: it detects a change after the fact. The `GRANT INSERT, SELECT` in
262
292
  the generated migration is what prevents one. The chain record needs `UPDATE` as well,
@@ -280,6 +310,10 @@ same door the built-in ones use, or verification reports them as unchained:
280
310
  Reeve::Audit::Chain.new(entry_class: Reeve::Audit::Entry).append!(row)
281
311
  ```
282
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
+
283
317
  **Upgrading from 0.5.0:**
284
318
 
285
319
  ```bash
@@ -583,6 +617,10 @@ touched*. Keep your existing auth.
583
617
  Ruby 3.0+, Rails 7.0+. No runtime dependencies. The fast-mcp adapter needs Ruby 3.1+,
584
618
  because fast-mcp does.
585
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
+
586
624
  ## License
587
625
 
588
626
  MIT. See [LICENSE.txt](LICENSE.txt).
@@ -16,8 +16,12 @@
16
16
  # -- the chain record: its head moves on every write
17
17
  # GRANT INSERT, SELECT, UPDATE ON reeve_audit_chains TO app_role;
18
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;
19
22
  #
20
- # Every row is chained to the one before it with a keyed digest (audit-entry contract 3),
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),
21
25
  # so a row changed, removed or reordered outside reeve is reported by
22
26
  # `bin/rails reeve:verify_ledger`. That is tamper-evidence, not tamper-proofing: it
23
27
  # detects a change after the fact and prevents nothing — the grant above is what
@@ -79,6 +83,10 @@ class CreateReeveAuditEntries < ActiveRecord::Migration[7.0]
79
83
  t.bigint :chain_sequence
80
84
  t.string :chain_key_id, limit: 16
81
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
82
90
  end
83
91
 
84
92
  add_index :reeve_audit_entries, :invocation_id, unique: true
@@ -111,5 +119,19 @@ class CreateReeveAuditEntries < ActiveRecord::Migration[7.0]
111
119
  end
112
120
 
113
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
114
136
  end
115
137
  end
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rails/generators"
4
4
  require "rails/generators/active_record"
5
+ require_relative "upgrade_notes"
5
6
 
6
7
  module Reeve
7
8
  module Generators
@@ -50,11 +51,17 @@ module Reeve
50
51
  creates: %w[reeve_audit_chains],
51
52
  template: "add_ledger_chain_to_reeve_audit_entries.rb.tt",
52
53
  destination: "add_ledger_chain_to_reeve_audit_entries.rb"
54
+ },
55
+ {
56
+ contract: 4,
57
+ adds: %w[chain_epoch],
58
+ creates: %w[reeve_audit_chain_epochs],
59
+ template: "add_key_epochs_to_reeve_audit_entries.rb.tt",
60
+ destination: "add_key_epochs_to_reeve_audit_entries.rb"
53
61
  }
54
62
  ].freeze
55
63
 
56
- KEY_LINE = "config.ledger_key = " \
57
- 'Rails.application.key_generator.generate_key("reeve audit ledger", 32)'
64
+ KEY_LINE = UpgradeNotes::KEY_LINE
58
65
 
59
66
  source_root File.expand_path("templates", __dir__)
60
67
 
@@ -117,29 +124,7 @@ module Reeve
117
124
  Existing rows are stamped with the contract they were actually written under,
118
125
  not with the current one.
119
126
  NEXT
120
- message += chain_message if @emitted.any? { |step| step[:contract] == 3 }
121
- message
122
- end
123
-
124
- # Contract 3 changes what the application must configure, not only the table: without
125
- # a key, every guarded call fails closed once the migration runs. The host is reading
126
- # this output at exactly the moment they need to know that.
127
- def chain_message
128
- <<~CHAIN
129
-
130
- The ledger is now chained (contract 3). Add this line to config/initializers/reeve.rb
131
- before you deploy, or every guarded call fails closed:
132
-
133
- #{KEY_LINE}
134
-
135
- It derives the key from secret_key_base, so rotating secret_key_base rotates the
136
- ledger key: keep the old derived key in config.retired_ledger_keys, or entries it
137
- signed become unverifiable. A dedicated key avoids that coupling.
138
-
139
- Very large ledger on PostgreSQL? The migration builds a unique index that blocks
140
- writes while it scans; the migration's header shows how to build it with
141
- algorithm: :concurrently instead.
142
- CHAIN
127
+ message + UpgradeNotes.for(@emitted.map { |step| step[:contract] })
143
128
  end
144
129
 
145
130
  # Asked of the table rather than of a version the host recorded: the table is the
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Generators
5
+ # What `reeve:upgrade` tells the host after writing a step whose contract changes more
6
+ # than the table. The host is reading this output at exactly the moment they need it.
7
+ module UpgradeNotes
8
+ KEY_LINE = "config.ledger_key = " \
9
+ 'Rails.application.key_generator.generate_key("reeve audit ledger", 32)'
10
+
11
+ # Contract 3 changes what the application must configure, not only the table: without
12
+ # a key, every guarded call fails closed once the migration runs.
13
+ CHAIN = <<~CHAIN.freeze
14
+
15
+ The ledger is now chained (contract 3). Add this line to config/initializers/reeve.rb
16
+ before you deploy, or every guarded call fails closed:
17
+
18
+ #{KEY_LINE}
19
+
20
+ It derives the key from secret_key_base, so rotating secret_key_base rotates the
21
+ ledger key: keep the old derived key in config.retired_ledger_keys, or entries it
22
+ signed become unverifiable. A dedicated key avoids that coupling.
23
+
24
+ Very large ledger on PostgreSQL? The migration builds a unique index that blocks
25
+ writes while it scans; the migration's header shows how to build it with
26
+ algorithm: :concurrently instead.
27
+ CHAIN
28
+
29
+ # Contract 4 changes how a rotation is done, so the host learns the command here.
30
+ EPOCHS = <<~EPOCHS
31
+
32
+ The ledger now records key epochs (contract 4): the first chained write opens
33
+ epoch 0 with the configured ledger key. To rotate the key later, set the new key
34
+ as config.ledger_key, keep the old one in config.retired_ledger_keys, deploy, and run:
35
+
36
+ bin/rails reeve:rotate_ledger_key
37
+
38
+ The application role needs INSERT and SELECT on reeve_audit_chain_epochs.
39
+ EPOCHS
40
+
41
+ BY_CONTRACT = { 3 => CHAIN, 4 => EPOCHS }.freeze
42
+
43
+ module_function
44
+
45
+ # The notes for the contracts just written, oldest first.
46
+ def for(contracts)
47
+ contracts.filter_map { |contract| BY_CONTRACT[contract] }.join
48
+ end
49
+ end
50
+ end
51
+ end
@@ -31,19 +31,27 @@ module Reeve
31
31
  ENTRY_TAG = "reeve-ledger-entry-v1"
32
32
  GENESIS_TAG = "reeve-ledger-genesis-v1"
33
33
 
34
+ EPOCH_TAG = "reeve-ledger-epoch-v1"
35
+
36
+ CONTRACT_3_FIELDS = %w[
37
+ chain_sequence chain_key_id contract_version
38
+ invocation_id occurred_at agent_id agent_name principal_type principal_id
39
+ tool_name arguments outcome rule detail record_type record_ids record_count
40
+ truncated derived guard duration_ms metadata
41
+ ].freeze
42
+
43
+ # Contract 4 (specs/003) adds the key epoch, next to the key it names.
34
44
  ENTRY_FIELDS = {
35
- 3 => %w[
36
- chain_sequence chain_key_id contract_version
37
- invocation_id occurred_at agent_id agent_name principal_type principal_id
38
- tool_name arguments outcome rule detail record_type record_ids record_count
39
- truncated derived guard duration_ms metadata
40
- ].freeze
45
+ 3 => CONTRACT_3_FIELDS,
46
+ 4 => CONTRACT_3_FIELDS.dup.insert(2, "chain_epoch").freeze
41
47
  }.freeze
42
48
 
43
49
  GENESIS_FIELDS = %w[genesis_at pre_chain_count pre_chain_unchained_count
44
50
  genesis_key_id].freeze
45
51
 
46
- TIME_FIELDS = %w[occurred_at genesis_at].freeze
52
+ EPOCH_FIELDS = %w[epoch key_id first_sequence opened_at].freeze
53
+
54
+ TIME_FIELDS = %w[occurred_at genesis_at opened_at].freeze
47
55
 
48
56
  module_function
49
57
 
@@ -56,6 +64,13 @@ module Reeve
56
64
  hmac(secret, GENESIS_TAG, dump(pairs))
57
65
  end
58
66
 
67
+ # An epoch record (specs/003 FR-002), linked to the epoch before it — or, for epoch 0,
68
+ # to the genesis — so the epochs are a chain of their own.
69
+ def epoch_digest(secret, epoch, previous_digest:)
70
+ pairs = EPOCH_FIELDS.map { |field| [field, value(epoch, field)] }
71
+ hmac(secret, EPOCH_TAG, dump([["previous_digest", previous_digest], *pairs]))
72
+ end
73
+
59
74
  def entry_message_body(row, previous_digest)
60
75
  version = value(row, "contract_version")
61
76
  fields = ENTRY_FIELDS.fetch(version) do