kairos-chain 3.34.0 → 3.35.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: a393cfbac08011bdd6f7b8cd3129177f535c7507868215d6b85ef2af732e8521
4
- data.tar.gz: 202af122d66197111412f5cdfc48287872713781602fe7f5f428ec40f3e111a9
3
+ metadata.gz: 790cf38c2263e065c856211e0dce54a56a4e9599b2b028dbd3335405f78d069f
4
+ data.tar.gz: 3a568dc9d900c4aefc24572657043c82d94ea62027a349f6c3e20094d778cbb7
5
5
  SHA512:
6
- metadata.gz: 880c1f5206e0e84ab3b448abd25e5ec3a2fd012840a0f0f2b1d67bd8883fc54da27a945e451de3c2dfc96c36628658db625190b8a8ad8dd8796b1c5b7cbf2eb1
7
- data.tar.gz: 4a2187976ddddc4e4a27ef51075f38ddeff13539f60c8928150bce1d3d97d607b6bb09aa327179569209ddcf440cae575b53b1460e5f9f350553d2760a6e4a7a
6
+ metadata.gz: a3d7d364ecfe3512de1ca75375eefe890002b4c228f7dd3245ea00277abe1552030261a3916ef27ec3c1e968381646ac73a0811672016a8d16d6fab282954c31
7
+ data.tar.gz: 1bc3370f34c14841d0b57c471ff9e71185521bfe36b583c788732f6513d453c619a07e53070bb6e4d674b6b3b2ae4ec716663f53247d65e9a380a968e942752c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ 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.35.0] - 2026-07-05
8
+
9
+ ### Added — Synoptis: L2 constitutive attestation (Slice 1)
10
+
11
+ First slice of the L2 attestation capability (design v0.9 FROZEN, 6/6 unanimous),
12
+ extending the Synoptis SkillSet. Enters selected L2 judgments into a dedicated
13
+ append-only attestation ledger — a dated line stating "at moment T, subject X had
14
+ digest D" — with a propose-only activation policy so entries actually get made
15
+ (the system proposes; the human approves).
16
+
17
+ New constitutive module (siblings to the trust engine; the existing attestation
18
+ engine, verifier, revocation manager, and trust scorer are untouched):
19
+ `constitutive/{content_attestation_entry,subject_ref,attestation_chain,proposal_criterion}`.
20
+
21
+ Three tools: `l2_attestation_scan` (ACT-5+ACT-2: actively proposes a session's
22
+ judgment-bearing contexts — frontmatter type handoff/decision/debrief — and logs a
23
+ trigger record), `l2_attestation_commit` (ACT-1: `approved: true` two-call human
24
+ gate appends one content-attestation `(subject, digest, moment)`; digest = SHA256
25
+ of the subject's persisted bytes), `l2_attestation_decline` (ACT-4: content-free
26
+ decline record).
27
+
28
+ Two dedicated append-only stores on Synoptis's FileRegistry chain machinery,
29
+ neither the Meta Ledger (LED-5): `l2_attestation.jsonl` (human-approved) and
30
+ `l2_operational_log.jsonl` (telemetry). FileRegistry gains additive generic
31
+ `append`/`read`; no existing behaviour changed. Posture is flat — workflow-level
32
+ human approval, no cryptographic consent signal, never exceeding the L0/L1 posture
33
+ (LED-6).
34
+
35
+ Invariants satisfied this slice: LED-1, LED-2a, LED-3, LED-5, ACT-1, ACT-2 (simple
36
+ frontmatter criterion, revisable), ACT-4, ACT-5. Deferred to later slices: LED-2b
37
+ (lineage/supersession), LED-6 (anchoring), ACT-3 (approval-to-digest binding).
38
+ 35 unit tests (`test_l2_constitutive_slice1.rb`) green.
39
+
7
40
  ## [3.34.0] - 2026-07-03
8
41
 
9
42
  ### Added — L1 knowledge (dev repo): loop_validation
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.34.0"
2
+ VERSION = "3.35.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -47,6 +47,16 @@ trust_v2:
47
47
  integrity_verified: 0.60
48
48
  default: 0.50
49
49
 
50
+ # Constitutive L2 attestation (Slice 1) — dedicated append-only ledger + operational
51
+ # log on Synoptis's FileRegistry chain machinery (NOT the Meta Ledger). Design v0.9.
52
+ constitutive:
53
+ digest_alg: sha256
54
+ # ACT-2 first-cut criterion (revisable): propose only judgment-bearing L2 contexts.
55
+ judgment_types:
56
+ - handoff
57
+ - decision
58
+ - debrief
59
+
50
60
  challenge:
51
61
  response_timeout: 3600 # 1 hour
