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.
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # The key epochs as verification sees them (specs/003 FR-011): read once, then asked
7
+ # about every row the walk visits. Counts, as it goes, the rows that named each epoch and
8
+ # the rows chained before the first, for the report.
9
+ #
10
+ # A row at contract 4 names its epoch, and must carry that epoch's key and not precede
11
+ # its start. A contract-3 row names none and is checked exactly as 0.6.0 checked it,
12
+ # wherever it sits: one written by a 0.6.0 server after the first epoch opened may carry
13
+ # any key that server had, and a verifier without that key cannot tell it from a
14
+ # rewritten one — calling it tampered would report a rolling deploy as an attack.
15
+ class EpochRules
16
+ attr_reader :records, :counts, :before_first_epoch, :summaries
17
+
18
+ # Every epoch record, in one query whatever the ledger's size (FR-014). A ledger whose
19
+ # gem was upgraded before its table was migrated has none — and no row that could
20
+ # name one, since writes refuse until it is migrated (FR-018).
21
+ def self.read(keyring:, entry_class:)
22
+ present = entry_class.connection_pool.with_connection do |connection|
23
+ connection.table_exists?(EPOCHS_TABLE)
24
+ end
25
+ new(present ? ChainEpoch.uncached { ChainEpoch.all.to_a } : [], keyring: keyring)
26
+ end
27
+
28
+ # `records` are ChainEpoch rows, in any order.
29
+ def initialize(records, keyring:)
30
+ @records = records.sort_by(&:epoch).freeze
31
+ @keyring = keyring
32
+ @by_number = @records.to_h { |record| [record.epoch, record] }
33
+ @first_start = @records.map(&:first_sequence).min
34
+ @counts = Hash.new(0)
35
+ @before_first_epoch = 0
36
+ @summaries = []
37
+ end
38
+
39
+ # Checks the records themselves once the walk has counted their rows (EpochCheck);
40
+ # `head` is the chain record as read after the walk. Leaves the result in `summaries`.
41
+ def check!(record, head, report:)
42
+ @summaries = EpochCheck.new(keyring: keyring, report: report)
43
+ .call(self, genesis_digest: record&.genesis_digest, head: head)
44
+ end
45
+
46
+ # [kind, detail] for a row that contradicts the epoch records, or nil.
47
+ def judge(entry)
48
+ return epoch_bearing(entry) if entry.contract_version.to_i >= EPOCH_CONTRACT
49
+
50
+ @before_first_epoch += 1 if before_first?(entry.chain_sequence)
51
+ nil
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :keyring
57
+
58
+ def epoch_bearing(entry)
59
+ number = entry.chain_epoch
60
+ epoch = number && @by_number[number]
61
+ return [:epoch_missing, missing(number)] unless epoch
62
+
63
+ @counts[number] += 1
64
+ if epoch.key_id != entry.chain_key_id
65
+ [:epoch_mismatch,
66
+ "signed by key #{entry.chain_key_id}, but epoch #{number}'s key is #{epoch.key_id}"]
67
+ elsif entry.chain_sequence < epoch.first_sequence
68
+ [:epoch_mismatch,
69
+ "names epoch #{number}, which begins at sequence #{epoch.first_sequence}"]
70
+ end
71
+ end
72
+
73
+ # True for a row chained before any epoch began — and for every row when none has.
74
+ def before_first?(sequence)
75
+ @first_start.nil? || sequence < @first_start
76
+ end
77
+
78
+ def missing(number)
79
+ number.nil? ? "names no key epoch" : "names epoch #{number}, which has no record"
80
+ end
81
+ end
82
+
83
+ # The epoch records themselves (specs/003 FR-010): numbered 0..n, each beginning after
84
+ # the one before (or with it: two rotations with no write between them begin at the same
85
+ # sequence) and no later than one past the head, one key each, and each matching
86
+ # its digest — which links it to the epoch before, or, for epoch 0, to the genesis. A
87
+ # record whose key is not configured cannot be checked, and that is said, never passed
88
+ # over. Returns one Verification::EpochSummary per record; breaks go to `report`, as
89
+ # (kind, nil, detail, nil, key_id, epoch:) — the verifier's own `add`.
90
+ class EpochCheck
91
+ def initialize(keyring:, report:)
92
+ @keyring = keyring
93
+ @report = report
94
+ end
95
+
96
+ # `head` is the chain record as read after the walk — later than the epochs were
97
+ # read, so an epoch a rotation opened mid-run is never taken for one past the head.
98
+ def call(rules, genesis_digest:, head:)
99
+ previous = { digest: genesis_digest, first_sequence: 0, keys: [] }
100
+ expected = 0
101
+ rules.records.map do |epoch|
102
+ (expected...epoch.epoch).each do |gap|
103
+ report(:epoch_missing, gap, "epoch #{gap} has no record")
104
+ end
105
+ expected = epoch.epoch + 1
106
+ summary(epoch, rules.counts[epoch.epoch], check(epoch, previous, head)).tap do
107
+ previous = { digest: epoch.epoch_digest, first_sequence: epoch.first_sequence,
108
+ keys: previous[:keys] + [epoch.key_id] }
109
+ end
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ attr_reader :keyring
116
+
117
+ # :altered, :key_not_configured or :verified, reporting what makes it so.
118
+ def check(epoch, previous, head)
119
+ problem = out_of_place(epoch, previous, head) || digest_problem(epoch, previous)
120
+ return :altered.tap { report(:epoch_altered, epoch.epoch, problem) } if problem
121
+ return :verified if keyring.for(epoch.key_id)
122
+
123
+ report(:unknown_key, epoch.epoch, "epoch #{epoch.epoch} is signed by key #{epoch.key_id}",
124
+ epoch.key_id)
125
+ :key_not_configured
126
+ end
127
+
128
+ def report(kind, number, detail, key_id = nil)
129
+ @report.call(kind, nil, detail, nil, key_id, epoch: number)
130
+ end
131
+
132
+ def out_of_place(epoch, previous, head)
133
+ name = "epoch #{epoch.epoch}"
134
+ if epoch.epoch.positive? && epoch.first_sequence < previous[:first_sequence]
135
+ "#{name} begins at sequence #{epoch.first_sequence}, before the epoch before it"
136
+ elsif head && epoch.first_sequence > head.head_sequence + 1
137
+ "#{name} begins at sequence #{epoch.first_sequence}, past the head"
138
+ elsif previous[:keys].include?(epoch.key_id)
139
+ "#{name} reuses the key of an earlier epoch"
140
+ end
141
+ end
142
+
143
+ def digest_problem(epoch, previous)
144
+ secret = keyring.for(epoch.key_id)
145
+ return nil if secret.nil? || previous[:digest].nil?
146
+
147
+ expected = Canonical.epoch_digest(secret, epoch, previous_digest: previous[:digest])
148
+ "epoch #{epoch.epoch} does not match its digest" unless expected == epoch.epoch_digest
149
+ end
150
+
151
+ def summary(epoch, count, status)
152
+ Verification::EpochSummary.new(epoch: epoch.epoch, key_id: epoch.key_id,
153
+ first_sequence: epoch.first_sequence, entry_count: count,
154
+ status: status)
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # The key epochs, as a write sees them (specs/003 FR-001–FR-005).
7
+ #
8
+ # One instance per append or rotation, bound to the connection it runs on and used
9
+ # only under the chain record's lock (Head), so finding a key's epoch and opening the
10
+ # next one are a single step as far as any other writer can tell. The epoch record
11
+ # commits or rolls back with the write that opened it.
12
+ #
13
+ # A key opens one epoch, ever. A process signing with a key whose epoch is not the
14
+ # latest — one still on the old key during a rolling deploy — writes under that
15
+ # earlier epoch: a straggler, legitimate, and verified by its own key.
16
+ class Epochs
17
+ def initialize(connection:)
18
+ @connection = connection
19
+ end
20
+
21
+ # The number of the epoch `signer`'s key opened, opening the next one at `sequence`
22
+ # if it has none.
23
+ def for_signing!(signer, sequence:)
24
+ number_for(signer.signing_id) || open!(signer, first_sequence: sequence)[:epoch]
25
+ end
26
+
27
+ def number_for(key_id)
28
+ sql = "SELECT epoch FROM #{EPOCHS_TABLE} WHERE key_id = #{connection.quote(key_id)}"
29
+ value = connection.select_value(current(sql))
30
+ value.nil? ? nil : Integer(value)
31
+ end
32
+
33
+ # Every epoch, oldest first, as hashes with symbol keys.
34
+ def all
35
+ sql = "SELECT epoch, key_id, first_sequence FROM #{EPOCHS_TABLE} ORDER BY epoch"
36
+ connection.select_all(current(sql))
37
+ .map do |row|
38
+ { epoch: Integer(row["epoch"]), key_id: row["key_id"],
39
+ first_sequence: Integer(row["first_sequence"]) }
40
+ end
41
+ end
42
+
43
+ # Writes epoch `max + 1` (or 0) for `signer`'s key and returns it. Linked to the
44
+ # latest epoch's digest, or to the genesis for epoch 0, so the chain must have
45
+ # started.
46
+ def open!(signer, first_sequence:)
47
+ sql = "SELECT epoch, epoch_digest FROM #{EPOCHS_TABLE} ORDER BY epoch DESC LIMIT 1"
48
+ latest = connection.select_one(current(sql))
49
+ epoch = { epoch: latest ? Integer(latest["epoch"]) + 1 : 0, key_id: signer.signing_id,
50
+ first_sequence: first_sequence, opened_at: Time.now.utc.floor(6) }
51
+ previous = latest ? latest["epoch_digest"] : genesis_digest
52
+ digest = Canonical.epoch_digest(signer.signing_secret, epoch, previous_digest: previous)
53
+ epoch.merge(epoch_digest: digest).tap { |values| insert(values) }
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :connection
59
+
60
+ def current(sql)
61
+ Head.current(connection, sql)
62
+ end
63
+
64
+ def insert(values)
65
+ quoted = values.values.map { |value| connection.quote(value) }
66
+ connection.insert("INSERT INTO #{EPOCHS_TABLE} (#{values.keys.join(', ')}) " \
67
+ "VALUES (#{quoted.join(', ')})")
68
+ end
69
+
70
+ def genesis_digest
71
+ connection.select_value("SELECT genesis_digest FROM #{TABLE} " \
72
+ "WHERE name = #{connection.quote(ChainRecord::LEDGER)}")
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -18,6 +18,17 @@ module Reeve
18
18
  # engines: SQLite cannot upgrade a read lock while another writer waits, and ignores
