kairos-chain 3.63.0 → 3.64.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: 96a6f46091b47614f44d089e6459dde7e8435cf7caa893c9b335b7359ad25394
4
- data.tar.gz: 965451d952fb8fa61d3bfcc581241242de0f25eb492dddc56ebe913e4d07683b
3
+ metadata.gz: 4ba66e678675e9b4e7776676832765d509f0ac7bb49feed159ee1513ba4b7318
4
+ data.tar.gz: 28af1484af3a0c98529603bfa30fd9ff188e5b18895c06c11a313bcf3a95d8b2
5
5
  SHA512:
6
- metadata.gz: 1c3812249637a923526a17b29cbbf34549f3f3039f51ae37941fa990ceec99611d68e88de280b39ff673aaf5f64e55be3133b079578b9f2887df8a6a6c81f102
7
- data.tar.gz: a3688710826e2de6bd2d05ee24b0f9c6194e32e6d9dd8587c776b5e3bfa1b614549af903eee8a9bac295b5e0efc36c5e50537d5a54b8352acd40e05f2ef291c1
6
+ metadata.gz: 67e98820708350d674970e3db49f3583efc01491ba8d58f5ecb702b988f64a5ad1efeb083637f45e77897c44dbb30a84569400431e8f669020fb13a2dae7b4aa
7
+ data.tar.gz: 27fef53ab90cdb4a7745955b49d41e3834d8313f0a0417605a96e21e84b0ab44fcb5c0e3ad27d9ff2cf698bda2a75c0dc7712869ab5e686daeb8cc468da77cdf
data/CHANGELOG.md CHANGED
@@ -4,6 +4,44 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
4
4
 
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [3.64.0] - 2026-08-06
8
+
9
+ ### Fixed
10
+
11
+ - **An append can no longer erase the chain history on disk.** Two paths
12
+ erased history before this release, both measured, neither raising an
13
+ exception: two holders of the chain at the same length both saved and the
14
+ second save replaced the disk sequence with its own; and a ledger that
15
+ existed but could not be read collapsed to nil, read as a fresh install,
16
+ and the next save rebuilt from genesis over it — 687 blocks became 2. An
17
+ append now takes a key file beside the ledger, re-reads and classifies the
18
+ disk under that key (absent / unreadable / empty / corrupt / readable),
19
+ refuses every state but `:readable` and `:absent`, and always builds on
20
+ the tail that is on disk at that moment. Failures on the read and write
21
+ side raise `Storage::Error` instead of flattening into nil or false, and
22
+ every reader consults `Chain#load_state` before trusting the sequence.
23
+ Includes the 3.62.1 locale fix, and one more of the same family: while
24
+ the ledger does not yet exist, a symlinked name resolves to its target
25
+ before the lock key is derived, so a fresh install reached through two
26
+ names is written under one key (measured pre-fix: 51 blocks shrank to
27
+ 35–49 under concurrent two-name appends). Verified by 149 assertions and
28
+ a 34-row falsification harness in which each protective mechanism is
29
+ removed in isolation and its named check confirmed to go red.
30
+
31
+ ## [3.62.1] - 2026-08-06
32
+
33
+ ### Fixed
34
+
35
+ - **A non-UTF-8 locale no longer erases the ledger on the next append.**
36
+ Emergency hotfix cut from the shipped 3.62.0 (branch
37
+ `hotfix/utf8-ledger-read`): `FileBackend#load_blocks` read
38
+ `blockchain.json` with the locale-derived default encoding, so a
39
+ LANG-unset start (launchd, cron, plain containers) made a non-ASCII
40
+ ledger unparseable, the failure was flattened into "no ledger", and one
41
+ ordinary append overwrote 705 blocks with 2 (measured on a copy of a
42
+ production ledger). The ledger is now read as UTF-8 explicitly. This is
43
+ the only change over 3.62.0; 3.64.0 contains the full erasure fix.
44
+
7
45
  ## [3.63.0] - 2026-08-06
8
46
 
9
47
  ### Changed
@@ -346,6 +346,7 @@ module KairosMcp
346
346
 
347
347
  html_response(200, render_partial('_chain_blocks',
348
348
  blocks: paged_blocks,
349
+ load_state: chain.load_state,
349
350
  total: total,
350
351
  limit: limit,
351
352
  offset: offset))
@@ -353,6 +354,10 @@ module KairosMcp
353
354
 
354
355
  def handle_chain_block_detail_partial(index)
355
356
  chain = KairosChain::Chain.new
357
+ unless chain.valid?
358
+ return html_response(200, "<p>Block ##{index} unavailable: ledger is #{chain.load_state}.</p>")
359
+ end
360
+
356
361
  block = chain.chain.find { |b| b.respond_to?(:index) ? b.index == index : b['index'] == index }
357
362
 
358
363
  if block
@@ -364,13 +369,17 @@ module KairosMcp
364
369
 
365
370
  def handle_chain_verify_partial
366
371
  chain = KairosChain::Chain.new
367
- valid = chain.valid?
368
- length = chain.chain.length
369
372
 
370
- result = if valid
371
- "<div class='flash flash-success'>Chain is valid. #{length} blocks verified.</div>"
373
+ # Three outcomes: a ledger that does not exist yet is not a failure.
374
+ result = case chain.load_state
375
+ when :readable
376
+ "<div class='flash flash-success'>Chain is valid. " \
377
+ "#{chain.chain.length} blocks verified.</div>"
378
+ when :absent
379
+ "<div class='flash'>Chain not created yet. Nothing to verify.</div>"
372
380
  else
373
- "<div class='flash flash-error'>Chain integrity check FAILED!</div>"
381
+ "<div class='flash flash-error'>Chain integrity check FAILED! " \
382
+ "(state: #{chain.load_state})</div>"
374
383
  end
