kairos-chain 3.93.0 → 3.94.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: a1624b7fa2adf2856c0f55ca0012dc527870d5649cdcad7ebe2d8ae90cda1d4a
4
- data.tar.gz: 12cdc74b57b87266ca3364654468fa077774665e9b1bef090dea9806907c4b1b
3
+ metadata.gz: 05055bfa3f7c4377e2e88011754c85d8d87c690e07f19837c4dca5a0c1cd033c
4
+ data.tar.gz: 48b9c8a70d27304e21293440f121ec069cb0c7ec47ed41b01573cdd2e3c80505
5
5
  SHA512:
6
- metadata.gz: 9e47baf8089acd2fb9cc4f7f5fc4264d55d4096f8ed21aacb1a736ab27099beebda372d429de1e5a424e66307ccda5a274f5532073e7e21eaa4bc5d58684a718
7
- data.tar.gz: 4deb91c26655f7c6e11a72802635a7c088aa0a7246124785984d2fe2f20fbb0ab02e0b2d27e425948a8107be69fe5cd1f72d4ca90efc069f12731bef9ec36e79
6
+ metadata.gz: '08ee4ff5f61407bdcc390eb8c4cda6c286472672a6549ded81d5d0b1c7cf29f8203a472f9cfa5d4cfc811b1b8d923d4ee77ee6746decad1893a33c01187ecc1e'
7
+ data.tar.gz: 0b94dde53ddce2444defb9933515dda0d4e754bb0e6053129927d82032bdb9b73383b5d67234a9b57f3e003e82e1b004c17c94f6e551a45ba2fc5dca412bd340
data/CHANGELOG.md CHANGED
@@ -6,6 +6,32 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.94.0] - 2026-10-20
10
+
11
+ ### Added — L0 changes proposed for later approval, L0 restores on the record, L1 body history
12
+
13
+ - **L0 proposals.** `skills_evolve` keeps a pending L0 change as (base, result,
14
+ content) without evaluating it; `command: "pending"` lists the proposals and
15
+ `apply` takes a `proposal_id`. Applying writes exactly the stored bytes under
16
+ an exclusive lock, only while the base is still the L0 in force and the
17
+ content hashes to its result. A failed load, an interrupt or a killed thread
18
+ never leaves an unrecorded L0 on disk.
19
+ - **L0 restore on the record.** `skills_rollback` restores a snapshot only if
20
+ its hash appears in a transition recorded on the chain. Without
21
+ `approved: true` it writes nothing and returns a pending proposal; with
22
+ `proposal_id` the approval applies exactly the proposal that was shown.
23
+ - **L1 body history.** `knowledge_update` gains `history`, `restore` and
24
+ `erase_body` (the last is irreversible and needs `approved: true`). Bodies
25
+ are kept by content address from a recorded activation point, and restore and
26
+ erasure decide from the chain whether their record landed.
27
+
28
+ ### Changed — read before upgrading
29
+
30
+ - `VersionManager.rollback` is removed; nothing shipped calls it. Code that
31
+ called it should use `skills_rollback` with a `proposal_id`.
32
+ - `chain_import` never overwrites an existing chain: it is refused when the
33
+ target already holds chain data. Import into a new data directory instead.
34
+
9
35
  ## [3.93.0] - 2026-10-19
10
36
 