19
19
  # SELECT ... FOR UPDATE.
20
20
  class Head
21
+ LOCKING_READ_ADAPTERS = /mysql|trilogy/i
22
+
23
+ # A read made under the head's lock must see the latest committed rows. PostgreSQL
24
+ # and SQLite read them anyway; InnoDB at REPEATABLE READ serves a plain SELECT from
25
+ # the snapshot of the caller's transaction, which a writer that committed while this
26
+ # one queued for the lock is not in (code review, specs/003). A locking read is
27
+ # current; LOCK IN SHARE MODE is the spelling every MySQL version accepts.
28
+ def self.current(connection, sql)
29
+ connection.adapter_name.match?(LOCKING_READ_ADAPTERS) ? "#{sql} LOCK IN SHARE MODE" : sql
30
+ end
31
+
21
32
  def initialize(connection:, entry_class:)
22
33
  @connection = connection
23
34
  @entry_class = entry_class
@@ -62,14 +73,31 @@ module Reeve
62
73
  [Integer(head["head_sequence"]), head["head_digest"]]
63
74
  end
64
75
 
76
+ # Takes the head's lock without consuming a sequence (specs/003 FR-008) and returns the
77
+ # head: a rotation queues at the same point as a write, so the epoch it opens begins
78
+ # exactly one past the last row any writer committed. A write that changes nothing
79
+ # still takes the row lock on PostgreSQL and MySQL and the write lock on SQLite.
80
+ def lock!
81
+ updated = connection.update("UPDATE #{TABLE} SET head_sequence = head_sequence " \
82
+ "WHERE #{named}")
83
+ raise ActiveRecord::StatementInvalid, "the ledger chain record vanished" if updated.zero?
84
+
85
+ sql = "SELECT head_sequence FROM #{TABLE} WHERE #{named}"
86
+ Integer(connection.select_value(Head.current(connection, sql)))
87
+ end
88
+
65
89
  def point_at!(digest)
