kairos-chain 3.45.0 → 3.46.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: ea7401710d6467287e35146c84d731c08733f184fac36be13e76de11d7af5433
4
- data.tar.gz: a823ba8bf1bb1157394ff04466cdcf96c0347eee50796816fe8c2fd1d188792c
3
+ metadata.gz: 372dd921a298d45c97b957ae724a253bb22da73b5bb211025fe894eab5de4ff7
4
+ data.tar.gz: f15e37a2caeef2d4d08b92f49608aa52307b1a93124a1742d3f40c10ae145bf8
5
5
  SHA512:
6
- metadata.gz: a11ba74c10eb1c56ef15b57992755083b6d36bd4fef1ea73bf1cae64afc1f07ad26f8774ee703c3c98eefa25c8a422441c5c5af032b9ae523d7518c7dc50193d
7
- data.tar.gz: 26f95dd3245e9e39311349ef540954d200985a59bf9da38b3b08256d11ec50a445d60cc3f0c41538b6a0c0bfb2eb591ce742dc4523950d9ffe8f06471d22e50a
6
+ metadata.gz: c9fc60d9acee8dcb8b7a2bfb43021d3c8b129cb94471fe8488092683ade4755665faa72860fb8c1bed77d254e977226c3d8cd85bec4dfe95eea2d0dc12352190
7
+ data.tar.gz: e5769c4b23ce1daef9704423f41e097d8969f5b86442ab1592beab4e3dc7623a3a79e30aba51d7ca2d5621e55fd635db740b5489761995bc2b9b33de8eb0b80e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,43 @@ 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.46.0] - 2026-07-22
8
+
9
+ ### Synoptis — reproduction endorsement rpr-1 (AUD-L3 slice 1)
10
+
11
+ Implements AUD-L3 design v0.4 (RPR-1..5, review-converged) slice 1 in the
12
+ synoptis SkillSet. All additive: rpr-1 builds on map-1/khab-1 exactly as
13
+ map-1 built on khab-1; no pre-existing file changed.
14
+
15
+ - `conventions/rpr-1.md`: content-addressed reproduction-endorsement
16
+ convention — re-execution target (inputs/environment/pipeline/output
17
+ digests; the first three form the committed computation identification,
18
+ output excluded), target-bound result-free tolerance declaration
19
+ (bit-identity at rpr-1), reproduction endorsement carrying the adjudicated
20
+ verdict (reproduced / not-reproduced) and its adjudication mode
21
+ (hand / procedure), declaration-set assessment (order-independent, sibling
22
+ targets pooled by computation identification, residue disclosed), map-1 §3
23
+ retraction reused unchanged.
24
+ - `reproduction.rb`: build/parse/digest for target, tolerance, endorsement
25
+ (closed schemas, canonical-serialization equality, refuse-not-coerce);
26
+ endorsement signing/verification via the unchanged map-1 §1.1 attestation
27
+ signature (ChainCredential); foreignness conformance check (RPR-4);
28
+ declaration-set assessment reporting invoked commitment (earliest
29
+ anterior), posterior commitments, digest-tiebroken rank, distinct-digest
30
+ multiplicity, and unresolved-equivalence residue.
31
+ - `bin/rpr_verify.rb`: offline auditor verifier (target / tolerance /
32
+ endorsement / assess / convention). Strict intake (at most one trailing
33
+ LF via delete_suffix — CRLF refused), canonical base-10 numerals only,
34
+ exit-code discipline (0 verified/report, 1 rejected, 2 unresolvable),
35
+ RPR-1/RPR-4 condition disclosure in output.
36
+ - `test/test_rpr_reproduction.rb`: 37 design-constraint assertions
37
+ (RPR-1..5), including presentation-order independence of the assessment
38
+ and rank, CRLF/padding refusal, and map-1 non-interference (75/75
39
+ regression green).
40
+
41
+ Implementation review: 3 rounds, R3 5/5 APPROVE, zero remaining P0/P1
42
+ (executable-probe personas; dispositions in docs/drafts/).
43
+
7
44
  ## [3.45.0] - 2026-07-21
8
45
 