11
37
  ### Added — `proactive_tutorial`, an opt-in mode that suggests KairosChain's own workflow
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'chain'
5
+
6
+ module KairosMcp
7
+ module KairosChain
8
+ # Answers "did this exact entry reach the ledger?" after an append did not
9
+ # return normally.
10
+ #
11
+ # Chain#add_block raises Storage::Error when it cannot tell whether the
12
+ # write landed, and a thread can be interrupted after the write but before
13
+ # add_block returns. Either way the caller holds a change it must keep
14
+ # (the record is on the chain) or undo (it is not). Looking only at the last
15
+ # block answers wrongly once anything else has been appended since, so the
16
+ # caller takes a mark before appending and the probe scans every block after
17
+ # that mark for an entry equal to the one the caller tried to append.
18
+ module LandedProbe
19
+ # The ledger length now, i.e. the index from which the caller's block can
20
+ # appear. nil when the ledger cannot be read; every later status is then
21
+ # :unknown.
22
+ def self.mark(storage_backend: nil)
23
+ chain = Chain.new(storage_backend: storage_backend)
24
+ case chain.load_state
25
+ when :readable then chain.chain.length
26
+ when :absent then 0
27
+ end
28
+ rescue StandardError
29
+ nil
30
+ end
31
+
32
+ # :landed -- a block after the mark carries an entry equal to entry_json
33
+ # :absent -- the ledger is readable (or absent) and no such entry is there
34
+ # :unknown -- the mark or the ledger could not be read
35
+ #
36
+ # Equality is on the parsed entry, so a backend that re-serialises the JSON
37
+ # still matches. Only StandardError is absorbed: an interrupt during the
38
+ # probe propagates, and callers run their undo in an ensure for that case.
39
+ def self.status(mark, entry_json, storage_backend: nil)
40
+ return :unknown if mark.nil?
41
+
42
+ expected = JSON.parse(entry_json)
43
+ chain = Chain.new(storage_backend: storage_backend)
44
+ return :absent if chain.load_state == :absent
45
+ return :unknown unless chain.load_state == :readable
46
+
47
+ found = chain.chain.drop(mark).any? do |block|
48
+ Array(block.data).any? do |raw|
49
+ begin
50
+ JSON.parse(raw.to_s) == expected
51
+ rescue JSON::ParserError
52
+ false
53
+ end
54
+ end
55
+ end
56
+ found ? :landed : :absent
57
+ rescue StandardError
58
+ :unknown
59
+ end
60
+ end
61
+ end
62
+ end
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
+ require 'json'
4
5
  require 'fileutils'
5
6
  require 'yaml'
7
+ require 'securerandom'
6
8
  require_relative 'anthropic_skill_parser'
7
9
  require_relative 'path_containment'
8
10
  require_relative 'skillset_manager'
9
11
  require_relative 'kairos_chain/chain'
12
+ require_relative 'kairos_chain/landed_probe'
13
+ require_relative 'l1_history'
10
14
  require_relative 'vector_search/provider'
11
15
  require_relative '../kairos_mcp'
12
16
 
@@ -189,6 +193,10 @@ module KairosMcp
189
193
  return { success: false, error: "Knowledge '#{name}' already exists" }
190
194
  end
191
195
 
196
+ # C2: store the body before the file write and before the chain record
197
+ refusal = keep_bodies!(content)
198
+ return refusal if refusal
199
+
192
200
  skill = AnthropicSkillParser.create(@knowledge_dir, name, content, create_subdirs: create_subdirs)
193
201
 
194
202
  # Record hash reference to blockchain
@@ -232,6 +240,10 @@ module KairosMcp
232
240
  return { success: false, error: "No changes detected" }
233
241
  end
234
242
 
243
+ # C2: both bodies kept before anything is written or recorded
244
+ refusal = keep_bodies!(prev_content, new_content)
245
+ return refusal if refusal
246
+
235
247
  # Update the file
236
248
  updated_skill = AnthropicSkillParser.update(skill.base_path, new_content)
237
249
 
@@ -269,6 +281,10 @@ module KairosMcp
269
281
  prev_content = File.read(skill.md_file_path)
270
282
  prev_hash = Digest::SHA256.hexdigest(prev_content)
271
283
 
284
+ # C2: keep the previous body before the directory goes away
285
+ refusal = keep_bodies!(prev_content)
286
+ return refusal if refusal
287
+
272
288
  # Delete the directory
273
289
  FileUtils.rm_rf(skill.base_path)
274
290
 
@@ -290,6 +306,367 @@ module KairosMcp
290
306
  { success: true, deleted: name, prev_hash: prev_hash }
291
307
  end
292
308
 