375
384
  html_response(200, result)
376
385
  end
@@ -475,17 +484,20 @@ module KairosMcp
475
484
  backend = SkillsConfig.storage_backend
476
485
 
477
486
  latest = chain.latest_block
478
- latest_h = latest.respond_to?(:to_h) ? latest.to_h : latest
487
+ latest_h = latest.respond_to?(:to_h) ? latest&.to_h : latest
479
488
 
480
489
  {
481
490
  valid: chain.valid?,
491
+ state: chain.load_state,
482
492
  length: chain.chain.length,
483
493
  storage: { backend: backend },
484
494
  latest_block: latest_h
485
495
  }
486
496
  rescue StandardError => e
487
- { valid: false, length: 0, storage: { backend: 'unknown' },
488
- latest_block: {}, error: e.message }
497
+ # Chain.new does not raise; this branch is reachable only for failures
498
+ # outside the ledger (config, rendering). It must not fabricate a state.
499
+ { valid: false, state: :error, length: 0, storage: { backend: 'unknown' },
500
+ latest_block: nil, error: e.message }
489
501
  end
490
502
 
491
503
  def fetch_knowledge_list(search: nil)
@@ -11,13 +11,18 @@
11
11
  <div>
12
12
  <p>
13
13
  <strong>Status:</strong>
14
+ <%# Three displays, not two: an absent ledger is a fresh install. %>
14
15
  <% if chain[:valid] %>
15
16
  <span class="badge badge-success">Valid</span>
17
+ <% elsif chain[:state] == :absent %>
18
+ <span class="badge">Not created yet</span>
16
19
  <% else %>
17
- <span class="badge badge-error">Invalid</span>
20
+ <span class="badge badge-error">Invalid (<%= h(chain[:state].to_s) %>)</span>
18
21
  <% end %>
19
22
  </p>
20
- <p><strong>Length:</strong> <%= chain[:length] %> blocks</p>
23
+ <% if chain[:valid] %>
24
+ <p><strong>Length:</strong> <%= chain[:length] %> blocks</p>
25
+ <% end %>
21
26
  </div>
22
27
  <div>
23
28
  <p><strong>Backend:</strong> <%= h(chain.dig(:storage, :backend) || 'unknown') %></p>
@@ -9,13 +9,18 @@
9
9
  <article>
10
10
  <header>Blockchain</header>
11
11
  <p>
12
+ <%# Three displays, not two: an absent ledger is a fresh install. %>
12
13
  <% if chain[:valid] %>
13
14
  <span class="badge badge-success">Valid</span>
15
+ <% elsif chain[:state] == :absent %>
16
+ <span class="badge">Not created yet</span>
14
17
  <% else %>
15
- <span class="badge badge-error">Invalid</span>
18
+ <span class="badge badge-error">Invalid (<%= h(chain[:state].to_s) %>)</span>
16
19
  <% end %>
17
20
  </p>
18
- <p><strong><%= chain[:length] %></strong> blocks</p>
21
+ <% if chain[:valid] %>
22
+ <p><strong><%= chain[:length] %></strong> blocks</p>
23
+ <% end %>
19
24
  <p><small>Backend: <%= h(chain.dig(:storage, :backend) || 'unknown') %></small></p>
20
25
  <footer><a href="/admin/chain">View Chain &rarr;</a></footer>
21
26
  </article>
@@ -1,5 +1,13 @@
1
1
  <% if blocks.empty? %>
2
- <p>No blocks found.</p>
2
+ <%# Three displays, not two: an absent ledger is a fresh install, and an
3
+ unreadable one must not be reported as an empty history. %>
4
+ <% if load_state == :readable %>
5
+ <p>No blocks found.</p>
6
+ <% elsif load_state == :absent %>
7
+ <p>Chain not created yet.</p>
8
+ <% else %>
9
+ <p>History unavailable: ledger is <%= h(load_state.to_s) %>.</p>
10
+ <% end %>
3
11
  <% else %>
4
12
  <div class="overflow-auto">
5
13
  <table>
@@ -64,7 +64,23 @@ module KairosMcp
64
64
  end
65
65
 
66
66
  active = Digest::SHA256.hexdigest(File.read(md_file_path))
67
- recorded = recorded_digest_for(name, storage_backend)
67
+
68
+ chain = KairosChain::Chain.new(storage_backend: storage_backend)
69
+ # :absent completes normally: a fresh install has no ledger, the scan
70
+ # over zero blocks finds nothing, and :missing_record is the honest
71
+ # verdict. The other failed states mean provenance is UNAVAILABLE,
72
+ # which is not the same claim as "never recorded" — reporting
73
+ # :missing_record there would accuse every artifact whenever the
74
+ # ledger cannot be read.
75
+ unless chain.valid? || chain.load_state == :absent
76
+ return Result.new(
77
+ status: :error, name: name,
78
+ active_digest: active, recorded_digest: nil,
79
+ message: "L1 '#{name}': provenance unavailable — ledger state is #{chain.load_state}"
80
+ )
81
+ end
82
+
83
+ recorded = recorded_digest_for(name, chain)
68
84
 
69
85
  if recorded.nil?