66
90
  connection.update("UPDATE #{TABLE} SET head_digest = #{connection.quote(digest)} " \
67
91
  "WHERE #{named}")
68
92
  end
69
93
 
94
+ # The connection this append runs on; Epochs uses it so the epoch an append opens
95
+ # commits or rolls back with the append.
96
+ attr_reader :connection
97
+
70
98
  private
71
99
 
72
- attr_reader :connection, :entry_class
100
+ attr_reader :entry_class
73
101
 
74
102
  def increment
75
103
  connection.update("UPDATE #{TABLE} SET head_sequence = head_sequence + 1 WHERE #{named}")
@@ -27,10 +27,11 @@ module Reeve
27
27
 
28
28
  def to_s(limit:)
29
29
  listed = @v.breaks.reject { |found| found.kind == :unknown_key }
30
- return summary if listed.empty?
30
+ epochs = EpochLines.new(@v).to_a
31
+ return [summary, *epochs].join("\n") if listed.empty?
31
32
 
32
33
  lines = listed.first(limit).map { |found| line_for(found) }
33
- [summary, totals, *lines, more(listed, limit)].compact.join("\n")
34
+ [summary, totals, *epochs, *lines, more(listed, limit)].compact.join("\n")
34
35
  end
35
36
 
36
37
  private