9
46
  ### Synoptis — mutual anchoring protocol MAP-1..4 (map-1, AUD-L2)
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.45.0"
2
+ VERSION = "3.46.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # rpr_verify — offline auditor verifier for rpr-1 reproduction-endorsement
5
+ # artifacts (RPR-1..5, aud_l3_reproducibility_design v0.4).
6
+ #
7
+ # Verifies with EXACTLY the disclosed trust base: the artifacts named on the
8
+ # command line plus the shipped rpr-1 and map-1 convention definitions. No
9
+ # registry, no network, no operator cooperation. (Authentic chain views are
10
+ # the caller's inputs; their integrity is the khab-1/map-1 surfaces' business.)
11
+ #
12
+ # Usage:
13
+ # rpr_verify.rb target <target.json>
14
+ # rpr_verify.rb tolerance <tolerance.json> [target.json]
15
+ # with target.json: additionally checks the binding matches that target.
16
+ # rpr_verify.rb endorsement <endorsement.json> <endorser_credential.json> <signature-hex> [operator_credential.json]
17
+ # with operator credential: additionally checks foreignness (RPR-4).
18
+ # rpr_verify.rb assess <targets.json> <declarations.json> <invoked_tolerance_sha256> <endorsement_position>
19
+ # targets.json = JSON array of target record STRINGS
20
+ # declarations.json = JSON array of {"tolerance": "<record string>", "position": <int>}
21
+ # rpr_verify.rb convention
22
+ # prints the shipped rpr-1 convention digest.
23
+ #
24
+ # Exit status: 0 = VERIFIED/REPORT, 1 = REJECTED, 2 = usage / unresolvable.
25
+
26
+ require 'json'
27
+ require 'digest'
28
+ require_relative '../lib/synoptis/anchoring/entry'
29
+ require_relative '../lib/synoptis/anchoring/chain_credential'
30
+ require_relative '../lib/synoptis/anchoring/reproduction'
31
+
32
+ CRED = Synoptis::Anchoring::ChainCredential
33
+ REPRO = Synoptis::Anchoring::Reproduction
34
+
35
+ def die(msg)
36
+ warn "rpr_verify: #{msg}"
37
+ exit 2
38
+ end
39
+
40
+ def reject(msg)
41
+ puts "REJECTED: #{msg}"
42
+ exit 1
43
+ end
44
+
45
+ # Read a record artifact: at most one trailing newline is tolerated (file
46
+ # convenience); any other surrounding bytes stay in the string and fail the
47
+ # canonical check downstream (refuse-not-coerce — a padded artifact is not
48
+ # the canonical record).
49
+ def read_file(path)
50
+ # delete_suffix, not chomp: chomp("\n") also swallows "\r\n" and a bare
51
+ # trailing "\r", which would coerce CRLF artifacts into canonical form.
52
+ File.read(path).delete_suffix("\n")
53
+ rescue SystemCallError => e
54
+ die "cannot read #{path}: #{e.message}"
55
+ end
56
+
57
+ def load_json(path)
58
+ JSON.parse(File.read(path))
59
+ rescue JSON::ParserError, SystemCallError => e
60
+ die "cannot read #{path}: #{e.message}"
61
+ end
62
+
63
+ def load_json_hash(path, label)
64
+ parsed = load_json(path)
65
+ die "#{path}: #{label} must be a JSON object, got #{parsed.class}" unless parsed.is_a?(Hash)
66
+ parsed
67
+ end
68
+
69
+ # Canonical base-10 numerals only: leading zeros are refused, not
70
+ # reinterpreted (a non-canonical numeral is a malformed input, exit 2).
71
+ def strict_int(value, label)
72
+ die "#{label} must be a canonical base-10 integer, got #{value.inspect}" unless value.is_a?(String) && value.match?(/\A(0|[1-9]\d*)\z/)
73
+ Integer(value, 10)
74
+ end
75
+
76
+ HEX64 = /\A[a-f0-9]{64}\z/
77
+
78
+ mode = ARGV.shift
79
+
80
+ case mode
81
+ when 'target'
82
+ die 'usage: rpr_verify.rb target <target.json>' unless ARGV.size == 1
83
+ record = read_file(ARGV[0])
84
+ begin
85
+ REPRO.parse_target!(record)
86
+ rescue REPRO::ReproductionError => e
87
+ reject e.message
88
+ end
89
+ puts "VERIFIED: target digest #{REPRO.target_digest(record)}"
90
+ puts " computation identification: #{REPRO.computation_id(record).split('|').map { |h| h[0, 12] }.join('… | ')}… (output excluded, rpr-1 §1)"
91
+ puts 'NOTE: binding fixes referents, never validates them (RPR-2, MPR-4).'
92
+
93
+ when 'tolerance'
94
+ die 'usage: rpr_verify.rb tolerance <tolerance.json> [target.json]' unless (1..2).cover?(ARGV.size)
95
+ record = read_file(ARGV[0])
96
+ tol = begin
97
+ REPRO.parse_tolerance!(record)
98
+ rescue REPRO::ReproductionError => e
99
+ reject e.message
100
+ end
101
+ if ARGV[1]
102
+ target = read_file(ARGV[1])
103
+ begin
104
+ digest = REPRO.target_digest(target)
105
+ rescue REPRO::ReproductionError => e
106
+ reject e.message
107
+ end
108
+ reject "tolerance binds target #{tol['target_sha256'][0, 12]}…, not the supplied target #{digest[0, 12]}…" unless tol['target_sha256'] == digest
109
+ end
110
+ puts "VERIFIED: tolerance digest #{Digest::SHA256.hexdigest(record)} (kind #{tol['kind']}, target #{tol['target_sha256'][0, 12]}…)"
111
+ puts 'NOTE: anteriority to any endorsement is decided by committed order (MPR-8), not by this tool.'
112
+
113
+ when 'endorsement'
114
+ die 'usage: rpr_verify.rb endorsement <endorsement.json> <endorser_credential.json> <signature-hex> [operator_credential.json]' unless (3..4).cover?(ARGV.size)
115
+ record = read_file(ARGV[0])
116
+ cred = load_json_hash(ARGV[1], 'endorser credential')
117
+ ok = begin
118
+ REPRO.verify_endorsement(record, cred, ARGV[2])
119
+ rescue REPRO::ReproductionError, CRED::CredentialError => e
120
+ reject e.message
121
+ end
122
+ reject 'endorsement signature does not verify under the endorser credential' unless ok
123
+ e = REPRO.parse_endorsement!(record)
124
+ if ARGV[3]
125
+ operator = load_json_hash(ARGV[3], 'operator credential')
126
+ begin
127
+ reject 'endorser credential equals operator credential — same-party, not a conforming rpr-1 endorsement (RPR-4)' unless REPRO.foreign?(cred, operator)
128
+ rescue CRED::CredentialError => err
129
+ # An unresolvable operator credential is a caller-input problem, not a
130
+ # verdict about the endorsement (exit-code discipline: 2, not 1).
131
+ die "operator credential unresolvable: #{err.message}"
132
+ end
133
+ end
134
+ puts "VERIFIED: #{e['verdict']} endorsement of target #{e['target_sha256'][0, 12]}… under tolerance #{e['tolerance_sha256'][0, 12]}…"
135
+ puts " adjudication mode: #{e['adjudication_mode']}#{e['procedure_sha256'] ? " (procedure #{e['procedure_sha256'][0, 12]}…)" : ''}"
136
+ puts 'CONDITIONS (travel with every stronger reading, rpr-1 §3):'
137
+ puts ' - the verdict asserts reproduction or its failure, never correctness (RPR-1, MPR-6)'
138
+ puts ' - evidence of a committed claim, never proof a re-execution happened (RPR-4)'
139
+ puts ' - distinctness is not independence; a colluding pair can fabricate (RPR-4)'
140
+ puts ' - the named mode is a declaration, not a proof (map-1 §3 non-self-certifying)'
141
+ if ARGV[3]
142
+ puts ' - foreignness checked against the SUPPLIED operator credential only'
143
+ else
144
+ puts ' - foreignness (RPR-4 conformance condition) NOT assessed — no operator credential supplied'
145
+ end
146
+
147
+ when 'assess'
148
+ die 'usage: rpr_verify.rb assess <targets.json> <declarations.json> <invoked_tolerance_sha256> <endorsement_position>' unless ARGV.size == 4
149
+ targets = load_json(ARGV[0])
150
+ die "#{ARGV[0]}: expected a JSON array of target record strings" unless targets.is_a?(Array) && targets.all? { |t| t.is_a?(String) }
151
+ declarations = load_json(ARGV[1])
152
+ die "#{ARGV[1]}: expected a JSON array of {tolerance, position} objects" unless declarations.is_a?(Array)
153
+ invoked = ARGV[2]
154
+ die 'invoked_tolerance_sha256 must be 64-char lowercase hex' unless invoked.match?(HEX64)
155
+ position = strict_int(ARGV[3], 'endorsement_position')
156
+ begin
157
+ report = REPRO.assess_declarations(targets: targets, declarations: declarations,
158
+ invoked_tolerance_sha256: invoked, endorsement_position: position)
159
+ rescue REPRO::ReproductionError => e
160
+ reject e.message
161
+ end
162
+ puts JSON.pretty_generate(report)
163
+ puts 'NOTE: the assessment reports, the reader prices (RPR-3: exposure, not prevention).'
164
+ puts 'NOTE: a produced report always exits 0; the conformance bit is invoked_conforming in the report.'
165
+
166
+ when 'convention'
167
+ puts "rpr-1 convention sha256: #{REPRO.convention_sha256}"
168
+
169
+ else
170
+ die 'usage: rpr_verify.rb target|tolerance|endorsement|assess|convention ... (see header)'
171
+ end
@@ -0,0 +1,141 @@
1
+ # rpr-1 — KairosChain Reproduction Endorsement Convention, version 1
2
+
3
+ Status: committed verification convention (RPR-1..5 of
4
+ aud_l3_reproducibility_design v0.4). The SHA-256 over this file's raw bytes is
5
+ the convention integrity digest; every artifact that names `rpr-1` commits it
6
+ as its convention reference. A verifier holding a definition whose digest does
7
+ not match the committed one MUST treat the artifact's convention as
8
+ unresolvable and refuse to verify under it. Any change to this convention is a
9
+ new identifier (`rpr-2`, ...); this file is never edited in place.
10
+ rpr-1 builds on map-1 (credentials, attestation signatures, attestation types,
11
+ retraction) and khab-1 (committed identity, committed order) and modifies
12
+ neither: a map-1 artifact is valid with or without any rpr-1 artifact present.
13
+
14
+ All record literals below are canonical JSON (keys sorted recursively,
15
+ `JSON.generate` compact form). A record is valid only in its canonical
16
+ serialization with exactly the fields shown — a non-canonical or extra-field
17
+ record is not an rpr-1 artifact and is ignored as noise (map-1 §4 precedent).
18
+
19
+ ## 1. Re-execution target (RPR-2)
20
+
21
+ {"environment_sha256":"<64-hex>","format":"rpr-1/target",
22
+ "input_sha256":"<64-hex>","output_sha256":"<64-hex>",
23
+ "pipeline_sha256":"<64-hex>"}
24
+
25
+ The target commits both halves of the verdict's referent: what is re-run
26
+ (inputs, environment, pipeline version) and what the re-run output is judged
27
+ against (the committed output). The first three digests — `input_sha256`,
28
+ `environment_sha256`, `pipeline_sha256` — constitute the target's **committed
29
+ computation identification** (design §3(b)): the identification of what is
30
+ re-run, which two targets can share while naming different committed outputs.
31
+ `output_sha256` completes the target but does not participate in that
32
+ identification. The target digest is the SHA-256 of the target's canonical
33
+ JSON. What the digests are digests OF (file formats, environment pinning,
34
+ pipeline identity) is the referenced material's business; rpr-1 commits
35
+ references, it does not validate referents (MPR-4 asymmetry, RPR-2 limit).
36
+
37
+ ## 2. Tolerance declaration (RPR-3)
38
+
39
+ {"format":"rpr-1/tolerance","kind":"bit-identity","target_sha256":"<64-hex>"}
40
+
41
+ A tolerance declaration is bound to a named target and is result-free by
42
+ construction: the schema is closed and `kind` is the only expressive field, so
43
+ no outcome-referencing content can be expressed. At rpr-1 the only kind is
44
+ `bit-identity` (re-run output matches the committed output byte for byte);
45
+ richer tolerance grammars (numerical bounds, domain equivalence) are a new
46
+ convention — this narrowness is disclosed, not hidden. The declaration is
47
+ committed as an internal-chain record ahead of the endorsements it governs;
48
+ committed position provides decidable anteriority (MPR-8). One declaration has
49
+ exactly one digest (canonical serialization).
50
+
51
+ ### 2.1 Declaration-set assessment (RPR-3)
52
+
53
+ Conformance of an endorsement's tolerance is assessed against the record, not
54
+ the endorser's selection. Given target records, tolerance declarations with
55
+ their committed positions, and the endorsement's committed position, the
56
+ assessment reports:
57
+
58
+ - every anterior declaration bound to the endorsement's target;
59
+ - every anterior declaration bound to any sibling target sharing the same
60
+ committed computation identification (§1) — a menu spread across
61
+ near-duplicate targets is the same menu and is read as one set (at rpr-1,
62
+ sharing = equality of the three computation-identification digests);
63
+ - the multiplicity of that pooled set and the invoked declaration's place in
64
+ it;
65
+ - the unresolved residue: declarations whose `target_sha256` resolves to no
66
+ supplied target record (sharing undecidable there), disclosed rather than
67
+ silently dropped.
68
+
69
+ The assessment reports; the reader prices. A verdict judged under one of many
70
+ anterior declarations is conforming and wears the multiplicity openly; a
71
+ tolerance chosen after the result is non-conforming (its posteriority is
72
+ decidable from committed order). The assessment is a function of the committed
73
+ record alone, never of presentation order: a declaration committed at several
74
+ positions is one declaration (one digest) with several commitments; the
75
+ invoked declaration conforms iff any of its commitments is anterior,
76
+ represented by the earliest, with posterior commitments disclosed alongside;
77
+ multiplicity counts distinct declarations, and the invoked declaration's
78
+ place is reported as its rank in the pooled anterior set ordered by earliest
79
+ anterior position, with equal positions ordered by declaration digest — so
80
+ every reported quantity, ranks included, is decidable from the committed
81
+ record alone.
82
+
83
+ ## 3. Reproduction endorsement (RPR-1, RPR-4)
84
+
85
+ hand-adjudicated:
86
+
87
+ {"adjudication_mode":"hand","format":"rpr-1/endorsement",
88
+ "target_sha256":"<64-hex>","tolerance_sha256":"<64-hex>",
89
+ "verdict":"reproduced"}
90
+
91
+ procedure-adjudicated:
92
+
93
+ {"adjudication_mode":"procedure","format":"rpr-1/endorsement",
94
+ "procedure_sha256":"<64-hex>","target_sha256":"<64-hex>",
95
+ "tolerance_sha256":"<64-hex>","verdict":"not-reproduced"}
96
+
97
+ `verdict` is `reproduced` or `not-reproduced`: the affirmative and the
98
+ negative verdict are the same kind of record under the same bindings, so the
99
+ record has a conforming place for failure as well as success (RPR-1).
100
+ `adjudication_mode` names the gate behind the verdict (RPR-4): `hand`, or
101
+ `procedure` with `procedure_sha256` naming the committed adjudication
102
+ procedure whose anterior adoption is the judgment act. `procedure_sha256` is
103
+ present exactly when the mode is `procedure` (closed schema per mode). The
104
+ named mode, like the attestation type itself, is a declaration and not a
105
+ proof (map-1 §3 non-self-certifying).
106
+
107
+ The endorsement is signed under the endorser's map-1 credential using the
108
+ map-1 §1.1 attestation signature, with the payload being the endorsement
109
+ record string. Verification needs only the credential, the record, and the
110
+ signature. The endorsement is carried with `attestation_type` =
111
+ `quality-endorsement` (map-1 §3, unchanged).
112
+
113
+ Foreignness (RPR-4) is a conformance condition: an endorsement whose issuer
114
+ credential digest equals the operator credential digest for the target's
115
+ chain is not a conforming rpr-1 endorsement, whatever else it may be as an
116
+ ordinary same-party claim. Distinctness is not independence: a colluding pair
117
+ can fabricate an endorsement, affirmative or negative, without any run having
118
+ occurred — the endorsement is evidence of a committed claim, never proof that
119
+ a re-execution happened (RPR-4 disclosed limit).
120
+
121
+ What a verified endorsement asserts is exactly: this endorser claimed, at a
122
+ committed moment, that the target was re-run and its output did or did not
123
+ match the target's committed output within the named tolerance — and nothing
124
+ else. No claim of correctness, validity, fitness, or significance (RPR-1,
125
+ MPR-6); absence of any endorsement proves nothing (MPR-9 non-production).
126
+
127
+ ## 4. Retraction (RPR-5)
128
+
129
+ A mistaken endorsement is withdrawn by the map-1 §3 `retraction` unchanged:
130
+ same issuer, unambiguous target, appended. A retracted endorsement still
131
+ stands as a committed record that the verdict was once claimed and later
132
+ taken back; nothing is unsaid by deletion and no anchored commitment's
133
+ coverage is subtracted (AHM-4).
134
+
135
+ ## 5. Verification procedure and trust base
136
+
137
+ All rpr-1 verification needs only: the artifacts named above, this
138
+ definition, the map-1 definition (for credentials, signatures, types, and
139
+ retraction), and authentic views of the logs/chains the artifacts reference.
140
+ No registry, no network, no operator cooperation for verification —
141
+ production of artifacts remains the endorser's act (MPR-4 asymmetry).
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require_relative 'entry'
6
+ require_relative 'chain_credential'
7
+
8
+ module Synoptis
9
+ module Anchoring
10
+ # Reproduction endorsement artifacts (aud_l3_reproducibility_design v0.4,
11
+ # RPR-1..5) under the rpr-1 convention: re-execution target, tolerance
12
+ # declaration, reproduction endorsement, and the declaration-set
13
+ # assessment. rpr-1 builds on map-1 (credentials, §1.1 attestation
14
+ # signatures, types, retraction) and modifies nothing there: signing and
15
+ # verification are ChainCredential calls, retraction is the map-1 §3 act
16
+ # unchanged. Refuse-not-coerce for authored artifacts; the assessment is
17
+ # diagnostic and reports rather than enforces (RPR-3: exposure, not
18
+ # prevention).
19
+ module Reproduction
20
+ CONVENTION_ID = 'rpr-1'
21
+ CONVENTION_PATH = File.expand_path('conventions/rpr-1.md', __dir__)
22
+ TARGET_FORMAT = 'rpr-1/target'
23
+ TOLERANCE_FORMAT = 'rpr-1/tolerance'
24
+ ENDORSEMENT_FORMAT = 'rpr-1/endorsement'
25
+ TOLERANCE_KINDS = %w[bit-identity].freeze
26
+ VERDICTS = %w[reproduced not-reproduced].freeze
27
+ MODES = %w[hand procedure].freeze
28
+ HEX_DIGEST = /\A[a-f0-9]{64}\z/
29
+
30
+ # Exactly these fields, sorted (closed schemas; extension = rpr-2).
31
+ TARGET_FIELDS = %w[environment_sha256 format input_sha256 output_sha256 pipeline_sha256].freeze
32
+ # The first three digests in field-name-independent terms: what is
33
+ # re-run. output_sha256 completes the target but is OUTSIDE the
34
+ # committed computation identification (design §3(b)).
35
+ COMPUTATION_ID_FIELDS = %w[environment_sha256 input_sha256 pipeline_sha256].freeze
36
+ TOLERANCE_FIELDS = %w[format kind target_sha256].freeze
37
+ ENDORSEMENT_FIELDS_HAND = %w[adjudication_mode format target_sha256 tolerance_sha256 verdict].freeze
38
+ ENDORSEMENT_FIELDS_PROCEDURE = %w[adjudication_mode format procedure_sha256 target_sha256 tolerance_sha256 verdict].freeze
39
+
40
+ class ReproductionError < StandardError; end
41
+
42
+ module_function
43
+
44
+ # SHA-256 of the shipped rpr-1 convention definition's raw bytes.
45
+ def convention_sha256
46
+ @convention_sha256 ||= begin
47
+ Digest::SHA256.hexdigest(File.binread(CONVENTION_PATH))
48
+ rescue SystemCallError => e
49
+ raise ReproductionError, "rpr-1 convention definition unreadable at #{CONVENTION_PATH}: #{e.message}"
50
+ end
51
+ end
52
+
53
+ # -- re-execution target (rpr-1 §1, RPR-2) --
54
+
55
+ def build_target(input_sha256:, environment_sha256:, pipeline_sha256:, output_sha256:)
56
+ record = {
57
+ 'environment_sha256' => environment_sha256,
58
+ 'format' => TARGET_FORMAT,
59
+ 'input_sha256' => input_sha256,
60
+ 'output_sha256' => output_sha256,
61
+ 'pipeline_sha256' => pipeline_sha256
62
+ }
63
+ %w[input_sha256 environment_sha256 pipeline_sha256 output_sha256].each do |k|
64
+ unless record[k].is_a?(String) && record[k].match?(HEX_DIGEST)
65
+ raise ReproductionError, "target.#{k} must be 64-char lowercase hex, got #{record[k].inspect}"
66
+ end
67
+ end
68
+ Entry.canonical_json(record)
69
+ end
70
+
71
+ def parse_target!(target_string)
72
+ parse_record!(target_string, TARGET_FORMAT, TARGET_FIELDS, 'target') do |t|
73
+ (TARGET_FIELDS - %w[format]).each do |k|
74
+ raise ReproductionError, "target.#{k} must be 64-char lowercase hex" unless t[k].is_a?(String) && t[k].match?(HEX_DIGEST)
75
+ end
76
+ end
77
+ end
78
+
79
+ def target_digest(target_string)
80
+ parse_target!(target_string)
81
+ Digest::SHA256.hexdigest(target_string.to_s)
82
+ end
83
+
84
+ # The committed computation identification (design §3(b)): the first
85
+ # three digests. Two targets SHARE a computation iff these are equal;
86
+ # output_sha256 never participates.
87
+ def computation_id(target_string)
88
+ t = parse_target!(target_string)
89
+ COMPUTATION_ID_FIELDS.map { |k| t[k] }.join('|')
90
+ end
91
+
92
+ # -- tolerance declaration (rpr-1 §2, RPR-3) --
93
+
94
+ def build_tolerance(target_sha256:, kind: 'bit-identity')
95
+ raise ReproductionError, "tolerance.kind must be one of #{TOLERANCE_KINDS.join(', ')}, got #{kind.inspect}" unless TOLERANCE_KINDS.include?(kind.to_s)
96
+ unless target_sha256.is_a?(String) && target_sha256.match?(HEX_DIGEST)
97
+ raise ReproductionError, 'tolerance.target_sha256 must be 64-char lowercase hex'
98
+ end
99
+
100
+ Entry.canonical_json('format' => TOLERANCE_FORMAT, 'kind' => kind.to_s, 'target_sha256' => target_sha256)
101
+ end
102
+
103
+ def parse_tolerance!(tolerance_string)
104
+ parse_record!(tolerance_string, TOLERANCE_FORMAT, TOLERANCE_FIELDS, 'tolerance') do |t|
105
+ raise ReproductionError, "tolerance.kind must be one of #{TOLERANCE_KINDS.join(', ')}" unless TOLERANCE_KINDS.include?(t['kind'])
106
+ unless t['target_sha256'].is_a?(String) && t['target_sha256'].match?(HEX_DIGEST)
107
+ raise ReproductionError, 'tolerance.target_sha256 must be 64-char lowercase hex'
108
+ end
109
+ end
110
+ end
111
+
112
+ def tolerance_digest(tolerance_string)
113
+ parse_tolerance!(tolerance_string)
114
+ Digest::SHA256.hexdigest(tolerance_string.to_s)
115
+ end
116
+
117
+ # -- reproduction endorsement (rpr-1 §3, RPR-1/RPR-4) --
118
+
119
+ def build_endorsement(target_sha256:, tolerance_sha256:, verdict:, adjudication_mode:, procedure_sha256: nil)
120
+ mode = adjudication_mode.to_s
121
+ raise ReproductionError, "verdict must be one of #{VERDICTS.join(', ')}, got #{verdict.inspect}" unless VERDICTS.include?(verdict.to_s)
122
+ raise ReproductionError, "adjudication_mode must be one of #{MODES.join(', ')}, got #{mode.inspect}" unless MODES.include?(mode)
123
+
124
+ record = {
125
+ 'adjudication_mode' => mode,
126
+ 'format' => ENDORSEMENT_FORMAT,
127
+ 'target_sha256' => target_sha256,
128
+ 'tolerance_sha256' => tolerance_sha256,
129
+ 'verdict' => verdict.to_s
130
+ }
131
+ if mode == 'procedure'
132
+ unless procedure_sha256.is_a?(String) && procedure_sha256.match?(HEX_DIGEST)
133
+ raise ReproductionError, 'procedure mode requires procedure_sha256 as 64-char lowercase hex (RPR-4: the adopted procedure is named)'
134
+ end
135
+ record['procedure_sha256'] = procedure_sha256
136
+ elsif !procedure_sha256.nil?
137
+ raise ReproductionError, 'hand mode must not carry procedure_sha256 (closed schema per mode)'
138
+ end
139
+ %w[target_sha256 tolerance_sha256].each do |k|
140
+ raise ReproductionError, "endorsement.#{k} must be 64-char lowercase hex" unless record[k].is_a?(String) && record[k].match?(HEX_DIGEST)
141
+ end
142
+ Entry.canonical_json(record)
143
+ end
144
+
145
+ def parse_endorsement!(endorsement_string)
146
+ parsed = json_object!(endorsement_string, 'endorsement')
147
+ e = parsed.transform_keys(&:to_s)
148
+ raise ReproductionError, "unknown format #{e['format'].inspect} (#{ENDORSEMENT_FORMAT} only)" unless e['format'] == ENDORSEMENT_FORMAT
149
+ raise ReproductionError, "adjudication_mode must be one of #{MODES.join(', ')}" unless MODES.include?(e['adjudication_mode'])
150
+
151
+ expected = e['adjudication_mode'] == 'procedure' ? ENDORSEMENT_FIELDS_PROCEDURE : ENDORSEMENT_FIELDS_HAND
152
+ keys = e.keys.sort
153
+ raise ReproductionError, "endorsement fields must be exactly #{expected.join(', ')}, got #{keys.join(', ')}" unless keys == expected
154
+ raise ReproductionError, "verdict must be one of #{VERDICTS.join(', ')}" unless VERDICTS.include?(e['verdict'])
155
+ (expected - %w[format verdict adjudication_mode]).each do |k|
156
+ raise ReproductionError, "endorsement.#{k} must be 64-char lowercase hex" unless e[k].is_a?(String) && e[k].match?(HEX_DIGEST)
157
+ end
158
+ unless Entry.canonical_json(e) == endorsement_string.to_s
159
+ raise ReproductionError, 'endorsement is not in canonical serialization (one record, one digest)'
160
+ end
161
+
162
+ e
163
+ end
164
+
165
+ # Sign an endorsement under the endorser's map-1 credential (map-1 §1.1
166
+ # attestation signature; the payload is the endorsement record string).
167
+ def sign_endorsement(endorsement_string, credential, key)
168
+ parse_endorsement!(endorsement_string)
169
+ ChainCredential.sign_attestation(credential, key, endorsement_string.to_s)
170
+ end
171
+
172
+ # Verify signature + structure. Returns true/false for the signature;
173
+ # malformed endorsement raises (a verdict about an unresolvable record
174
+ # would be noise dressed as judgment).
175
+ def verify_endorsement(endorsement_string, credential, signature_hex)
176
+ parse_endorsement!(endorsement_string)
177
+ ChainCredential.verify_attestation(credential, endorsement_string.to_s, signature_hex)
178
+ end
179
+
180
+ # Foreignness conformance (RPR-4): a conforming rpr-1 endorsement's
181
+ # issuer credential is NOT the operator credential of the chain the
182
+ # target belongs to. Digest equality is the decidable surface at rpr-1;
183
+ # distinctness is not independence (disclosed in the convention).
184
+ def foreign?(endorser_credential, operator_credential)
185
+ ChainCredential.credential_digest(endorser_credential) !=
186
+ ChainCredential.credential_digest(operator_credential)
187
+ end
188
+
189
+ # -- declaration-set assessment (rpr-1 §2.1, RPR-3) --
190
+ #
191
+ # +targets+ Array of target record STRINGS (the resolvable universe).
192
+ # +declarations+ Array of {'tolerance' => record string, 'position' => Integer}.
193
+ # +invoked_tolerance_sha256+ digest the endorsement names.
194
+ # +endorsement_position+ committed position of the endorsement.
195
+ #
196
+ # Reports the pooled anterior declaration set for the endorsement's
197
+ # target AND its computation-identification siblings, multiplicity, the
198
+ # invoked declaration's standing, and the unresolved residue. Diagnostic:
199
+ # malformed declarations land in the residue, never raise.
200
+ def assess_declarations(targets:, declarations:, invoked_tolerance_sha256:, endorsement_position:)
201
+ pos = endorsement_position
202
+ raise ReproductionError, 'endorsement_position must be an Integer' unless pos.is_a?(Integer)
203
+
204
+ by_digest = {}
205
+ Array(targets).each do |t|
206
+ by_digest[Digest::SHA256.hexdigest(t.to_s)] = computation_id(t)
207
+ rescue ReproductionError
208
+ next # an unparsable target resolves nothing; bindings to it fall to residue
209
+ end
210
+
211
+ pooled = []
212
+ residue = []
213
+ seen = {}
214
+ Array(declarations).each do |d|
215
+ h = d.is_a?(Hash) ? d.transform_keys(&:to_s) : {}
216
+ decl = h['tolerance'].to_s
217
+ position = h['position']
218
+ digest = Digest::SHA256.hexdigest(decl)
219
+ begin
220
+ t = parse_tolerance!(decl)
221
+ rescue ReproductionError => e
222
+ residue << { tolerance_sha256: digest, reason: "malformed declaration: #{e.message}" }
223
+ next
224
+ end
225
+ unless position.is_a?(Integer)
226
+ residue << { tolerance_sha256: digest, reason: 'no committed position supplied (anteriority undecidable)' }
227
+ next
228
+ end
229
+ # A commitment is (digest, position); the same pair supplied twice is
230
+ # one commitment, not two (one declaration, one digest — rpr-1 §2).
231
+ next if seen[[digest, position]]
232
+
233
+ seen[[digest, position]] = true
234
+ comp = by_digest[t['target_sha256']]
235
+ if comp.nil?
236
+ residue << { tolerance_sha256: digest, target_sha256: t['target_sha256'],
237
+ reason: 'target unresolvable in supplied view (computation sharing undecidable)' }
238
+ next
239
+ end
240
+ pooled << { tolerance_sha256: digest, target_sha256: t['target_sha256'],
241
+ computation_id: comp, position: position, anterior: position < pos }
242
+ end
243
+
244
+ # The invoked digest may be committed at several positions; anteriority
245
+ # is a property of the committed record, not of presentation order
246
+ # (RPR-3): conforming iff ANY anterior commitment exists, represented
247
+ # by the earliest one, with posterior commitments disclosed.
248
+ invoked_commitments = pooled.select { |e| e[:tolerance_sha256] == invoked_tolerance_sha256 }
249
+ invoked_anterior = invoked_commitments.select { |e| e[:anterior] }.min_by { |e| e[:position] }
250
+ invoked = invoked_anterior || invoked_commitments.min_by { |e| e[:position] }
251
+ invoked_posterior = invoked_commitments.reject { |e| e[:anterior] }
252
+
253
+ invoked_comp = invoked && invoked[:computation_id]
254
+ set = invoked_comp.nil? ? [] : pooled.select { |e| e[:computation_id] == invoked_comp && e[:anterior] }
255
+ # Deterministic report order (position, then digest): the report's
256
+ # bytes, not just its quantities, are presentation-order-free.
257
+ set = set.sort_by { |e| [e[:position], e[:tolerance_sha256]] }
258
+ # Multiplicity counts distinct declarations (digests), not commitments;
259
+ # rank is the invoked declaration's place in the pooled anterior set,
260
+ # ordered by each declaration's earliest anterior position (rpr-1 §2.1).
261
+ earliest = set.group_by { |e| e[:tolerance_sha256] }.transform_values { |es| es.map { |e| e[:position] }.min }
262
+ # Digest tiebreak: equal earliest positions must not fall back to hash
263
+ # insertion (= presentation) order — the rank, like the verdict, is a
264
+ # function of the committed record alone (rpr-1 §2.1).
265
+ ranked = earliest.sort_by { |d, p| [p, d] }.map(&:first)
266
+ rank = invoked_anterior && (ranked.index(invoked_tolerance_sha256) + 1)
267
+ {
268
+ invoked: invoked,
269
+ invoked_conforming: !invoked_anterior.nil?,
270
+ invoked_posterior: invoked_posterior,
271
+ invoked_rank: rank,
272
+ declaration_set: set,
273
+ multiplicity: ranked.size,
274
+ residue: residue,
275
+ note: if invoked.nil?
276
+ 'invoked tolerance not found among supplied declarations (or unresolvable); nothing to assess'
277
+ elsif invoked_anterior.nil?
278
+ 'no anterior commitment of the invoked tolerance (RPR-3: non-conforming)'
279
+ elsif ranked.size > 1
280
+ "#{ranked.size} anterior declaration(s) share the invoked computation identification; the menu is visible and priced by the reader (RPR-3)"
281
+ else
282
+ 'single anterior declaration for this computation'
283
+ end
284
+ }
285
+ end
286
+
287
+ # -- internal helpers --
288
+
289
+ def json_object!(string, label)
290
+ parsed = begin
291
+ JSON.parse(string.to_s)
292
+ rescue JSON::ParserError => e
293
+ raise ReproductionError, "#{label} is not valid JSON: #{e.message}"
294
+ end
295
+ raise ReproductionError, "#{label} must be a JSON object, got #{parsed.class}" unless parsed.is_a?(Hash)
296
+
297
+ parsed
298
+ end
299
+
300
+ def parse_record!(string, format, fields, label)
301
+ parsed = json_object!(string, label)
302
+ r = parsed.transform_keys(&:to_s)
303
+ keys = r.keys.sort
304
+ raise ReproductionError, "#{label} fields must be exactly #{fields.join(', ')}, got #{keys.join(', ')}" unless keys == fields
305
+ raise ReproductionError, "unknown format #{r['format'].inspect} (#{format} only)" unless r['format'] == format
306
+
307
+ yield r if block_given?
308
+ unless Entry.canonical_json(r) == string.to_s
309
+ raise ReproductionError, "#{label} is not in canonical serialization (one record, one digest)"
310
+ end
311
+
312
+ r
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Design-constraint tests for the AUD-L3 reproduction-endorsement slice
4
+ # (aud_l3_reproducibility_design v0.4, freeze candidate; invariants RPR-1..5).
5
+ # Each block names the invariant whose implementable consequence it pins.
6
+
7
+ require 'json'
8
+ require 'digest'
9
+
10
+ anchoring = File.expand_path('../lib/synoptis/anchoring', __dir__)
11
+ require File.join(anchoring, 'entry')
12
+ require File.join(anchoring, 'chain_credential')
13
+ require File.join(anchoring, 'attestation_types')
14
+ require File.join(anchoring, 'reproduction')
15
+
16
+ Entry = Synoptis::Anchoring::Entry
17
+ Cred = Synoptis::Anchoring::ChainCredential
18
+ AType = Synoptis::Anchoring::AttestationTypes
19
+ Repro = Synoptis::Anchoring::Reproduction
20
+
21
+ $pass = 0
22
+ $fail = 0
23
+
24
+ def assert(condition, message)
25
+ if condition
26
+ $pass += 1
27
+ puts " PASS: #{message}"
28
+ else
29
+ $fail += 1
30
+ puts " FAIL: #{message}"
31
+ end
32
+ end
33
+
34
+ def assert_raises(klass, message)
35
+ yield
36
+ assert(false, "#{message} (no exception raised)")
37
+ rescue klass
38
+ assert(true, message)
39
+ rescue StandardError => e
40
+ assert(false, "#{message} (raised #{e.class}: #{e.message})")
41
+ end
42
+
43
+ HEX_A = 'a' * 64
44
+ HEX_B = 'b' * 64
45
+ HEX_C = 'c' * 64
46
+ HEX_D = 'd' * 64
47
+ HEX_E = 'e' * 64
48
+
49
+ OPERATOR_IDENTITY = "block1-sha256:#{'1' * 64}"
50
+ ENDORSER_IDENTITY = "block1-sha256:#{'2' * 64}"
51
+ OPERATOR_KEY = Cred.generate_key
52
+ ENDORSER_KEY = Cred.generate_key
53
+ OPERATOR_CRED = Cred.build(OPERATOR_IDENTITY, OPERATOR_KEY)
54
+ ENDORSER_CRED = Cred.build(ENDORSER_IDENTITY, ENDORSER_KEY)
55
+
56
+ puts '== rpr-1 convention is content-addressed =='
57
+
58
+ assert(Repro.convention_sha256.match?(/\A[a-f0-9]{64}\z/), 'rpr-1 convention digest is 64-hex (content-addressed)')
59
+
60
+ puts '== RPR-2: re-execution target, output included, closed schema =='
61
+
62
+ target = Repro.build_target(input_sha256: HEX_A, environment_sha256: HEX_B,
63
+ pipeline_sha256: HEX_C, output_sha256: HEX_D)
64
+ assert(Repro.parse_target!(target).is_a?(Hash), 'a built target parses (canonical, exact fields)')
65
+ assert(Repro.target_digest(target).match?(/\A[a-f0-9]{64}\z/), 'target digest is 64-hex of canonical JSON')
66
+
67
+ assert_raises(Repro::ReproductionError, 'extra field is refused, not coerced (closed schema)') do
68
+ extra = JSON.parse(target).merge('note' => 'x')
69
+ Repro.parse_target!(Entry.canonical_json(extra))
70
+ end
71
+
72
+ assert_raises(Repro::ReproductionError, 'non-canonical serialization is refused (one record, one digest)') do
73
+ Repro.parse_target!(JSON.pretty_generate(JSON.parse(target)))
74
+ end
75
+
76
+ assert_raises(Repro::ReproductionError, 'mutable/uncommitted referent (bad hex) is refused (RPR-2)') do
77
+ Repro.build_target(input_sha256: 'not-a-digest', environment_sha256: HEX_B,
78
+ pipeline_sha256: HEX_C, output_sha256: HEX_D)
79
+ end
80
+
81
+ puts '== §3(b): computation identification excludes the output digest =='
82
+
83
+ sibling = Repro.build_target(input_sha256: HEX_A, environment_sha256: HEX_B,
84
+ pipeline_sha256: HEX_C, output_sha256: HEX_E)
85
+ other = Repro.build_target(input_sha256: HEX_E, environment_sha256: HEX_B,
86
+ pipeline_sha256: HEX_C, output_sha256: HEX_D)
87
+ assert(Repro.computation_id(target) == Repro.computation_id(sibling),
88
+ 'targets differing only in output share the computation identification')
89
+ assert(Repro.computation_id(target) != Repro.computation_id(other),
90
+ 'targets differing in input do not share the computation identification')
91
+ assert(Repro.target_digest(target) != Repro.target_digest(sibling),
92
+ 'sibling targets remain distinct targets (identification is coarser than identity)')
93
+
94
+ puts '== RPR-3: tolerance is target-bound, result-free, closed schema =='
95
+
96
+ tol = Repro.build_tolerance(target_sha256: Repro.target_digest(target))
97
+ assert(Repro.parse_tolerance!(tol)['kind'] == 'bit-identity', 'rpr-1 tolerance kind is bit-identity (narrowness disclosed)')
98
+ assert(Repro.parse_tolerance!(tol)['target_sha256'] == Repro.target_digest(target), 'tolerance binds its target by digest')
99
+
100
+ assert_raises(Repro::ReproductionError, 'unknown tolerance kind is refused (closed schema = result-free by construction)') do
101
+ Repro.build_tolerance(target_sha256: Repro.target_digest(target), kind: 'whatever-fits')
102
+ end
103
+
104
+ puts '== RPR-1: verdict both ways, nothing else =='
105
+
106
+ endo = Repro.build_endorsement(target_sha256: Repro.target_digest(target),
107
+ tolerance_sha256: Repro.tolerance_digest(tol),
108
+ verdict: 'reproduced', adjudication_mode: 'hand')
109
+ nendo = Repro.build_endorsement(target_sha256: Repro.target_digest(target),
110
+ tolerance_sha256: Repro.tolerance_digest(tol),
111
+ verdict: 'not-reproduced', adjudication_mode: 'hand')
112
+ assert(Repro.parse_endorsement!(endo)['verdict'] == 'reproduced', 'affirmative verdict is a conforming record')
113
+ assert(Repro.parse_endorsement!(nendo)['verdict'] == 'not-reproduced', 'negative verdict is the same kind of record (RPR-1: no publication bias by vocabulary)')
114
+
115
+ assert_raises(Repro::ReproductionError, 'a verdict outside reproduced/not-reproduced is refused') do
116
+ Repro.build_endorsement(target_sha256: Repro.target_digest(target),
117
+ tolerance_sha256: Repro.tolerance_digest(tol),
118
+ verdict: 'correct', adjudication_mode: 'hand')
119
+ end
120
+
121
+ puts '== RPR-4: adjudication mode is named, per mode closed schema =='
122
+
123
+ pendo = Repro.build_endorsement(target_sha256: Repro.target_digest(target),
124
+ tolerance_sha256: Repro.tolerance_digest(tol),
125
+ verdict: 'reproduced', adjudication_mode: 'procedure',
126
+ procedure_sha256: HEX_E)
127
+ assert(Repro.parse_endorsement!(pendo)['procedure_sha256'] == HEX_E, 'procedure mode names the adopted procedure')
128
+
129
+ assert_raises(Repro::ReproductionError, 'procedure mode without procedure_sha256 is refused (gate must be readable)') do
130
+ Repro.build_endorsement(target_sha256: Repro.target_digest(target),
131
+ tolerance_sha256: Repro.tolerance_digest(tol),
132
+ verdict: 'reproduced', adjudication_mode: 'procedure')
133
+ end
134
+
135
+ assert_raises(Repro::ReproductionError, 'hand mode carrying procedure_sha256 is refused (closed schema per mode)') do
136
+ Repro.build_endorsement(target_sha256: Repro.target_digest(target),
137
+ tolerance_sha256: Repro.tolerance_digest(tol),
138
+ verdict: 'reproduced', adjudication_mode: 'hand',
139
+ procedure_sha256: HEX_E)
140
+ end
141
+
142
+ puts '== map-1 §1.1 signature reuse: sign/verify endorsement =='
143
+
144
+ sig = Repro.sign_endorsement(endo, ENDORSER_CRED, ENDORSER_KEY)
145
+ assert(Repro.verify_endorsement(endo, ENDORSER_CRED, sig), 'endorsement verifies with credential+record+signature only (MAP-2 self-authentication)')
146
+ assert(!Repro.verify_endorsement(nendo, ENDORSER_CRED, sig), 'signature does not transfer to a different verdict record')
147
+ assert(!Repro.verify_endorsement(endo, OPERATOR_CRED, sig), 'signature does not verify under a foreign credential')
148
+
149
+ puts '== RPR-4: foreignness is a conformance condition =='
150
+
151
+ assert(Repro.foreign?(ENDORSER_CRED, OPERATOR_CRED), 'distinct endorser credential is foreign (conforming)')
152
+ assert(!Repro.foreign?(OPERATOR_CRED, OPERATOR_CRED), 'operator self-endorsement is not foreign (non-conforming as rpr-1)')
153
+
154
+ puts '== RPR-3: declaration-set assessment (menu + siblings + residue) =='
155
+
156
+ tol_sib = Repro.build_tolerance(target_sha256: Repro.target_digest(sibling))
157
+ tol_other = Repro.build_tolerance(target_sha256: Repro.target_digest(other))
158
+ tol_orphan = Repro.build_tolerance(target_sha256: HEX_E) # target not in supplied view
159
+
160
+ report = Repro.assess_declarations(
161
+ targets: [target, sibling, other],
162
+ declarations: [
163
+ { 'tolerance' => tol, 'position' => 10 },
164
+ { 'tolerance' => tol_sib, 'position' => 11 },
165
+ { 'tolerance' => tol_other, 'position' => 12 },
166
+ { 'tolerance' => tol_orphan, 'position' => 13 }
167
+ ],
168
+ invoked_tolerance_sha256: Repro.tolerance_digest(tol),
169
+ endorsement_position: 20
170
+ )
171
+ assert(report[:invoked_conforming], 'anterior invoked tolerance is conforming (RPR-3)')
172
+ assert(report[:multiplicity] == 2, 'sibling-target declaration pools into the same set (target-splitting exposed)')
173
+ assert(report[:declaration_set].none? { |e| e[:target_sha256] == Repro.target_digest(other) },
174
+ 'a different computation does not pool into the set')
175
+ assert(report[:residue].size == 1 && report[:residue][0][:reason].include?('unresolvable'),
176
+ 'unresolvable target binding is disclosed as residue, not silently dropped')
177
+
178
+ posterior = Repro.assess_declarations(
179
+ targets: [target],
180
+ declarations: [{ 'tolerance' => tol, 'position' => 30 }],
181
+ invoked_tolerance_sha256: Repro.tolerance_digest(tol),
182
+ endorsement_position: 20
183
+ )
184
+ assert(!posterior[:invoked_conforming], 'a tolerance committed after the endorsement is non-conforming (anteriority, MPR-8)')
185
+
186
+ puts '== RPR-3: assessment is order-independent over commitments =='
187
+
188
+ both_orders = [
189
+ [{ 'tolerance' => tol, 'position' => 5 }, { 'tolerance' => tol, 'position' => 30 }],
190
+ [{ 'tolerance' => tol, 'position' => 30 }, { 'tolerance' => tol, 'position' => 5 }]
191
+ ].map do |decls|
192
+ Repro.assess_declarations(targets: [target], declarations: decls,
193
+ invoked_tolerance_sha256: Repro.tolerance_digest(tol),
194
+ endorsement_position: 20)
195
+ end
196
+ assert(both_orders.all? { |r| r[:invoked_conforming] },
197
+ 'a digest committed anterior AND posterior conforms in both presentation orders (committed order, not array order)')
198
+ assert(both_orders.all? { |r| r[:invoked][:position] == 5 },
199
+ 'the earliest anterior commitment represents the invoked declaration in both orders')
200
+ assert(both_orders.all? { |r| r[:invoked_posterior].size == 1 && r[:invoked_posterior][0][:position] == 30 },
201
+ 'the posterior commitment of the invoked digest is disclosed, not dropped')
202
+
203
+ dup = Repro.assess_declarations(
204
+ targets: [target],
205
+ declarations: [{ 'tolerance' => tol, 'position' => 10 }, { 'tolerance' => tol, 'position' => 10 }],
206
+ invoked_tolerance_sha256: Repro.tolerance_digest(tol),
207
+ endorsement_position: 20
208
+ )
209
+ assert(dup[:multiplicity] == 1, 'the same (digest, position) supplied twice is one commitment; multiplicity counts distinct declarations')
210
+ assert(dup[:invoked_rank] == 1, 'the invoked declaration reports its rank in the pooled anterior set (rpr-1 §2.1)')
211
+
212
+ tol_a = Repro.build_tolerance(target_sha256: Repro.target_digest(target))
213
+ tol_b = Repro.build_tolerance(target_sha256: Repro.target_digest(sibling))
214
+ tie_orders = [
215
+ [{ 'tolerance' => tol_a, 'position' => 5 }, { 'tolerance' => tol_b, 'position' => 5 }],
216
+ [{ 'tolerance' => tol_b, 'position' => 5 }, { 'tolerance' => tol_a, 'position' => 5 }]
217
+ ].map do |decls|
218
+ Repro.assess_declarations(targets: [target, sibling], declarations: decls,
219
+ invoked_tolerance_sha256: Repro.tolerance_digest(tol_a),
220
+ endorsement_position: 20)
221
+ end
222
+ assert(tie_orders[0][:invoked_rank] == tie_orders[1][:invoked_rank],
223
+ 'equal earliest positions rank identically in both presentation orders (digest tiebreak, rpr-1 §2.1)')
224
+
225
+ puts '== RPR-5: retraction is the map-1 §3 act unchanged =='
226
+
227
+ retraction_ok = AType.validate_intake!('retraction', { 'target_entry_hash' => HEX_A })
228
+ assert(retraction_ok, 'a reproduction-endorsement retraction rides map-1 §3 intake unchanged')
229
+ assert(AType::VOCABULARY.include?('quality-endorsement'), 'endorsements are carried by the unchanged map-1 quality-endorsement type')
230
+
231
+ puts '== additive-only: rpr-1 leaves map-1 vocabulary untouched =='
232
+
233
+ assert(AType::VOCABULARY == %w[observation quality-endorsement succession-designation retraction],
234
+ 'map-1 vocabulary unchanged by the rpr-1 slice (extension would be a new convention)')
235
+
236
+ puts
237
+ puts "#{$pass} passed, #{$fail} failed"
238
+ exit($fail.zero? ? 0 : 1)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.45.0
4
+ version: 3.46.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama
@@ -654,6 +654,7 @@ files:
654
654
  - templates/skillsets/skillset_exchange/tools/skillset_withdraw.rb
655
655
  - templates/skillsets/synoptis/bin/khab_verify.rb
656
656
  - templates/skillsets/synoptis/bin/map_verify.rb
657
+ - templates/skillsets/synoptis/bin/rpr_verify.rb
657
658
  - templates/skillsets/synoptis/config/synoptis.yml
658
659
  - templates/skillsets/synoptis/knowledge/synoptis_protocol/synoptis_protocol.md
659
660
  - templates/skillsets/synoptis/lib/synoptis.rb
@@ -663,6 +664,7 @@ files:
663
664
  - templates/skillsets/synoptis/lib/synoptis/anchoring/containment.rb
664
665
  - templates/skillsets/synoptis/lib/synoptis/anchoring/conventions/khab-1.md
665
666
  - templates/skillsets/synoptis/lib/synoptis/anchoring/conventions/map-1.md
667
+ - templates/skillsets/synoptis/lib/synoptis/anchoring/conventions/rpr-1.md
666
668
  - templates/skillsets/synoptis/lib/synoptis/anchoring/cumulative_commitment.rb
667
669
  - templates/skillsets/synoptis/lib/synoptis/anchoring/deposit_board.rb
668
670
  - templates/skillsets/synoptis/lib/synoptis/anchoring/entry.rb
@@ -671,6 +673,7 @@ files:
671
673
  - templates/skillsets/synoptis/lib/synoptis/anchoring/public_read.rb
672
674
  - templates/skillsets/synoptis/lib/synoptis/anchoring/public_verifier.rb
673
675
  - templates/skillsets/synoptis/lib/synoptis/anchoring/read_path.rb
676
+ - templates/skillsets/synoptis/lib/synoptis/anchoring/reproduction.rb
674
677
  - templates/skillsets/synoptis/lib/synoptis/anchoring/succession.rb
675
678
  - templates/skillsets/synoptis/lib/synoptis/anchoring/write_budget.rb
676
679
  - templates/skillsets/synoptis/lib/synoptis/anchoring/write_path.rb
@@ -699,6 +702,7 @@ files:
699
702
  - templates/skillsets/synoptis/test/test_l2_constitutive_slice2.rb
700
703
  - templates/skillsets/synoptis/test/test_l2_constitutive_slice3.rb
701
704
  - templates/skillsets/synoptis/test/test_map_anchoring.rb
705
+ - templates/skillsets/synoptis/test/test_rpr_reproduction.rb
702
706
  - templates/skillsets/synoptis/test/test_synoptis.rb
703
707
  - templates/skillsets/synoptis/tools/attestation_issue.rb
704
708
  - templates/skillsets/synoptis/tools/attestation_list.rb