Module: KairosMcp::Daemon::Canonical

Defined in:
lib/kairos_mcp/daemon/canonical.rb

Overview

Canonical — deterministic object serialization for WAL & idempotency key derivation.

Design (v0.2 §5.3, [FIX: CF-15]):

Two tool invocations with "the same intent" must produce identical bytes
(and therefore identical hashes and idempotency keys) even if their
inputs arrived in different key order or included volatile bookkeeping
fields (timestamps, trace_ids, nonces).

Rules:

1. Volatile keys in STRIP_KEYS are removed recursively from Hashes.
2. Remaining Hash keys are sorted by their stringified form.
3. Array order is preserved (order is semantically meaningful).
4. Serialization uses JSON.generate (stable because keys are sorted).

Edge cases:

- Hash keys may be Symbols or Strings; STRIP_KEYS matches on to_s.
- Nested Hashes/Arrays are fully traversed.
- Non-container scalars pass through unchanged.

Constant Summary collapse

STRIP_KEYS =
%w[timestamp ts request_id trace_id nonce].freeze

Class Method Summary collapse

Class Method Details

.canonicalize(obj) ⇒ Object

Deeply strip volatile keys and sort Hash keys. Returns a new structure.



32
33
34
# File 'lib/kairos_mcp/daemon/canonical.rb', line 32

def canonicalize(obj)
  deep_sort(strip_volatile(obj))
end

.deep_sort(obj) ⇒ Object

Recursively sort Hash keys by their stringified form. Array order is preserved.



54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/kairos_mcp/daemon/canonical.rb', line 54

def deep_sort(obj)
  case obj
  when Hash
    obj.keys.sort_by(&:to_s).each_with_object({}) do |k, acc|
      acc[k] = deep_sort(obj[k])
    end
  when Array
    obj.map { |v| deep_sort(v) }
  else
    obj
  end
end

.serialize(obj) ⇒ Object

Canonical JSON string for obj.



68
69
70
# File 'lib/kairos_mcp/daemon/canonical.rb', line 68

def serialize(obj)
  JSON.generate(canonicalize(obj))
end

.sha256(str) ⇒ Object

SHA-256 hash of an arbitrary string, prefixed “sha256-”.



78
79
80
# File 'lib/kairos_mcp/daemon/canonical.rb', line 78

def sha256(str)
  "sha256-#{Digest::SHA256.hexdigest(str.to_s)}"
end

.sha256_json(obj) ⇒ Object

SHA-256 hash of canonicalized obj, prefixed “sha256-”.



73
74
75
# File 'lib/kairos_mcp/daemon/canonical.rb', line 73

def sha256_json(obj)
  "sha256-#{Digest::SHA256.hexdigest(serialize(obj))}"
end

.strip_volatile(obj) ⇒ Object

Recursively remove STRIP_KEYS entries from every Hash in obj.



37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/kairos_mcp/daemon/canonical.rb', line 37

def strip_volatile(obj)
  case obj
  when Hash
    obj.each_with_object({}) do |(k, v), acc|
      next if STRIP_KEYS.include?(k.to_s)

      acc[k] = strip_volatile(v)
    end
  when Array
    obj.map { |v| strip_volatile(v) }
  else
    obj
  end
end