@@ -113,6 +114,7 @@ module Reeve
113
114
  def label(found)
114
115
  sequence = found.sequence
115
116
  return "invocation #{found.invocation_id}" if found.invocation_id
117
+ return "epoch #{found.epoch}" if sequence.nil? && found.epoch
116
118
  return "—" if sequence.nil?
117
119
  return "##{range(sequence)}" if sequence.is_a?(Range)
118
120
 
@@ -129,6 +131,47 @@ module Reeve
129
131
  self.class.number(value.to_i)
130
132
  end
131
133
  end
134
+
135
+ # The key epochs, one line each (specs/003 FR-013), shown once they say something the
136
+ # summary does not: more than one, one that is not verified, or rows chained before the
137
+ # first. A ledger with one verified epoch and nothing before it reads as 0.6.0's did.
138
+ class EpochLines
139
+ STATUS_WORDS = {
140
+ verified: "verified", key_not_configured: "key not configured", altered: "altered"
141
+ }.freeze
142
+
143
+ def initialize(verification)
144
+ @epochs = verification.epochs
145
+ @before = verification.before_first_epoch.to_i
146
+ end
147
+
148
+ def to_a
149
+ return [] unless shown?
150
+
151
+ lines = @epochs.map { |epoch| line(epoch) }
152
+ return lines unless @before.positive?
153
+
154
+ [" before epoch 0 #{entries(@before)} chained before key epochs began", *lines]
155
+ end
156
+
157
+ private
158
+
159
+ def shown?
160
+ return false if @epochs.empty?
161
+
162
+ @epochs.size > 1 || @epochs.first.status != :verified || @before.positive?
163
+ end
164
+
165
+ def line(epoch)
166
+ " epoch #{epoch.epoch} key #{epoch.key_id} " \
167
+ "from sequence #{Report.number(epoch.first_sequence)} " \
168
+ "#{entries(epoch.entry_count)} #{STATUS_WORDS.fetch(epoch.status)}"
169
+ end
170
+
171
+ def entries(count)
172
+ "#{Report.number(count)} #{count == 1 ? 'entry' : 'entries'}"
173
+ end
174
+ end
132
175
  end