52
62
  max_active_per_subject: 5
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Synoptis
6
+ module Constitutive
7
+ # Two dedicated append-only stores on Synoptis's FileRegistry chain machinery
8
+ # (design v0.9, LED-5). Neither is the Meta Ledger:
9
+ #
10
+ # - l2_attestation content-attestation entries; human-approved (ACT-1)
11
+ # - l2_operational_log trigger + decline records; telemetry, not approved (ACT-4/5)
12
+ #
13
+ # Both inherit FileRegistry's append-only + hash-chain (_prev_entry_hash) discipline
14
+ # — the bank-ledger integrity model (LED-2a): lines are only ever appended, never
15
+ # rewritten. The two stores are distinct files, so a judgment-bearing entry and a
16
+ # telemetry record never share one line definition (the recurring taxonomy defect
17
+ # the design closed via §Kinds).
18
+ class AttestationChain
19
+ LEDGER = :l2_attestation
20
+ OPLOG = :l2_operational_log
21
+
22
+ def initialize(registry:)
23
+ @registry = registry
24
+ end
25
+
26
+ # ACT-1: append an approved content-attestation entry to the ledger.
27
+ def append_content_attestation(entry)
28
+ @registry.append(LEDGER, entry.to_h)
29
+ end
30
+
31
+ def entries
32
+ @registry.read(LEDGER)
33
+ end
34
+
35
+ # ACT-5: telemetry that the criterion fired and surfaced N proposals. Subject-free
36
+ # and content-free, so it neither indexes L2 (LED-4) nor records a per-context
37
+ # verdict (ACT-4).
38
+ def append_trigger(surfaced_count:, moment: nil)
39
+ @registry.append(OPLOG, {
40
+ record: 'trigger',
41
+ surfaced_count: surfaced_count,
42
+ moment: moment || Time.now.utc.iso8601
43
+ })
44
+ end
45
+
46
+ # ACT-4: telemetry that a proposed subject was declined. Content-free, keyed by
47
+ # subject id only. An approval writes NO decision record — it is evidenced by the
48
+ # content-attestation entry it produced.
49
+ def append_decline(subject_id:, moment: nil)
50
+ @registry.append(OPLOG, {
51
+ record: 'decision',
52
+ decision: 'declined',
53
+ subject_id: subject_id,
54
+ moment: moment || Time.now.utc.iso8601
55
+ })
56
+ end
57
+
58
+ def oplog
59
+ @registry.read(OPLOG)
60
+ end
61
+
62
+ def verify_chain(type = LEDGER)
63
+ @registry.verify_chain(type)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'digest'
5
+ require 'securerandom'
6
+ require 'time'
7
+
8
+ module Synoptis
9
+ module Constitutive
10
+ # A single content-attestation entry on the L2 attestation chain (design v0.9,
11
+ # §Kinds / LED-3). It commits (subject_id, digest, moment) and may embed an
12
+ # optional content snapshot.
13
+ #
14
+ # Deliberately carries NO signature and NO ttl: the entry is workflow-approved
15
+ # (ACT-1), not crypto-gated, and an attestation entry does not expire (design §11).
16
+ # This is posture parity with L0/L1 (LED-6), not a regression against Synoptis's
17
+ # signed ProofEnvelope — it is a different kind for a different purpose.
18
+ class ContentAttestationEntry
19
+ KIND = 'content_attestation'
20
+ DEFAULT_DIGEST_ALG = 'sha256'
21
+
22
+ attr_reader :entry_id, :subject_id, :digest, :digest_alg, :moment, :snapshot
23
+
24
+ def initialize(subject_id:, digest:, moment:, digest_alg: DEFAULT_DIGEST_ALG,
25
+ snapshot: nil, entry_id: nil)
26
+ @entry_id = entry_id || SecureRandom.uuid
27
+ @subject_id = subject_id
28
+ @digest = digest
29
+ @digest_alg = digest_alg
30
+ @moment = moment
31
+ @snapshot = snapshot
32
+ end
33
+
34
+ def to_h
35
+ {
36
+ kind: KIND,
37
+ entry_id: @entry_id,
38
+ subject_id: @subject_id,
39
+ digest: @digest,
40
+ digest_alg: @digest_alg,
41
+ moment: @moment,
42
+ snapshot: @snapshot
43
+ }
44
+ end
45
+
46
+ # Deterministic canonical form for hashing. A nil snapshot is retained as JSON
47
+ # null (no .compact) so the hash is stable regardless of which optional fields
48
+ # are populated — same discipline as ProofEnvelope#canonical_json.
49
+ def canonical_json
50
+ JSON.generate(to_h, sort_keys: true)
51
+ end
52
+
53
+ def entry_hash
54
+ Digest::SHA256.hexdigest(canonical_json)
55
+ end
56
+
57
+ def self.from_h(hash)
58
+ hash = hash.transform_keys(&:to_sym) if hash.is_a?(Hash)
59
+ new(
60
+ subject_id: hash[:subject_id],
61
+ digest: hash[:digest],
62
+ moment: hash[:moment],
63
+ digest_alg: hash[:digest_alg] || DEFAULT_DIGEST_ALG,
64
+ snapshot: hash[:snapshot],
65
+ entry_id: hash[:entry_id]
66
+ )
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Synoptis
4
+ module Constitutive
5
+ # ACT-2 selection criterion (first-cut, revisable). Given a session, proposes the
6
+ # session's judgment-bearing L2 contexts — those whose frontmatter `type` is in the
7
+ # configured judgment set (default: handoff / decision / debrief).
8
+ #
9
+ # This is intentionally simple and config-driven now; it evolves toward an
10
+ # LLM-semantic criterion that reads content (design §4 note, §11). Simplicity is safe
11
+ # because ACT-1 puts a human approval gate after every proposal: a wrong proposal is
12
+ # declined, never silently attested. Proposals are recommendations only — they create
13
+ # no attestation and no obligation until approved.
14
+ class ProposalCriterion
15
+ DEFAULT_JUDGMENT_TYPES = %w[handoff decision debrief].freeze
16
+
17
+ def initialize(context_dir:, judgment_types: DEFAULT_JUDGMENT_TYPES)
18
+ @context_dir = context_dir
19
+ @judgment_types = Array(judgment_types).map(&:to_s)
20
+ end
21
+
22
+ # Returns an array of proposals:
23
+ # { subject_id:, type:, content_state: }
24
+ def propose(session_id:)
25
+ session_dir = File.join(@context_dir, session_id.to_s)
26
+ return [] unless Dir.exist?(session_dir)
27
+
28
+ Dir.children(session_dir).sort.filter_map do |context_name|
29
+ next unless File.directory?(File.join(session_dir, context_name))
30
+
31
+ uri = "context://#{session_id}/#{context_name}"
32
+ type = SubjectRef.frontmatter_type(uri, context_dir: @context_dir)
33
+ next unless type && @judgment_types.include?(type.to_s)
34
+
35
+ {
36
+ subject_id: uri,
37
+ type: type.to_s,
38
+ content_state: SubjectRef.content_state(uri, context_dir: @context_dir)
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'yaml'
5
+
6
+ module Synoptis
7
+ module Constitutive
8
+ # Resolves a stable subject identity to its persisted L2 context file and computes
9
+ # a digest of the ACTUAL persisted bytes (design v0.9, LED-3).
10
+ #
11
+ # The subject-id is the `context://<session_id>/<context_name>` URI itself. It is
12
+ # stable across file rename/relocation because identity is the URI, not the path:
13
+ # the path is derived from the URI at read time. (If the underlying file is later
14
+ # moved, path resolution is a mechanism detail deferred to §11; the id is stable
15
+ # by construction.)
16
+ #
17
+ # The digest binds the entry to the fact that specific bytes existed at a moment —
18
+ # it proves THAT content matched, not WHAT it said (an audit that needs the wording
19
+ # embeds a snapshot; that is a later slice).
20
+ module SubjectRef
21
+ SCHEME = 'context://'
22
+ DIGEST_ALG = 'sha256'
23
+
24
+ module_function
25
+
26
+ # "context://<session_id>/<context_name>" -> { session_id:, context_name: }
27
+ def parse(uri)
28
+ s = uri.to_s
29
+ raise ArgumentError, "not a context uri: #{uri}" unless s.start_with?(SCHEME)
30
+
31
+ rest = s[SCHEME.length..]
32
+ session_id, context_name = rest.to_s.split('/', 2)
33
+ if session_id.nil? || session_id.empty? || context_name.nil? || context_name.empty?
34
+ raise ArgumentError, "malformed context uri: #{uri}"
35
+ end
36
+
37
+ { session_id: session_id, context_name: context_name }
38
+ end
39
+
40
+ # Layout: context_dir/<session_id>/<context_name>/<context_name>.md
41
+ def resolve_path(uri, context_dir:)
42
+ p = parse(uri)
43
+ File.join(context_dir, p[:session_id], p[:context_name], "#{p[:context_name]}.md")
44
+ end
45
+
46
+ def exists?(uri, context_dir:)
47
+ File.exist?(resolve_path(uri, context_dir: context_dir))
48
+ end
49
+
50
+ def read_bytes(uri, context_dir:)
51
+ File.binread(resolve_path(uri, context_dir: context_dir))
52
+ end
53
+
54
+ # SHA256 of the subject's persisted content bytes. Raises if the file is absent
55
+ # (fail-closed: never attest content that does not exist).
56
+ def digest(uri, context_dir:)
57
+ Digest::SHA256.hexdigest(read_bytes(uri, context_dir: context_dir))
58
+ end
59
+
60
+ # A pre-commit snapshot of the subject's persisted state, for surfacing in a
61
+ # proposal or approval. Content-free about meaning: only size + digest.
62
+ def content_state(uri, context_dir:)
63
+ path = resolve_path(uri, context_dir: context_dir)
64
+ if File.exist?(path)
65
+ bytes = File.binread(path)
66
+ { exists: true, bytes: bytes.bytesize,
67
+ digest: Digest::SHA256.hexdigest(bytes), digest_alg: DIGEST_ALG }
68
+ else
69
+ { exists: false, bytes: 0, digest: nil, digest_alg: DIGEST_ALG }
70
+ end
71
+ end
72
+
73
+ # Best-effort read of the frontmatter `type:` field (nil if absent/unparseable).
74
+ def frontmatter_type(uri, context_dir:)
75
+ path = resolve_path(uri, context_dir: context_dir)
76
+ return nil unless File.exist?(path)
77
+
78
+ fm = extract_frontmatter(File.read(path))
79
+ return nil unless fm.is_a?(Hash)
80
+
81
+ fm['type'] || fm[:type]
82
+ end
83
+
84
+ def extract_frontmatter(text)
85
+ return nil unless text.start_with?('---')
86
+
87
+ parts = text.split(/^---\s*$/, 3)
88
+ return nil if parts.length < 3
89
+
90
+ YAML.safe_load(parts[1])
91
+ rescue StandardError
92
+ nil
93
+ end
94
+ end
95
+ end
96
+ end
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'digest'
5
5
  require 'fileutils'
6
+ require 'time'
6
7
 
7
8
  module Synoptis
8
9
  module Registry
@@ -80,6 +81,26 @@ module Synoptis
80
81
  apply_filter(records, filter)
81
82
  end
82
83
 
84
+ # --- Generic append-only access (constitutive attestation reuse) ---
85
+ # Append any record to a named store type with the same append-only + hash-chain
86
+ # discipline as the typed stores above (LED-2a). Returns the stored record (with
87
+ # the _type / _stored_at / _prev_entry_hash envelope fields). Additive and
88
+ # non-breaking: the typed proof/revocation/challenge methods are unchanged.
89
+
90
+ def append(type, record)
91
+ full = record.merge(
92
+ _type: type.to_s,
93
+ _stored_at: Time.now.utc.iso8601,
94
+ _prev_entry_hash: last_entry_hash(type)
95
+ )
96
+ append_record(type, full)
97
+ full
98
+ end
99
+
100
+ def read(type)
101
+ read_records(type)
102
+ end
103
+
83
104
  # --- Chain verification (Proposition 5) ---
84
105
 
85
106
  def verify_chain(type)
@@ -31,6 +31,39 @@ module Synoptis
31
31
  @_scorer ||= TrustScorer.new(registry: registry, config: synoptis_config)
32
32
  end
33
33
 
34
+ # --- Constitutive L2 attestation (Slice 1) ---
35
+
36
+ def context_root
37
+ KairosMcp.context_dir
38
+ end
39
+
40
+ def constitutive_chain
41
+ @_constitutive_chain ||= Constitutive::AttestationChain.new(registry: registry)
42
+ end
43
+
44
+ def proposal_criterion
45
+ @_proposal_criterion ||= Constitutive::ProposalCriterion.new(
46
+ context_dir: context_root,
47
+ judgment_types: constitutive_config['judgment_types'] ||
48
+ Constitutive::ProposalCriterion::DEFAULT_JUDGMENT_TYPES
49
+ )
50
+ end
51
+
52
+ def constitutive_config
53
+ synoptis_config['constitutive'] || synoptis_config[:constitutive] || {}
54
+ end
55
+
56
+ # Best-effort "current session": the most recently modified session dir under
57
+ # the L2 context root. Used when a scan is invoked without an explicit session_id.
58
+ def latest_session_id
59
+ root = context_root
60
+ return nil unless Dir.exist?(root)
61
+
62
+ Dir.children(root)
63
+ .select { |c| File.directory?(File.join(root, c)) }
64
+ .max_by { |c| File.mtime(File.join(root, c)) }
65
+ end
66
+
34
67
  def resolve_agent_id
35
68
  mmp_identity.introduce.dig(:identity, :instance_id)
36
69
  rescue StandardError
@@ -5,6 +5,10 @@ require_relative 'synoptis/verifier'
5
5
  require_relative 'synoptis/attestation_engine'
6
6
  require_relative 'synoptis/revocation_manager'
7
7
  require_relative 'synoptis/registry/file_registry'
8
+ require_relative 'synoptis/constitutive/content_attestation_entry'
9
+ require_relative 'synoptis/constitutive/subject_ref'
10
+ require_relative 'synoptis/constitutive/attestation_chain'
11
+ require_relative 'synoptis/constitutive/proposal_criterion'
8
12
  require_relative 'synoptis/challenge_manager'
9
13
  require_relative 'synoptis/trust_identity'
10
14
  require_relative 'synoptis/trust_scorer'
@@ -15,7 +15,10 @@
15
15
  "KairosMcp::SkillSets::Synoptis::Tools::AttestationList",
16
16
  "KairosMcp::SkillSets::Synoptis::Tools::TrustQuery",
17
17
  "KairosMcp::SkillSets::Synoptis::Tools::ChallengeCreate",
18
- "KairosMcp::SkillSets::Synoptis::Tools::ChallengeRespond"
18
+ "KairosMcp::SkillSets::Synoptis::Tools::ChallengeRespond",
19
+ "KairosMcp::SkillSets::Synoptis::Tools::L2AttestationScan",
20
+ "KairosMcp::SkillSets::Synoptis::Tools::L2AttestationCommit",
21
+ "KairosMcp::SkillSets::Synoptis::Tools::L2AttestationDecline"
19
22
  ],
