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 +4 -4
- data/CHANGELOG.md +33 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/synoptis/config/synoptis.yml +10 -0
- data/templates/skillsets/synoptis/lib/synoptis/constitutive/attestation_chain.rb +67 -0
- data/templates/skillsets/synoptis/lib/synoptis/constitutive/content_attestation_entry.rb +70 -0
- data/templates/skillsets/synoptis/lib/synoptis/constitutive/proposal_criterion.rb +44 -0
- data/templates/skillsets/synoptis/lib/synoptis/constitutive/subject_ref.rb +96 -0
- data/templates/skillsets/synoptis/lib/synoptis/registry/file_registry.rb +21 -0
- data/templates/skillsets/synoptis/lib/synoptis/tool_helpers.rb +33 -0
- data/templates/skillsets/synoptis/lib/synoptis.rb +4 -0
- data/templates/skillsets/synoptis/skillset.json +4 -1
- data/templates/skillsets/synoptis/test/test_l2_constitutive_slice1.rb +189 -0
- data/templates/skillsets/synoptis/tools/l2_attestation_commit.rb +97 -0
- data/templates/skillsets/synoptis/tools/l2_attestation_decline.rb +68 -0
- data/templates/skillsets/synoptis/tools/l2_attestation_scan.rb +69 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 790cf38c2263e065c856211e0dce54a56a4e9599b2b028dbd3335405f78d069f
|
|
4
|
+
data.tar.gz: 3a568dc9d900c4aefc24572657043c82d94ea62027a349f6c3e20094d778cbb7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|