133
176
  end
134
177
  end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # What `bin/rails reeve:rotate_ledger_key` did, or why it did nothing (specs/003 FR-006,
7
+ # FR-009, contracts/key-rotation.md). A value, so a deploy script can act on it without
8
+ # reading the text. Names keys by fingerprint only, never by value (FR-007).
9
+ #
10
+ # :rotated — opened `epoch` with the configured key, from `first_sequence`
11
+ # :current — the configured key opened the latest epoch already; nothing written
12
+ # :refused — nothing written; `reason` is :no_key, :key_reused or :outgoing_key_missing
13
+ class Rotation
14
+ PREFIX = "reeve ledger key:"
15
+
16
+ FIELDS = %i[status reason epoch key_id first_sequence previous_epoch previous_key_id
17
+ warnings].freeze
18
+
19
+ attr_reader(*FIELDS)
20
+
21
+ # Run under the chain record's lock (Chain#rotate_key!). `epochs` writes; `keyring`
22
+ # is this host's — the configured key and the retired ones.
23
+ def self.decide(epochs, keyring, head_sequence:, accept_unverifiable:)
24
+ all = epochs.all
25
+ own = all.find { |epoch| epoch[:key_id] == keyring.signing_id }
26
+ # `own` first: with no epochs at all, nil would equal the missing latest one.
27
+ return current(all, keyring) if own && all.last.equal?(own)
28
+ return refused(:key_reused, epoch: own[:epoch], key_id: own[:key_id]) if own
29
+
30
+ open_next(epochs, keyring, all.last, head_sequence: head_sequence,
31
+ accept_unverifiable: accept_unverifiable)
32
+ end
33
+
34
+ def self.refused(reason, **fields)
35
+ new(status: :refused, reason: reason, **fields)
36
+ end
37
+
38
+ def self.open_next(epochs, keyring, latest, head_sequence:, accept_unverifiable:)
39
+ lost = latest && keyring.for(latest[:key_id]).nil?
40
+ return refused(:outgoing_key_missing, **previous(latest)) if lost && !accept_unverifiable
41
+
42
+ opened = epochs.open!(keyring, first_sequence: head_sequence + 1)
43
+ new(status: :rotated, epoch: opened[:epoch], key_id: opened[:key_id],
44
+ first_sequence: opened[:first_sequence], **previous(latest),
45
+ warnings: lost ? [unverifiable(latest)] : [])
46
+ end
47
+
48
+ def self.current(all, keyring)
49
+ latest = all.last
50
+ before = all[-2]
51
+ lost = before && keyring.for(before[:key_id]).nil?
52
+ new(status: :current, epoch: latest[:epoch], key_id: latest[:key_id],
53
+ first_sequence: latest[:first_sequence], **previous(before),
54
+ warnings: lost ? [unverifiable(before)] : [])
55
+ end
56
+
57
+ def self.previous(epoch)
58
+ epoch ? { previous_epoch: epoch[:epoch], previous_key_id: epoch[:key_id] } : {}
59
+ end
60
+
61
+ def self.unverifiable(epoch)
62
+ "epoch #{epoch[:epoch]}'s key #{epoch[:key_id]} is not configured on this host, so " \
63
+ "its entries are unverifiable here"
64
+ end
65
+
66
+ private_class_method :open_next, :current, :previous, :unverifiable
67
+
68
+ def initialize(status:, warnings: [], **fields)
69
+ @status = status
70
+ @warnings = warnings.freeze
71
+ (FIELDS - %i[status warnings]).each do |name|
72
+ instance_variable_set(:"@#{name}", fields[name])
73
+ end
74
+ freeze
75
+ end
76
+
77
+ def rotated? = status == :rotated
78
+ def current? = status == :current
79
+ def refused? = status == :refused
80
+
81
+ def to_s
82
+ [first_line, *warnings.map { |warning| "warning: #{warning}" }].join("\n")
83
+ end
84
+
85
+ private
86
+
87
+ def first_line
88
+ case status
89
+ when :rotated then "#{PREFIX} ROTATED — #{rotated_text}"
90
+ when :current
91
+ "#{PREFIX} CURRENT — epoch #{epoch} already signs with the configured key " \
92
+ "#{key_id}; nothing to do"
93
+ else "#{PREFIX} REFUSED — #{refusal}; nothing was written"
94
+ end
95
+ end
96
+
97
+ def rotated_text
98
+ text = "epoch #{epoch} opened with key #{key_id} from sequence " \
99
+ "#{Report.number(first_sequence)}"
100
+ return text if previous_epoch.nil?
101
+
102
+ previous = "epoch #{previous_epoch} (key #{previous_key_id})"
103
+ return "#{text}; the previous epoch was #{previous}" if warnings.any?
104
+
105
+ "#{text}; epoch #{previous_epoch} (key #{previous_key_id}) stays verifiable while " \
106
+ "that key is in retired_ledger_keys"
107
+ end
108
+
109
+ def refusal
110
+ case reason
111
+ when :no_key then "no ledger key configured (config.ledger_key)"
112
+ when :key_reused
113
+ "the configured key #{key_id} already opened epoch #{epoch}; a key opens one " \
114
+ "epoch only — configure a new key"
115
+ else
116
+ "epoch #{previous_epoch}'s key #{previous_key_id} is not configured on this host, " \
117
+ "so its entries would become unverifiable here — add it to " \
118
+ "config.retired_ledger_keys, or run with ACCEPT_UNVERIFIABLE=1 if that key is lost"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # Whether the ledger has the shape this reeve writes: every chain column and, from
7
+ # contract 4, the epochs table (specs/003 FR-018). A table one contract behind fails
8
+ # with the fix named.
9
+ #
10
+ # The columns are checked before every write — ActiveRecord caches them, so that costs
11
+ # nothing. The epochs table is not: asking the catalog would be a query per guarded
12
+ # call (FR-019b), so its absence is diagnosed only once the database has refused a
13
+ # write that needed it.
14
+ class Schema
15
+ def initialize(entry_class)
16
+ @entry_class = entry_class
17
+ end
18
+
19
+ def require_columns!
20
+ missing = COLUMNS - entry_class.column_names
21
+ outdated!("#{entry_class.table_name} is missing #{missing.join(', ')}") if missing.any?
22
+ end
23
+
24
+ # Runs the write; if the database refuses it, names a missing epochs table as the
25
+ # cause. Recorder builds a Chain per write, so asking up front would be a catalog
26
+ # query per guarded call.
27
+ def explaining
28
+ yield
29
+ rescue ActiveRecord::StatementInvalid => e
30
+ explain!(e)
31
+ end
32
+
33
+ # Re-raises `error` unless the epochs table is what the ledger lacks. A ledger with no
34
+ # entries table at all is not an older contract but a missing ledger, and keeps the
35
+ # database's own error.
36
+ def explain!(error)
37
+ missing = entry_class.connection_pool.with_connection do |connection|
38
+ !connection.table_exists?(EPOCHS_TABLE) &&
39
+ connection.table_exists?(entry_class.table_name)
40
+ end
41
+ raise error unless missing
42
+
43
+ outdated!("there is no #{EPOCHS_TABLE} table")
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :entry_class
49
+
50
+ def outdated!(problem)
51
+ raise ConfigurationError,
52
+ "#{problem}: the ledger is at an older audit-entry contract than this reeve " \
53
+ "writes (#{CONTRACT_VERSION}). Run `bin/rails generate reeve:upgrade` and " \
54
+ "`bin/rails db:migrate`."
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -15,13 +15,28 @@ module Reeve
15
15
  # `sequence` is an Integer or a Range (a gap), or nil for a break that has no
16
16
  # position; an unchained row has none and is named by `invocation_id` instead.
17
17
  # `key_id` is set for unknown_key: the fingerprint of the key the verifier lacked.
18
- Break = Struct.new(:kind, :sequence, :detail, :invocation_id, :key_id, keyword_init: true)
18
+ # `epoch` is set for a break about a key epoch's record (specs/003), whose sequence is nil.
19
+ Break = Struct.new(:kind, :sequence, :detail, :invocation_id, :key_id, :epoch,
20
+ keyword_init: true)
21
+
22
+ # One key epoch as verification found it (specs/003 FR-013). `entry_count` counts the
23
+ # walked rows that named it; `status` is :verified, :key_not_configured or :altered.
24
+ EpochSummary = Struct.new(:epoch, :key_id, :first_sequence, :entry_count, :status,
25
+ keyword_init: true) do
26
+ def as_json(*)
27
+ { "epoch" => epoch, "key_id" => key_id, "first_sequence" => first_sequence,
28
+ "entries" => entry_count, "status" => status.to_s }
29
+ end
30
+ end
31
+
32
+ EPOCH_STATUSES = %i[verified key_not_configured altered].freeze
19
33
 
20
34
  DEFAULT_LIMIT = 100
21
35
 
22
36
  KINDS = %i[
23
37
  digest_mismatch missing duplicate unchained head_mismatch beyond_head
24
38
  genesis_altered chain_record_missing unknown_key
39
+ epoch_missing epoch_mismatch epoch_altered
25
40
  ].freeze
26
41
 
27
42
  # The status rules (data-model), for a verification that had a key and found a
@@ -38,11 +53,11 @@ module Reeve
38
53
 
39
54
  attr_reader :status, :covered, :breaks, :head_at_start, :head_at_end,
40
55
  :uncovered_now, :uncovered_at_genesis, :unchained_now, :unchained_at_genesis,
41
- :reason
56
+ :reason, :epochs, :before_first_epoch
42
57
 
43
58
  def initialize(status:, covered: nil, breaks: [], head_at_start: nil, head_at_end: nil,
44
59
  uncovered_now: 0, uncovered_at_genesis: nil, unchained_now: 0,
45
- unchained_at_genesis: nil, reason: nil)
60
+ unchained_at_genesis: nil, reason: nil, epochs: [], before_first_epoch: nil)
46
61
  raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
47
62
 
48
63
  @status = status
@@ -55,6 +70,8 @@ module Reeve
55
70
  @unchained_now = unchained_now
56
71
  @unchained_at_genesis = unchained_at_genesis
57
72
  @reason = reason
73
+ @epochs = epochs.freeze
74
+ @before_first_epoch = before_first_epoch
58
75
  freeze
59
76
  end
60
77
 
@@ -86,18 +103,22 @@ module Reeve
86
103
  "uncovered_now" => uncovered_now, "uncovered_at_genesis" => uncovered_at_genesis,
87
104
  "unchained_now" => unchained_now, "unchained_at_genesis" => unchained_at_genesis,
88
105
  "unverifiable_count" => unverifiable_count,
89
- "totals" => totals.transform_keys(&:to_s), "reason" => reason&.to_s,
90
- "breaks" => breaks.map { |b| break_json(b) }
91
- }
106
+ "totals" => totals.transform_keys(&:to_s), "reason" => reason&.to_s
107
+ }.merge(listing_json)
92
108
  end
93
109
 
94
110
  private
95
111
 
112
+ def listing_json
113
+ { "breaks" => breaks.map { |b| break_json(b) }, "epochs" => epochs.map(&:as_json),
114
+ "before_first_epoch" => before_first_epoch }
115
+ end
116
+
96
117
  def break_json(found)
97
118
  {
98
119
  "kind" => found.kind.to_s, "sequence" => range_json(found.sequence),
99
120
  "detail" => found.detail, "invocation_id" => found.invocation_id,
100
- "key_id" => found.key_id
121
+ "key_id" => found.key_id, "epoch" => found.epoch
101
122
  }
102
123
  end
103
124