20
23
  "config_files": ["config/synoptis.yml"],
21
24
  "knowledge_dirs": ["knowledge/synoptis_protocol"]
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Slice 1 unit + wiring tests for the L2 constitutive attestation module.
4
+ # Pure Ruby: requires only FileRegistry + the constitutive module (no MMP).
5
+ # Run from project root:
6
+ # ruby -I KairosChain_mcp_server/lib \
7
+ # KairosChain_mcp_server/templates/skillsets/synoptis/test/test_l2_constitutive_slice1.rb
8
+
9
+ require 'tmpdir'
10
+ require 'json'
11
+ require 'digest'
12
+ require 'fileutils'
13
+
14
+ lib = File.expand_path('../lib', __dir__)
15
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
16
+
17
+ require 'synoptis/registry/file_registry'
18
+ require 'synoptis/constitutive/content_attestation_entry'
19
+ require 'synoptis/constitutive/subject_ref'
20
+ require 'synoptis/constitutive/attestation_chain'
21
+ require 'synoptis/constitutive/proposal_criterion'
22
+
23
+ $pass = 0
24
+ $fail = 0
25
+
26
+ def assert(cond, msg)
27
+ if cond
28
+ $pass += 1
29
+ puts " PASS: #{msg}"
30
+ else
31
+ $fail += 1
32
+ puts " FAIL: #{msg}"
33
+ end
34
+ end
35
+
36
+ def section(t)
37
+ puts "\n#{'=' * 60}\nSECTION: #{t}\n#{'=' * 60}"
38
+ end
39
+
40
+ CAE = Synoptis::Constitutive::ContentAttestationEntry
41
+ SR = Synoptis::Constitutive::SubjectRef
42
+ AC = Synoptis::Constitutive::AttestationChain
43
+ PC = Synoptis::Constitutive::ProposalCriterion
44
+
45
+ # Build a fake L2 context tree: context_dir/<session>/<name>/<name>.md
46
+ def write_context(context_dir, session_id, name, type, body = "body of #{name}")
47
+ dir = File.join(context_dir, session_id, name)
48
+ FileUtils.mkdir_p(dir)
49
+ fm = "---\ntitle: \"#{name}\"\ntype: #{type}\n---\n\n#{body}\n"
50
+ File.write(File.join(dir, "#{name}.md"), fm)
51
+ "context://#{session_id}/#{name}"
52
+ end
53
+
54
+ Dir.mktmpdir do |root|
55
+ context_dir = File.join(root, 'context')
56
+ data_dir = File.join(root, 'synoptis_data')
57
+ FileUtils.mkdir_p(context_dir)
58
+ FileUtils.mkdir_p(data_dir)
59
+ session = 'session_test_0001'
60
+
61
+ uri_handoff = write_context(context_dir, session, 'my_handoff', 'handoff')
62
+ uri_decision = write_context(context_dir, session, 'my_decision', 'decision')
63
+ uri_debrief = write_context(context_dir, session, 'my_debrief', 'debrief')
64
+ uri_plain = write_context(context_dir, session, 'my_notes', 'session_summary')
65
+
66
+ # ---------------------------------------------------------------
67
+ section('ContentAttestationEntry — canonical form + hashing')
68
+
69
+ e1 = CAE.new(subject_id: uri_handoff, digest: 'abc123', moment: '2026-07-05T00:00:00Z')
70
+ e1b = CAE.new(subject_id: uri_handoff, digest: 'abc123', moment: '2026-07-05T00:00:00Z',
71
+ entry_id: e1.entry_id)
72
+ assert(e1.canonical_json == e1b.canonical_json, 'canonical_json deterministic for equal fields')
73
+ assert(e1.entry_hash == e1b.entry_hash, 'entry_hash deterministic for equal fields')
74
+
75
+ e2 = CAE.new(subject_id: uri_handoff, digest: 'abc124', moment: '2026-07-05T00:00:00Z',
76
+ entry_id: e1.entry_id)
77
+ assert(e1.entry_hash != e2.entry_hash, 'entry_hash changes when digest changes')
78
+
79
+ h = e1.to_h
80
+ assert(h[:kind] == 'content_attestation', 'to_h carries kind=content_attestation')
81
+ assert(h.key?(:snapshot) && h[:snapshot].nil?, 'nil snapshot retained (not compacted)')
82
+ round = CAE.from_h(e1.to_h)
83
+ assert(round.entry_hash == e1.entry_hash, 'from_h round-trips entry_hash')
84
+ assert(!e1.to_h.key?(:signature) && !e1.to_h.key?(:ttl), 'no signature / no ttl fields (posture parity)')
85
+
86
+ # ---------------------------------------------------------------
87
+ section('SubjectRef — parse / resolve / digest / type')
88
+
89
+ parsed = SR.parse(uri_handoff)
90
+ assert(parsed[:session_id] == session && parsed[:context_name] == 'my_handoff', 'parse splits uri')
91
+ assert_raised = begin; SR.parse('skill://foo'); false; rescue ArgumentError; true; end
92
+ assert(assert_raised, 'parse rejects non-context uri')
93
+
94
+ path = SR.resolve_path(uri_handoff, context_dir: context_dir)
95
+ assert(File.exist?(path), 'resolve_path points at the persisted file')
96
+
97
+ manual = Digest::SHA256.hexdigest(File.binread(path))
98
+ assert(SR.digest(uri_handoff, context_dir: context_dir) == manual, 'digest = SHA256 of persisted bytes')
99
+
100
+ # byte sensitivity: change one byte -> different digest; id unchanged
101
+ d_before = SR.digest(uri_handoff, context_dir: context_dir)
102
+ File.write(path, File.read(path) + 'x')
103
+ d_after = SR.digest(uri_handoff, context_dir: context_dir)
104
+ assert(d_before != d_after, 'digest changes when one byte changes')
105
+ assert(SR.parse(uri_handoff)[:context_name] == 'my_handoff', 'subject-id stable across content change')
106
+
107
+ assert(SR.frontmatter_type(uri_decision, context_dir: context_dir) == 'decision', 'frontmatter_type reads type')
108
+ cs = SR.content_state(uri_debrief, context_dir: context_dir)
109
+ assert(cs[:exists] && cs[:bytes] > 0 && cs[:digest], 'content_state reports exists/bytes/digest')
110
+
111
+ fail_closed = begin
112
+ SR.digest('context://nope/missing', context_dir: context_dir); false
113
+ rescue StandardError; true; end
114
+ assert(fail_closed, 'digest fail-closed on missing file (never attest absent content)')
115
+
116
+ # ---------------------------------------------------------------
117
+ section('ProposalCriterion (ACT-2) — judgment types only')
118
+
119
+ crit = PC.new(context_dir: context_dir)
120
+ proposals = crit.propose(session_id: session)
121
+ subjects = proposals.map { |p| p[:subject_id] }
122
+ assert(subjects.include?(uri_handoff), 'proposes handoff type')
123
+ assert(subjects.include?(uri_decision), 'proposes decision type')
124
+ assert(subjects.include?(uri_debrief), 'proposes debrief type')
125
+ assert(!subjects.include?(uri_plain), 'does NOT propose non-judgment (session_summary) type')
126
+ assert(proposals.length == 3, 'exactly 3 judgment contexts proposed')
127
+ assert(crit.propose(session_id: 'no_such_session') == [], 'empty for unknown session')
128
+
129
+ # ---------------------------------------------------------------
130
+ section('AttestationChain (LED-2a append-only + LED-5 two stores)')
131
+
132
+ reg = Synoptis::Registry::FileRegistry.new(data_dir: data_dir)
133
+ chain = AC.new(registry: reg)
134
+
135
+ before_meta = 0 # stand-in: no Meta Ledger touched by this module at all
136
+
137
+ ent_a = CAE.new(subject_id: uri_handoff, digest: SR.digest(uri_handoff, context_dir: context_dir),
138
+ moment: '2026-07-05T01:00:00Z')
139
+ chain.append_content_attestation(ent_a)
140
+ ent_b = CAE.new(subject_id: uri_decision, digest: SR.digest(uri_decision, context_dir: context_dir),
141
+ moment: '2026-07-05T02:00:00Z')
142
+ chain.append_content_attestation(ent_b)
143
+
144
+ entries = chain.entries
145
+ assert(entries.length == 2, 'two appends -> two ledger entries')
146
+ assert(entries[0][:_prev_entry_hash].nil?, 'first entry has no prev hash')
147
+ assert(!entries[1][:_prev_entry_hash].nil?, 'second entry links to prev (hash chain)')
148
+ vc = chain.verify_chain(AC::LEDGER)
149
+ assert(vc[:valid] && vc[:length] == 2, 'verify_chain valid, length 2')
150
+
151
+ ledger_file = File.join(data_dir, 'l2_attestation.jsonl')
152
+ oplog_file = File.join(data_dir, 'l2_operational_log.jsonl')
153
+ assert(File.exist?(ledger_file), 'ledger is its own file l2_attestation.jsonl')
154
+
155
+ # ---------------------------------------------------------------
156
+ section('Operational log (ACT-4 decline / ACT-5 trigger)')
157
+
158
+ chain.append_trigger(surfaced_count: 3, moment: '2026-07-05T03:00:00Z')
159
+ chain.append_decline(subject_id: uri_debrief, moment: '2026-07-05T03:01:00Z')
160
+ oplog = chain.oplog
161
+ assert(oplog.length == 2, 'oplog has trigger + decline')
162
+ trig = oplog.find { |r| r[:record] == 'trigger' }
163
+ dec = oplog.find { |r| r[:record] == 'decision' }
164
+ assert(trig && trig[:surfaced_count] == 3 && !trig.key?(:subject_id), 'trigger record content-free + subject-free')
165
+ assert(dec && dec[:decision] == 'declined' && dec[:subject_id] == uri_debrief, 'decline keyed by subject')
166
+ assert(!dec.key?(:digest) && !dec.key?(:content) && !dec.key?(:snapshot), 'decline is content-free (no digest/content)')
167
+ assert(File.exist?(oplog_file) && ledger_file != oplog_file, 'two distinct store files (LED-5)')
168
+
169
+ # ---------------------------------------------------------------
170
+ section('LED-1 selective — live context untouched by attestation')
171
+
172
+ live_before = File.binread(SR.resolve_path(uri_handoff, context_dir: context_dir))
173
+ chain.append_content_attestation(
174
+ CAE.new(subject_id: uri_handoff, digest: 'zzz', moment: '2026-07-05T04:00:00Z')
175
+ )
176
+ live_after = File.binread(SR.resolve_path(uri_handoff, context_dir: context_dir))
177
+ assert(live_before == live_after, 'appending an attestation does not modify the live context file')
178
+
179
+ # ---------------------------------------------------------------
180
+ section('Append-only surface — no edit/delete API')
181
+
182
+ assert(!chain.respond_to?(:delete) && !chain.respond_to?(:update), 'chain exposes no delete/update')
183
+ assert(!reg.respond_to?(:delete) && !reg.respond_to?(:overwrite), 'registry exposes no delete/overwrite')
184
+ end
185
+
186
+ puts "\n#{'=' * 60}"
187
+ puts "RESULTS: #{$pass} passed, #{$fail} failed"
188
+ puts '=' * 60
189
+ exit($fail.zero? ? 0 : 1)
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KairosMcp
4
+ module SkillSets
5
+ module Synoptis
6
+ module Tools
7
+ # ACT-1: appending a content-attestation entry requires human approval on every
8
+ # path. This is a workflow-level, human-in-the-loop confirmation (the `approved:
9
+ # true` two-call pattern, as with skills_evolve / skills_audit archive) — the same
10
+ # posture L0/L1 use, no cryptographic consent signal (LED-6).
11
+ #
12
+ # - Called WITHOUT approved:true -> returns the proposal (subject + content
13
+ # state), writes NOTHING.
14
+ # - Called WITH approved:true -> appends exactly one content-attestation
15
+ # entry (subject_id, digest, moment) to the l2_attestation chain.
16
+ #
17
+ # The digest is SHA256 of the subject's actual persisted bytes at commit time
18
+ # (LED-3); the moment is the append moment. (ACT-3 approval-to-digest binding and
19
+ # supersession arrive with Slice 2; Slice 1 recomputes at commit and attests the
20
+ # current bytes.)
21
+ class L2AttestationCommit < KairosMcp::Tools::BaseTool
22
+ include ::Synoptis::ToolHelpers
23
+
24
+ def name
25
+ 'l2_attestation_commit'
26
+ end
27
+
28
+ def description
29
+ 'Append one human-approved content-attestation (subject, digest, moment) for an L2 context to the constitutive attestation ledger. Requires approved: true; without it, returns the proposal and writes nothing (ACT-1).'
30
+ end
31
+
32
+ def category
33
+ :attestation
34
+ end
35
+
36
+ def usecase_tags
37
+ %w[attestation commit constitutive l2 approve audit]
38
+ end
39
+
40
+ def related_tools
41
+ %w[l2_attestation_scan l2_attestation_decline]
42
+ end
43
+
44
+ def input_schema
45
+ {
46
+ type: 'object',
47
+ properties: {
48
+ subject: { type: 'string', description: 'The L2 context to attest, as a context:// URI (e.g. "context://<session>/<name>").' },
49
+ approved: { type: 'boolean', description: 'Human approval. Must be true to append. Omitted/false returns the proposal without writing.' }
50
+ },
51
+ required: %w[subject]
52
+ }
53
+ end
54
+
55
+ def call(arguments)
56
+ subject = arguments['subject']
57
+ unless subject
58
+ return text_content(JSON.pretty_generate({ status: 'error', message: 'subject is required' }))
59
+ end
60
+
61
+ state = ::Synoptis::Constitutive::SubjectRef.content_state(subject, context_dir: context_root)
62
+ unless state[:exists]
63
+ return text_content(JSON.pretty_generate({
64
+ status: 'error', message: 'Subject content does not exist; cannot attest absent content.', subject: subject
65
+ }))
66
+ end
67
+
68
+ unless arguments['approved'] == true
69
+ return text_content(JSON.pretty_generate({
70
+ status: 'pending_approval',
71
+ subject: subject,
72
+ content_state: state,
73
+ note: 'Re-call with approved: true to append this content-attestation. Nothing was written.'
74
+ }))
75
+ end
76
+
77
+ entry = ::Synoptis::Constitutive::ContentAttestationEntry.new(
78
+ subject_id: subject,
79
+ digest: state[:digest],
80
+ digest_alg: state[:digest_alg],
81
+ moment: Time.now.utc.iso8601
82
+ )
83
+ constitutive_chain.append_content_attestation(entry)
84
+
85
+ text_content(JSON.pretty_generate({
86
+ status: 'attested',
87
+ entry: entry.to_h,
88
+ entry_hash: entry.entry_hash
89
+ }))
90
+ rescue StandardError => e
91
+ text_content(JSON.pretty_generate({ status: 'error', message: e.message }))
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KairosMcp
4
+ module SkillSets
5
+ module Synoptis
6
+ module Tools
7
+ # ACT-4: when the policy proposed a context and the human declined, append a
8
+ # content-free decision record to the operational log, keyed by subject id. An
9
+ # approval writes NO decision record — it is evidenced by the content-attestation
10
+ # entry it produced (via l2_attestation_commit), not duplicated here.
11
+ #
12
+ # A decline is not permanent in effect: an evolved criterion may re-propose, and
13
+ # that is a fresh decision, not a reversal. The record binds no content and no
14
+ # artifact (LED-1), so a declined context may still be freely edited or die.
15
+ class L2AttestationDecline < KairosMcp::Tools::BaseTool
16
+ include ::Synoptis::ToolHelpers
17
+
18
+ def name
19
+ 'l2_attestation_decline'
20
+ end
21
+
22
+ def description
23
+ 'Record that a proposed L2 context attestation was declined by the human. Appends a content-free decision record to the operational log (ACT-4). Does not constrain the context.'
24
+ end
25
+
26
+ def category
27
+ :attestation
28
+ end
29
+
30
+ def usecase_tags
31
+ %w[attestation decline constitutive l2 audit]
32
+ end
33
+
34
+ def related_tools
35
+ %w[l2_attestation_scan l2_attestation_commit]
36
+ end
37
+
38
+ def input_schema
39
+ {
40
+ type: 'object',
41
+ properties: {
42
+ subject: { type: 'string', description: 'The proposed L2 context that was declined, as a context:// URI.' }
43
+ },
44
+ required: %w[subject]
45
+ }
46
+ end
47
+
48
+ def call(arguments)
49
+ subject = arguments['subject']
50
+ unless subject
51
+ return text_content(JSON.pretty_generate({ status: 'error', message: 'subject is required' }))
52
+ end
53
+
54
+ constitutive_chain.append_decline(subject_id: subject)
55
+
56
+ text_content(JSON.pretty_generate({
57
+ status: 'declined',
58
+ subject: subject,
59
+ note: 'A content-free decline was logged. The context is unchanged and may still be edited or allowed to die.'
60
+ }))
61
+ rescue StandardError => e
62
+ text_content(JSON.pretty_generate({ status: 'error', message: e.message }))
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KairosMcp
4
+ module SkillSets
5
+ module Synoptis
6
+ module Tools
7
+ # ACT-5 (liveness) + ACT-2 (criterion): fire the selection criterion at a trigger
8
+ # point, surface judgment-bearing L2 contexts as PROPOSALS (no attestation, no
9
+ # obligation), and append one trigger record to the operational log so that
10
+ # "fired and surfaced N" is distinguishable from "never ran".
11
+ #
12
+ # Propose-only: this tool writes only telemetry (a trigger record). Nothing is
13
+ # attested here — approval happens in l2_attestation_commit (ACT-1).
14
+ class L2AttestationScan < KairosMcp::Tools::BaseTool
15
+ include ::Synoptis::ToolHelpers
16
+
17
+ def name
18
+ 'l2_attestation_scan'
19
+ end
20
+
21
+ def description
22
+ 'Propose judgment-bearing L2 contexts (handoff/decision/debrief) from a session for constitutive attestation. Surfaces proposals only; approve each via l2_attestation_commit. Logs a trigger record (ACT-5).'
23
+ end
24
+
25
+ def category
26
+ :attestation
27
+ end
28
+
29
+ def usecase_tags
30
+ %w[attestation propose constitutive l2 audit]
31
+ end
32
+
33
+ def related_tools
34
+ %w[l2_attestation_commit l2_attestation_decline]
35
+ end
36
+
37
+ def input_schema
38
+ {
39
+ type: 'object',
40
+ properties: {
41
+ session_id: { type: 'string', description: 'Session to scan (default: most recently modified session).' }
42
+ }
43
+ }
44
+ end
45
+
46
+ def call(arguments)
47
+ session_id = arguments['session_id'] || latest_session_id
48
+ unless session_id
49
+ return text_content(JSON.pretty_generate({ status: 'error', message: 'No session found to scan.' }))
50
+ end
51
+
52
+ proposals = proposal_criterion.propose(session_id: session_id)
53
+ constitutive_chain.append_trigger(surfaced_count: proposals.length)
54
+
55
+ text_content(JSON.pretty_generate({
56
+ status: 'proposed',
57
+ session_id: session_id,
58
+ surfaced_count: proposals.length,
59
+ proposals: proposals,
60
+ note: 'Approve each via l2_attestation_commit(subject:, approved: true), or record a decline via l2_attestation_decline(subject:).'
61
+ }))
62
+ rescue StandardError => e
63
+ text_content(JSON.pretty_generate({ status: 'error', message: e.message }))
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.34.0
4
+ version: 3.35.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-03 00:00:00.000000000 Z
11
+ date: 2026-07-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -624,6 +624,10 @@ files:
624
624
  - templates/skillsets/synoptis/lib/synoptis.rb
625
625
  - templates/skillsets/synoptis/lib/synoptis/attestation_engine.rb
626
626
  - templates/skillsets/synoptis/lib/synoptis/challenge_manager.rb
627
+ - templates/skillsets/synoptis/lib/synoptis/constitutive/attestation_chain.rb
628
+ - templates/skillsets/synoptis/lib/synoptis/constitutive/content_attestation_entry.rb
629
+ - templates/skillsets/synoptis/lib/synoptis/constitutive/proposal_criterion.rb
630
+ - templates/skillsets/synoptis/lib/synoptis/constitutive/subject_ref.rb
627
631
  - templates/skillsets/synoptis/lib/synoptis/meeting_trust_adapter.rb
628
632
  - templates/skillsets/synoptis/lib/synoptis/proof_envelope.rb
629
633
  - templates/skillsets/synoptis/lib/synoptis/registry/file_registry.rb
@@ -637,6 +641,7 @@ files:
637
641
  - templates/skillsets/synoptis/lib/synoptis/trust_scorer.rb
638
642
  - templates/skillsets/synoptis/lib/synoptis/verifier.rb
639
643
  - templates/skillsets/synoptis/skillset.json
644
+ - templates/skillsets/synoptis/test/test_l2_constitutive_slice1.rb
640
645
  - templates/skillsets/synoptis/test/test_synoptis.rb
641
646
  - templates/skillsets/synoptis/tools/attestation_issue.rb
642
647
  - templates/skillsets/synoptis/tools/attestation_list.rb
@@ -644,6 +649,9 @@ files:
644
649
  - templates/skillsets/synoptis/tools/attestation_verify.rb
645
650
  - templates/skillsets/synoptis/tools/challenge_create.rb
646
651
  - templates/skillsets/synoptis/tools/challenge_respond.rb
652
+ - templates/skillsets/synoptis/tools/l2_attestation_commit.rb
653
+ - templates/skillsets/synoptis/tools/l2_attestation_decline.rb
654
+ - templates/skillsets/synoptis/tools/l2_attestation_scan.rb
647
655
  - templates/skillsets/synoptis/tools/trust_query.rb
648
656
  - templates/storage/embeddings/knowledge/.gitkeep
649
657
  - templates/storage/embeddings/skills/.gitkeep