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 +4 -4
- data/CHANGELOG.md +38 -0
- data/lib/kairos_mcp/admin/router.rb +20 -8
- data/lib/kairos_mcp/admin/views/chain.erb +7 -2
- data/lib/kairos_mcp/admin/views/dashboard.erb +7 -2
- data/lib/kairos_mcp/admin/views/partials/_chain_blocks.erb +9 -1
- data/lib/kairos_mcp/drift_detection/correspondence_checker.rb +18 -3
- data/lib/kairos_mcp/kairos_chain/chain.rb +327 -59
- data/lib/kairos_mcp/storage/backend.rb +31 -2
- data/lib/kairos_mcp/storage/file_backend.rb +43 -7
- data/lib/kairos_mcp/tools/chain_history.rb +13 -4
- data/lib/kairos_mcp/tools/chain_status.rb +5 -1
- data/lib/kairos_mcp/tools/chain_verify.rb +8 -4
- data/lib/kairos_mcp/tools/formalization_history.rb +14 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/introspection/lib/introspection/safety_inspector.rb +5 -1
- data/templates/skillsets/introspection/tools/introspection_check.rb +28 -7
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4ba66e678675e9b4e7776676832765d509f0ac7bb49feed159ee1513ba4b7318
|
|
4
|
+
data.tar.gz: 28af1484af3a0c98529603bfa30fd9ff188e5b18895c06c11a313bcf3a95d8b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
371
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 →</a></footer>
|
|
21
26
|
</article>
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
<% if blocks.empty? %>
|
|
2
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
20
|
-
@chain =
|
|
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
|
-
#
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
142
|
+
private
|
|
58
143
|
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
#
|
|
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]
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +=
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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!
|
|
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
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
124
|
-
|
|
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
|