70
86
  return Result.new(
@@ -101,8 +117,7 @@ module KairosMcp
101
117
  # the most recent knowledge_update record, scanning the chain from head
102
118
  # backward. Returns nil when the most recent relevant record removed the
103
119
  # artifact (next_hash nil — delete/archive) or when none exists.
104
- def recorded_digest_for(name, storage_backend)
105
- chain = KairosChain::Chain.new(storage_backend: storage_backend)
120
+ def recorded_digest_for(name, chain)
106
121
  chain.chain.reverse_each do |block|
107
122
  Array(block.data).each do |entry|
108
123
  record = parse_entry(entry)
@@ -1,97 +1,347 @@
1
1
  require_relative 'block'
2
2
  require_relative 'merkle_tree'
3
3
  require 'json'
4
+ require 'time'
4
5
  require 'fileutils'
6
+ require_relative '../storage/backend'
5
7
  require_relative '../../kairos_mcp'
6
8
 
7
9
  module KairosMcp
8
10
  module KairosChain
11
+ # Raised when the ledger's state on disk forbids appending, when a re-entrant
12
+ # append is attempted, or when the storage backend does not declare the
13
+ # contract of INV-G. Nothing has been written to the ledger; the lock file and
14
+ # its parent directory may have been created.
15
+ #
16
+ # #state carries one of Chain::LOAD_STATES.
17
+ class ChainStateError < StandardError
18
+ attr_reader :state
19
+
20
+ def initialize(message, state:)
21
+ super(message)
22
+ @state = state
23
+ end
24
+ end
25
+
26
+ # INV-D: an append may only add a block to the end of the sequence that is on
27
+ # disk at that moment. The in-memory sequence never replaces the one on
28
+ # disk. When the sequence on disk cannot be determined, nothing is
29
+ # appended.
30
+ # INV-E: the ledger's state is carried by one entrance that every reader
31
+ # passes through (#load_state), not by the block sequence.
32
+ # INV-G: under a backend that does not declare its contract, Chain refuses to
33
+ # append and reads the ledger as unreadable.
9
34
  class Chain
10
- attr_reader :chain
35
+ # The five values #load_state can take. Only :readable and :absent permit an
36
+ # append; only :readable makes #chain / #latest_block meaningful.
37
+ LOAD_STATES = %i[absent unreadable empty corrupt readable].freeze
11
38
 
12
- # Initialize the chain
13
- #
14
- # @param chain_file [String] Path to blockchain file (for backward compatibility)
15
- # @param storage_backend [Storage::Backend, nil] Storage backend to use
39
+ # Re-entrancy flag. Thread-level (not fiber-level) on purpose: an append may
40
+ # cross fibers, and a nested append would deadlock on its own flock.
41
+ APPEND_FLAG = :kairos_chain_append_in_progress
42
+
43
+ EMPTY_CHAIN = [].freeze
44
+
45
+ attr_reader :load_state
46
+
47
+ # @param chain_file [String, nil] dead argument, kept for call-site compatibility
48
+ # @param storage_backend [Storage::Backend, nil] storage backend to use
49
+ # Constructing the default backend can itself fail — a malformed config
50
+ # scalar, a data directory that cannot be created on a read-only or full
51
+ # mount. That construction is therefore deferred into the protected region
52
+ # of classify_disk; doing it here would put it outside every rescue and
53
+ # break "Chain.new never raises".
16
54
  def initialize(chain_file: nil, storage_backend: nil)
17
- chain_file ||= KairosMcp.blockchain_path
55
+ # FIX B — NEW CLAIM: Chain.new resolves no paths of its own, so it
56
+ # returns an object for every input. KairosMcp.blockchain_path here ran
57
+ # outside every rescue and raised for an unresolvable data directory
58
+ # (KAIROS_DATA_DIR='~nosuchuser/…' → ArgumentError; deleted CWD →
59
+ # Errno::ENOENT from Dir.pwd — both measured). @chain_file is dead
60
+ # (assigned, never read); the argument is kept for call-site
61
+ # compatibility only. Path resolution now happens solely inside
62
+ # classify_disk's protected region, where it degrades to :unreadable.
18
63
  @chain_file = chain_file
19
- @storage_backend = storage_backend || default_storage_backend
20
- @chain = load_chain || [Block.genesis]
64
+ @storage_backend = storage_backend
65
+ @load_state, @chain = classify_disk
66
+ end
67
+
68
+ # The block sequence, but only when the ledger was readable. Every other
69
+ # state yields a frozen empty array: callers must consult #load_state first.
70
+ def chain
71
+ @load_state == :readable ? @chain : EMPTY_CHAIN
21
72
  end
22
73
 
23
74
  def latest_block
24
- @chain.last
75
+ @load_state == :readable ? @chain.last : nil
76
+ end
77
+
78
+ # An alias for "the ledger was readable". Not a separate integrity pass:
79
+ # the four predicates of §3 already ran during classification.
80
+ def valid?
81
+ @load_state == :readable
25
82
  end
26
83
 
84
+ # Append one block to whatever is on disk right now.
85
+ #
86
+ # @return [Block] the appended block
87
+ # @raise [ChainStateError] nothing was written to the ledger
88
+ # @raise [Storage::Error] it is unknown whether the write landed; re-read
27
89
  def add_block(data)
28
- # Ensure data is array of strings (serialize if needed)
29
- normalized_data = data.map { |d| d.is_a?(String) ? d : d.to_json }
90
+ # flag first, before the lock: a nested append would deadlock on its own
91
+ # flock. The path question is asked here too, so a backend that answers
92
+ # a relative path is refused before any file is created.
93
+ if Thread.current.thread_variable_get(APPEND_FLAG)
94
+ raise ChainStateError.new(
95
+ 'append already in progress on this thread (nested append is forbidden)',
96
+ state: @load_state
97
+ )
98
+ end
99
+ path = ledger_path
30
100
 
31
- # 1. Create Merkle Root from data
32
- merkle_tree = MerkleTree.new(normalized_data)
33
- merkle_root = merkle_tree.root
101
+ Thread.current.thread_variable_set(APPEND_FLAG, true)
102
+ begin
103
+ # take the key (a file beside the ledger, never the ledger itself)
104
+ with_lock(path) do
105
+ # ② read disk and classify
106
+ state, disk_blocks = classify_disk
34
107
 
35
- # 2. Create new block
36
- new_block = Block.new(
37
- index: latest_block.index + 1,
38
- timestamp: Time.now.utc,
39
- data: normalized_data,
40
- previous_hash: latest_block.hash,
41
- merkle_root: merkle_root
42
- )
108
+ # refuse anything that is neither readable nor absent
109
+ unless %i[readable absent].include?(state)
110
+ raise ChainStateError.new("cannot append: ledger is #{state}", state: state)
111
+ end
112
+
113
+ # ④ the base is the disk sequence, or a genesis-only sequence
114
+ base = state == :readable ? disk_blocks : [Block.genesis]
43
115
 
44
- # 3. Add to chain
45
- @chain << new_block
46
-
47
- # 4. Persist
48
- save_chain
49
-
50
- new_block
116
+ # build on the base's tail and write base + new
117
+ new_block = build_block(base.last, data)
118
+ appended = base + [new_block]
119
+ storage_backend.save_all_blocks(appended.map(&:to_h))
120
+
121
+ # ⑥ only a successful write advances this instance's sequence and state
122
+ @chain = appended
123
+ @load_state = :readable
124
+
125
+ new_block
126
+ end
127
+ ensure
128
+ # ⑦ lower the flag — reached only by the call that raised it, because ⓪
129
+ # raises before this begin block
130
+ Thread.current.thread_variable_set(APPEND_FLAG, nil)
131
+ end
51
132
  end
52
133
 
53
- def valid?
54
- @chain.each_with_index do |block, i|
55
- next if i == 0 # Skip genesis block
134
+ # @return [Symbol] :file, :sqlite, ... or :unavailable when the backend
135
+ # could not be constructed
136
+ def storage_type
137
+ storage_backend.backend_type
138
+ rescue StandardError
139
+ :unavailable
140
+ end
56
141
 
57
- previous_block = @chain[i - 1]
142
+ private
58
143
 
59
- # 1. Check previous_hash reference
60
- return false if block.previous_hash != previous_block.hash
144
+ # Memoised so the construction is attempted once and its failure is a
145
+ # failure of the read, not of the object.
146
+ def storage_backend
147
+ @storage_backend ||= Storage::Backend.default
148
+ end
61
149
 
62
- # 2. Check block hash integrity
63
- return false if block.hash != block.calculate_hash
64
-
65
- # 3. Check Merkle Root integrity
66
- calculated_merkle_root = MerkleTree.new(block.data).root
67
- return false if block.merkle_root != calculated_merkle_root
150
+ # INV-G's question: "state the absolute path of your ledger". A backend that
151
+ # does not answer cannot be appended to; an answer that is not absolute is a
152
+ # CWD-dependent key, which is a silent loss of exclusion.
153
+ def ledger_path
154
+ backend = begin
155
+ storage_backend
156
+ rescue StandardError => e
157
+ raise ChainStateError.new("storage backend unavailable: #{e.message}", state: @load_state)
68
158
  end
69
159
 
70
- true
160
+ unless backend.respond_to?(:blockchain_file)
161
+ raise ChainStateError.new(
162
+ "storage backend #{backend.class} does not declare its ledger path",
163
+ state: @load_state
164
+ )
165
+ end
166
+
167
+ path = backend.blockchain_file
168
+ unless path.is_a?(String) && !path.empty? && File.absolute_path?(path)
169
+ raise Storage::Error, "storage backend answered a non-absolute ledger path: #{path.inspect}"
170
+ end
171
+
172
+ path
71
173
  end
72
174
 
73
- def save_chain
74
- @storage_backend.save_all_blocks(@chain.map(&:to_h))
175
+ # (あ) the key is a separate file — locking the ledger itself makes a fresh
176
+ # install permanently unwritable.
177
+ # (う) the key file is never unlinked — a deleted key file lets a later
178
+ # locker take a different inode and exclusion is lost in silence.
179
+ def with_lock(path)
180
+ # Only the key's own I/O is wrapped — a failure inside the yield must
181
+ # not come back wearing the "lock unavailable" label.
182
+ # FIX C — NEW CLAIM: every name that reaches one ledger takes one key.
183
+ # The key path is canonicalised BEFORE ".lock" is appended. The R1
184
+ # withdrawal of exactly this fix rested on a half-refuted premise: its
185
+ # fixture ("dir/x", "dir/./x", a symlinked DIRECTORY) does resolve to
186
+ # one lock inode, because the kernel resolves those inside the lock
187
+ # path itself — but "link.json.lock" is its own name, so a symlink to
188
+ # the ledger FILE split the lock ("real.json.lock" vs "link.json.lock";
189
+ # measured over 8 runs of 2×60 appends: 4 to 21 of 120 blocks lost,
190
+ # every run, silently — it is a race, so the count varies).
191
+ # FIX F — NEW CLAIM: a name reaches one key even while the ledger does
192
+ # not yet exist. R3 refuted FIX C's fallback (four reviewers
193
+ # independently): while the ledger is absent — a fresh install reached
194
+ # through two names — realpath fails with ENOENT, the fallback keyed on
195
+ # the UNRESOLVED basename, and the premise "a ledger realpath cannot
196
+ # resolve is one classify_disk cannot stat either" is false for exactly
197
+ # that state, because :absent permits an append (measured: 51 blocks
198
+ # shrank to 35–49). realdirpath resolves the final component's symlink
199
+ # without requiring its target to exist, so both names converge on the
200
+ # target's spelling before any ledger byte exists. The basename
201
+ # fallback remains only for names realdirpath itself refuses, and no
202
+ # append completes a write through such a name: ELOOP and EPERM fail
203
+ # classify_disk's stat (refused as :unreadable), and a symlink whose
204
+ # target directory is missing classifies :absent but the write through
205
+ # it fails with ENOENT before any ledger byte lands. Hard links to the
206
+ # same ledger remain outside what any path-derived key can reach:
207
+ # realpath cannot distinguish them. The design records this as
208
+ # limitation L-2 in its "what this does not close" section — it is NOT
209
+ # in the §7 scope-exclusion list, so it is an acknowledged open hole
210
+ # rather than a deferred item. An I/O failure while deriving the key
211
+ # (the directory itself unresolvable) still surfaces as Storage::Error,
212
+ # as before.
213
+ lock_path = nil
214
+ handle = begin
215
+ FileUtils.mkdir_p(File.dirname(path))
216
+ canonical = begin
217
+ File.realpath(path)
218
+ rescue SystemCallError
219
+ begin
220
+ File.realdirpath(path)
221
+ rescue SystemCallError
222
+ File.join(File.realpath(File.dirname(path)), File.basename(path))
223
+ end
224
+ end
225
+ lock_path = "#{canonical}.lock"
226
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644)
227
+ rescue SystemCallError, IOError => e
228
+ raise Storage::Error, "ledger lock unavailable (#{lock_path || path}): #{e.message}"
229
+ end
230
+
231
+ completed = false
232
+ begin
233
+ begin
234
+ handle.flock(File::LOCK_EX)
235
+ rescue SystemCallError, IOError => e
236
+ raise Storage::Error, "ledger lock unavailable (#{lock_path}): #{e.message}"
237
+ end
238
+ result = yield
239
+ completed = true
240
+ result
241
+ ensure
242
+ # Closing the key must never replace an exception already on its way
243
+ # out: the caller's rule is read off the exception's class, and an
244
+ # IOError from close would erase the Storage::Error that told the
245
+ # caller to re-read. Whether the body finished is tracked explicitly
246
+ # rather than read off $!, which inside an ensure also carries an
247
+ # exception being handled further up the stack — under a caller that
248
+ # wraps add_block in a rescue, $! is non-nil even on the normal path
249
+ # and a close failure would be swallowed.
250
+ begin
251
+ handle.close
252
+ rescue StandardError => e
253
+ raise Storage::Error, "ledger lock close failed (#{lock_path}): #{e.message}" if completed
254
+ end
255
+ end
75
256
  end
