reeve 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +293 -3
  3. data/README.md +276 -11
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +52 -5
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +10 -0
  6. data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
  7. data/lib/generators/reeve/upgrade/templates/add_ledger_retention_to_reeve_audit_entries.rb.tt +37 -0
  8. data/lib/generators/reeve/upgrade/upgrade_generator.rb +19 -25
  9. data/lib/generators/reeve/upgrade/upgrade_notes.rb +62 -0
  10. data/lib/reeve/audit/chain/anchoring.rb +91 -0
  11. data/lib/reeve/audit/chain/canonical.rb +38 -7
  12. data/lib/reeve/audit/chain/checkpoint.rb +228 -0
  13. data/lib/reeve/audit/chain/checkpoint_check.rb +156 -0
  14. data/lib/reeve/audit/chain/entrypoints.rb +29 -0
  15. data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
  16. data/lib/reeve/audit/chain/epochs.rb +83 -0
  17. data/lib/reeve/audit/chain/head.rb +55 -2
  18. data/lib/reeve/audit/chain/keyring.rb +6 -0
  19. data/lib/reeve/audit/chain/report.rb +232 -32
  20. data/lib/reeve/audit/chain/retention/archive.rb +98 -0
  21. data/lib/reeve/audit/chain/retention/archive_check.rb +179 -0
  22. data/lib/reeve/audit/chain/retention/check.rb +94 -0
  23. data/lib/reeve/audit/chain/retention/connection.rb +65 -0
  24. data/lib/reeve/audit/chain/retention/digest.rb +29 -0
  25. data/lib/reeve/audit/chain/retention/plan.rb +100 -0
  26. data/lib/reeve/audit/chain/retention/preflight.rb +48 -0
  27. data/lib/reeve/audit/chain/retention/purge.rb +155 -0
  28. data/lib/reeve/audit/chain/retention/purge_result.rb +134 -0
  29. data/lib/reeve/audit/chain/retention/records.rb +23 -0
  30. data/lib/reeve/audit/chain/rotation.rb +124 -0
  31. data/lib/reeve/audit/chain/schema.rb +59 -0
  32. data/lib/reeve/audit/chain/verification.rb +53 -10
  33. data/lib/reeve/audit/chain/verifier.rb +75 -22
  34. data/lib/reeve/audit/chain/walk.rb +24 -4
  35. data/lib/reeve/audit/chain.rb +61 -13
  36. data/lib/reeve/audit/chain_epoch.rb +24 -0
  37. data/lib/reeve/audit/chain_record.rb +14 -8
  38. data/lib/reeve/audit/file_anchor.rb +49 -0
  39. data/lib/reeve/audit/retention_record.rb +24 -0
  40. data/lib/reeve/audit.rb +30 -3
  41. data/lib/reeve/configuration.rb +25 -0
  42. data/lib/reeve/errors.rb +15 -0
  43. data/lib/reeve/railtie.rb +2 -1
  44. data/lib/reeve/tasks/descriptions.rb +22 -0
  45. data/lib/reeve/tasks/purge_options.rb +54 -0
  46. data/lib/reeve/tasks.rb +104 -7
  47. data/lib/reeve/testing/checks/contract_version.rb +2 -2
  48. data/lib/reeve/testing/checks/ledger_chain.rb +56 -2
  49. data/lib/reeve/testing/ledger.rb +19 -3
  50. data/lib/reeve/version.rb +1 -1
  51. metadata +28 -2
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # What `bin/rails reeve:purge_ledger` did, or why it did nothing (specs/005 data-model,
7
+ # contracts/retention.md). A value, so a script can act on it without reading the text.
8
+ #
9
+ # :dry_run — what a run with CONFIRM=1 would remove; nothing removed
10
+ # :purged — removed through `through`, recorded as retention record `purge`
11
+ # :nothing_to_purge — no checkpoint above the current boundary qualifies
12
+ # :refused — nothing removed; `reason` says why
13
+ class PurgeResult
14
+ PREFIX = "reeve ledger purge:"
15
+ AGAIN = "run again with CONFIRM=1"
16
+
17
+ FIELDS = %i[status reason detail from through through_made_at removed_chained
18
+ removed_pre_chain archive archive_sha256 purge resumed warnings].freeze
19
+
20
+ REFUSALS = {
21
+ no_key: "no ledger key configured (config.ledger_key)",
22
+ no_anchor: "no anchor configured (config.ledger_anchor): a purge must stop at a " \
23
+ "checkpoint kept outside the database",
24
+ no_retention_connection: "no retention connection configured " \
25
+ "(config.ledger_retention_connection)",
26
+ other_database: "config.ledger_retention_connection reaches another database than " \
27
+ "the ledger's",
28
+ application_role: "config.ledger_retention_connection connects as the application's " \
29
+ "own database user; the purge must run on a role the application " \
30
+ "does not use",
31
+ no_checkpoint: "no checkpoint the anchor holds is at that sequence (nearest below: %s, " \
32
+ "above: %s)",
33
+ not_intact: "the ledger does not verify intact, and a purge never removes what it " \
34
+ "cannot prove: %s",
35
+ no_archive_choice: "give ARCHIVE=path to keep the purged entries, or NO_ARCHIVE=1 to " \
36
+ "delete them without one",
37
+ archive_exists: "the archive %s already exists; an archive is never overwritten",
38
+ archive_unwritable: "the archive %s cannot be written there",
39
+ purge_running: "another purge took the next retention record number; run again once " \
40
+ "it is done"
41
+ }.freeze
42
+
43
+ attr_reader(*FIELDS)
44
+
45
+ def self.refused(reason, detail: nil)
46
+ new(status: :refused, reason: reason, detail: detail)
47
+ end
48
+
49
+ def self.nothing(reason, current:)
50
+ new(status: :nothing_to_purge, reason: reason, through: current)
51
+ end
52
+
53
+ def self.from(plan, status, **fields)
54
+ new(status: status, from: plan.from, through: plan.through,
55
+ through_made_at: plan.checkpoint.made_at, removed_chained: plan.removed_chained,
56
+ removed_pre_chain: plan.removed_pre_chain, **fields)
57
+ end
58
+
59
+ def self.resumed(record, removed, warnings:)
60
+ new(status: :purged, from: record.previous_through + 1, through: record.through_sequence,
61
+ purge: record.purge_number, removed_chained: record.removed_chained,
62
+ removed_pre_chain: record.removed_pre_chain, resumed: removed,
63
+ archive_sha256: record.archive_sha256, warnings: warnings)
64
+ end
65
+
66
+ def initialize(status:, warnings: [], **fields)
67
+ @status = status
68
+ @warnings = warnings.freeze
69
+ (FIELDS - %i[status warnings]).each do |name|
70
+ instance_variable_set(:"@#{name}", fields[name])
71
+ end
72
+ freeze
73
+ end
74
+
75
+ def dry_run? = status == :dry_run
76
+ def purged? = status == :purged
77
+ def refused? = status == :refused
78
+
79
+ def to_s
80
+ [first_line, *warnings.map { |warning| "warning: #{warning}" }].join("\n")
81
+ end
82
+
83
+ private
84
+
85
+ def first_line
86
+ case status
87
+ when :dry_run then "#{PREFIX} WOULD PURGE — #{removal}; #{archive_text}; #{AGAIN}"
88
+ when :purged then "#{PREFIX} PURGED — #{purged_text}"
89
+ when :nothing_to_purge then "#{PREFIX} NOTHING TO PURGE — #{nothing_text}"
90
+ else "#{PREFIX} REFUSED — #{refusal}; nothing was removed"
91
+ end
92
+ end
93
+
94
+ def purged_text
95
+ record = "#{removal}; retention record #{purge}"
96
+ return "#{record} (resumed; #{n(resumed)} rows removed this run)" if resumed
97
+
98
+ "#{record}; #{archive_text}"
99
+ end
100
+
101
+ def removal
102
+ made = through_made_at ? " (#{AnchorText.made_on(through_made_at)})" : ""
103
+ "sequences #{n(from || 1)}–#{n(through)} through checkpoint ##{n(through)}#{made}: " \
104
+ "#{n(removed_chained)} chained entries and #{n(removed_pre_chain)} pre-chain rows"
105
+ end
106
+
107
+ def archive_text
108
+ return "no archive written (NO_ARCHIVE=1)" if archive.nil?
109
+ return "archive to #{archive}" if dry_run?
110
+
111
+ "archive #{archive} (sha256 #{archive_sha256})"
112
+ end
113
+
114
+ def nothing_text
115
+ where = "above the current boundary (##{n(through)})"
116
+ return "no checkpoint old enough #{where}" unless reason == :at_or_below
117
+
118
+ "the ledger is already purged through that sequence #{where}"
119
+ end
120
+
121
+ def refusal
122
+ template = REFUSALS.fetch(reason)
123
+ return template unless template.include?("%s")
124
+
125
+ format(template, *Array(detail))
126
+ end
127
+
128
+ def n(value)
129
+ Report.number(value.to_i)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # The ledger's purges (specs/005), as verification and the purge read them.
7
+ module Retention
8
+ # Every retention record, oldest first, in one query. None for a ledger never purged,
9
+ # or one whose table has not been migrated: it verifies exactly as before.
10
+ module Records
11
+ module_function
12
+
13
+ def read(entry_class:)
14
+ present = entry_class.connection_pool.with_connection do |connection|
15
+ connection.table_exists?(RetentionRecord.table_name)
16
+ end
17
+ present ? RetentionRecord.uncached { RetentionRecord.order(:purge_number).to_a } : []
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ 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,33 @@ 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
+ # `checkpoint` is the head sequence of the checkpoint a break is about (specs/004); such a
20
+ # break has no sequence of its own.
21
+ # `retention` is the number of the retention record a break is about (specs/005).
22
+ Break = Struct.new(:kind, :sequence, :detail, :invocation_id, :key_id, :epoch, :checkpoint,
23
+ :retention, keyword_init: true)
24
+
25
+ # One key epoch as verification found it (specs/003 FR-013). `entry_count` counts the
26
+ # walked rows that named it; `status` is :verified, :key_not_configured or :altered.
27
+ EpochSummary = Struct.new(:epoch, :key_id, :first_sequence, :entry_count, :status,
28
+ keyword_init: true) do
29
+ def as_json(*)
30
+ { "epoch" => epoch, "key_id" => key_id, "first_sequence" => first_sequence,
31
+ "entries" => entry_count, "status" => status.to_s }
32
+ end
33
+ end
34
+
35
+ EPOCH_STATUSES = %i[verified key_not_configured altered].freeze
19
36
 
