unidpp 0.1.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 +7 -0
- data/lib/unidpp/annexb.rb +296 -0
- data/lib/unidpp/canonical_json.rb +37 -0
- data/lib/unidpp/event.rb +103 -0
- data/lib/unidpp/event_log.rb +225 -0
- data/lib/unidpp/gs1.rb +105 -0
- data/lib/unidpp/identifier.rb +240 -0
- data/lib/unidpp/link.rb +197 -0
- data/lib/unidpp/manifest.rb +328 -0
- data/lib/unidpp/quantity.rb +150 -0
- data/lib/unidpp/registry/applicability.rb +66 -0
- data/lib/unidpp/registry/client.rb +141 -0
- data/lib/unidpp/registry/file_store.rb +121 -0
- data/lib/unidpp/registry/item.rb +132 -0
- data/lib/unidpp/registry/item_version.rb +66 -0
- data/lib/unidpp/registry/register.rb +36 -0
- data/lib/unidpp/registry/store.rb +26 -0
- data/lib/unidpp/registry.rb +21 -0
- data/lib/unidpp/verdict.rb +72 -0
- data/lib/unidpp/version.rb +5 -0
- data/lib/unidpp.rb +31 -0
- metadata +108 -0
data/lib/unidpp/gs1.rb
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module Unidpp
|
|
6
|
+
# GS1 key arithmetic shared by the identifier parsers: application
|
|
7
|
+
# identifiers, element-string parsing (parenthesized and bracketed
|
|
8
|
+
# human-readable forms) and the GS1 mod-10 check digit (GTIN-13/GTIN-14,
|
|
9
|
+
# SSCC-18).
|
|
10
|
+
module Gs1
|
|
11
|
+
# Identity-relevant application identifiers (AI -> value shape).
|
|
12
|
+
# "00" SSCC (18 digits) "01" GTIN-14
|
|
13
|
+
# "10" batch/lot (<= 20 GS1 AI charset) "21" serial (<= 20 charset)
|
|
14
|
+
IDENTITY_AIS = {
|
|
15
|
+
"00" => /\A\d{18}\z/,
|
|
16
|
+
"01" => /\A\d{14}\z/,
|
|
17
|
+
"10" => /\A[\x21-\x22\x25-\x2F\x30-\x39\x3A-\x3F\x41-\x5A\x5F\x61-\x7A]{1,20}\z/,
|
|
18
|
+
"21" => /\A[\x21-\x22\x25-\x2F\x30-\x39\x3A-\x3F\x41-\x5A\x5F\x61-\x7A]{1,20}\z/,
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Application identifiers accepted in a Digital Link URI path.
|
|
22
|
+
DL_PATH_AIS = %w[00 01 10 21].freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# GS1 general specification check digit: from the rightmost digit of
|
|
27
|
+
# the base (excluding the check position), multiply alternately by 3
|
|
28
|
+
# and 1; the check digit completes the next multiple of ten.
|
|
29
|
+
#
|
|
30
|
+
# @param base [String, #to_s] digits without the check digit
|
|
31
|
+
# @return [Integer] the check digit
|
|
32
|
+
def check_digit(base)
|
|
33
|
+
digits = base.to_s.chars.map(&:to_i)
|
|
34
|
+
raise Error, "check digit base must be non-empty digits" if digits.empty?
|
|
35
|
+
|
|
36
|
+
sum = digits.reverse.each_with_index.sum do |d, i|
|
|
37
|
+
d * (i.even? ? 3 : 1)
|
|
38
|
+
end
|
|
39
|
+
(10 - (sum % 10)) % 10
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param with_check [String] full code including trailing check digit
|
|
43
|
+
# @return [Boolean]
|
|
44
|
+
def valid_check_digit?(with_check)
|
|
45
|
+
s = with_check.to_s
|
|
46
|
+
return false unless /\A\d+\z/.match?(s)
|
|
47
|
+
|
|
48
|
+
check_digit(s[0...-1]) == s[-1].to_i
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Parses a human-readable element string, e.g.
|
|
52
|
+
# (01)09506000134352(10)ABC-123(21)9876543
|
|
53
|
+
# or the bracketed form
|
|
54
|
+
# [00]006141411234567890
|
|
55
|
+
#
|
|
56
|
+
# @param input [String]
|
|
57
|
+
# @return [Hash{String => String}] AI -> value, in encounter order
|
|
58
|
+
# @raise [Unidpp::Error] on malformed input
|
|
59
|
+
def parse_element_string(input)
|
|
60
|
+
opener = input.to_s.start_with?("[") ? "[" : "("
|
|
61
|
+
values = {}
|
|
62
|
+
scanner = StringScanner.new(input.to_s.strip)
|
|
63
|
+
until scanner.eos?
|
|
64
|
+
scanner.skip(/\s*/)
|
|
65
|
+
break if scanner.eos?
|
|
66
|
+
|
|
67
|
+
unless scanner.scan(/#{Regexp.escape(opener)}/)
|
|
68
|
+
raise Error, "malformed element string at #{scanner.pos}: #{input}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
ai = scanner.scan(/\d{2,4}/)
|
|
72
|
+
raise Error, "missing application identifier in #{input}" if ai.nil?
|
|
73
|
+
|
|
74
|
+
scanner.skip(/\]/) if opener == "["
|
|
75
|
+
scanner.skip(/\)/) if opener == "("
|
|
76
|
+
# value runs until the next opener or end of string
|
|
77
|
+
value = scanner.scan_until(/(?=#{Regexp.escape(opener)}|\z)/) || scanner.rest
|
|
78
|
+
value = value.sub(/\s+\z/, "")
|
|
79
|
+
raise Error, "empty value for AI (#{ai})" if value.empty?
|
|
80
|
+
|
|
81
|
+
values[ai] = value
|
|
82
|
+
end
|
|
83
|
+
values
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Validates AI values against the identity AI table, including the
|
|
87
|
+
# mod-10 check digits of SSCC (00) and GTIN (01).
|
|
88
|
+
#
|
|
89
|
+
# @param values [Hash{String => String}]
|
|
90
|
+
# @raise [Unidpp::Error] on an unsupported AI, bad shape or bad check
|
|
91
|
+
# digit
|
|
92
|
+
def validate_identity_ais!(values)
|
|
93
|
+
values.each do |ai, value|
|
|
94
|
+
pattern = IDENTITY_AIS[ai]
|
|
95
|
+
raise Error, "unsupported identity AI (#{ai})" if pattern.nil?
|
|
96
|
+
raise Error, "invalid value for AI (#{ai}): #{value}" unless pattern.match?(value)
|
|
97
|
+
end
|
|
98
|
+
%w[00 01].each do |ai|
|
|
99
|
+
next unless (code = values[ai])
|
|
100
|
+
|
|
101
|
+
raise Error, "invalid check digit for AI (#{ai}): #{code}" unless valid_check_digit?(code)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unidpp
|
|
4
|
+
# Scheme-agnostic product identifier (framework layer L0, invariant I1:
|
|
5
|
+
# one subject, one identity, never re-minted).
|
|
6
|
+
#
|
|
7
|
+
# Parses the identity carriers the framework must bridge:
|
|
8
|
+
# * GS1 Digital Link URIs — https://id.example.com/01/09506000134352/10/LOT/21/SER
|
|
9
|
+
# * GS1 human-readable element strings — (01)09506000134352(10)LOT(21)SER
|
|
10
|
+
# * ISO/IEC 15459-style unique identifiers — [00]006141411234567890 (SSCC)
|
|
11
|
+
# * GB/T 33993-style national carrier wrappers — an https URL whose path
|
|
12
|
+
# embeds a GTIN-13/GTIN-14, with optional lot/serial query parameters
|
|
13
|
+
# * plain URNs, including urn:epc:id:sgtin/sscc forms
|
|
14
|
+
#
|
|
15
|
+
# The parsed model normalizes the GS1 components (gtin, lot, serial,
|
|
16
|
+
# sscc) and derives the identifier granularity (model / batch / item per
|
|
17
|
+
# the identity lattice: bare GTIN -> model, +lot -> batch, +serial or
|
|
18
|
+
# SSCC -> item).
|
|
19
|
+
#
|
|
20
|
+
# (De)serialization is generated by lutaml-model from the attribute +
|
|
21
|
+
# mapping declarations below — no hand-rolled to_h / from_h.
|
|
22
|
+
class ProductIdentifier < Lutaml::Model::Serializable
|
|
23
|
+
class ParseError < Unidpp::Error; end
|
|
24
|
+
|
|
25
|
+
SCHEMES = %w[gs1-dl gs1-element iso-15459 gbt-33993 urn other].freeze
|
|
26
|
+
GRANULARITIES = %w[model batch item].freeze
|
|
27
|
+
|
|
28
|
+
attribute :scheme, :string, values: SCHEMES, default: -> { "other" }
|
|
29
|
+
# Original carrier value exactly as encountered.
|
|
30
|
+
attribute :value, :string
|
|
31
|
+
attribute :granularity, :string, values: GRANULARITIES,
|
|
32
|
+
default: -> { "model" }
|
|
33
|
+
# Normalized GS1 components (nil when absent).
|
|
34
|
+
attribute :gtin, :string # canonical GTIN-14
|
|
35
|
+
attribute :lot, :string # AI (10)
|
|
36
|
+
attribute :serial, :string # AI (21)
|
|
37
|
+
attribute :sscc, :string # SSCC-18, ISO/IEC 15459 transport unit
|
|
38
|
+
|
|
39
|
+
json do
|
|
40
|
+
map "scheme", to: :scheme
|
|
41
|
+
map "value", to: :value
|
|
42
|
+
map "granularity", to: :granularity
|
|
43
|
+
map "gtin", to: :gtin, render_nil: false
|
|
44
|
+
map "lot", to: :lot, render_nil: false
|
|
45
|
+
map "serial", to: :serial, render_nil: false
|
|
46
|
+
map "sscc", to: :sscc, render_nil: false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
yaml do
|
|
50
|
+
map "scheme", to: :scheme
|
|
51
|
+
map "value", to: :value
|
|
52
|
+
map "granularity", to: :granularity
|
|
53
|
+
map "gtin", to: :gtin, render_nil: false
|
|
54
|
+
map "lot", to: :lot, render_nil: false
|
|
55
|
+
map "serial", to: :serial, render_nil: false
|
|
56
|
+
map "sscc", to: :sscc, render_nil: false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# -- parsing ------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
# @param input [String] carrier value
|
|
62
|
+
# @return [ProductIdentifier]
|
|
63
|
+
# @raise [ParseError] when no parser claims the input
|
|
64
|
+
def self.parse(input)
|
|
65
|
+
s = input.to_s.strip
|
|
66
|
+
raise ParseError, "blank identifier" if s.empty?
|
|
67
|
+
|
|
68
|
+
if s.match?(/\Aurn:/i)
|
|
69
|
+
parse_urn(s)
|
|
70
|
+
elsif s.match?(/\Ahttps?:\/\//i)
|
|
71
|
+
parse_web_uri(s)
|
|
72
|
+
elsif s.match?(/\A[\(\[]\d{2,4}[\)\]]/)
|
|
73
|
+
parse_element_string(s)
|
|
74
|
+
else
|
|
75
|
+
raise ParseError, "unrecognized identifier syntax: #{s}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param input [String] parenthesized / bracketed element string
|
|
80
|
+
def self.parse_element_string(input)
|
|
81
|
+
values = Gs1.parse_element_string(input)
|
|
82
|
+
Gs1.validate_identity_ais!(values)
|
|
83
|
+
if values["00"]
|
|
84
|
+
new(scheme: "iso-15459", value: input, sscc: values["00"],
|
|
85
|
+
granularity: "item")
|
|
86
|
+
elsif values["01"]
|
|
87
|
+
new(scheme: "gs1-element", value: input, gtin: values["01"],
|
|
88
|
+
lot: values["10"], serial: values["21"],
|
|
89
|
+
granularity: granularity_for(values["21"], values["10"]))
|
|
90
|
+
else
|
|
91
|
+
raise ParseError, "no identity AI (00/01) in #{input}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param input [String] GS1 Digital Link or GB/T 33993-style URI
|
|
96
|
+
def self.parse_web_uri(input)
|
|
97
|
+
uri = URI.parse(input)
|
|
98
|
+
segments = uri.path.to_s.split("/").reject(&:empty?)
|
|
99
|
+
if dl_identity_pairs?(segments)
|
|
100
|
+
values = segments.each_slice(2).to_h
|
|
101
|
+
Gs1.validate_identity_ais!(values)
|
|
102
|
+
if values["00"]
|
|
103
|
+
new(scheme: "gs1-dl", value: input, sscc: values["00"],
|
|
104
|
+
granularity: "item")
|
|
105
|
+
else
|
|
106
|
+
new(scheme: "gs1-dl", value: input, gtin: values["01"],
|
|
107
|
+
lot: values["10"], serial: values["21"],
|
|
108
|
+
granularity: granularity_for(values["21"], values["10"]))
|
|
109
|
+
end
|
|
110
|
+
elsif (gtin = gbt33993_gtin(segments))
|
|
111
|
+
params = URI.decode_www_form(uri.query.to_s).to_h
|
|
112
|
+
new(scheme: "gbt-33993", value: input, gtin: gtin,
|
|
113
|
+
lot: params["lot"], serial: params["serial"],
|
|
114
|
+
granularity: granularity_for(params["serial"], params["lot"]))
|
|
115
|
+
else
|
|
116
|
+
raise ParseError, "unrecognized identifier URI: #{input}"
|
|
117
|
+
end
|
|
118
|
+
rescue URI::InvalidURIError => e
|
|
119
|
+
raise ParseError, "invalid identifier URI: #{e.message}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @param input [String] plain or EPC URN
|
|
123
|
+
def self.parse_urn(input)
|
|
124
|
+
urn = input.downcase
|
|
125
|
+
if urn.start_with?("urn:epc:id:sgtin:")
|
|
126
|
+
parse_sgtin_urn(input)
|
|
127
|
+
elsif urn.start_with?("urn:epc:id:sscc:")
|
|
128
|
+
parse_sscc_urn(input)
|
|
129
|
+
else
|
|
130
|
+
new(scheme: "urn", value: input)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# urn:epc:id:sgtin:<companyPrefix>.<itemRef>.<serial>
|
|
135
|
+
# prefix+itemRef total 12 digits (GTIN-13 base) or 13 digits (GTIN-14
|
|
136
|
+
# base with leading indicator digit).
|
|
137
|
+
def self.parse_sgtin_urn(input)
|
|
138
|
+
parts = input.split(":").last.split(".")
|
|
139
|
+
raise ParseError, "malformed sgtin URN: #{input}" unless parts.size == 3
|
|
140
|
+
|
|
141
|
+
prefix, item_ref, serial = parts
|
|
142
|
+
base = "#{prefix}#{item_ref}"
|
|
143
|
+
unless /\A\d{12,13}\z/.match?(base)
|
|
144
|
+
raise ParseError,
|
|
145
|
+
"sgtin company prefix + item ref must total 12 or 13 digits"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
gtin14 = base.length == 13 ? base : "0#{base}"
|
|
149
|
+
new(scheme: "urn", value: input,
|
|
150
|
+
gtin: "#{gtin14}#{Gs1.check_digit(base)}",
|
|
151
|
+
serial: serial, granularity: "item")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# urn:epc:id:sscc:<companyPrefix>.<ssccDigits>
|
|
155
|
+
def self.parse_sscc_urn(input)
|
|
156
|
+
parts = input.split(":").last.split(".")
|
|
157
|
+
raise ParseError, "malformed sscc URN: #{input}" unless parts.size == 2
|
|
158
|
+
|
|
159
|
+
joined = parts.join
|
|
160
|
+
sscc =
|
|
161
|
+
if joined.length == 17
|
|
162
|
+
"#{joined}#{Gs1.check_digit(joined)}"
|
|
163
|
+
elsif joined.length == 18
|
|
164
|
+
joined
|
|
165
|
+
else
|
|
166
|
+
raise ParseError, "sscc URN must carry 17 or 18 digits: #{input}"
|
|
167
|
+
end
|
|
168
|
+
raise ParseError, "invalid SSCC check digit in #{input}" unless Gs1.valid_check_digit?(sscc)
|
|
169
|
+
|
|
170
|
+
new(scheme: "urn", value: input, sscc: sscc, granularity: "item")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# -- derived identity ----------------------------------------------------
|
|
174
|
+
|
|
175
|
+
# Canonical human-readable form. GS1-based schemes canonicalize to the
|
|
176
|
+
# element string in ascending AI order, so the same product expressed
|
|
177
|
+
# as Digital Link or element string yields the same canonical form.
|
|
178
|
+
#
|
|
179
|
+
# @return [String]
|
|
180
|
+
def canonical
|
|
181
|
+
case scheme
|
|
182
|
+
when "gs1-dl", "gs1-element"
|
|
183
|
+
%w[01 10 21].filter_map do |ai|
|
|
184
|
+
v = { "01" => gtin, "10" => lot, "21" => serial }[ai]
|
|
185
|
+
"(#{ai})#{v}" if v
|
|
186
|
+
end.join
|
|
187
|
+
when "iso-15459"
|
|
188
|
+
sscc ? "(00)#{sscc}" : value.to_s
|
|
189
|
+
else
|
|
190
|
+
value.to_s
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Stable identity key across representations: scheme-independent for
|
|
195
|
+
# GS1-keyed identifiers, scheme-qualified otherwise.
|
|
196
|
+
#
|
|
197
|
+
# @return [String]
|
|
198
|
+
def canonical_id
|
|
199
|
+
if gtin
|
|
200
|
+
"gtin:#{gtin}#{lot ? "/lot:#{lot}" : ''}#{serial ? "/ser:#{serial}" : ''}"
|
|
201
|
+
elsif sscc
|
|
202
|
+
"sscc:#{sscc}"
|
|
203
|
+
elsif scheme == "urn"
|
|
204
|
+
value.to_s
|
|
205
|
+
else
|
|
206
|
+
"#{scheme}:#{value}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Granularity predicates (model? / batch? / item?) are generated by
|
|
211
|
+
# lutaml-model from the granularity value enumeration.
|
|
212
|
+
|
|
213
|
+
# @return [Boolean] check digits valid for the carried GS1 keys
|
|
214
|
+
def valid_keys?
|
|
215
|
+
[gtin, sscc].compact.all? { |key| Gs1.valid_check_digit?(key) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def to_s
|
|
219
|
+
canonical
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private_class_method def self.granularity_for(serial, lot)
|
|
223
|
+
serial ? "item" : (lot ? "batch" : "model")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private_class_method def self.dl_identity_pairs?(segments)
|
|
227
|
+
return false unless segments.size.even? && segments.size >= 2
|
|
228
|
+
|
|
229
|
+
segments.each_slice(2).all? { |ai, _| Gs1::DL_PATH_AIS.include?(ai) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private_class_method def self.gbt33993_gtin(segments)
|
|
233
|
+
segments.filter_map do |segment|
|
|
234
|
+
next unless /\A\d{13,14}\z/.match?(segment)
|
|
235
|
+
|
|
236
|
+
segment if Gs1.valid_check_digit?(segment)
|
|
237
|
+
end.first
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
data/lib/unidpp/link.rb
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Unidpp
|
|
7
|
+
# Temporal interval of a link's validity.
|
|
8
|
+
class LinkInterval < Lutaml::Model::Serializable
|
|
9
|
+
attribute :from, "Lutaml::Model::Type::DateTime"
|
|
10
|
+
attribute :until, "Lutaml::Model::Type::DateTime"
|
|
11
|
+
|
|
12
|
+
json do
|
|
13
|
+
map "from", to: :from, render_nil: false
|
|
14
|
+
map "until", to: :until, render_nil: false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
yaml do
|
|
18
|
+
map "from", to: :from, render_nil: false
|
|
19
|
+
map "until", to: :until, render_nil: false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param t [Time, DateTime]
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
def covers?(t)
|
|
25
|
+
t = t.is_a?(DateTime) ? t : DateTime.parse(t.to_s)
|
|
26
|
+
(!from || t >= from) && (!self.until || t < self.until)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Typed relationship edge between passports (I5). Edge types carry
|
|
31
|
+
# different legal and lifecycle semantics:
|
|
32
|
+
#
|
|
33
|
+
# association R1 loose (compatibility, supply) — navigational only
|
|
34
|
+
# derivation R2 ancestral (split/mix/refine/remanufacture)
|
|
35
|
+
# installation R3 structural parent<->child, temporal (see below)
|
|
36
|
+
# type-lineage R4 type versioning, derived types, config vectors
|
|
37
|
+
# profile-of R5 lens<->core (not a passport-to-passport edge)
|
|
38
|
+
# custody R6 social/control edges, orthogonal to structure
|
|
39
|
+
# membership R7 group nodes (shipments, kits, recall sets)
|
|
40
|
+
#
|
|
41
|
+
# Installation is its own event class, not generic containment: an
|
|
42
|
+
# installed component is a different legal object from the standalone
|
|
43
|
+
# one (permanent alteration, pairing, lifecycle coupling, and
|
|
44
|
+
# bidirectional parent knowledge — with the disclosure hazard that
|
|
45
|
+
# creates, hence visibility classes and salted parent commitments:
|
|
46
|
+
# proof-of-binding without knowledge-of-parent, I12).
|
|
47
|
+
#
|
|
48
|
+
# (De)serialization is generated by lutaml-model from the attribute +
|
|
49
|
+
# mapping declarations below — no hand-rolled to_h / from_h.
|
|
50
|
+
class PassportLink < Lutaml::Model::Serializable
|
|
51
|
+
TYPES = %w[
|
|
52
|
+
association derivation installation type-lineage profile-of custody membership
|
|
53
|
+
].freeze
|
|
54
|
+
DIRECTIONS = %w[upstream downstream undirected].freeze
|
|
55
|
+
BINDING_METHODS = %w[
|
|
56
|
+
none fastened soldered glued welded potted integrated keyed
|
|
57
|
+
].freeze
|
|
58
|
+
RECOVERABILITY = %w[restorable harvestable destructive absorbing].freeze
|
|
59
|
+
PAIRINGS = %w[none firmware key module].freeze
|
|
60
|
+
VISIBILITIES = %w[public restricted blind escrowed].freeze
|
|
61
|
+
ESCROWS = %w[none trustee].freeze
|
|
62
|
+
|
|
63
|
+
attribute :link_type, :string, values: TYPES
|
|
64
|
+
attribute :direction, :string, values: DIRECTIONS,
|
|
65
|
+
default: -> { "downstream" }
|
|
66
|
+
attribute :child, ProductIdentifier
|
|
67
|
+
attribute :parent, ProductIdentifier
|
|
68
|
+
attribute :interval, LinkInterval
|
|
69
|
+
# Binding strength: how the child is held by the parent.
|
|
70
|
+
attribute :binding_method, :string, values: BINDING_METHODS,
|
|
71
|
+
default: -> { "none" }
|
|
72
|
+
# Recoverability spectrum determines identity continuity:
|
|
73
|
+
# restorable disassembly restores a marketable standalone object
|
|
74
|
+
# harvestable recovered but altered; continues with parent history
|
|
75
|
+
# destructive child ceases; flows to material passport (derivation)
|
|
76
|
+
# absorbing identity dissolves (paint, adhesive)
|
|
77
|
+
attribute :recoverability, :string, values: RECOVERABILITY
|
|
78
|
+
# Parent-scoped slot identity ("battery slot 1 of car X").
|
|
79
|
+
attribute :slot_id, :string
|
|
80
|
+
# Pairing / re-identification on install.
|
|
81
|
+
attribute :pairing, :string, values: PAIRINGS, default: -> { "none" }
|
|
82
|
+
# Permanent alterations performed by the installation.
|
|
83
|
+
attribute :alterations, :string, collection: true, default: -> { [] }
|
|
84
|
+
# Edge visibility class (I12): blind is the consumer-install default —
|
|
85
|
+
# a child->parent edge reveals where a thing is.
|
|
86
|
+
attribute :visibility, :string, values: VISIBILITIES,
|
|
87
|
+
default: -> { "public" }
|
|
88
|
+
attribute :escrow, :string, values: ESCROWS, default: -> { "none" }
|
|
89
|
+
attribute :audiences, :string, collection: true, default: -> { [] }
|
|
90
|
+
# Proof-of-binding (blind edges): salted commitment to the parent,
|
|
91
|
+
# stored in the child's log; verifiable without revealing the parent.
|
|
92
|
+
attribute :parent_commitment, :string
|
|
93
|
+
attribute :commitment_salt, :string
|
|
94
|
+
|
|
95
|
+
json do
|
|
96
|
+
map "link_type", to: :link_type
|
|
97
|
+
map "direction", to: :direction
|
|
98
|
+
map "child", to: :child, render_nil: false
|
|
99
|
+
map "parent", to: :parent, render_nil: false
|
|
100
|
+
map "interval", to: :interval, render_nil: false
|
|
101
|
+
map "binding_method", to: :binding_method
|
|
102
|
+
map "recoverability", to: :recoverability, render_nil: false
|
|
103
|
+
map "slot_id", to: :slot_id, render_nil: false
|
|
104
|
+
map "pairing", to: :pairing
|
|
105
|
+
map "alterations", to: :alterations, render_nil: false
|
|
106
|
+
map "visibility", to: :visibility
|
|
107
|
+
map "escrow", to: :escrow
|
|
108
|
+
map "audiences", to: :audiences, render_nil: false
|
|
109
|
+
map "parent_commitment", to: :parent_commitment, render_nil: false
|
|
110
|
+
map "commitment_salt", to: :commitment_salt, render_nil: false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
yaml do
|
|
114
|
+
map "link_type", to: :link_type
|
|
115
|
+
map "direction", to: :direction
|
|
116
|
+
map "child", to: :child, render_nil: false
|
|
117
|
+
map "parent", to: :parent, render_nil: false
|
|
118
|
+
map "interval", to: :interval, render_nil: false
|
|
119
|
+
map "binding_method", to: :binding_method
|
|
120
|
+
map "recoverability", to: :recoverability, render_nil: false
|
|
121
|
+
map "slot_id", to: :slot_id, render_nil: false
|
|
122
|
+
map "pairing", to: :pairing
|
|
123
|
+
map "alterations", to: :alterations, render_nil: false
|
|
124
|
+
map "visibility", to: :visibility
|
|
125
|
+
map "escrow", to: :escrow
|
|
126
|
+
map "audiences", to: :audiences, render_nil: false
|
|
127
|
+
map "parent_commitment", to: :parent_commitment, render_nil: false
|
|
128
|
+
map "commitment_salt", to: :commitment_salt, render_nil: false
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# -- construction ---------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
# Builds an installation edge (R3) with the full binding schema. When
|
|
134
|
+
# +visibility+ is blind (the consumer-install default), the parent is
|
|
135
|
+
# replaced by a salted commitment: the child proves it is validly
|
|
136
|
+
# installed in *some* parent without revealing which. The salt is the
|
|
137
|
+
# disclosure key (escrowed or revealed at ceremony).
|
|
138
|
+
#
|
|
139
|
+
# @return [PassportLink]
|
|
140
|
+
def self.install(child:, parent:, visibility: "blind",
|
|
141
|
+
salt: SecureRandom.hex(16), **attrs)
|
|
142
|
+
link = new(link_type: "installation", direction: "downstream",
|
|
143
|
+
child: child, parent: parent, visibility: visibility,
|
|
144
|
+
**attrs)
|
|
145
|
+
if visibility == "blind"
|
|
146
|
+
link.parent = nil
|
|
147
|
+
link.parent_commitment = PassportLink.parent_commitment(child,
|
|
148
|
+
parent, salt)
|
|
149
|
+
link.commitment_salt = salt
|
|
150
|
+
end
|
|
151
|
+
link
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @return [String] hex SHA-256 over the canonical child and parent
|
|
155
|
+
# identities and the salt
|
|
156
|
+
def self.parent_commitment(child, parent, salt)
|
|
157
|
+
Digest::SHA256.hexdigest(
|
|
158
|
+
[child.canonical, parent.canonical, salt].join("\n")
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# -- queries --------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
# Edge-type and visibility predicates (installation? / blind? /
|
|
165
|
+
# escrowed?) are generated by lutaml-model from the corresponding
|
|
166
|
+
# value enumerations.
|
|
167
|
+
|
|
168
|
+
def hidden_parent?
|
|
169
|
+
blind? && parent.nil? && !parent_commitment.nil?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Does disassembly of this installation restore a marketable object
|
|
173
|
+
# with identity continuity? (R3 vs R2 boundary.)
|
|
174
|
+
def identity_continuity?
|
|
175
|
+
%w[restorable harvestable].include?(recoverability)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Proof-of-binding check. For visible edges this is identity equality;
|
|
179
|
+
# for blind edges the candidate parent is recomputed against the
|
|
180
|
+
# stored salted commitment — full disclosure by ceremony only.
|
|
181
|
+
#
|
|
182
|
+
# @param candidate [ProductIdentifier] the parent being tested
|
|
183
|
+
# @return [Boolean]
|
|
184
|
+
def proves_parent?(candidate)
|
|
185
|
+
return false if candidate.nil?
|
|
186
|
+
|
|
187
|
+
if parent
|
|
188
|
+
parent.canonical == candidate.canonical
|
|
189
|
+
elsif parent_commitment
|
|
190
|
+
PassportLink.parent_commitment(child, candidate,
|
|
191
|
+
commitment_salt) == parent_commitment
|
|
192
|
+
else
|
|
193
|
+
false
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|