76
257
 
77
- # Get the storage backend type
78
- # @return [Symbol] :file or :sqlite
79
- def storage_type
80
- @storage_backend.backend_type
258
+ def build_block(tail, data)
259
+ normalized_data = data.map { |d| d.is_a?(String) ? utf8_for_ledger(d) : d.to_json }
260
+
261
+ Block.new(
262
+ index: tail.index + 1,
263
+ timestamp: Time.now.utc,
264
+ data: normalized_data,
265
+ previous_hash: tail.hash,
266
+ merkle_root: MerkleTree.new(normalized_data).root
267
+ )
81
268
  end
82
269
 
83
- private
270
+ # FIX A — NEW CLAIM: any String add_block accepts round-trips through the
271
+ # ledger byte-identically — the bytes MerkleTree and Block hash are the
272
+ # bytes JSON.pretty_generate writes. Decision: a String already in valid
273
+ # UTF-8 passes unchanged; one in another valid encoding is transcoded to
274
+ # its UTF-8 spelling (which IS its ledger representation); one holding
275
+ # bytes invalid for its own encoding raises EncodingError before anything
276
+ # is written. Before this, hashing the original bytes and writing the
277
+ # transcoded ones made one ISO-8859-1 append classify a healthy 7-block
278
+ # ledger :corrupt on reload (measured) — history unreachable, no raise.
279
+ def utf8_for_ledger(text)
280
+ utf8 = text.encode(Encoding::UTF_8)
281
+ # encode is a no-op when source and destination are both UTF-8, so
282
+ # invalid bytes under a UTF-8 label must be refused explicitly.
283
+ unless utf8.valid_encoding?
284
+ raise EncodingError,
285
+ "block data is not valid #{text.encoding.name}: it cannot round-trip through the ledger"
286
+ end
287
+ utf8
288
+ end
84
289
 