309
+ # C3: return an entry to a body already kept in history
310
+ def restore(name, content_hash, reason: nil)
311
+ unless content_hash.to_s.match?(L1History::HASH_PATTERN)
312
+ return { success: false, error: "invalid content hash: #{content_hash}" }
313
+ end
314
+
315
+ # A quarantine left by an interrupted erasure is settled first, so a body
316
+ # whose erasure is on the chain is not restored from its quarantine.
317
+ settle_quarantine!
318
+
319
+ # The body must be present before anything else is decided
320
+ body = history_store.retrievable?(content_hash) ? history_store.fetch(content_hash) : nil
321
+ return { success: false, error: "body not retrievable: #{content_hash}" } if body.nil?
322
+
323
+ skill = get(name)
324
+
325
+ if skill
326
+ return not_owned(name) unless owned?(skill)
327
+
328
+ prev_content = File.read(skill.md_file_path)
329
+ prev_hash = Digest::SHA256.hexdigest(prev_content)
330
+ if prev_hash == content_hash
331
+ return { success: false, error: "#{name} already holds the content of hash #{content_hash}" }
332
+ end
333
+
334
+ # C2: previous body and restored body, both before the write
335
+ refusal = keep_bodies!(prev_content, body)
336
+ return refusal if refusal
337
+
338
+ tracked_action = 'update'
339
+ else
340
+ # Same name checks as create, in the same order (safe_segment first:
341
+ # it is the check that tolerates a non-String argument).
342
+ unless PathContainment.safe_segment?(name)
343
+ return { success: false, error: "Invalid knowledge name #{name.inspect}: not a single path segment" }
344
+ end
345
+ return { success: false, error: "Invalid knowledge name '#{name}': reserved by the knowledge store" } if reserved_name?(name)
346
+
347
+ skill_dir = File.join(@knowledge_dir, name)
348
+ unless PathContainment.contained?(@knowledge_dir, skill_dir)
349
+ return { success: false, error: "Invalid knowledge name '#{name}': resolves outside the knowledge directory" }
350
+ end
351
+ return { success: false, error: "Knowledge '#{name}' already exists" } if File.exist?(skill_dir)
352
+
353
+ refusal = keep_bodies!(body)
354
+ return refusal if refusal
355
+
356
+ prev_hash = nil
357
+ tracked_action = 'create'
358
+ end
359
+
360
+ # restore_id makes the record unique, so the landed check below finds
361
+ # this restore and not an identical one made in the same second.
362
+ entry = hash_reference_entry(
363
+ name: name,
364
+ action: 'restore',
365
+ prev_hash: prev_hash,
366
+ next_hash: content_hash,
367
+ reason: "returned to content of hash #{content_hash}#{reason ? ": #{reason}" : ''}",
368
+ extra: { restored_from: content_hash, restore_id: SecureRandom.hex(8) }
369
+ )
370
+ mark = KairosChain::LandedProbe.mark(storage_backend: storage_backend)
371
+ status = nil
372
+ error = nil
373
+ attempted = false
374
+
375
+ # Undo touches the live file only while it holds the restored bytes. A
376
+ # write that never took effect, or content a same-name writer has put
377
+ # there since, is not this restore's to undo.
378
+ live_md = skill ? skill.md_file_path : File.join(skill_dir, "#{name}.md")
379
+ holds_restored = lambda do
380
+ File.file?(live_md) && Digest::SHA256.hexdigest(File.binread(live_md)) == content_hash
381
+ rescue StandardError
382
+ false
383
+ end
384
+
385
+ # The live write sits inside the guarded region, so no interrupt can fall
386
+ # between the write and the ensure that undoes it.
387
+ begin
388
+ restored_skill = if skill
389
+ AnthropicSkillParser.update(skill.base_path, body)
390
+ else
391
+ AnthropicSkillParser.create(@knowledge_dir, name, body)
392
+ end
393
+ attempted = true
394
+ status, error = append_entry(entry, mark)
395
+ ensure
396
+ # In ensure, so an interrupt or a thread kill cannot leave the live
397
+ # entry changed without its record. A record that is on the chain
398
+ # stands whatever add_block raised; any other answer, including an
399
+ # unknown one, puts the live entry back exactly as it was: an entry
400
+ # that already existed gets its previous body written back, one that
401
+ # was recreated here is removed again -- provided it still holds what
402
+ # this restore wrote.
403
+ begin
404
+ status ||= KairosChain::LandedProbe.status(mark, entry, storage_backend: storage_backend) if attempted
405
+ ensure
406
+ if status != :landed && holds_restored.call
407
+ if skill
408
+ AnthropicSkillParser.update(skill.base_path, prev_content)
409
+ else
410
+ FileUtils.rm_rf(skill_dir)
411
+ end
412
+ end
413
+ end
414
+ end
415
+
416
+ unless status == :landed
417
+ return { success: false, error: "restore not recorded (#{error&.message || status}); entry left as it was" }
418
+ end
419
+
420
+ # After the record, and outside it: a metadata failure is reported but
421
+ # never undoes a restore the chain already holds.
422
+ update_knowledge_meta(name, content_hash)
423
+ update_vector_index(name, body, restored_skill)
424
+ track_pending_change(layer: 'L1', action: tracked_action, skill_id: name, reason: reason)
425
+
426
+ {
427
+ success: true,
428
+ name: name,
429
+ action: 'restore',
430
+ content_hash: content_hash,
431
+ restored_from: content_hash
432
+ }
433
+ end
434
+
435
+ # C3: every body this entry has pointed at, in chain order
436
+ def history(name)
437
+ # Settled first, so `retrievable` below reflects what the chain records.
438
+ settle_quarantine!
439
+ chain = KairosChain::Chain.new(storage_backend: storage_backend)
440
+ # L1: start inactive; only an l1_history_activation record flips this.
441
+ active = false
442
+ rows = []
443
+
444
+ chain.chain.each do |block|
445
+ Array(block.data).each do |raw|
446
+ record = begin
447
+ JSON.parse(raw, symbolize_names: true)
448
+ rescue JSON::ParserError, TypeError
449
+ nil
450
+ end
451
+ next unless record.is_a?(Hash)
452
+
453
+ case record[:type]
454
+ when 'l1_history_activation'
455
+ # Activation threads forward from the block that declared it
456
+ flag = record.key?(:active) ? record[:active] : record[:enabled]
457
+ active = (flag == true || flag == 'true')
458
+ when 'knowledge_update'
459
+ next unless record[:knowledge_id] == name && record[:next_hash]
460
+
461
+ rows << {
462
+ index: block.index,
463
+ timestamp: record[:timestamp],
464
+ action: record[:action],
465
+ next_hash: record[:next_hash],
466
+ retrievable: begin
467
+ history_store.retrievable?(record[:next_hash])
468
+ rescue ArgumentError
469
+ false
470
+ end,
471
+ recorded_while_active: active
472
+ }
473
+ end
474
+ end
475
+ end
476
+
477
+ rows
478
+ end
479
+
480
+ # C4: drop one stored body; the chain keeps the fact of the removal
481
+ def erase_body(name, content_hash, reason:)
482
+ if reason.nil? || reason.to_s.strip.empty?
483
+ return { success: false, error: 'a reason is required' }
484
+ end
485
+ unless content_hash.to_s.match?(L1History::HASH_PATTERN)
486
+ return { success: false, error: "invalid content hash: #{content_hash}" }
487
+ end
488
+ # Nothing was ever stored; checked before the lock, which would create
489
+ # the store root.
490
+ unless File.directory?(history_store.root)
491
+ return { success: false, error: "body not stored: #{content_hash}" }
492
+ end
493
+
494
+ # The checks, the quarantine and the record run under one lock, so a
495
+ # second erasure cannot settle this one's quarantine as a leftover while
496
+ # its record is still being appended.
497
+ history_store.with_erase_lock do
498
+ settle_quarantine!(locked: true)
499
+
500
+ # E3: live content blocks the erasure. Judged per directory, so an entry
501
+ # whose directory name differs from its declared name is still seen.
502
+ live = live_directories_for_body(content_hash)
503
+ unless live.empty?
504
+ return { success: false,
505
+ error: "body is live content in: #{live.sort.join(', ')}" }
506
+ end
507
+
508
+ # E2: the hash must be cited by a knowledge_update of this entry, and by
509
+ # no other entry. A hash cited elsewhere is a body shared between entries,
510
+ # and erasing it would remove the other entry's history too.
511
+ citing = knowledge_ids_citing_body(content_hash)
512
+ unless citing.include?(name)
513
+ return { success: false,
514
+ error: "#{name} never pointed at this body: #{content_hash}" }
515
+ end
516
+ shared_with = (citing - [name]).sort
517
+ unless shared_with.empty?
518
+ return { success: false,
519
+ error: "body is shared with: #{shared_with.join(', ')}" }
520
+ end
521
+
522
+ unless history_store.retrievable?(content_hash)
523
+ return { success: false, error: "body not stored: #{content_hash}" }
524
+ end
525
+
526
+ # E1: quarantine first, append second. Nothing is removed until the
527
+ # erasure record is on the chain. The record carries the quarantine's
528
+ # erase id, which is what lets a leftover quarantine be matched to
529
+ # exactly this record later.
530
+ # The mark is taken before the quarantine, so the only step between the
531
+ # quarantine and the guarded region below is building the record. A
532
+ # quarantine orphaned there anyway is settled by settle_quarantine!.
533
+ mark = KairosChain::LandedProbe.mark(storage_backend: storage_backend)
534
+ erase_id = history_store.begin_erase(content_hash)
535
+ return { success: false, error: "body not stored: #{content_hash}" } unless erase_id
536
+
537
+ entry = {
538
+ type: 'l1_body_erasure',
539
+ layer: 'L1',
540
+ knowledge_id: name,
541
+ content_hash: content_hash,
542
+ erase_id: erase_id,
543
+ reason: reason,
544
+ timestamp: Time.now.iso8601
545
+ }.to_json
546
+ status = nil
547
+ error = nil
548
+ settled = true
549
+
550
+ begin
551
+ status, error = append_entry(entry, mark)
552
+ ensure
553
+ # In ensure, so an interrupt cannot leave a quarantine behind without
554
+ # deciding it. Landed: drop the bytes. Known absent: put them back.
555
+ # Unknown: leave the quarantine; settle_quarantine! decides it from the
556
+ # chain once the chain can be read.
557
+ begin
558
+ status ||= KairosChain::LandedProbe.status(mark, entry, storage_backend: storage_backend)
559
+ ensure
560
+ begin
561
+ case status
562
+ when :landed then history_store.commit_erase(content_hash, erase_id)
563
+ when :absent then history_store.abort_erase(content_hash, erase_id)
564
+ end
565
+ rescue StandardError => e
566
+ # The quarantine stays and is settled on the next history access.
567
+ warn "[l1_history] quarantine #{erase_id} left for settling: #{e.message}"
568
+ settled = false
569
+ end
570
+ end
571
+ end
572
+
573
+ case status
574
+ when :landed
575
+ { success: true, name: name, content_hash: content_hash, erased: settled }
576
+ when :absent
577
+ { success: false, error: "erasure not recorded, body kept: #{error&.message}" }
578
+ else
579
+ { success: false,
580
+ error: "erasure outcome unknown (#{error&.message}); the body stays quarantined " \
581
+ 'and is settled from the chain on the next history access' }
582
+ end
583
+ end
584
+ end
585
+
586
+ # Settle quarantines an interrupted erasure left behind (L1History
587
+ # #reconcile_quarantine!). Cheap when there are none: the chain is read only
588
+ # when a quarantine exists. Takes the erase lock unless the caller holds it.
589
+ private def settle_quarantine!(locked: false)
590
+ return if history_store.quarantined.empty?
591
+ return history_store.reconcile_quarantine!(recorded_erase_ids) if locked
592
+
593
+ history_store.with_erase_lock { history_store.reconcile_quarantine!(recorded_erase_ids) }
594
+ end
595
+
596
+ # Erase ids in l1_body_erasure records on the chain; nil when the chain
597
+ # cannot be read, which leaves every quarantine undecided.
598
+ private def recorded_erase_ids
599
+ chain = KairosChain::Chain.new(storage_backend: storage_backend)
600
+ return [] if chain.load_state == :absent
601
+ return nil unless chain.load_state == :readable
602
+
603
+ chain.chain.each_with_object([]) do |block, ids|
604
+ Array(block.data).each do |raw|
605
+ record = begin
606
+ JSON.parse(raw.to_s)
607
+ rescue JSON::ParserError
608
+ nil
609
+ end
610
+ next unless record.is_a?(Hash) && record['type'] == 'l1_body_erasure' && record['erase_id']
611
+
612
+ ids << record['erase_id']
613
+ end
614
+ end
615
+ rescue StandardError
616
+ nil
617
+ end
618
+
619
+ # E3 helper. Every entry directory whose current body hashes to
620
+ # content_hash blocks the erasure: the main knowledge dir and every external
621
+ # knowledge dir a SkillSet registered, as `list` exposes them. Each
622
+ # directory is parsed on its own and never looked up by its declared name,
623
+ # which is what let a directory whose name differs slip past the old guard.
624
+ # A directory that cannot be parsed is skipped rather than treated as a
625
+ # match.
626
+ private def live_directories_for_body(content_hash)
627
+ candidates = skill_dirs.map { |dir| [dir, File.basename(dir)] }
628
+ @external_dirs.each do |ext|
629
+ external_skill_dirs(ext[:dir], only: ext[:only]).each do |dir|
630
+ candidates << [dir, "#{ext[:source]}:#{File.basename(dir)}"]
631
+ end
632
+ end
633
+
634
+ candidates.each_with_object([]) do |(dir, label), found|
635
+ begin
636
+ skill = AnthropicSkillParser.parse(dir)
637
+ path = skill && skill.md_file_path
638
+ next unless path && File.exist?(path)
639
+
640
+ found << label if Digest::SHA256.hexdigest(File.read(path)) == content_hash
641
+ rescue StandardError
642
+ next
643
+ end
644
+ end
645
+ end
646
+
647
+ # E2 helper. The knowledge ids whose knowledge_update records cite this hash
648
+ # as the body before or after the update. Each block's data is an array of
649
+ # JSON strings; an unparsable entry is skipped rather than fatal.
650
+ private def knowledge_ids_citing_body(content_hash)
651
+ ids = []
652
+ KairosChain::Chain.new(storage_backend: storage_backend).chain.each do |block|
653
+ data = block.respond_to?(:data) ? block.data : block['data']
654
+ Array(data).each do |raw|
655
+ record = begin
656
+ JSON.parse(raw.to_s)
657
+ rescue StandardError
658
+ nil
659
+ end
660
+ next unless record.is_a?(Hash)
661
+ next unless record['type'] == 'knowledge_update'
662
+ next unless [record['prev_hash'], record['next_hash']].include?(content_hash)
663
+
664
+ ids << record['knowledge_id'] if record['knowledge_id']
665
+ end
666
+ end
667
+ ids.uniq
668
+ end
669
+
293
670
  # List scripts in a knowledge skill
294
671
  #
295
672
  # @param name [String] Skill name
@@ -433,6 +810,10 @@ module KairosMcp
433
810
  content = File.read(skill.md_file_path)
434
811
  content_hash = Digest::SHA256.hexdigest(content)
435
812
 
813
+ # C2: `content` is the body being archived; keep it before the move
814
+ refusal = keep_bodies!(content)
815
+ return refusal if refusal
816
+
436
817
  dest_path = File.join(archived_dir, name)
437
818
  meta_path = File.join(dest_path, ARCHIVE_META_FILE)
438
819
 
@@ -532,6 +913,11 @@ module KairosMcp
532
913
  meta_file = archived_meta
533
914
  meta = File.exist?(meta_file) ? YAML.safe_load(File.read(meta_file)) : {}
534
915
 
916
+ # C2: read the archived body while it is still at the archived path
917
+ archived_body = File.read(AnthropicSkillParser.parse(archived_path).md_file_path)
918
+ refusal = keep_bodies!(archived_body)
919
+ return refusal if refusal
920
+
535
921
  # Move back to active
536
922
  FileUtils.mv(archived_path, active_path)
537
923
 
@@ -835,9 +1221,63 @@ module KairosMcp
835
1221
  basename.match?(BACKUP_DIR_PATTERN)
