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.
@@ -23,7 +23,7 @@ module Reeve
23
23
  BATCH_SIZE = 1_000
24
24
 
25
25
  RECORD_MISSING = "chained rows exist but the chain record does not"
26
- UNCHAINED_ROW = "a contract-3 row outside the chain, written after it started (by clock)"
26
+ UNCHAINED_ROW = "a contract-3+ row outside the chain, written after it started (by clock)"
27
27
 
28
28
  def initialize(entry_class:, keyring:, batch_size: BATCH_SIZE)
29
29
  @entry_class = entry_class
@@ -46,20 +46,26 @@ module Reeve
46
46
 
47
47
  attr_reader :entry_class, :keyring, :batch_size, :breaks
48
48
 
49
+ # The epochs are read after the head: every row the walk visits committed, with any
50
+ # epoch it opened, before that read. They are checked against the head read *after*
51
+ # the walk, which is later than they were read, so an epoch opened by a rotation
52
+ # during the run is never mistaken for one placed beyond the head.
49
53
  def verify_chain(record)
50
54
  @breaks = []
51
- walk = walk(record)
55
+ epochs = EpochRules.read(keyring: keyring, entry_class: entry_class)
56
+ walk = walk(record, epochs)
52
57
  check_record(record, walk)
53
58
  head_at_end = record && read_head
54
59
  check_beyond_head(head_at_end || record)
55
60
  check_unchained(record)
61
+ epochs.check!(record, head_at_end || record, report: method(:add))
56
62
 
57
- result(record, head_at_end, walk.last_sequence)
63
+ result(record, head_at_end, walk.last_sequence, epochs)
58
64
  end
59
65
 
60
- def walk(record)
66
+ def walk(record, epochs)
61
67
  Walk.new(entry_class: entry_class, keyring: keyring, batch_size: batch_size,
62
- link: record&.genesis_digest, report: method(:add))
68
+ link: record&.genesis_digest, report: method(:add), epochs: epochs)
63
69
  .call(record&.head_sequence)
64
70
  end
65
71
 
@@ -121,15 +127,17 @@ module Reeve
121
127
  end
122
128
  end
123
129
 
124
- def add(kind, sequence, detail = nil, invocation_id = nil, key_id = nil)
125
- breaks << Verification::Break.new(kind: kind, sequence: sequence, detail: detail,
126
- invocation_id: invocation_id, key_id: key_id)
130
+ # `epoch` names the key epoch a break is about when it has no sequence (specs/003).
131
+ def add(kind, sequence, detail = nil, invocation_id = nil, key_id = nil, epoch: nil)
132
+ found = { kind: kind, sequence: sequence, detail: detail, invocation_id: invocation_id }
133
+ breaks << Verification::Break.new(**found, key_id: key_id, epoch: epoch)
127
134
  end
128
135
 
129
- def result(record, head_at_end, last_sequence)
136
+ def result(record, head_at_end, last_sequence, epochs)
130
137
  Verification.new(
131
138
  status: Verification.status_for(breaks), breaks: breaks,
132
139
  covered: covered(record, last_sequence),
140
+ epochs: epochs.summaries, before_first_epoch: epochs.before_first_epoch,
133
141
  head_at_start: record&.head_sequence, head_at_end: head_at_end&.head_sequence,
134
142
  uncovered_now: count_uncovered, uncovered_at_genesis: record&.pre_chain_count,
135
143
  unchained_now: count_unchained, unchained_at_genesis: record&.pre_chain_unchained_count
@@ -20,11 +20,13 @@ module Reeve
20
20
 
21
21
  # `link` is the digest the first row must link to (the genesis), or nil when there
22
22
  # is no chain record to take it from. `report` receives (kind, sequence, detail).
23
- def initialize(entry_class:, keyring:, batch_size:, link:, report:)
23
+ # `epochs` is the EpochRules the rows are checked against (specs/003 FR-011).
24
+ def initialize(entry_class:, keyring:, batch_size:, link:, report:, epochs:)
24
25
  @entry_class = entry_class
25
26
  @keyring = keyring
26
27
  @batch_size = batch_size
27
28
  @report = report
29
+ @epochs = epochs
28
30
  @expected = 1
29
31
  @last_sequence = 0
30
32
  @link = link
@@ -39,7 +41,7 @@ module Reeve
39
41
 
40
42
  private
41
43
 
42
- attr_reader :entry_class, :keyring, :batch_size
44
+ attr_reader :entry_class, :keyring, :batch_size, :epochs
43
45
 
44
46
  def visit(entry)
45
47
  sequence = entry.chain_sequence
@@ -71,6 +73,10 @@ module Reeve
71
73
  end
72
74
 
73
75
  def verify(entry, previous)
76
+ # A row whose key or epoch contradicts the epoch records is a break on its own; its
77
+ # digest would only repeat it.
78
+ return if epoch_break?(entry)
79
+
74
80
  # With no chain record there is no genesis for the first row to link to. That is
75
81
  # reported once, as chain_record_missing, not again here as a mismatch.
76
82
  return if !@verify_first_link && entry.chain_sequence == 1
@@ -88,6 +94,14 @@ module Reeve
88
94
  @report.call(:digest_mismatch, entry.chain_sequence, e.message)
89
95
  end
90
96
 
97
+ def epoch_break?(entry)
98
+ kind, detail = epochs.judge(entry)
99
+ return false unless kind
100
+
101
+ @report.call(kind, entry.chain_sequence, detail)
102
+ true
103
+ end
104
+
91
105
  def each_chained(limit, &block)
92
106
  after = nil
93
107
  loop do
@@ -38,7 +38,12 @@ module Reeve
38
38
  class Chain
39
39
  TABLE = "reeve_audit_chains"
40
40
 
41
- COLUMNS = %w[chain_sequence chain_key_id chain_digest].freeze
41
+ COLUMNS = %w[chain_sequence chain_key_id chain_digest chain_epoch].freeze
42
+
43
+ EPOCHS_TABLE = "reeve_audit_chain_epochs"
44
+
45
+ # The first contract whose rows name their key epoch.
46
+ EPOCH_CONTRACT = 4
42
47
 
43
48
  # A contract-3 row outside the chain: the walk only reads rows with a sequence, so a
44
49
  # row without one is counted here or nowhere. Written once and used by both the
@@ -55,6 +60,12 @@ module Reeve
55
60
  Verifier.new(entry_class: entry_class, keyring: keyring, batch_size: batch_size).call
56
61
  end
57
62
 
63
+ # Makes the configured ledger key the key of the latest epoch (specs/003 FR-006) and
64
+ # returns a Rotation saying what happened. `bin/rails reeve:rotate_ledger_key` runs it.
65
+ def self.rotate_key!(accept_unverifiable: false, entry_class: Entry)
66
+ new(entry_class: entry_class).rotate_key!(accept_unverifiable: accept_unverifiable)
67
+ end
68
+
58
69
  def initialize(entry_class: Entry, keyring: nil)
59
70
  @entry_class = entry_class
60
71
  @keyring = keyring
@@ -77,7 +88,9 @@ module Reeve
77
88
  insert_once(normalized) do
78
89
  head = Head.new(connection: connection, entry_class: entry_class)
79
90
  head.start!(signer)
80
- entry_class.transaction(requires_new: true) { link!(head, normalized, signer) }
91
+ schema.explaining do
92
+ entry_class.transaction(requires_new: true) { link!(head, normalized, signer) }
93
+ end
81
94
  end
82
95
  end
83
96
  end
@@ -95,6 +108,26 @@ module Reeve
95
108
  end
96
109
  end
97
110
 
111
+ # Starts the chain if it has not started — the genesis needs the key the epoch will
112
+ # open with — then decides under the head's lock, so a write and a rotation never
113
+ # interleave. A refusal writes nothing.
114
+ def rotate_key!(accept_unverifiable: false)
115
+ signer = keyring
116
+ return Rotation.refused(:no_key) unless signer.signing?
117
+
118
+ require_columns!
119
+ with_connection do |connection|
120
+ head = Head.new(connection: connection, entry_class: entry_class)
121
+ head.start!(signer)
122
+ schema.explaining do
123
+ entry_class.transaction(requires_new: true) do
124
+ Rotation.decide(Epochs.new(connection: connection), signer,
125
+ head_sequence: head.lock!, accept_unverifiable: accept_unverifiable)
126
+ end
127
+ end
128
+ end
129
+ end
130
+
98
131
  private
99
132
 
100
133
  attr_reader :entry_class
@@ -111,10 +144,18 @@ module Reeve
111
144
  # column defaults (`truncated: false`) and casts on assignment (an Integer principal_id
112
145
  # becomes a String), and the instance is exactly what the database will be given.
113
146
  # Digesting the hash a caller passed would sign values that are never stored.
147
+ #
148
+ # A row at contract 4 or later names the key epoch its key opened, opening the next one
149
+ # if the key has none (specs/003 FR-004); a contract-3 row from a custom recorder is
150
+ # chained exactly as 0.6.0 chained it.
114
151
  def link!(head, normalized, signer)
115
152
  sequence, previous = head.advance!
116
- entry = entry_class.new(normalized.merge(chain_sequence: sequence,
117
- chain_key_id: signer.signing_id))
153
+ chain = { chain_sequence: sequence, chain_key_id: signer.signing_id, chain_epoch: nil }
154
+ if normalized[:contract_version].to_i >= EPOCH_CONTRACT
155
+ chain[:chain_epoch] = Epochs.new(connection: head.connection)
156
+ .for_signing!(signer, sequence: sequence)
157
+ end
158
+ entry = entry_class.new(normalized.merge(chain))
118
159
  entry.chain_digest = Canonical.entry_digest(signer.signing_secret, entry,
119
160
  previous_digest: previous)
120
161
  entry.save!
@@ -136,13 +177,11 @@ module Reeve
136
177
  # A table one contract behind the gem fails here, naming the fix, rather than as an
137
178
  # "unknown attribute" or "no such column" from deep inside the write.
138
179
  def require_columns!
139
- missing = COLUMNS - entry_class.column_names
140
- return if missing.empty?
180
+ schema.require_columns!
181
+ end
141
182
 
142
- raise ConfigurationError,
143
- "#{entry_class.table_name} is missing #{missing.join(', ')}: the ledger is at an " \
144
- "older audit-entry contract than this reeve writes (#{CONTRACT_VERSION}). Run " \
145
- "`bin/rails generate reeve:upgrade` and `bin/rails db:migrate`."
183
+ def schema
184
+ @schema ||= Schema.new(entry_class)
146
185
  end
147
186
 
148
187
  def replayed(normalized)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ # One key epoch: the key that began signing the ledger at `first_sequence` (specs/003
6
+ # data-model "reeve_audit_chain_epochs").
7
+ #
8
+ # Written by Chain — when a key first signs, or by `bin/rails reeve:rotate_ledger_key` —
9
+ # through the ledger's own connection, inside the write that needs it. This model only
10
+ # reads them, for verification, and refuses to change one: an epoch is written once.
11
+ # As with ChainRecord, that protects against the library, not against raw SQL; the
12
+ # verifier reports an epoch changed outside reeve.
13
+ class ChainEpoch < ActiveRecord::Base
14
+ self.table_name = "reeve_audit_chain_epochs"
15
+
16
+ before_update { raise ActiveRecord::ReadOnlyRecord, "a key epoch is written once" }
17
+ before_destroy { throw :abort }
18
+
19
+ def readonly?
20
+ persisted? || super
21
+ end
22
+ end
23
+ end
24
+ end
@@ -44,17 +44,22 @@ module Reeve
44
44
  end
45
45
 
46
46
  # The record is written through the ledger's own connection (Chain::Head) and read
47
- # through this model's. A host that moves the ledger to a database of its own and
48
- # leaves this model behind would have verification read the wrong record and report
49
- # tampering on every run; refusing, with the fix, is the honest answer.
47
+ # through this model's — and so are the key epochs (ChainEpoch, specs/003 FR-019a). A
48
+ # host that moves the ledger to a database of its own and leaves either model behind
49
+ # would have verification read the wrong rows and report tampering on every run;
50
+ # refusing, with the fix, is the honest answer.
50
51
  def self.require_same_database!(entry_class)
51
52
  ledger_database = entry_class.connection_db_config.configuration_hash
52
- return if connection_db_config.configuration_hash == ledger_database
53
+ [self, ChainEpoch].each do |model|
54
+ next if model.connection_db_config.configuration_hash == ledger_database
53
55
 
54
- raise ConfigurationError,
55
- "Reeve::Audit::ChainRecord is connected to a different database than " \
56
- "#{entry_class.name}. Connect ChainRecord the same way as the ledger (for " \
57
- "example with the same connects_to) so the chain is read where it is written."
56
+ short = model.name.split("::").last
57
+ raise ConfigurationError,
58
+ "#{model.name} is connected to a different database than #{entry_class.name}. " \
59
+ "Connect ChainRecord and ChainEpoch the same way as the ledger (for example " \
60
+ "with the same connects_to) so the chain is read where it is written; #{short} " \
61
+ "is the one that is not."
62
+ end
58
63
  end
59
64
  end
60
65
  end
data/lib/reeve/audit.rb CHANGED
@@ -59,8 +59,14 @@ module Reeve
59
59
  module Audit
60
60
  # The version of the audit-entry shape, as documented in
61
61
  # specs/001-guardrails-core/contracts/audit-entry.md (FR-015). Adding a nullable
62
- # column is a MINOR change and leaves this alone; removing or renaming a column, or
63
- # changing what a value means, is MAJOR and bumps it.
62
+ # column is a MINOR change and leaves this alone — unless what its absence means, or
63
+ # what an existing value covers, differs between rows written before and after it;
64
+ # removing or renaming a column, or changing what a value means, is MAJOR and bumps it.
65
+ #
66
+ # 4 — entries name their key epoch (specs/003). One nullable column, `chain_epoch`, but
67
+ # it is inside the digest, so `chain_digest` on a contract-4 row covers something a
68
+ # contract-3 digest does not, and a NULL epoch means "before key epochs" on a contract-3
69
+ # row where a chained contract-4 row always has one.
64
70
  #
65
71
  # 3 — entries are chained (specs/002). The three chain columns are nullable, so the
66
72
  # shape change is additive, but what a NULL digest means changed: on a contract-3 row
@@ -71,7 +77,7 @@ module Reeve
71
77
  # was written NULL on every row regardless of what was passed, so anything mapping
72
78
  # version 1 rows could reasonably have read the column as "always empty". The shape
73
79
  # did not change; what a value means did.
74
- CONTRACT_VERSION = 3
80
+ CONTRACT_VERSION = 4
75
81
 
76
82
  TABLE_NAME = "reeve_audit_entries"
77
83
 
@@ -108,9 +114,14 @@ require_relative "audit/chain/keyring"
108
114
  require_relative "audit/chain/normalizer"
109
115
  require_relative "audit/chain/canonical"
110
116
  require_relative "audit/chain_record"
117
+ require_relative "audit/chain_epoch"
111
118
  require_relative "audit/chain/verification"
112
119
  require_relative "audit/chain/report"
113
120
  require_relative "audit/chain/head"
121
+ require_relative "audit/chain/epochs"
122
+ require_relative "audit/chain/rotation"
123
+ require_relative "audit/chain/epoch_rules"
124
+ require_relative "audit/chain/schema"
114
125
  require_relative "audit/chain/walk"
115
126
  require_relative "audit/chain/verifier"
116
127
  require_relative "audit/chain"
data/lib/reeve/railtie.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Reeve
4
- # Registers `bin/rails reeve:verify_ledger`, and does nothing else.
4
+ # Registers `bin/rails reeve:verify_ledger` and `bin/rails reeve:rotate_ledger_key`, and
5
+ # does nothing else.
5
6
  #
6
7
  # Loaded by lib/reeve.rb only when Rails::Railtie is already defined, so `require "reeve"`
7
8
  # in a plain Ruby process still loads nothing beyond reeve's own files and its short stdlib
data/lib/reeve/tasks.rb CHANGED
@@ -14,6 +14,7 @@ module Reeve
14
14
  module Tasks
15
15
  EXIT = { intact: 0, broken: 1, unverifiable: 2, no_chain: 2 }.freeze
16
16
  FORMATS = %w[text json].freeze
17
+ ROTATE_EXIT = { rotated: 0, current: 0, refused: 1 }.freeze
17
18
 
18
19
  module_function
19
20
 
@@ -35,6 +36,28 @@ module Reeve
35
36
  EXIT.fetch(result.status)
36
37
  end
37
38
 
39
+ # Makes the configured ledger key the latest epoch's key (specs/003, contracts/
40
+ # key-rotation.md), writes what happened to `out`, and returns the exit status: 0 rotated
41
+ # or already current, 1 refused.
42
+ #
43
+ # ACCEPT_UNVERIFIABLE=1 opens the epoch even though the outgoing epoch's key is not
44
+ # configured here — for a key that is lost, and only then.
45
+ def rotate_ledger_key(env: ENV, out: $stdout)
46
+ accept = accept_unverifiable_from(env)
47
+ require "reeve/audit"
48
+ rotation = Reeve::Audit::Chain.rotate_key!(accept_unverifiable: accept)
49
+ out.puts(rotation.to_s)
50
+ ROTATE_EXIT.fetch(rotation.status)
51
+ end
52
+
53
+ def accept_unverifiable_from(env)
54
+ raw = env.fetch("ACCEPT_UNVERIFIABLE", "")
55
+ return false if raw.empty?
56
+ return true if raw == "1"
57
+
58
+ raise ArgumentError, "ACCEPT_UNVERIFIABLE must be 1 or unset, got #{raw.inspect}"
59
+ end
60
+
38
61
  def limit_from(env)
39
62
  raw = env.fetch("LIMIT", Reeve::Audit::Chain::Verification::DEFAULT_LIMIT.to_s)
40
63
  Integer(raw, 10).tap { |limit| raise ArgumentError if limit.negative? }
@@ -55,6 +78,12 @@ module Reeve
55
78
  task verify_ledger: :environment do
56
79
  exit Reeve::Tasks.verify_ledger
57
80
  end
81
+
82
+ desc "Make config.ledger_key the ledger's current key epoch (exit 0 rotated or " \
83
+ "current, 1 refused; ACCEPT_UNVERIFIABLE=1 if the old key is lost)"
84
+ task rotate_ledger_key: :environment do
85
+ exit Reeve::Tasks.rotate_ledger_key
86
+ end
58
87
  end
59
88
  end
60
89
  end
@@ -46,10 +46,10 @@ module Reeve
46
46
  invocation_id occurred_at agent_id agent_name principal_type principal_id
47
47
  tool_name arguments outcome rule detail record_type record_ids record_count
48
48
  truncated derived guard duration_ms metadata contract_version
49
- chain_sequence chain_key_id chain_digest
49
+ chain_sequence chain_key_id chain_digest chain_epoch
50
50
  ].freeze
51
51
 
52
- EXPECTED_VERSION = 3
52
+ EXPECTED_VERSION = 4
53
53
 
54
54
  def initialize(tool: nil, expected: EXPECTED_VERSION, ledger: nil)
55
55
  super(tool: tool, ledger: ledger)
data/lib/reeve/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Reeve
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reeve
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Victor Velazquez
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-10 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Reeve makes it safe for a Rails application to expose MCP (Model Context Protocol)
@@ -31,19 +31,26 @@ files:
31
31
  - lib/generators/reeve/install/templates/create_audit_entries.rb.tt
32
32
  - lib/generators/reeve/install/templates/initializer.rb.tt
33
33
  - lib/generators/reeve/upgrade/templates/add_contract_version_to_reeve_audit_entries.rb.tt
34
+ - lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt
34
35
  - lib/generators/reeve/upgrade/templates/add_ledger_chain_to_reeve_audit_entries.rb.tt
35
36
  - lib/generators/reeve/upgrade/upgrade_generator.rb
37
+ - lib/generators/reeve/upgrade/upgrade_notes.rb
36
38
  - lib/reeve.rb
37
39
  - lib/reeve/audit.rb
38
40
  - lib/reeve/audit/chain.rb
39
41
  - lib/reeve/audit/chain/canonical.rb
42
+ - lib/reeve/audit/chain/epoch_rules.rb
43
+ - lib/reeve/audit/chain/epochs.rb
40
44
  - lib/reeve/audit/chain/head.rb
41
45
  - lib/reeve/audit/chain/keyring.rb
42
46
  - lib/reeve/audit/chain/normalizer.rb
43
47
  - lib/reeve/audit/chain/report.rb
48
+ - lib/reeve/audit/chain/rotation.rb
49
+ - lib/reeve/audit/chain/schema.rb
44
50
  - lib/reeve/audit/chain/verification.rb
45
51
  - lib/reeve/audit/chain/verifier.rb
46
52
  - lib/reeve/audit/chain/walk.rb
53
+ - lib/reeve/audit/chain_epoch.rb
47
54
  - lib/reeve/audit/chain_record.rb
48
55
  - lib/reeve/audit/entry.rb
49
56
  - lib/reeve/audit/isolated_recorder.rb