85
- def default_storage_backend
86
- require_relative '../storage/backend'
87
- Storage::Backend.default
290
+ # The single entrance of INV-E. Never raises: every failure becomes one of
291
+ # the five states.
292
+ #
293
+ # @return [Array(Symbol, Array<Block>)]
294
+ def classify_disk
295
+ # A backend that cannot even be built is a channel we cannot read
296
+ # through, not data we have judged. It is the :unreadable case.
297
+ backend = begin
298
+ storage_backend
299
+ rescue StandardError => e
300
+ note "[Chain] storage backend unavailable: #{e.message}"
301
+ return [:unreadable, []]
302
+ end
303
+
304
+ # The dividing line: only a backend that declared the contract has its
305
+ # return read in three shapes. Anything else reads as unreadable.
306
+ return [:unreadable, []] unless backend.respond_to?(:blockchain_file)
307
+
308
+ raw = backend.load_blocks
309
+ return [:absent, []] if raw.nil?
310
+
311
+ blocks = rebuild(raw)
312
+ return [:empty, []] if blocks.empty?
313
+ return [:corrupt, []] unless well_formed?(blocks)
314
+
315
+ [:readable, blocks]
316
+ rescue Storage::Error => e
317
+ note "[Chain] ledger unreadable: #{e.message}"
318
+ [:unreadable, []]
319
+ rescue StandardError => e
320
+ # Totality: an exception raised while rebuilding or while evaluating a
321
+ # predicate is itself corruption, not a crash.
322
+ note "[Chain] ledger corrupt: #{e.message}"
323
+ [:corrupt, []]
88
324
  end