836
1222
  end
837
1223
 
838
- def record_hash_reference(name:, action:, prev_hash:, next_hash:, reason:)
839
- chain = KairosChain::Chain.new(storage_backend: storage_backend)
840
- chain.add_block([{
1224
+ # Memoized body store
1225
+ def history_store
1226
+ @history_store ||= L1History.new(@knowledge_dir)
1227
+ end
1228
+
1229
+ # C2: store every body whose hash enters the chain, before any write.
1230
+ # nil = nothing to refuse (stored, or history inactive); hash = refusal.
1231
+ def keep_bodies!(*bodies)
1232
+ unless defined?(@history_active)
1233
+ if L1History.enabled?
1234
+ begin
1235
+ FileUtils.mkdir_p(history_store.root)
1236
+ rescue StandardError => e
1237
+ # L2: no store root means nothing may be appended; stay unmemoized
1238
+ # so a later write retries.
1239
+ return { success: false, error: "history store unavailable: #{e.message}" }
1240
+ end
1241
+ end
1242
+
1243
+ @history_active = begin
1244
+ history_store.sync_activation!(KairosChain::Chain.new(storage_backend: storage_backend))
1245
+ rescue StandardError => e
1246
+ # Chain unwritable: warn and fall back; never block L1 writes on activation
1247
+ warn "[l1_history] activation sync failed: #{e.message}"
1248
+ L1History.enabled?
1249
+ end
1250
+ end
1251
+ return nil unless @history_active
1252
+
1253
+ bodies.compact.each do |body|
1254
+ begin
1255
+ history_store.store(body)
1256
+ rescue StandardError => e
1257
+ return { success: false, error: "history store failed: #{e.message}" }
1258
+ end
1259
+ end
1260
+
1261
+ nil
1262
+ end
1263
+
1264
+ # Record a knowledge_update. A failed append is warned about and the caller
1265
+ # continues (restore, which must undo its write, appends on its own path).
1266
+ def record_hash_reference(name:, action:, prev_hash:, next_hash:, reason:, extra: {})
1267
+ entry = hash_reference_entry(name: name, action: action, prev_hash: prev_hash,
1268
+ next_hash: next_hash, reason: reason, extra: extra)
1269
+ status, error = append_entry(entry, KairosChain::LandedProbe.mark(storage_backend: storage_backend))
1270
+ unless status == :landed
1271
+ # Log but don't fail if blockchain recording fails
1272
+ warn "Failed to record to blockchain: #{error&.message || status}"
1273
+ return
1274
+ end
1275
+
1276
+ update_knowledge_meta(name, next_hash)
1277
+ end
1278
+
1279
+ def hash_reference_entry(name:, action:, prev_hash:, next_hash:, reason:, extra: {})
1280
+ {
841
1281
  type: 'knowledge_update',
842
1282
  layer: 'L1',
843
1283
  knowledge_id: name,
@@ -846,22 +1286,40 @@ module KairosMcp
846
1286
  next_hash: next_hash,
847
1287
  reason: reason,
848
1288
  timestamp: Time.now.iso8601
849
- }.to_json])
1289
+ }.merge(extra).to_json
1290
+ end
850
1291
 
851
- # If using SQLite backend, also update knowledge metadata
852
- if storage_backend.backend_type == :sqlite
853
- meta = {
1292
+ # Append one entry. => [status, error]. status is :landed when add_block
1293
+ # returned, or when it raised but the entry is on the chain after `mark`
1294
+ # (LandedProbe); otherwise :absent or :unknown, with the StandardError that
1295
+ # add_block raised. An exception that is not a StandardError propagates;
1296
+ # a caller that must undo something on failure probes again in its ensure.
1297
+ def append_entry(entry, mark)
1298
+ KairosChain::Chain.new(storage_backend: storage_backend).add_block([entry])
1299
+ [:landed, nil]
1300
+ rescue StandardError => e
1301
+ [KairosChain::LandedProbe.status(mark, entry, storage_backend: storage_backend), e]
1302
+ end
1303
+
1304
+ # SQLite keeps knowledge metadata beside the chain. Runs only after the
1305
+ # record landed, and a failure here never reaches the record's caller: the
1306
+ # chain already holds the change.
1307
+ def update_knowledge_meta(name, next_hash)
1308
+ return unless storage_backend.backend_type == :sqlite
1309
+
1310
+ if next_hash
1311
+ skill = get(name)
1312
+ storage_backend.save_knowledge_meta(name, {
854
1313
  content_hash: next_hash,
855
- version: get(name)&.version,
856
- description: get(name)&.description,
857
- tags: get(name)&.tags
858
- }
859
- storage_backend.save_knowledge_meta(name, meta) if next_hash
860
- storage_backend.delete_knowledge_meta(name) unless next_hash
1314
+ version: skill&.version,
1315
+ description: skill&.description,
1316
+ tags: skill&.tags
1317
+ })
1318
+ else
1319
+ storage_backend.delete_knowledge_meta(name)
861
1320
  end
862
1321
  rescue StandardError => e
863
- # Log but don't fail if blockchain recording fails
864
- warn "Failed to record to blockchain: #{e.message}"
1322
+ warn "[KnowledgeProvider] Recorded; knowledge metadata update failed: #{e.message}"
865
1323
  end
866
1324
 
867
1325
  def storage_backend