kairos-chain 3.43.0 → 3.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/lib/kairos_mcp/version.rb +1 -1
  4. data/templates/skillsets/synoptis/bin/khab_verify.rb +172 -0
  5. data/templates/skillsets/synoptis/bin/map_verify.rb +250 -0
  6. data/templates/skillsets/synoptis/lib/synoptis/anchoring/anchoring_rule.rb +184 -0
  7. data/templates/skillsets/synoptis/lib/synoptis/anchoring/attestation_types.rb +107 -0
  8. data/templates/skillsets/synoptis/lib/synoptis/anchoring/chain_credential.rb +187 -0
  9. data/templates/skillsets/synoptis/lib/synoptis/anchoring/containment.rb +17 -1
  10. data/templates/skillsets/synoptis/lib/synoptis/anchoring/conventions/khab-1.md +112 -0
  11. data/templates/skillsets/synoptis/lib/synoptis/anchoring/conventions/map-1.md +198 -0
  12. data/templates/skillsets/synoptis/lib/synoptis/anchoring/cumulative_commitment.rb +229 -0
  13. data/templates/skillsets/synoptis/lib/synoptis/anchoring/deposit_board.rb +4 -2
  14. data/templates/skillsets/synoptis/lib/synoptis/anchoring/entry.rb +28 -1
  15. data/templates/skillsets/synoptis/lib/synoptis/anchoring/head_binding.rb +294 -0
  16. data/templates/skillsets/synoptis/lib/synoptis/anchoring/log.rb +15 -3
  17. data/templates/skillsets/synoptis/lib/synoptis/anchoring/public_verifier.rb +5 -0
  18. data/templates/skillsets/synoptis/lib/synoptis/anchoring/succession.rb +222 -0
  19. data/templates/skillsets/synoptis/lib/synoptis/anchoring/write_path.rb +5 -2
  20. data/templates/skillsets/synoptis/test/test_head_anchor.rb +431 -0
  21. data/templates/skillsets/synoptis/test/test_map_anchoring.rb +359 -0
  22. metadata +13 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ef5b418a3202426bfd13d7f62a849363f52144978f6b091c9ebb8bb43564927
4
- data.tar.gz: 471e635837cc5e5b65cd704dd78a9b48bd7312f61f7a27aa420c5323c5c0d43b
3
+ metadata.gz: ea7401710d6467287e35146c84d731c08733f184fac36be13e76de11d7af5433
4
+ data.tar.gz: a823ba8bf1bb1157394ff04466cdcf96c0347eee50796816fe8c2fd1d188792c
5
5
  SHA512:
6
- metadata.gz: e45299ac0b7b574419742d6b8243fbd9b492641c37e5f5f1e81e22ea77623e2cf986ca96da63dfdde8cc5f08bef07ff83bb42ab816296c4fe50a002d80805ae5
7
- data.tar.gz: 8138a96211b233b85f9e6342a32f2a02745dbf230ad319c274880c659c444ad03bf1909013864a79abeab2dccba7d754edbd3350506e7ffd751b112aac2803b0
6
+ metadata.gz: a11ba74c10eb1c56ef15b57992755083b6d36bd4fef1ea73bf1cae64afc1f07ad26f8774ee703c3c98eefa25c8a422441c5c5af032b9ae523d7518c7dc50193d
7
+ data.tar.gz: 26f95dd3245e9e39311349ef540954d200985a59bf9da38b3b08256d11ec50a445d60cc3f0c41538b6a0c0bfb2eb591ce742dc4523950d9ffe8f06471d22e50a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ 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.45.0] - 2026-07-21
8
+
9
+ ### Synoptis — mutual anchoring protocol MAP-1..4 (map-1, AUD-L2)
10
+
11
+ Implements AUD-L2 design v0.5 (FROZEN) slice 1 in the synoptis SkillSet:
12
+
13
+ - `conventions/map-1.md`: content-addressed mutual-anchoring convention
14
+ (chain identity credential, typed attestation vocabulary, succession
15
+ records, declared anchoring rule, changeover event). Builds on khab-1
16
+ without modifying it.
17
+ - `chain_credential.rb`: Ed25519 self-authenticating chain identity
18
+ credential — verification needs no registry and no network.
19
+ - `succession.rb`: succession governance (earliest non-retracted designation
20
+ governs; at-or-before changeover boundary; issuer-only retraction;
21
+ retract-and-redesignate trails surfaced in the contested register).
22
+ - `anchoring_rule.rb`: result-free declared anchoring rule (closed schema)
23
+ with a coverage checker that reports and never enforces.
24
+ - `attestation_types.rb`: MAP-4 vocabulary intake validation and anchor-log
25
+ retraction coherence (issuer = depositor at map-1, disclosed deferral).
26
+ - `entry.rb` / `log.rb` / `write_path.rb`: additive `attestation_type` on
27
+ newly appended entries only (AHM-4 byte-invariance preserved).
28
+ - `bin/map_verify.rb`: standalone offline verifier (credential, attestation,
29
+ succession, coverage, pair, retraction subcommands).
30
+ - `test/test_map_anchoring.rb`: 75 design-constraint tests.
31
+
32
+ Design review converged R5 5/6 APPROVE; implementation review converged
33
+ R4 5/5 APPROVE with zero residual P0/P1. The inaugural mutual anchor with a
34
+ second, independently operated HestiaChain instance is deliberately pending.
35
+
7
36
  ## [3.42.2] - 2026-07-14
8
37
 
9
38
  ### Knowledge — loop_engineering_patterns §B: Model/Effort budget allocation
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.43.0"
2
+ VERSION = "3.45.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # khab_verify — offline auditor verifier for khab-1 head bindings (MPR-4).
5
+ #
6
+ # Verifies inclusion and consistency proofs with EXACTLY the disclosed trust
7
+ # base: the proof artifact, the published head binding(s), and the shipped
8
+ # khab-1 convention definition. No internal-chain access, no operator
9
+ # cooperation, no network. (The authentic anchor-log view — that the binding
10
+ # really is committed at its claimed position — is checked against the log's
11
+ # public verification surface, outside this script.)
12
+ #
13
+ # Usage:
14
+ # khab_verify.rb inclusion <proof.json> <binding.json>
15
+ # khab_verify.rb consistency <proof.json> <earlier_binding.json> <later_binding.json>
16
+ #
17
+ # A binding file may be the bare head_binding object, or any JSON object
18
+ # carrying it under "head_binding" (e.g. a public-verifier record or an anchor
19
+ # entry body).
20
+ #
21
+ # Exit status: 0 = VERIFIED, 1 = REJECTED, 2 = usage / unresolvable input.
22
+
23
+ require 'json'
24
+ require 'digest'
25
+ require_relative '../lib/synoptis/anchoring/cumulative_commitment'
26
+
27
+ CONVENTION_PATH = File.expand_path('../lib/synoptis/anchoring/conventions/khab-1.md', __dir__)
28
+ CC = Synoptis::Anchoring::CumulativeCommitment
29
+
30
+ def die(msg)
31
+ warn "khab_verify: #{msg}"
32
+ exit 2
33
+ end
34
+
35
+ def reject(msg)
36
+ puts "REJECTED: #{msg}"
37
+ exit 1
38
+ end
39
+
40
+ def load_json(path)
41
+ parsed = JSON.parse(File.read(path))
42
+ die "#{path}: expected a JSON object, got #{parsed.class}" unless parsed.is_a?(Hash)
43
+ parsed
44
+ rescue JSON::ParserError, SystemCallError => e
45
+ die "cannot read #{path}: #{e.message}"
46
+ end
47
+
48
+ def extract_binding(obj, path)
49
+ b = obj.is_a?(Hash) && obj.key?('head_binding') ? obj['head_binding'] : obj
50
+ die "#{path} carries no head binding" unless b.is_a?(Hash) && b['convention']
51
+ b
52
+ end
53
+
54
+ # Structural well-formedness of a binding's verifiable fields, checked BEFORE
55
+ # any verdict logic. The strongest claims this verifier can emit (VERIFIED,
56
+ # DIVERGENCE) must be unreachable from malformed input: a missing or
57
+ # non-canonical root/extent is unresolvable input (exit 2), never evidence.
58
+ def require_wellformed!(binding, path)
59
+ unless binding['cumulative_root'].is_a?(String) && binding['cumulative_root'].match?(/\A[a-f0-9]{64}\z/)
60
+ die "#{path}: cumulative_root missing or not 64-char lowercase hex (khab-1 §4)"
61
+ end
62
+ unless binding['tree_size'].is_a?(Integer) && binding['tree_size'].positive? && binding['tree_size'] < 2**53
63
+ die "#{path}: tree_size missing or not a positive JSON-safe integer (khab-1 §4)"
64
+ end
65
+ return if binding['chain_identity'].is_a?(String) &&
66
+ binding['chain_identity'].match?(/\Ablock1-sha256:[a-f0-9]{64}\z/)
67
+
68
+ die "#{path}: chain_identity missing or not in khab-1 §5 canonical form (block1-sha256:<64-hex>)"
69
+ end
70
+
71
+ # MPR-3: the convention identifier must resolve to a definition whose own
72
+ # integrity the auditor can verify. A digest mismatch means the binding was
73
+ # computed under a definition this verifier does not hold — unresolvable, so
74
+ # verification refuses rather than guessing.
75
+ def check_convention!(binding)
76
+ reject "unknown convention #{binding['convention'].inspect} (this verifier implements khab-1)" unless
77
+ binding['convention'] == 'khab-1'
78
+ local = begin
79
+ Digest::SHA256.hexdigest(File.binread(CONVENTION_PATH))
80
+ rescue SystemCallError => e
81
+ die "khab-1 convention definition unreadable at #{CONVENTION_PATH}: #{e.message}"
82
+ end
83
+ return if binding['convention_sha256'] == local
84
+
85
+ reject "convention_sha256 #{binding['convention_sha256']} does not match the held khab-1 " \
86
+ "definition (#{local}); convention unresolvable"
87
+ end
88
+
89
+ def require_match(proof, binding, proof_key, binding_key, label)
90
+ return if proof[proof_key] == binding[binding_key]
91
+
92
+ reject "proof #{label} #{proof[proof_key].inspect} does not match binding #{binding[binding_key].inspect}"
93
+ end
94
+
95
+ mode = ARGV.shift
96
+
97
+ case mode
98
+ when 'inclusion'
99
+ die 'usage: khab_verify.rb inclusion <proof.json> <binding.json>' unless ARGV.size == 2
100
+ proof = load_json(ARGV[0])
101
+ binding = extract_binding(load_json(ARGV[1]), ARGV[1])
102
+ check_convention!(binding)
103
+ require_wellformed!(binding, ARGV[1])
104
+ reject "proof format #{proof['format'].inspect} is not khab-1/inclusion" unless proof['format'] == 'khab-1/inclusion'
105
+ require_match(proof, binding, 'chain_identity', 'chain_identity', 'chain_identity')
106
+ require_match(proof, binding, 'tree_size', 'tree_size', 'tree_size')
107
+ require_match(proof, binding, 'cumulative_root', 'cumulative_root', 'cumulative_root')
108
+
109
+ ok = CC.verify_inclusion(
110
+ record_commitment: proof['record_commitment'],
111
+ index: proof['index'],
112
+ tree_size: binding['tree_size'],
113
+ path: proof['path'],
114
+ root: binding['cumulative_root']
115
+ )
116
+ reject 'inclusion proof does not recompute the committed cumulative root' unless ok
117
+
118
+ puts 'VERIFIED: record commitment ' \
119
+ "#{proof['record_commitment']} is member ##{proof['index']} of the anchored state " \
120
+ "(tree_size #{binding['tree_size']}, chain #{binding['chain_identity']})."
121
+ puts 'Scope (MPR-5/6): membership, integrity at anchor time, position, order — nothing about ' \
122
+ 'content quality or completeness of anchoring; commitment, not content.'
123
+
124
+ when 'consistency'
125
+ die 'usage: khab_verify.rb consistency <proof.json> <earlier.json> <later.json>' unless ARGV.size == 3
126
+ proof = load_json(ARGV[0])
127
+ earlier = extract_binding(load_json(ARGV[1]), ARGV[1])
128
+ later = extract_binding(load_json(ARGV[2]), ARGV[2])
129
+ check_convention!(earlier)
130
+ check_convention!(later)
131
+ require_wellformed!(earlier, ARGV[1])
132
+ require_wellformed!(later, ARGV[2])
133
+ reject "proof format #{proof['format'].inspect} is not khab-1/consistency" unless proof['format'] == 'khab-1/consistency'
134
+ # MPR-9: extension-relatedness is defined only over the same committed chain
135
+ # identity; different identities terminate the claim rather than failing it.
136
+ reject "bindings commit different chain identities (#{earlier['chain_identity']} vs " \
137
+ "#{later['chain_identity']}); extension claim terminated across an identity change" unless
138
+ earlier['chain_identity'] == later['chain_identity']
139
+ # Equal committed extent with different committed roots needs no proof at
140
+ # all: the two states differ at the same size, so no extension relation can
141
+ # exist. This is arithmetic on the committed bindings — a POSITIVE
142
+ # divergence witness (MPR-9), unlike a mere proof-verification failure.
143
+ if earlier['tree_size'] == later['tree_size'] && earlier['cumulative_root'] != later['cumulative_root']
144
+ puts "DIVERGENCE: both bindings commit chain #{later['chain_identity']} at extent " \
145
+ "#{later['tree_size']} with different cumulative roots; conclusive evidence of " \
146
+ 'rewriting or forking (MPR-9)'
147
+ exit 1
148
+ end
149
+ require_match(proof, earlier, 'first_root', 'cumulative_root', 'first_root')
150
+ require_match(proof, later, 'second_root', 'cumulative_root', 'second_root')
151
+ require_match(proof, earlier, 'first_size', 'tree_size', 'first_size')
152
+ require_match(proof, later, 'second_size', 'tree_size', 'second_size')
153
+
154
+ ok = CC.verify_consistency(
155
+ first_root: earlier['cumulative_root'],
156
+ first_size: earlier['tree_size'],
157
+ second_root: later['cumulative_root'],
158
+ second_size: later['tree_size'],
159
+ path: proof['path']
160
+ )
161
+ # Design §11: failure of an operator-supplied proof is NOT by itself a
162
+ # positive inconsistency witness — report it as unestablished, not as forgery.
163
+ reject 'consistency proof does not verify; extension claim remains UNESTABLISHED ' \
164
+ '(not by itself evidence of rewriting — MPR-9)' unless ok
165
+
166
+ puts "VERIFIED: the later anchored state (tree_size #{later['tree_size']}) commits to an " \
167
+ "extension of the earlier state (tree_size #{earlier['tree_size']}) unchanged as a prefix " \
168
+ "(chain #{later['chain_identity']})."
169
+
170
+ else
171
+ die "usage: khab_verify.rb {inclusion|consistency} <proof.json> <binding(s).json...>"
172
+ end
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # map_verify — offline auditor verifier for map-1 mutual-anchoring artifacts
5
+ # (MAP-1..4, aud_l2_mutual_anchoring_design v0.5).
6
+ #
7
+ # Verifies with EXACTLY the disclosed trust base: the artifacts named on the
8
+ # command line plus the shipped map-1 convention definition. No registry, no
9
+ # network, no operator cooperation. (Authentic log/chain views are the
10
+ # caller's inputs; their own integrity is checked by the khab-1 surface.)
11
+ #
12
+ # Usage:
13
+ # map_verify.rb credential <credential.json>
14
+ # map_verify.rb attestation <credential.json> <payload-file> <signature-hex>
15
+ # map_verify.rb succession <old_credential.json> <records.json> [changeover_position]
16
+ # records.json = JSON array of internal-chain record STRINGS, committed order
17
+ # map_verify.rb coverage <rule-file> <anchors.json> [chain_extent] [rule_position] [rule_moment] [now]
18
+ # anchors.json = JSON array of {"tree_size":..,"moment":".."} observations;
19
+ # every_n_records needs chain_extent (+ optional rule_position);
20
+ # every_n_days needs rule_moment and now (ISO8601) as args 5 and 6 —
21
+ # pass 0 0 for the unused args 3 and 4.
22
+ # map_verify.rb pair <log_a.json> <identity_a> <log_b.json> <identity_b>
23
+ # log_*.json = JSON array of anchor-entry objects (entry.to_h shape);
24
+ # reports what a verified pair establishes (MAP-1) — and ONLY that.
25
+ # map_verify.rb retraction <log.json> <retraction_entry_hash>
26
+ # checks map-1 §3 coherence of an anchor-log retraction against its target.
27
+ #
28
+ # Exit status: 0 = VERIFIED/REPORT, 1 = REJECTED, 2 = usage / unresolvable.
29
+
30
+ require 'json'
31
+ require 'digest'
32
+ require_relative '../lib/synoptis/anchoring/entry'
33
+ require_relative '../lib/synoptis/anchoring/chain_credential'
34
+ require_relative '../lib/synoptis/anchoring/succession'
35
+ require_relative '../lib/synoptis/anchoring/anchoring_rule'
36
+ require_relative '../lib/synoptis/anchoring/attestation_types'
37
+
38
+ ENTRY = Synoptis::Anchoring::Entry
39
+ CRED = Synoptis::Anchoring::ChainCredential
40
+ SUCC = Synoptis::Anchoring::Succession
41
+ RULE = Synoptis::Anchoring::AnchoringRule
42
+ ATYPE = Synoptis::Anchoring::AttestationTypes
43
+
44
+ def die(msg)
45
+ warn "map_verify: #{msg}"
46
+ exit 2
47
+ end
48
+
49
+ def reject(msg)
50
+ puts "REJECTED: #{msg}"
51
+ exit 1
52
+ end
53
+
54
+ def load_json(path)
55
+ JSON.parse(File.read(path))
56
+ rescue JSON::ParserError, SystemCallError => e
57
+ die "cannot read #{path}: #{e.message}"
58
+ end
59
+
60
+ # Malformed caller input is unresolvable (exit 2), never a verdict.
61
+ # Explicit base 10: Integer('010') would read octal and silently shift a
62
+ # governance boundary.
63
+ def strict_int(value, label)
64
+ # Digits only: Integer() would also accept '1_0' and padded whitespace.
65
+ die "#{label} must be a base-10 integer, got #{value.inspect}" unless value.is_a?(String) && value.match?(/\A\d+\z/)
66
+ Integer(value, 10)
67
+ end
68
+
69
+ def load_json_hash(path, label)
70
+ parsed = load_json(path)
71
+ die "#{path}: #{label} must be a JSON object, got #{parsed.class}" unless parsed.is_a?(Hash)
72
+ parsed
73
+ end
74
+
75
+ HEX64 = /\A[a-f0-9]{64}\z/
76
+
77
+ # Structural well-formedness of a matched head binding (khab-1 §4 shapes).
78
+ # The strongest claim this tool prints must be unreachable from malformed
79
+ # input: a garbage binding is unresolvable, never PAIR ESTABLISHED.
80
+ def require_binding_wellformed!(binding, where)
81
+ die "#{where}: head_binding.convention is #{binding['convention'].inspect}, not khab-1" unless binding['convention'] == 'khab-1'
82
+ %w[convention_sha256 cumulative_root].each do |k|
83
+ die "#{where}: head_binding.#{k} missing or not 64-char lowercase hex" unless binding[k].is_a?(String) && binding[k].match?(HEX64)
84
+ end
85
+ die "#{where}: head_binding.tree_size must be a positive Integer" unless binding['tree_size'].is_a?(Integer) && binding['tree_size'].positive?
86
+ unless binding['chain_identity'].is_a?(String) && binding['chain_identity'].match?(/\Ablock1-sha256:[a-f0-9]{64}\z/)
87
+ die "#{where}: head_binding.chain_identity is not block1-sha256:<64-hex> (khab-1 §5)"
88
+ end
89
+ end
90
+
91
+ # Recompute an entry's committed hash from its canonical content. Detects an
92
+ # in-place edited entry without any chain access; the full log hash chain is
93
+ # the khab-1 surface's business (khab_verify / public verifier).
94
+ def require_entry_hash!(entry_h, where)
95
+ content = { 'position' => entry_h['position'], 'prev' => entry_h['prev'],
96
+ 'kind' => entry_h['kind'], 'body' => entry_h['body'] }
97
+ recomputed = ENTRY.compute_hash(content)
98
+ return if recomputed == entry_h['entry_hash']
99
+
100
+ reject "#{where}: entry_hash does not recompute from committed content (edited in place?)"
101
+ end
102
+
103
+ mode = ARGV.shift
104
+
105
+ case mode
106
+ when 'credential'
107
+ die 'usage: map_verify.rb credential <credential.json>' unless ARGV.size == 1
108
+ cred = load_json_hash(ARGV[0], 'credential')
109
+ begin
110
+ CRED.validate!(cred)
111
+ rescue CRED::CredentialError => e
112
+ reject e.message
113
+ end
114
+ puts "VERIFIED: credential digest #{CRED.credential_digest(cred)} binds #{cred['chain_identity']}"
115
+ puts 'NOTE: binding is a SELF-attestation (map-1 §1); that the credential speaks'
116
+ puts 'for the chain requires the chain to commit the credential digest (map-1 §2).'
117
+
118
+ when 'attestation'
119
+ die 'usage: map_verify.rb attestation <credential.json> <payload-file> <signature-hex>' unless ARGV.size == 3
120
+ cred = load_json_hash(ARGV[0], 'credential')
121
+ payload = begin
122
+ File.binread(ARGV[1])
123
+ rescue SystemCallError => e
124
+ die "cannot read #{ARGV[1]}: #{e.message}"
125
+ end
126
+ begin
127
+ ok = CRED.verify_attestation(cred, payload, ARGV[2])
128
+ rescue CRED::CredentialError => e
129
+ reject e.message
130
+ end
131
+ reject 'attestation signature does not verify under the credential' unless ok
132
+ puts "VERIFIED: attestation signature valid under credential #{CRED.credential_digest(cred)[0, 12]}…"
133
+
134
+ when 'succession'
135
+ die 'usage: map_verify.rb succession <old_credential.json> <records.json> [changeover_position]' unless (2..3).cover?(ARGV.size)
136
+ cred = load_json_hash(ARGV[0], 'credential')
137
+ records = load_json(ARGV[1])
138
+ unless records.is_a?(Array) && records.all? { |r| r.is_a?(String) }
139
+ die "#{ARGV[1]}: expected a JSON array of record STRINGS (each element the exact committed record string)"
140
+ end
141
+ changeover = ARGV[2].nil? ? nil : strict_int(ARGV[2], 'changeover_position')
142
+ die 'changeover_position must be non-negative' if !changeover.nil? && changeover.negative?
143
+ begin
144
+ verdict = SUCC.governance(cred, records, changeover_position: changeover)
145
+ rescue CRED::CredentialError => e
146
+ reject e.message
147
+ end
148
+ puts JSON.pretty_generate(verdict)
149
+ puts 'NOTE: the verdict reports what the records show, never intent (key'
150
+ puts 'compromise is indistinguishable from the issuer — MAP-2 disclosed limit).'
151
+
152
+ when 'coverage'
153
+ unless (2..6).cover?(ARGV.size)
154
+ die 'usage: map_verify.rb coverage <rule-file> <anchors.json> [chain_extent] [rule_position] [rule_moment] [now]'
155
+ end
156
+ rule_string = begin
157
+ File.read(ARGV[0]).strip
158
+ rescue SystemCallError => e
159
+ die "cannot read #{ARGV[0]}: #{e.message}"
160
+ end
161
+ rule = begin
162
+ RULE.parse!(rule_string)
163
+ rescue RULE::RuleError => e
164
+ reject e.message
165
+ end
166
+ anchors = load_json(ARGV[1])
167
+ die "#{ARGV[1]}: expected a JSON array of observations" unless anchors.is_a?(Array)
168
+ extent = ARGV[2].nil? ? nil : strict_int(ARGV[2], 'chain_extent')
169
+ rule_pos = ARGV[3].nil? ? nil : strict_int(ARGV[3], 'rule_position')
170
+ rule_moment = ARGV[4]
171
+ now = ARGV[5]
172
+ # A tool capability gap is a usage error (exit 2), never a REJECTED verdict.
173
+ if rule['trigger'] == 'every_n_records' && extent.nil?
174
+ die 'every_n_records coverage needs chain_extent'
175
+ end
176
+ if rule['trigger'] == 'every_n_days' && (rule_moment.nil? || now.nil?)
177
+ die 'every_n_days coverage needs rule_moment and now (ISO8601), as arguments 5 and 6'
178
+ end
179
+ begin
180
+ report = RULE.coverage(rule_string, anchors, chain_extent: extent, rule_position: rule_pos,
181
+ rule_moment: rule_moment, now: now)
182
+ rescue RULE::RuleError => e
183
+ reject e.message
184
+ end
185
+ puts JSON.pretty_generate(report)
186
+
187
+ when 'pair'
188
+ die 'usage: map_verify.rb pair <log_a.json> <identity_a> <log_b.json> <identity_b>' unless ARGV.size == 4
189
+ log_a, id_a, log_b, id_b = load_json(ARGV[0]), ARGV[1], load_json(ARGV[2]), ARGV[3]
190
+ [[log_a, ARGV[0]], [log_b, ARGV[2]]].each do |(log, path)|
191
+ die "#{path}: expected a JSON array of anchor-entry objects" unless log.is_a?(Array)
192
+ end
193
+ find = lambda do |log, identity, where|
194
+ log.each_with_index.filter_map do |entry, i|
195
+ e = entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : {}
196
+ # Only anchor entries carry head bindings; anything else shaped to look
197
+ # like one never counts toward a pair.
198
+ next unless e['kind'] == 'anchor'
199
+
200
+ body = e['body'].is_a?(Hash) ? e['body'] : {}
201
+ hb = body['head_binding']
202
+ next unless hb.is_a?(Hash) && hb['chain_identity'] == identity
203
+
204
+ require_binding_wellformed!(hb, "#{where} entry #{e['position'] || i}")
205
+ require_entry_hash!(e, "#{where} entry #{e['position'] || i}")
206
+ { position: e['position'] || i, cumulative_root: hb['cumulative_root'],
207
+ tree_size: hb['tree_size'], attestation_type: body['attestation_type'] }
208
+ end
209
+ end
210
+ a_holds_b = find.call(log_a, id_b, ARGV[0])
211
+ b_holds_a = find.call(log_b, id_a, ARGV[2])
212
+ reject "no entry in #{ARGV[0]} commits a head binding of #{id_b}" if a_holds_b.empty?
213
+ reject "no entry in #{ARGV[2]} commits a head binding of #{id_a}" if b_holds_a.empty?
214
+ puts 'PAIR ESTABLISHED (MAP-1 — exactly this, nothing stronger):'
215
+ puts " #{id_b} head(s) committed in log A at #{a_holds_b.map { |e| e[:position] }.join(', ')}"
216
+ puts " #{id_a} head(s) committed in log B at #{b_holds_a.map { |e| e[:position] }.join(', ')}"
217
+ untyped = (a_holds_b + b_holds_a).count { |e| e[:attestation_type].nil? }
218
+ puts " untyped (pre-map-1, grandfathered) inscriptions: #{untyped}" if untyped.positive?
219
+ puts 'CONDITIONS (travel with every stronger reading, map-1 §6):'
220
+ puts ' - partner independence is auditor-supplied; a common operator can fabricate the pair'
221
+ puts ' - temporal weight reaches only as far as each log\'s own khab-1 anchoring'
222
+ puts ' - equivocation detection requires authentic views of BOTH logs and cross-comparison'
223
+ puts ' - matched entry hashes recomputed and binding shapes checked here; full log hash-chain'
224
+ puts ' and cumulative-root verification are the khab-1 surface\'s business (khab_verify)'
225
+ puts ' - this pair supplies split-view detection material; it closes nothing'
226
+
227
+ when 'retraction'
228
+ die 'usage: map_verify.rb retraction <log.json> <retraction_entry_hash>' unless ARGV.size == 2
229
+ log = load_json(ARGV[0])
230
+ die "#{ARGV[0]}: expected a JSON array of anchor-entry objects" unless log.is_a?(Array)
231
+ entries = log.map { |e| e.is_a?(Hash) ? e.transform_keys(&:to_s) : {} }
232
+ retraction = entries.find { |e| e['entry_hash'] == ARGV[1] }
233
+ die "no entry with entry_hash #{ARGV[1]} in #{ARGV[0]}" if retraction.nil?
234
+ body = retraction['body'].is_a?(Hash) ? retraction['body'] : {}
235
+ meta = body['metadata'].is_a?(Hash) ? body['metadata'] : {}
236
+ target_ref = meta['target_entry_hash']
237
+ unless target_ref.is_a?(String) && target_ref.match?(HEX64)
238
+ reject "retraction carries no resolvable metadata.target_entry_hash (got #{target_ref.inspect})"
239
+ end
240
+ target = entries.find { |e| e['entry_hash'] == target_ref }
241
+ reject "target entry #{target_ref} not present in the supplied log view" if target.nil?
242
+ require_entry_hash!(retraction, 'retraction')
243
+ require_entry_hash!(target, 'target')
244
+ verdict = ATYPE.retraction_coherence(retraction, target)
245
+ puts JSON.pretty_generate(verdict)
246
+ exit(verdict[:coherent] ? 0 : 1)
247
+
248
+ else
249
+ die 'usage: map_verify.rb credential|attestation|succession|coverage|pair|retraction ... (see header)'
250
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'time'
6
+ require_relative 'entry'
7
+
8
+ module Synoptis
9
+ module Anchoring
10
+ # Declared anchoring rule (aud_l2_mutual_anchoring_design v0.5 MAP-3)
11
+ # under map-1 §5: a result-free rule artifact whose schema is closed by
12
+ # construction (no field can reference an outcome), committed on the chain
13
+ # for decidable anteriority, and a coverage CHECKER that reports — never
14
+ # enforces. A sparse or vacuous rule conforms; the report makes its cost
15
+ # visible. No automation lives here (cadence automation is out of this
16
+ # slice; the canonical-works human gate is untouched).
17
+ module AnchoringRule
18
+ FORMAT = 'map-1/anchoring-rule'
19
+ COMMITMENT_FORMAT = 'map-1/rule-commitment'
20
+ TRIGGERS = %w[every_n_records every_n_days].freeze
21
+ FIELDS = %w[format n trigger].freeze
22
+ HEX_DIGEST = /\A[a-f0-9]{64}\z/
23
+ # JSON-safe bound, matching HeadBinding (ANC-2 boundedness).
24
+ JSON_SAFE_BOUND = 2**53
25
+
26
+ class RuleError < StandardError; end
27
+
28
+ module_function
29
+
30
+ # Build a rule artifact string (canonical JSON, map-1 §5).
31
+ def build(trigger, n)
32
+ t = trigger.to_s
33
+ raise RuleError, "trigger must be one of #{TRIGGERS.join(', ')}, got #{t.inspect}" unless TRIGGERS.include?(t)
34
+ unless n.is_a?(Integer) && n.positive? && n < JSON_SAFE_BOUND
35
+ raise RuleError, "n must be a positive JSON-safe Integer, got #{n.inspect}"
36
+ end
37
+
38
+ Entry.canonical_json('format' => FORMAT, 'n' => n, 'trigger' => t)
39
+ end
40
+
41
+ # Parse + validate a rule artifact string. Raises RuleError on the first
42
+ # violation (a rule is an operator-authored artifact: malformed means
43
+ # refused, not coerced).
44
+ def parse!(rule_string)
45
+ parsed = begin
46
+ JSON.parse(rule_string.to_s)
47
+ rescue JSON::ParserError => e
48
+ raise RuleError, "rule is not valid JSON: #{e.message}"
49
+ end
50
+ raise RuleError, "rule must be a JSON object, got #{parsed.class}" unless parsed.is_a?(Hash)
51
+
52
+ r = parsed.transform_keys(&:to_s)
53
+ keys = r.keys.sort
54
+ raise RuleError, "rule fields must be exactly #{FIELDS.join(', ')}, got #{keys.join(', ')}" unless keys == FIELDS
55
+ raise RuleError, "unknown format #{r['format'].inspect} (#{FORMAT} only)" unless r['format'] == FORMAT
56
+ raise RuleError, "trigger must be one of #{TRIGGERS.join(', ')}" unless TRIGGERS.include?(r['trigger'])
57
+ unless r['n'].is_a?(Integer) && r['n'].positive? && r['n'] < JSON_SAFE_BOUND
58
+ raise RuleError, 'rule.n must be a positive JSON-safe Integer'
59
+ end
60
+ # map-1 §5: a rule is valid only in its canonical serialization, so one
61
+ # rule has exactly one digest — a re-ordered or re-spaced serialization
62
+ # would commit a different digest for the same logical rule.
63
+ unless Entry.canonical_json(r) == rule_string.to_s
64
+ raise RuleError, 'rule is not in canonical serialization (map-1 §5: one rule, one digest)'
65
+ end
66
+
67
+ r
68
+ end
69
+
70
+ # The rule-commitment record string (map-1 §5) the chain commits so the
71
+ # rule's anteriority is decidable in committed order (MPR-8).
72
+ def commitment_record(rule_string)
73
+ parse!(rule_string)
74
+ Entry.canonical_json(
75
+ 'format' => COMMITMENT_FORMAT,
76
+ 'rule_digest' => Digest::SHA256.hexdigest(rule_string.to_s)
77
+ )
78
+ end
79
+
80
+ # Coverage report (map-1 §5): the rule held against the visible anchor
81
+ # history. Diagnostic posture: never raises on the history side — asked
82
+ # "is this history covered?", the answer is a report, not an exception.
83
+ #
84
+ # +rule_string+ the committed rule artifact.
85
+ # +anchors+ the binding-carrying anchor observations, each a Hash
86
+ # with 'tree_size' (Integer) and 'moment' (ISO8601
87
+ # String) — the shape a reader extracts from head
88
+ # bindings + entry moments.
89
+ # +chain_extent+ current record count of the internal chain.
90
+ # +rule_position+ committed record position of the rule commitment
91
+ # (for every_n_records) — expectation starts after it.
92
+ # +rule_moment+ committed moment of the rule commitment (ISO8601, for
93
+ # every_n_days).
94
+ # +now+ end of the assessed window (ISO8601); required for
95
+ # every_n_days so the checker stays clock-free.
96
+ #
97
+ # Returns { conforms: true, expected: [...], matched: [...], gaps: [...],
98
+ # note: } — `conforms` is always true for a valid rule (MAP-3:
99
+ # a vacuous rule conforms); the gaps are the visible cost.
100
+ def coverage(rule_string, anchors, chain_extent: nil, rule_position: nil, rule_moment: nil, now: nil)
101
+ rule = parse!(rule_string)
102
+ observations = Array(anchors).map { |a| a.is_a?(Hash) ? a.transform_keys(&:to_s) : {} }
103
+
104
+ case rule['trigger']
105
+ when 'every_n_records'
106
+ coverage_by_records(rule, observations, chain_extent, rule_position)
107
+ when 'every_n_days'
108
+ coverage_by_days(rule, observations, rule_moment, now)
109
+ end
110
+ end
111
+
112
+ # -- internal helpers --
113
+
114
+ def coverage_by_records(rule, observations, chain_extent, rule_position)
115
+ extent = begin
116
+ Integer(chain_extent || 0)
117
+ rescue ArgumentError, ::TypeError
118
+ raise RuleError, "chain_extent must be an Integer, got #{chain_extent.inspect}"
119
+ end
120
+ start = begin
121
+ Integer(rule_position || 0)
122
+ rescue ArgumentError, ::TypeError
123
+ raise RuleError, "rule_position must be an Integer, got #{rule_position.inspect}"
124
+ end
125
+ n = rule['n']
126
+ sizes = observations.map { |o| o['tree_size'] }.select { |s| s.is_a?(Integer) }.sort
127
+ expected = []
128
+ threshold = start + n
129
+ while threshold <= extent
130
+ expected << threshold
131
+ threshold += n
132
+ end
133
+ matched = expected.select { |e| sizes.any? { |s| s >= e && s < e + n } }
134
+ gaps = expected - matched
135
+ {
136
+ conforms: true,
137
+ # Echo the assessed frame so a defaulted rule_position is visible in
138
+ # the report, never silently assumed.
139
+ chain_extent: extent,
140
+ rule_position: start,
141
+ expected: expected,
142
+ matched: matched,
143
+ gaps: gaps,
144
+ note: gaps.empty? ? 'covered' : "#{gaps.size} expected anchor point(s) without a binding-carrying anchor; temporal windows widen accordingly (MPR-7)"
145
+ }
146
+ end
147
+
148
+ def coverage_by_days(rule, observations, rule_moment, now)
149
+ raise RuleError, 'every_n_days coverage needs rule_moment and now (ISO8601)' if rule_moment.nil? || now.nil?
150
+
151
+ t0 = Time.iso8601(rule_moment.to_s)
152
+ t1 = Time.iso8601(now.to_s)
153
+ n_sec = rule['n'] * 86_400
154
+ moments = observations.map { |o| o['moment'] }.filter_map do |m|
155
+ Time.iso8601(m.to_s)
156
+ rescue ArgumentError
157
+ nil
158
+ end
159
+ expected = []
160
+ boundary = t0 + n_sec
161
+ while boundary <= t1
162
+ expected << boundary.utc.iso8601
163
+ boundary += n_sec
164
+ end
165
+ matched = expected.select do |e|
166
+ et = Time.iso8601(e)
167
+ moments.any? { |m| m >= et && m < et + n_sec }
168
+ end
169
+ gaps = expected - matched
170
+ {
171
+ conforms: true,
172
+ rule_moment: t0.utc.iso8601,
173
+ assessed_until: t1.utc.iso8601,
174
+ expected: expected,
175
+ matched: matched,
176
+ gaps: gaps,
177
+ note: gaps.empty? ? 'covered' : "#{gaps.size} expected anchor window(s) without a binding-carrying anchor; temporal windows widen accordingly (MPR-7)"
178
+ }
179
+ rescue ArgumentError => e
180
+ raise RuleError, "invalid ISO8601 input: #{e.message}"
181
+ end
182
+ end
183
+ end
184
+ end