89
325
 
90
- def load_chain
91
- blocks_data = @storage_backend.load_blocks
92
- return nil unless blocks_data
326
+ # FIX D — NEW CLAIM: diagnostic output can never change the outcome of a
327
+ # classification. warn writes to $stderr, and these calls sit inside
328
+ # classify_disk's rescue branches: when $stderr has been replaced by a
329
+ # closed or failing writer, warn raises IOError there and escapes
330
+ # Chain.new (measured with a closed StringIO as $stderr; a close of the
331
+ # real STDERR alone does not reproduce — MRI falls back to the C-level
332
+ # stderr). StandardError, not just IOError, because the claim is about
333
+ # any failing writer, not one failure class of the real console.
334
+ def note(message)
335
+ warn message
336
+ rescue StandardError
337
+ # the diagnostic is lost; the classification is not
338
+ end
93
339
 
94
- blocks_data.map do |block_data|
340
+ # Classification runs on the rebuilt sequence, not on the backend's raw
341
+ # array: timestamps are strings there, and the stored `hash` column is
342
+ # discarded on load, so predicate 3 compares recomputed hashes.
343
+ def rebuild(raw)
344
+ raw.map do |block_data|
95
345
  Block.new(
96
346
  index: block_data[:index],
97
347
  timestamp: parse_timestamp(block_data[:timestamp]),
@@ -100,9 +350,27 @@ module KairosMcp
100
350
  merkle_root: block_data[:merkle_root]
101
351
  )
102
352
  end
103
- rescue StandardError => e
104
- warn "[Chain] Failed to load chain: #{e.message}"
105
- nil
353
+ end
354
+
355
+ def well_formed?(blocks)
356
+ # 1. block 0 is the canonical genesis. Comparing recomputed hashes covers
357
+ # exactly the five columns of Block.genesis and nothing else.
358
+ return false unless blocks.first.hash == Block.genesis.hash
359
+
360
+ blocks.each_with_index do |block, i|
361
+ # 2. indexes run from 0, one at a time
362
+ return false unless block.index == i
363
+ next if i.zero?
364
+
365
+ # 3. each previous_hash matches the recomputed hash before it
366
+ return false unless block.previous_hash == blocks[i - 1].hash
367
+
368
+ # 4. from block 1 on, the Merkle root matches recomputation from data.
369
+ # Genesis is excluded: its merkle_root is the constant filler 0…0.
370
+ return false unless block.merkle_root == MerkleTree.new(block.data).root
371
+ end
372
+
373
+ true
106
374
  end
107
375
 
108
376
  def parse_timestamp(timestamp)
@@ -2,6 +2,13 @@
2
2
 
3
3
  module KairosMcp
4
4
  module Storage
5
+ # Raised when a read or a write against the ledger fails, and when the key or
6
+ # the ledger path cannot be used. It wraps the underlying exception as #cause.
7
+ #
8
+ # For a write, this means **it is unknown whether the write landed**. It must
9
+ # not be read as "nothing was written" — re-read the ledger to find out.
10
+ class Error < StandardError; end
11
+
5
12
  # Abstract base class for storage backends
6
13
  #
7
14
  # KairosChain supports two storage backends:
@@ -34,7 +41,15 @@ module KairosMcp
34
41
  # ===========================================================================
35
42
 
36
43
  # Load all blocks from storage
37
- # @return [Array<Hash>, nil] Array of block data or nil if not found
44
+ #
45
+ # Read contract (a backend that declares #blockchain_file must honour it):
46
+ # nil is returned **only when the ledger does not exist**. A ledger that
47
+ # cannot be opened or cannot be parsed (including a zero-byte file) raises
48
+ # Storage::Error. Collapsing those into nil would let a damaged ledger pass
49
+ # as "absent" and be rebuilt from genesis.
50
+ #
51
+ # @return [Array<Hash>, nil] Array of block data, or nil when absent
52
+ # @raise [Storage::Error] the ledger exists but could not be read
38
53
  def load_blocks
39
54
  raise NotImplementedError, "#{self.class}#load_blocks must be implemented"
40
55
  end
@@ -47,8 +62,14 @@ module KairosMcp
47
62
  end
48
63
 
49
64
  # Save all blocks to storage (for file backend bulk write)
65
+ #
66
+ # Write contract: failure raises Storage::Error. Returning false for a
67
+ # failed write is forbidden — the caller cannot distinguish "refused" from
68
+ # "wrote and then failed", and a false return reads as a benign result.
69
+ #
50
70
  # @param blocks [Array<Hash>] Array of block data
51
- # @return [Boolean] Success status
71
+ # @return [Boolean] true
72
+ # @raise [Storage::Error] the write failed or its outcome is unknown
52
73
  def save_all_blocks(blocks)
53
74
  raise NotImplementedError, "#{self.class}#save_all_blocks must be implemented"
54
75
  end
@@ -142,6 +163,14 @@ module KairosMcp
142
163
  raise NotImplementedError, "#{self.class}#backend_type must be implemented"
143
164
  end
144
165
 
166
+ # INV-G — the contract question is a single one: "state the absolute path of
167
+ # your ledger". A backend answers it by defining #blockchain_file. This base
168
+ # class deliberately does not, so a backend that has not been written
169
+ # against the append procedure of INV-D (sqlite's INSERT OR REPLACE never
170
+ # truncates the sequence; postgresql is registered by a SkillSet) is refused
171
+ # rather than silently driven by a file-shaped procedure. Under such a
172
+ # backend, Chain refuses to append and reads the ledger as unreadable.
173
+
145
174
  # ===========================================================================
