kairos-chain 3.49.0 → 3.50.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 +60 -1
  3. data/bin/kairos-chain +30 -7
  4. data/lib/kairos_mcp/version.rb +1 -1
  5. data/templates/skillsets/chain_distillation/config/chain_distillation.yml +13 -0
  6. data/templates/skillsets/chain_distillation/lib/chain_distillation/canon.rb +44 -0
  7. data/templates/skillsets/chain_distillation/lib/chain_distillation/certificate.rb +308 -0
  8. data/templates/skillsets/chain_distillation/lib/chain_distillation/distiller.rb +305 -0
  9. data/templates/skillsets/chain_distillation/lib/chain_distillation/recorder.rb +144 -0
  10. data/templates/skillsets/chain_distillation/skillset.json +18 -0
  11. data/templates/skillsets/chain_distillation/test/test_chain_distillation.rb +464 -0
  12. data/templates/skillsets/chain_distillation/tools/cd_distill.rb +67 -0
  13. data/templates/skillsets/chain_distillation/tools/cd_revoke.rb +57 -0
  14. data/templates/skillsets/chain_distillation/tools/cd_verify.rb +56 -0
  15. data/templates/skillsets/confidentiality_guard/config/profile.example.yml +9 -0
  16. data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/policy.rb +8 -0
  17. data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/recorder.rb +4 -1
  18. data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/regime.rb +4 -1
  19. data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/surfaces.rb +21 -0
  20. data/templates/skillsets/confidentiality_guard/lib/confidentiality_guard/verdict.rb +14 -0
  21. data/templates/skillsets/confidentiality_guard/test/test_cg_guard.rb +47 -0
  22. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69d783a71fb5c56c3b7bfc549695f337ad9099d2db5beaf0ae557b845f854a2f
4
- data.tar.gz: 3f858ee398aa3ecbe24c664e5618653ab1bfd38f6a5a01c6779270cefc4d7e68
3
+ metadata.gz: 826aabda2632b42bb5e4c688f443616faf29b9b97daba55aa02fa6834bf1629d
4
+ data.tar.gz: 2a743cfa76eb0d56db2220ed40c11ea0cc52f88c9b16ed8dfb84855d54b15981
5
5
  SHA512:
6
- metadata.gz: 16e67612af99d2dc86f85d9d128a3e5906f07f72677f7a8ff547685b9106e1c62b3dea2161ce776fcd68819676f7b665b8b8a9e127a2e69c6cc33831685aab60
7
- data.tar.gz: b202b151145f3bb7c1f1383450129d8b3455f0eeb3d9fa7716b3dea173d9c3eb06a56d3fa90b3236b0e8585cb88a3e2276707512e8cd06c833f64efd6ad7915a
6
+ metadata.gz: 0e0ab6e95d0f527e738053fe45e9899e761820809c0f26ab37dd55eacadf13b387b22737475a8685b894d095aa9ba4529df142ab3809adf917c2ff275b46f403
7
+ data.tar.gz: 1272d3d2ffd8cc80615dc1990346758982cb64cb78d947965d754d3ae5d0e148524044c8197efbb836e0ecf067cfcae69f51aa90a934a71da691525c0b190da2
data/CHANGELOG.md CHANGED
@@ -4,6 +4,65 @@ 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.50.0] - 2026-07-23
8
+
9
+ ### Chain Distillation SkillSet — slice 1 (CD-1..CD-6)
10
+
11
+ New `chain_distillation` SkillSet implementing design v0.5 (review-converged
12
+ 5/6 APPROVE + persona unanimity): provenance-certified distillation of the
13
+ instance chain into distributable SkillSets.
14
+
15
+ - `cd_distill`: closed-world integer designation (genesis excluded,
16
+ decline-not-coerce), guard-judged distillate and certificate crossings
17
+ (separate, in order, single-encoded presentation), CD-6 constitutive
18
+ record before release (salted sdp-1-style commitments over the distillate
19
+ and the claim core excluding the record citation), content-independent
20
+ pre-assigned certificate identity, attestation-carrier seam
21
+ (`proof_id` = certificate identity), designation-overlap re-issuance
22
+ citation against then-revoked predecessors.
23
+ - `cd_verify`: full-citation positive grounding (claim-core commitment
24
+ opening, identity, designation digest, guard policy sha, distillate
25
+ commitment, predecessors, citation block hash) plus chain-identity /
26
+ head-binding checks; pinned checkability-status table with
27
+ both-direction mislabeling fatal; closed claim-core vocabulary at every
28
+ depth; revocation folds into validity.
29
+ - `cd_revoke`: identity-keyed chain-wide revocation, closed reason
30
+ vocabulary (`superseded|defective|withdrawn|other`).
31
+ - Requires an active confidentiality-guard regime (CD-1: decline, never
32
+ degrade). 60 design-constraint tests incl. production-wiring and
33
+ real-Chain regressions (KAIROS_DATA_DIR-isolated).
34
+
35
+ ### Confidentiality Guard — slice-2 first increment (distillation crossing)
36
+
37
+ - New `:distillation_outward` crossing class (`cd_release_distillate`,
38
+ `cd_release_certificate`): conjunctive per-destination verdict
39
+ (closed-world `distillation_crossings` profile designation AND content
40
+ classes), outward verdicts recorded pass or deny (CG-4); the general
41
+ outward class stays denied wholesale.
42
+ - Certificate-crossing verdict records cite the certificate identity
43
+ (identifier-only, CG-4-compatible).
44
+ - Template-layout require-path parity fix in `regime.rb` / `recorder.rb`.
45
+ - Guard test suite: 75 design-constraint + regression tests.
46
+
47
+ ## [3.49.1] - 2026-07-22
48
+
49
+ ### CLI fix — `skillset upgrade` messaging for non-core new SkillSets
50
+
51
+ `kairos-chain skillset upgrade --apply` always runs with `core_only: true`
52
+ (the plain CLI form has no way to pass explicit `names`), so a new,
53
+ non-core SkillSet (e.g. `confidentiality_guard`, `daemon_runtime`,
54
+ `kairos_hook_projector`) was never installed by `--apply`, regardless of
55
+ how many times it was run — but the check-mode display mislabeled these as
56
+ "files changed" and told the user to run `--apply`, which silently did
57
+ nothing for them.
58
+
59
+ - Detected upgrades are now split into "applicable" (existing-installed
60
+ diffs + core new SkillSets, which `--apply` does handle) and "manual"
61
+ (non-core new SkillSets, which require explicit
62
+ `kairos-chain skillset install <name>`). Both the check-mode listing and
63
+ the post-`--apply` summary report each group accurately.
64
+ - Added the missing `upgrade` line to `kairos-chain skillset -h`.
65
+
7
66
  ## [3.49.0] - 2026-07-22
8
67
 
9
68
  ### ZK aggregate reproducibility spike (AUD-L4, Phases 1+2) — KairosChain's first genuine zero-knowledge proof
@@ -65,7 +124,7 @@ resource-scheme reads are denied wholesale under the CG-1 coverage clause.
65
124
  records carry the designation id, not the raw path. `canon.rb`: shared
66
125
  canonical serialization faithful to false/nil and mixed string/symbol keys.
67
126
  - `tools/cg_status.rb`: read-only regime-state tool (CG-1).
68
- - 67 design-constraint + regression tests (`test/test_cg_guard.rb`).
127
+ - 67 design-constraint + regression tests (`test/test_cg_guard.rb`); 75 as of the chain_distillation guard slice-2 first increment (distillation crossing).
69
128
 
70
129
  Core (minimal, guard-enabling): `tool_registry.rb` adds `FailClosedError`
71
130
  (re-raised by the loader, never swallowed), per-skillset isolated loading,
data/bin/kairos-chain CHANGED
@@ -134,6 +134,12 @@ when 'skillset'
134
134
  when 'upgrade'
135
135
  apply = ARGV.delete('--apply')
136
136
  upgrades = manager.upgrade_check