20
37
  DEFAULT_LIMIT = 100
21
38
 
22
39
  KINDS = %i[
23
40
  digest_mismatch missing duplicate unchained head_mismatch beyond_head
24
41
  genesis_altered chain_record_missing unknown_key
42
+ epoch_missing epoch_mismatch epoch_altered
43
+ checkpoint_not_reached checkpoint_mismatch checkpoint_altered
44
+ retention_altered
25
45
  ].freeze
26
46
 
27
47
  # The status rules (data-model), for a verification that had a key and found a
@@ -38,11 +58,15 @@ module Reeve
38
58
 
39
59
  attr_reader :status, :covered, :breaks, :head_at_start, :head_at_end,
40
60
  :uncovered_now, :uncovered_at_genesis, :unchained_now, :unchained_at_genesis,
41
- :reason
61
+ :reason, :epochs, :before_first_epoch,
62
+ :checkpoints, :anchored_at, :beyond_anchor, :walked_from,
63
+ :retention, :awaiting_purge
42
64
 
43
65
  def initialize(status:, covered: nil, breaks: [], head_at_start: nil, head_at_end: nil,
44
66
  uncovered_now: 0, uncovered_at_genesis: nil, unchained_now: 0,
45
- unchained_at_genesis: nil, reason: nil)
67
+ unchained_at_genesis: nil, reason: nil, epochs: [], before_first_epoch: nil,
68
+ checkpoints: [], anchored_at: nil, beyond_anchor: nil, walked_from: nil,
69
+ retention: nil, awaiting_purge: 0)
46
70
  raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
47
71
 
48
72
  @status = status
@@ -55,12 +79,18 @@ module Reeve
55
79
  @unchained_now = unchained_now
56
80
  @unchained_at_genesis = unchained_at_genesis
57
81
  @reason = reason
82
+ @epochs = epochs.freeze
83
+ @before_first_epoch = before_first_epoch
84
+ @checkpoints = checkpoints.freeze
85
+ @anchored_at = anchored_at
86
+ @beyond_anchor = beyond_anchor
87
+ @walked_from = walked_from
88
+ @retention = retention
89
+ @awaiting_purge = awaiting_purge
58
90
  freeze
59
91
  end
60
92
 
61
- def intact?
62
- status == :intact
63
- end
93
+ def intact? = status == :intact
64
94
 
65
95
  # Never capped, whatever the display does with the list.
66
96
  def totals
@@ -86,18 +116,31 @@ module Reeve
86
116
  "uncovered_now" => uncovered_now, "uncovered_at_genesis" => uncovered_at_genesis,
87
117
  "unchained_now" => unchained_now, "unchained_at_genesis" => unchained_at_genesis,
88
118
  "unverifiable_count" => unverifiable_count,
89
- "totals" => totals.transform_keys(&:to_s), "reason" => reason&.to_s,
90
- "breaks" => breaks.map { |b| break_json(b) }
91
- }
119
+ "totals" => totals.transform_keys(&:to_s), "reason" => reason&.to_s
120
+ }.merge(listing_json)
92
121
  end
93
122
 
94
123
  private
95
124
 
125
+ def listing_json
126
+ { "breaks" => breaks.map { |b| break_json(b) }, "epochs" => epochs.map(&:as_json),
127
+ "before_first_epoch" => before_first_epoch, "checkpoints" => checkpoints.map(&:as_json),
128
+ "anchored_at" => point_json(anchored_at), "beyond_anchor" => beyond_anchor,
129
+ "walked_from" => point_json(walked_from), "retention" => retention&.as_json,
130
+ "awaiting_purge" => awaiting_purge }
131
+ end
132
+
133
+ def point_json(checkpoint)
134
+ checkpoint && { "head_sequence" => checkpoint.head_sequence,
135
+ "made_at" => checkpoint.made_at }
136
+ end
137
+
96
138
  def break_json(found)
97
139
  {
98
140
  "kind" => found.kind.to_s, "sequence" => range_json(found.sequence),
99
141
  "detail" => found.detail, "invocation_id" => found.invocation_id,
100
- "key_id" => found.key_id
142
+ "key_id" => found.key_id, "epoch" => found.epoch, "checkpoint" => found.checkpoint,
143
+ "retention" => found.retention
101
144
  }
102
145
  end
103
146
 
@@ -23,12 +23,16 @@ 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
- def initialize(entry_class:, keyring:, batch_size: BATCH_SIZE)
28
+ # `checkpoints` are Checkpoints read from outside the database, and `from` one of them to
29
+ # walk from instead of the genesis (specs/004; Anchoring).
30
+ def initialize(entry_class:, keyring:, batch_size: BATCH_SIZE, checkpoints: [], from: nil)
29
31
  @entry_class = entry_class
30
32
  @keyring = keyring
31
33
  @batch_size = batch_size
34
+ @anchoring = Anchoring.new(entry_class: entry_class, keyring: keyring,
35
+ checkpoints: checkpoints, from: from)
32
36
  end
33
37
 
34
38
  def call
@@ -36,31 +40,70 @@ module Reeve
36
40
 
37
41
  ChainRecord.require_same_database!(entry_class)
38
42
 
43
+ @breaks = []
39
44
  record = read_head
40
- return unwalked(:no_chain) unless record&.head_sequence&.positive? || chained_rows?
45
+ return verify_chain(record) if record&.head_sequence&.positive? || chained_rows?
46
+ return unwalked(:no_chain) unless anchoring.any?
41
47
 
42
- verify_chain(record)
48
+ # A ledger with no chain that checkpoints saw: broken, not "no chain" (FR-013).
49
+ unwalked(checkpoints: anchoring.emptied(record, report: method(:add)))
43
50
  end
44
51
 
45
52
  private
46
53
 
47
- attr_reader :entry_class, :keyring, :batch_size, :breaks
54
+ attr_reader :entry_class, :keyring, :batch_size, :breaks, :anchoring
48
55
 
56
+ # The epochs are read after the head: every row the walk visits committed, with any
57
+ # epoch it opened, before that read. They are checked against the head read *after*
58
+ # the walk, which is later than they were read, so an epoch opened by a rotation
59
+ # during the run is never mistaken for one placed beyond the head.
49
60
  def verify_chain(record)
50
- @breaks = []
51
- walk = walk(record)
61
+ epochs = EpochRules.read(keyring: keyring, entry_class: entry_class)
62
+ retention = check_retention(record)
63
+ failed = anchoring.failed_start(record, epochs.records, report: method(:add))
64
+ return unwalked(record: record, checkpoints: failed) if failed
65
+
66
+ walk = walk(record, epochs)
67
+ head_at_end = check_around(record, walk, epochs)
68
+ anchored = anchoring.check(record, head_at_end || record, epochs.records,
69
+ report: method(:add))
70
+
71
+ result(record, head_at_end, walk.last_sequence, epochs,
72
+ **anchored, retention: retention, awaiting_purge: awaiting_purge)
73
+ end
74
+
75
+ # The purges, if any (specs/005): the latest that holds is where the walk starts.
76
+ def check_retention(record)
77
+ records = Retention::Records.read(entry_class: entry_class)
78
+ return nil if records.empty?
79
+
80
+ checked = Retention::Check.new(keyring: keyring, report: method(:add))
81
+ .call(records, genesis_digest: record&.genesis_digest)
82
+ anchoring.floor = checked.boundary
83
+ checked
84
+ end
85
+
86
+ # Rows still at or below the boundary: a purge stopped midway, or a restore (FR-010).
87
+ def awaiting_purge
88
+ floor = anchoring.floor
89
+ floor ? entry_class.where("chain_sequence <= ?", floor.through_sequence).count : 0
90
+ end
91
+
92
+ # The chain record, rows past the head, unchained rows and the epochs, each checked
93
+ # against what the walk found; returns the head as read after the walk.
94
+ def check_around(record, walk, epochs)
52
95
  check_record(record, walk)
53
96
  head_at_end = record && read_head
54
97
  check_beyond_head(head_at_end || record)
55
98
  check_unchained(record)
56
-
57
- result(record, head_at_end, walk.last_sequence)
99
+ epochs.check!(record, head_at_end || record, report: method(:add))
100
+ head_at_end
58
101
  end
59
102
 
60
- def walk(record)
103
+ def walk(record, epochs)
61
104
  Walk.new(entry_class: entry_class, keyring: keyring, batch_size: batch_size,
62
- link: record&.genesis_digest, report: method(:add))
63
- .call(record&.head_sequence)
105
+ link: anchoring.link(record), report: method(:add), epochs: epochs,
106
+ start: anchoring.start).call(record&.head_sequence)
64
107
  end
65
108
 
66
109
  # A seam as well as a read: the specs wrap it to land a write mid-walk.
@@ -121,15 +164,20 @@ module Reeve
121
164
  end
122
165
  end
123
166
 
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)
167
+ # `epoch` names the key epoch, and `checkpoint` the checkpoint, a break is about when it has
168
+ # no sequence (specs/003, specs/004).
169
+ def add(kind, sequence, detail = nil, invocation_id = nil, key_id = nil, epoch: nil,
170
+ checkpoint: nil, retention: nil)
171
+ found = { kind: kind, sequence: sequence, detail: detail, invocation_id: invocation_id }
172
+ breaks << Verification::Break.new(**found, key_id: key_id, epoch: epoch,
173
+ checkpoint: checkpoint, retention: retention)
127
174
  end
128
175
 
129
- def result(record, head_at_end, last_sequence)
176
+ def result(record, head_at_end, last_sequence, epochs, **found)
130
177
  Verification.new(
131
178
  status: Verification.status_for(breaks), breaks: breaks,
132
- covered: covered(record, last_sequence),
179
+ covered: covered(record, last_sequence), walked_from: anchoring.from, **found,
180
+ epochs: epochs.summaries, before_first_epoch: epochs.before_first_epoch,
133
181
  head_at_start: record&.head_sequence, head_at_end: head_at_end&.head_sequence,
134
182
  uncovered_now: count_uncovered, uncovered_at_genesis: record&.pre_chain_count,
135
183
  unchained_now: count_unchained, unchained_at_genesis: record&.pre_chain_unchained_count
@@ -138,13 +186,18 @@ module Reeve
138
186
 
139
187
  def covered(record, last_sequence)
140
188
  last = record ? record.head_sequence : last_sequence
141
- last.positive? ? 1..last : nil
189
+ first = anchoring.start + 1
190
+ last >= first ? first..last : nil
142
191
  end
143
192
 
144
- # An answer given before any walk: no key to walk with, or no chain to walk.
145
- def unwalked(status, reason = nil)
146
- Verification.new(status: status, reason: reason,
147
- uncovered_now: count_uncovered, unchained_now: count_unchained)
193
+ # An answer given before any walk: no key to walk with, no chain to walk, or checkpoints
194
+ # whose own breaks say why there was nothing to walk (specs/004). Without a status, the
195
+ # breaks decide it.
196
+ def unwalked(status = nil, reason = nil, record: nil, checkpoints: [])
197
+ Verification.new(status: status || Verification.status_for(breaks), reason: reason,
198
+ breaks: breaks || [], head_at_start: record&.head_sequence,
199
+ checkpoints: checkpoints, uncovered_now: count_uncovered,
200
+ unchained_now: count_unchained)
148
201
  end
149
202
 
150
203
  def chained_rows?
@@ -20,13 +20,20 @@ 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
+ #
25
+ # `start` is the sequence of a checkpoint the walk begins after (specs/004 FR-015), with
26
+ # `link` its digest: the walk is then exactly the one from the genesis, resumed there.
27
+ def initialize(entry_class:, keyring:, batch_size:, link:, report:, epochs:, start: 0)
24
28
  @entry_class = entry_class
25
29
  @keyring = keyring
26
30
  @batch_size = batch_size
27
31
  @report = report
28
- @expected = 1
29
- @last_sequence = 0
32
+ @epochs = epochs
33
+ @start = start
34
+ @expected = start + 1
35
+ @last_sequence = start
36
+ @last_digest = start.positive? ? link : nil
30
37
  @link = link
31
38
  @verify_first_link = !link.nil?
32
39
  end
@@ -39,7 +46,7 @@ module Reeve
39
46
 
40
47
  private
41
48
 
42
- attr_reader :entry_class, :keyring, :batch_size
49
+ attr_reader :entry_class, :keyring, :batch_size, :epochs
43
50
 
44
51
  def visit(entry)
45
52
  sequence = entry.chain_sequence
@@ -71,6 +78,10 @@ module Reeve
71
78
  end
72
79
 
73
80
  def verify(entry, previous)
81
+ # A row whose key or epoch contradicts the epoch records is a break on its own; its
82
+ # digest would only repeat it.
83
+ return if epoch_break?(entry)
84
+
74
85
  # With no chain record there is no genesis for the first row to link to. That is
75
86
  # reported once, as chain_record_missing, not again here as a mismatch.
76
87
  return if !@verify_first_link && entry.chain_sequence == 1
@@ -88,6 +99,14 @@ module Reeve
88
99
  @report.call(:digest_mismatch, entry.chain_sequence, e.message)
89
100
  end
90
101
 
102
+ def epoch_break?(entry)
103
+ kind, detail = epochs.judge(entry)
104
+ return false unless kind
105
+
106
+ @report.call(kind, entry.chain_sequence, detail)
107
+ true
108
+ end
109
+
91
110
  def each_chained(limit, &block)
92
111
  after = nil
93
112
  loop do
@@ -110,6 +129,7 @@ module Reeve
110
129
  # against SC-004's 300. SQLite derived the bound on its own, which hid it there.
111
130
  def batch_scope(after, limit)
112
131
  scope = entry_class.where.not(chain_sequence: nil)
132
+ scope = scope.where("chain_sequence > ?", @start) if @start.positive? && after.nil?
113
133
  scope = scope.where("chain_sequence <= ?", limit) if limit
114
134
  if after
115
135
  scope = scope.where("chain_sequence >= ?", after[0])