146
175
  # Factory Method
147
176
  # ===========================================================================
@@ -32,16 +32,50 @@ module KairosMcp
32
32
  # Block Operations
33
33
  # ===========================================================================
34
34
 
35
+ # Read contract (see Backend#load_blocks): nil means the ledger does not
36
+ # exist, and nothing else. A ledger that exists but cannot be opened or
37
+ # parsed — a zero-byte file included — raises Storage::Error. Returning nil
38
+ # there would make a damaged ledger indistinguishable from a fresh install,
39
+ # and the next append would rebuild from genesis over it.
35
40
  def load_blocks
36
- return nil unless File.exist?(@blockchain_file)
41
+ # Absence is decided by stat, not by File.exist?. File.exist? answers
42
+ # false for EVERY stat(2) failure, not only ENOENT — an unsearchable
43
+ # parent directory, a symlink loop, EIO on a network mount, or a macOS
44
+ # ACL denying readattr all read as "no ledger here". Measured on darwin
45
+ # 23.6: with `chmod +a "user deny readattr"` on the ledger, File.exist?
46
+ # is false while File.read and File.write both still succeed, so the
47
+ # ledger classified :absent and the next append rebuilt from genesis
48
+ # over it — 6 blocks became 2. Only ENOENT and ENOTDIR mean the ledger
49
+ # is not there; every other stat failure means we cannot tell.
50
+ begin
51
+ File.stat(@blockchain_file)
52
+ rescue Errno::ENOENT, Errno::ENOTDIR
53
+ return nil
54
+ rescue SystemCallError => e
55
+ raise Storage::Error,
56
+ "cannot determine whether ledger #{@blockchain_file} exists: #{e.message}"
57
+ end
58
+
59
+ # FIX E — NEW CLAIM: the bytes on disk alone decide the classification;
60
+ # the process locale never does. The ledger is always written as UTF-8,
61
+ # but File.read without an encoding tags the bytes with the locale-derived
62
+ # default. Started with LANG unset (launchd, cron, plain containers), a
63
+ # ledger holding non-ASCII text then fails the parse and a healthy 705-block
64
+ # ledger reads :corrupt (measured on a copy of a production ledger; the
65
+ # shipped pre-fix code went further and erased it to 2 blocks on the next
66
+ # append). The bytes were never wrong — only the read was.
67
+ json_data = JSON.parse(File.read(@blockchain_file, encoding: Encoding::UTF_8), symbolize_names: true)
68
+ unless json_data.is_a?(Array)
69
+ raise Storage::Error, "ledger #{@blockchain_file} is not a JSON array (got #{json_data.class})"
70
+ end
37
71
 
38
- json_data = JSON.parse(File.read(@blockchain_file), symbolize_names: true)
39
72
  json_data.map do |block_data|
40
73
  normalize_block_data(block_data)
41
74
  end
42
- rescue JSON::ParserError, ArgumentError => e
43
- warn "[FileBackend] Failed to load blocks: #{e.message}"
44
- nil
75
+ rescue Storage::Error
76
+ raise
77
+ rescue JSON::ParserError, ArgumentError, SystemCallError, IOError, NoMethodError, TypeError => e
78
+ raise Storage::Error, "failed to load blocks from #{@blockchain_file}: #{e.message}"
45
79
  end
46
80
 
47
81
  def save_block(block)
@@ -50,13 +84,15 @@ module KairosMcp
50
84
  save_all_blocks(blocks)
51
85
  end
52
86
 
87
+ # Write contract (see Backend#save_all_blocks): failure raises
88
+ # Storage::Error. The previous `false` return collapsed every failure into
89
+ # a value that reads as benign at the call site.
53
90
  def save_all_blocks(blocks)
54
91
  FileUtils.mkdir_p(File.dirname(@blockchain_file))
55
92
  File.write(@blockchain_file, JSON.pretty_generate(blocks.map { |b| block_to_hash(b) }))
56
93
  true
57
94
  rescue StandardError => e
58
- warn "[FileBackend] Failed to save blocks: #{e.message}"
59
- false
95
+ raise Storage::Error, "failed to save blocks to #{@blockchain_file}: #{e.message}"
60
96
  end
61
97
 
62
98
  def all_blocks
@@ -80,9 +80,13 @@ module KairosMcp
80
80
  blocks = blocks.first(limit)
81
81
 
82
82
  if format == 'json'
83
- text_content(JSON.pretty_generate(blocks.map(&:to_h)))
83
+ # Breaking change: the JSON payload is an object, not a bare array. An
84
+ # array cannot carry the ledger state, and an empty array from an
85
+ # unreadable ledger is indistinguishable from an empty history.
86
+ text_content(JSON.pretty_generate(state: chain.load_state,
87
+ blocks: blocks.map(&:to_h)))
84
88
  else
85
- format_blocks(blocks)
89
+ format_blocks(blocks, chain.load_state)
86
90
  end
87
91
  end
88
92
 
@@ -107,12 +111,17 @@ module KairosMcp
107
111
  end
108
112
  end
109
113
 
110
- def format_blocks(blocks)
114
+ def format_blocks(blocks, load_state)
111
115
  output = "Blockchain History\n"
112
116
  output += "=" * 50 + "\n\n"
117
+ output += "Ledger state: #{load_state}\n\n"
113
118
 
114
119
  if blocks.empty?
115
- output += "(No blocks found)\n"
120
+ output += case load_state
121
+ when :readable then "(No blocks found)\n"
122
+ when :absent then "(Blockchain not created yet)\n"
123
+ else "(History unavailable: ledger is #{load_state})\n"
124
+ end
116
125
  return text_content(output)