137
+ # --apply always runs with core_only: true (no way to pass names from this
138
+ # CLI form), so a new, non-core SkillSet is NEVER installed by --apply,
139
+ # no matter how many times it is run. Split the two groups so the
140
+ # displayed guidance matches what --apply will actually do.
141
+ manual = upgrades.select { |u| u[:new_skillset] && !KairosMcp::SkillSetManager::CORE_SKILLSETS.include?(u[:name]) }
142
+ applicable = upgrades - manual
137
143
 
138
144
  if upgrades.empty?
139
145
  puts "All SkillSets are up to date."
@@ -144,6 +150,12 @@ when 'skillset'
144
150
  end
145
151
  puts "Done. #{results.size} SkillSet(s) upgraded."
146
152
 
153
+ if manual.any?
154
+ puts ""
155
+ puts "Not installed (optional, requires explicit approval):"
156
+ manual.each { |u| puts " kairos-chain skillset install #{u[:name]}" }
157
+ end
158
+
147
159
  # Re-project plugin artifacts if any SkillSets changed
148
160
  if results.any?
149
161
  begin
@@ -162,15 +174,25 @@ when 'skillset'
162
174
  end
163
175
  end
164
176
  else
165
- puts "SkillSet upgrades available:"
166
- puts ""
167
- upgrades.each do |u|
168
- label = u[:version_bump] ? "v#{u[:installed_version]} -> v#{u[:available_version]}" : "files changed"
169
- puts " #{u[:name]}: #{label}"
170
- u[:changed_files].each { |f| puts " Modified: #{f}" }
177
+ if applicable.any?
178
+ puts "SkillSet upgrades available:"
179
+ puts ""
180
+ applicable.each do |u|
181
+ label = u[:version_bump] ? "v#{u[:installed_version]} -> v#{u[:available_version]}" : "files changed"
182
+ puts " #{u[:name]}: #{label}"
183
+ u[:changed_files].each { |f| puts " Modified: #{f}" }
184
+ puts ""
185
+ end
186
+ puts "Run 'kairos-chain skillset upgrade --apply' to apply updates."
187
+ end
188
+
189
+ if manual.any?
190
+ puts "" if applicable.any?
191
+ puts "Optional SkillSets available (not auto-installed; require explicit approval):"
192
+ manual.each { |u| puts " #{u[:name]} v#{u[:available_version]} — #{u[:description]}" }
171
193
  puts ""
194
+ puts "Install with: kairos-chain skillset install <name>"
172
195
  end
173
- puts "Run 'kairos-chain skillset upgrade --apply' to apply updates."
174
196
  end
175
197
 
176
198
  when 'remove'
@@ -258,6 +280,7 @@ when 'skillset'
258
280
  puts " install <path> [--layer L0|L1|L2] Install a SkillSet from path"
259
281
  puts " enable <name> Enable a SkillSet"
260
282
  puts " disable <name> Disable a SkillSet"
283
+ puts " upgrade [--apply] Check for/apply SkillSet updates from gem templates"
261
284
  puts " remove <name> Remove a SkillSet"
262
285
  puts " info <name> Show SkillSet details"
263
286
  puts " package <name> Package a knowledge-only SkillSet as JSON archive"
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.49.0"
2
+ VERSION = "3.50.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -0,0 +1,13 @@
1
+ # Chain Distillation SkillSet configuration (design v0.5 slice 1).
2
+ #
3
+ # The distiller carries no activation switch of its own: CD-1 couples
4
+ # certified distillation to the confidentiality guard regime, whose
5
+ # activation is environment-level (KAIROS_CONFIDENTIALITY_GUARD / the
6
+ # guard's own config). With the regime inactive the distiller declines.
7
+ #
8
+ # The guard-side designation for the distillation crossings lives in the
9
+ # GUARD's profile (distillation_crossings key) — the guard track owns
10
+ # that surface; see confidentiality_guard/config/profile.example.yml.
11
+ distillation:
12
+ # Reserved for slice-2 (distribution) settings.
13
+ {}
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module KairosMcp
6
+ module SkillSets
7
+ module ChainDistillation
8
+ # Canonical serialization for commitments and claim-core hashing
9
+ # (design v0.5 CD-2/CD-6). Same faithfulness rules as the guard's
10
+ # Canon (impl review R1 lineage): false/nil preserved distinctly;
11
+ # mixed string/symbol keys both emitted, sorted by stringified key
12
+ # with original order as tiebreak. One encoder for designation
13
+ # digests, artifact commitments, and claim-core commitments so no
14
+ # two bindings can drift apart.
15
+ module Canon
16
+ module_function
17
+
18
+ def canonical(obj)
19
+ case obj
20
+ when Hash
21
+ entries = obj.map.with_index { |(k, v), i| [k.to_s, i, v] }
22
+ entries.sort_by! { |key, i, _| [key, i] }
23
+ "{#{entries.map { |key, _, v| "#{key.to_json}:#{canonical(v)}" }.join(',')}}"
24
+ when Array
25
+ "[#{obj.map { |v| canonical(v) }.join(',')}]"
26
+ else
27
+ obj.to_json
28
+ end
29
+ end
30
+
31
+ def stringify(obj)
32
+ case obj
33
+ when Hash
34
+ obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = stringify(v) }
35
+ when Array
36
+ obj.map { |v| stringify(v) }
37
+ else
38
+ obj
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'digest'
5
+ require_relative 'canon'
6
+ require_relative 'recorder'
7
+
8
+ module KairosMcp
9
+ module SkillSets
10
+ module ChainDistillation
11
+ # Certificate shape and verification (design v0.5 §4, CD-2/CD-3/CD-6).
12
+ #
13
+ # The certificate's vocabulary is exhausted by three claim families —
14
+ # identity, derivation, recording — plus the checkability-status
15
+ # table and the designation-overlap predecessor citations. It is
16
+ # origin-only: there is no field a quality claim could occupy, and
17
+ # verification rejects any claim-core key outside the pinned
18
+ # vocabulary (CD-3's "incapable of expressing quality" made
19
+ # mechanical).
20
+ #
21
+ # Grounding is fixed at issuance (CD-6): the authoritative
22
+ # distillation record is the one whose claim-core commitment the
23
+ # certificate's claim core matches and which its finalized form
24
+ # cites — a positive check, never by exhaustion. Identity uniqueness,
25
+ # designation-overlap citation observance, the drawn-from link, and
26
+ # revocation status are trusted claims; mislabeling any status in
27
+ # either direction fails verification as a whole (CD-2).
28
+ module Certificate
29
+ module_function
30
+
31
+ CONVENTION = 'cd-1'
32
+
33
+ # Pinned claim-core vocabulary (CD-3): verification fails on any
34
+ # key outside this set, so the claim language cannot be extended
35
+ # into quality territory by an issuer.
36
+ CLAIM_CORE_KEYS = %w[
37
+ convention
38
+ certificate_identity
39
+ identity
40
+ derivation
41
+ recording
42
+ predecessors
43
+ statuses
44
+ ].freeze
45
+
46
+ # Pinned per-claim-family checkability-status table (design v0.5
47
+ # §6 slice 1; CD-2). The certificate must carry exactly this
48
+ # table — labeling a checkable claim as trusted is the same defect
49
+ # as the converse, and either fails verification as a whole.
50
+ STATUS_TABLE = {
51
+ 'identity.binding' => 'checkable',
52
+ 'identity.continuity' => 'trusted',
53
+ 'identity.uniqueness' => 'trusted',
54
+ 'derivation' => 'anchor-pending',
55
+ 'recording' => 'anchor-pending',
56
+ 'reissuance_citation_observance' => 'trusted',
57
+ 'drawn_from' => 'trusted',
58
+ 'revocation_status' => 'trusted'
59
+ }.freeze
60
+
61
+ # Claim core: the certificate's claim content EXCLUDING its
62
+ # citation of the CD-6 record, carrier lifecycle metadata, and
63
+ # commitment openings (all outside the core, CD-6) — which is what
64
+ # breaks the self-citation fixed point.
65
+ def build_claim_core(certificate_identity:, chain_identity:, head_index:, head_hash:,
66
+ designation:, guard_policy_sha256:, distillate_commitment:,
67
+ verdict_block_indices:, predecessors:)
68
+ {
69
+ 'convention' => CONVENTION,
70
+ 'certificate_identity' => certificate_identity,
71
+ 'identity' => {
72
+ 'chain_identity' => chain_identity,
73
+ 'chain_head_index' => head_index,
74
+ 'chain_head_hash' => head_hash
75
+ },
76
+ 'derivation' => {
77
+ 'designation' => designation.sort,
78
+ 'designation_digest' => Recorder.designation_digest(designation),
79
+ 'span' => { 'first_index' => designation.min, 'last_index' => designation.max },
80
+ 'guard_policy_sha256' => guard_policy_sha256,
81
+ 'distillate_commitment' => distillate_commitment,
82
+ 'verdict_block_indices' => verdict_block_indices
83
+ },
84
+ 'recording' => {
85
+ 'revocation_channel' => 'source-chain:cd_revocation'
86
+ },
87
+ 'predecessors' => predecessors,
88
+ 'statuses' => STATUS_TABLE.dup
89
+ }
90
+ end
91
+
92
+ # Finalization adds only what sits outside the claim core: the
93
+ # CD-6 record citation and the commitment openings for the
94
+ # released artifacts the holder possesses (CD-2). Later cites
95
+ # earlier; nothing here feeds back into the committed core.
96
+ def finalize(claim_core:, record_citation:, openings:)
97
+ {
98
+ 'claim_core' => claim_core,
99
+ 'record_citation' => record_citation,
100
+ 'openings' => openings
101
+ }
102
+ end
103
+
104
+ # Verification (CD-2/CD-6): positive checks only. `chain_entries`
105
+ # is the parsed record list of the source chain (index => entry),
106
+ # available to an in-instance or chain-access verifier; claims
107
+ # whose witness is the private chain alone are trusted-status and
108
+ # verified here only when chain access is provided.
109
+ # Returns { valid:, errors:, revoked: }.
110
+ # Pinned nested vocabulary per claim family (CD-3): nested extras
111
+ # and nested missing fields are both defects, so no quality-shaped
112
+ # content can hide one level down.
113
+ NESTED_KEYS = {
114
+ 'identity' => %w[chain_identity chain_head_index chain_head_hash],
115
+ 'derivation' => %w[designation designation_digest span guard_policy_sha256
116
+ distillate_commitment verdict_block_indices],
117
+ 'recording' => %w[revocation_channel]
118
+ }.freeze
119
+
120
+ # The span sub-mapping's closed vocabulary (CD-3 at every depth).
121
+ SPAN_KEYS = %w[first_index last_index].freeze
122
+
123
+ def verify(certificate, chain_entries: nil, distillate_json: nil, chain_hashes: nil)
124
+ unless certificate.is_a?(Hash)
125
+ return { valid: false, errors: ['certificate/not-a-mapping'], revoked: nil }
126
+ end
127
+ errors = []
128
+ cert = Canon.stringify(certificate)
129
+ core = cert['claim_core']
130
+ return { valid: false, errors: ['certificate/missing-claim-core'], revoked: nil } unless core.is_a?(Hash)
131
+
132
+ # CD-3 vocabulary bound: no key outside the pinned set, at the
133
+ # top level and inside each claim family.
134
+ extra = core.keys - CLAIM_CORE_KEYS
135
+ errors << "vocabulary/unknown-keys:#{extra.join(',')}" unless extra.empty?
136
+ missing = CLAIM_CORE_KEYS - core.keys
137
+ errors << "vocabulary/missing-keys:#{missing.join(',')}" unless missing.empty?
138
+ NESTED_KEYS.each do |family, keys|
139
+ fam = core[family]
140
+ unless fam.is_a?(Hash)
141
+ errors << "vocabulary/#{family}-not-a-mapping"
142
+ next
143
+ end
144
+ nested_extra = fam.keys - keys
145
+ errors << "vocabulary/#{family}-unknown-keys:#{nested_extra.join(',')}" unless nested_extra.empty?
146
+ nested_missing = keys - fam.keys
147
+ errors << "vocabulary/#{family}-missing-keys:#{nested_missing.join(',')}" unless nested_missing.empty?
148
+ end
149
+ # The span sub-mapping is likewise closed (CD-3 holds at every
150
+ # depth of the claim core — impl review R3).
151
+ span = core.is_a?(Hash) ? core.dig('derivation', 'span') : nil
152
+ if span.is_a?(Hash) && span.keys.sort != SPAN_KEYS
153
+ errors << "vocabulary/span-keys:#{(span.keys - SPAN_KEYS).join(',')}"
154
+ elsif !span.is_a?(Hash) && core['derivation'].is_a?(Hash)
155
+ errors << 'vocabulary/span-not-a-mapping'
156
+ end
157
+
158
+ # CD-2 mislabeling defect, both directions: statuses must equal
159
+ # the pinned table exactly.
160
+ errors << 'statuses/mislabeled' unless core['statuses'] == STATUS_TABLE
161
+
162
+ # Openings only over released artifacts (claim core + distillate).
163
+ openings = cert['openings'].is_a?(Hash) ? cert['openings'] : {}
164
+ errors << 'openings/extra' unless (openings.keys - %w[claim_core_salt distillate_salt]).empty?
165
+
166
+ revoked = nil
167
+ if chain_entries
168
+ revoked, chain_errors = verify_against_chain(cert, core, openings, chain_entries,
169
+ chain_hashes: chain_hashes)
170
+ errors.concat(chain_errors)
171
+ # A revoked certificate does not verify (CD-6: revocation is
172
+ # the authoritative channel); callers checking only `valid`
173
+ # must not accept a chain-revoked certificate.
174
+ errors << 'revocation/revoked' if revoked
175
+ end
176
+
177
+ if distillate_json
178
+ if openings['distillate_salt']
179
+ digest = core.dig('derivation', 'distillate_commitment')
180
+ unless Recorder.commitment_valid?(Recorder::ARTIFACT_DOMAIN, digest.to_s,
181
+ openings['distillate_salt'], distillate_json)
182
+ errors << 'distillate/commitment-mismatch'
183
+ end
184
+ else
185
+ # A supplied distillate that cannot be checked is a defect,
186
+ # not a silent pass (impl review R1).
187
+ errors << 'distillate/no-opening'
188
+ end
189
+ end
190
+
191
+ { valid: errors.empty?, errors: errors, revoked: revoked }
192
+ end
193
+
194
+ # Grounding and chain-witnessed checks (positive, never by
195
+ # exhaustion): locate the cited record, match commitments and
196
+ # identity, check revocation and the designation-overlap
197
+ # predecessor-citation obligation.
198
+ def verify_against_chain(cert, core, openings, chain_entries, chain_hashes: nil)
199
+ errors = []
200
+ citation = cert['record_citation'].is_a?(Hash) ? cert['record_citation'] : {}
201
+ record_index = normalize_index(citation['block_index'])
202
+ record = record_index ? chain_entries[record_index] : nil
203
+ unless record.is_a?(Hash) && record['type'] == 'cd_distillation'
204
+ return [nil, ['grounding/cited-record-absent']]
205
+ end
206
+
207
+ # Positive grounding: claim core matches the cited record's
208
+ # claim-core commitment (via the disclosed opening), and every
209
+ # field the record and the core both carry must agree — identity,
210
+ # designation digest, guard policy version, distillate
211
+ # commitment, predecessors (impl review R1: grounding must bind
212
+ # the whole citation, not the index alone).
213
+ if openings['claim_core_salt']
214
+ unless Recorder.commitment_valid?(Recorder::CLAIM_CORE_DOMAIN,
215
+ record['claim_core_commitment'].to_s,
216
+ openings['claim_core_salt'], Canon.canonical(core))
217
+ errors << 'grounding/claim-core-commitment-mismatch'
218
+ end
219
+ else
220
+ errors << 'grounding/no-claim-core-opening'
221
+ end
222
+ errors << 'grounding/identity-mismatch' unless record['certificate_identity'] == core['certificate_identity']
223
+ errors << 'grounding/designation-mismatch' unless record['designation_digest'] == core.dig('derivation', 'designation_digest')
224
+ errors << 'grounding/policy-mismatch' unless record['guard_policy_sha256'] == core.dig('derivation', 'guard_policy_sha256')
225
+ errors << 'grounding/distillate-commitment-mismatch' unless record['distillate_commitment'] == core.dig('derivation', 'distillate_commitment')
226
+ errors << 'grounding/predecessors-mismatch' unless Array(record['predecessors']).sort == Array(core['predecessors']).sort
227
+
228
+ # With block hashes available, bind the citation to the block
229
+ # itself and check the identity family (identity.binding is a
230
+ # CHECKABLE claim and must actually be checked — impl review R1).
231
+ if chain_hashes.is_a?(Hash) && !chain_hashes.empty?
232
+ # Full-citation grounding (CD-2): the block hash is part of the
233
+ # citation, not an optional extra — a certificate stripped of
234
+ # it must not verify (impl review R3).
235
+ cited_hash = citation['block_hash']
236
+ if cited_hash.nil? || cited_hash.to_s.empty?
237
+ errors << 'grounding/no-block-hash'
238
+ elsif chain_hashes[record_index] != cited_hash
239
+ errors << 'grounding/block-hash-mismatch'
240
+ end
241
+ genesis_hash = chain_hashes[chain_hashes.keys.min]
242
+ claimed_identity = core.dig('identity', 'chain_identity')
243
+ if genesis_hash && claimed_identity != "block1-sha256:#{genesis_hash}"
244
+ errors << 'identity/chain-identity-mismatch'
245
+ end
246
+ head_index = normalize_index(core.dig('identity', 'chain_head_index'))
247
+ head_hash = core.dig('identity', 'chain_head_hash')
248
+ if head_index && head_hash && chain_hashes[head_index] != head_hash
249
+ errors << 'identity/head-binding-mismatch'
250
+ end
251
+ end
252
+
253
+ # Revocation: keyed to the certificate identity, chain
254
+ # authoritative (trusted-status for outside verifiers; checked
255
+ # here because chain access was provided).
256
+ revoked = chain_entries.values.any? do |e|
257
+ e.is_a?(Hash) && e['type'] == 'cd_revocation' &&
258
+ e['certificate_identity'] == core['certificate_identity']
259
+ end
260
+
261
+ # Designation-overlap predecessor-citation obligation (CD-6,
262
+ # evaluated at issuance): every certificate identity that was
263
+ # revoked before this record and whose distillation record's
264
+ # designation shares records with this designation must appear
265
+ # in predecessors.
266
+ required = revoked_overlapping_identities(chain_entries, core.dig('derivation', 'designation'), record_index)
267
+ cited = Array(core['predecessors'])
268
+ missing = required - cited
269
+ errors << "reissuance/uncited-revoked-predecessors:#{missing.join(',')}" unless missing.empty?
270
+
271
+ [revoked, errors]
272
+ end
273
+
274
+ # Identities of certificates revoked before `before_index` whose
275
+ # distillation designation overlaps `designation`. Both the
276
+ # revocation AND its grounding distillation record must precede
277
+ # `before_index` (impl review R1: records written after the
278
+ # evaluated record cannot ground an obligation on it).
279
+ def revoked_overlapping_identities(chain_entries, designation, before_index)
280
+ designated = Array(designation)
281
+ distillations = {}
282
+ revoked_before = []
283
+ chain_entries.each do |index, entry|
284
+ next unless entry.is_a?(Hash)
285
+ next if index.to_i >= before_index
286
+ case entry['type']
287
+ when 'cd_distillation'
288
+ distillations[entry['certificate_identity']] = Array(entry['designation'])
289
+ when 'cd_revocation'
290
+ revoked_before << entry['certificate_identity']
291
+ end
292
+ end
293
+ revoked_before.uniq.select do |identity|
294
+ overlap = distillations[identity]
295
+ overlap && !(overlap & designated).empty?
296
+ end
297
+ end
298
+
299
+ # Integer indices only; a JSON round-trip may deliver strings.
300
+ def normalize_index(value)
301
+ return value if value.is_a?(Integer)
302
+ return value.to_i if value.is_a?(String) && value.match?(/\A\d+\z/)
303
+ nil
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end