117
126
  end
118
127
 
@@ -54,11 +54,15 @@ module KairosMcp
54
54
  storage_info[:wal_mode] = sqlite_config['wal_mode']
55
55
  end
56
56
 
57
+ # The ledger's state comes first; `length` and `latest_block` only carry
58
+ # meaning when the state is :readable. `latest_block` is nil otherwise
59
+ # (breaking change for consumers that assumed a hash).
57
60
  status = {
58
61
  valid: chain.valid?,
62
+ state: chain.load_state,
59
63
  length: chain.chain.length,
60
64
  storage: storage_info,
61
- latest_block: chain.latest_block.to_h
65
+ latest_block: chain.latest_block&.to_h
62
66
  }
63
67
 
64
68
  text_content(JSON.pretty_generate(status))
@@ -42,12 +42,16 @@ module KairosMcp
42
42
 
43
43
  def call(arguments)
44
44
  chain = KairosChain::Chain.new
45
- is_valid = chain.valid?
46
-
47
- if is_valid
45
+
46
+ # Three outcomes, not two: a ledger that does not exist yet is a fresh
47
+ # install, not a corrupted one.
48
+ case chain.load_state
49
+ when :readable
48
50
  text_content("Blockchain Integrity Verified: OK (Length: #{chain.chain.length})")
51
+ when :absent
52
+ text_content('Blockchain not created yet (state: absent). Nothing to verify.')
49
53
  else
50
- text_content("Blockchain Integrity Check FAILED! Chain may be corrupted.")
54
+ text_content("Blockchain Integrity Check FAILED! (state: #{chain.load_state})")
51
55
  end
52
56
  end
53
57
  end
@@ -68,6 +68,20 @@ module KairosMcp
68
68
  limit = (arguments['limit'] || 20).to_i
69
69
 
70
70
  chain = KairosChain::Chain.new
71
+
72
+ # Three outcomes, not two: an absent ledger is a fresh install with
73
+ # nothing recorded yet, and the failed states must be named rather than
74
+ # reported as "no decisions found".
75
+ case chain.load_state
76
+ when :readable # fall through to the scan
77
+ when :absent
78
+ return text_content('No formalization decisions: blockchain not created yet.')
79
+ else
80
+ return text_content(
81
+ "Cannot scan formalization decisions: ledger state is #{chain.load_state}."
82
+ )
83
+ end
84
+
71
85
  decisions = []
72
86
 
73
87
  # Scan all blocks for formalization decisions
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.63.0"
2
+ VERSION = "3.64.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -71,13 +71,17 @@ module KairosMcp
71
71
  def inspect_blockchain_health
72
72
  chain = ::KairosMcp::KairosChain::Chain.new
73
73
  blocks = chain.chain
74
+
75
+ # block_count and last_recorded only carry meaning when the ledger's
76
+ # state is :readable; the state itself is the load-bearing field.
74
77
  {
78
+ state: chain.load_state,
75
79
  block_count: blocks.size,
76
80
  last_recorded: blocks.last&.timestamp&.iso8601,
77
81
  integrity: chain.valid?
78
82
  }
79
83
  rescue StandardError => e
80
- { block_count: 0, integrity: false, error: e.message }
84
+ { state: :error, block_count: 0, integrity: false, error: e.message }
81
85
  end
82
86
  end
83
87
  end
@@ -91,16 +91,27 @@ module KairosMcp
91
91
 
92
92
  def check_blockchain
93
93
  chain = ::KairosMcp::KairosChain::Chain.new
94
- valid = chain.valid?
94
+ state = chain.load_state
95
95
  blocks = chain.chain
96
+
97
+ # Three outcomes, not two: a ledger that does not exist yet is a
98
+ # fresh install, not an integrity failure. block_count and
99
+ # latest_timestamp only carry meaning when the state is :readable.
100
+ status = case state
101
+ when :readable then 'healthy'
102
+ when :absent then 'not_created_yet'
103
+ else 'INTEGRITY_FAILURE'
104
+ end
105
+
96
106
  {
97
- valid: valid,
107
+ valid: chain.valid?,
108
+ state: state,
98
109
  block_count: blocks.size,
99
110
  latest_timestamp: blocks.last&.timestamp&.iso8601,
100
- status: valid ? 'healthy' : 'INTEGRITY_FAILURE'
111
+ status: status
101
112
  }
102
113
  rescue StandardError => e
103
- { valid: false, error: e.message, status: 'error' }
114
+ { valid: false, state: :error, error: e.message, status: 'error' }
104
115
  end
105
116
 
106
117
  def build_recommendations(report)
@@ -119,9 +130,19 @@ module KairosMcp
119
130
  end
120
131
  end
121
132
 
122
- # Blockchain issues
123
- if report[:blockchain] && !report[:blockchain][:valid]
124
- recs << { priority: 'critical', target: 'blockchain', message: 'Blockchain integrity check failed.' }
133
+ # Blockchain issues. A ledger that does not exist yet is a fresh
134
+ # install, not an integrity failure — keying off :valid alone would
135
+ # raise a critical alarm on every new installation.
136
+ blockchain = report[:blockchain]
137
+ if blockchain && !blockchain[:valid]
138
+ case blockchain[:state]&.to_sym
139
+ when :absent
140
+ recs << { priority: 'low', target: 'blockchain',
141
+ message: 'Blockchain not created yet. It is written on the first recorded change.' }
142
+ else
143
+ recs << { priority: 'critical', target: 'blockchain',
144
+ message: "Blockchain integrity check failed (state: #{blockchain[:state] || 'unknown'})." }
145
+ end
125
146
  end
126
147
 
127
148
  # Safety gaps
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.63.0
4
+ version: 3.64.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama