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.
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unidpp
4
+ # Subject capability classes (I8: twin testimonial by default, sensorial
5
+ # by exception). A profile's requirements must be satisfiable by the
6
+ # class its subjects belong to — demanding live freshness from an S0
7
+ # product is an unsatisfiable profile.
8
+ #
9
+ # S0 silent testimony-only (no connectivity, no keys)
10
+ # S1 passive-auth NFC chip / PUF / IEC 61406 identity link
11
+ # S2 logged-contact dumps on physical read, no comms
12
+ # S3 connected full edge segments
13
+ module CapabilityClass
14
+ ORDER = %w[S0 S1 S2 S3].freeze
15
+
16
+ module_function
17
+
18
+ # @param required [String] profile's minimum required class
19
+ # @param actual [String] the subject's capability class
20
+ # @return [Boolean] whether +actual+ meets or exceeds +required+
21
+ def satisfies?(required, actual)
22
+ ORDER.index(actual) >= ORDER.index(required)
23
+ end
24
+ end
25
+
26
+ # Applicability axis values of a profile: composition is three-axial —
27
+ # jurisdiction x sector x characteristic (I10). Characteristic profiles
28
+ # attach by predicate on twin facts (age, material content, heritage
29
+ # status, market status) rather than by law-of-place or industry.
30
+ class ProfileAxes < Lutaml::Model::Serializable
31
+ attribute :jurisdictions, :string, collection: true, default: -> { [] }
32
+ attribute :sectors, :string, collection: true, default: -> { [] }
33
+ attribute :characteristics, :string, collection: true, default: -> { [] }
34
+
35
+ json do
36
+ map "jurisdictions", to: :jurisdictions, render_nil: false
37
+ map "sectors", to: :sectors, render_nil: false
38
+ map "characteristics", to: :characteristics, render_nil: false
39
+ end
40
+
41
+ yaml do
42
+ map "jurisdictions", to: :jurisdictions, render_nil: false
43
+ map "sectors", to: :sectors, render_nil: false
44
+ map "characteristics", to: :characteristics, render_nil: false
45
+ end
46
+
47
+ def empty?
48
+ jurisdictions.empty? && sectors.empty? && characteristics.empty?
49
+ end
50
+ end
51
+
52
+ # One predicate of a profile's trigger (I10 / I12). Predicates are
53
+ # evaluated against twin facts by the subject's custodian — locally,
54
+ # without enumeration. Time predicates are clock-fired: an object
55
+ # becoming >100 years old is an applicability event no human declares.
56
+ class TriggerPredicate < Lutaml::Model::Serializable
57
+ OPERATORS = %w[eq neq gt gte lt lte contains exists before after].freeze
58
+ # Fact-path suffixes that make a predicate time-derived (clock-fired).
59
+ TIME_SUBJECT_SUFFIXES = %w[_at _date _years _age].freeze
60
+
61
+ attribute :subject, :string # fact path, e.g. "object.age_years"
62
+ attribute :operator, :string, values: OPERATORS
63
+ attribute :value, :string
64
+ attribute :cites, :string # clause-level provenance URN
65
+
66
+ json do
67
+ map "subject", to: :subject
68
+ map "operator", to: :operator
69
+ map "value", to: :value, render_nil: false
70
+ map "cites", to: :cites, render_nil: false
71
+ end
72
+
73
+ yaml do
74
+ map "subject", to: :subject
75
+ map "operator", to: :operator
76
+ map "value", to: :value, render_nil: false
77
+ map "cites", to: :cites, render_nil: false
78
+ end
79
+
80
+ # @return [Boolean] whether this predicate is time-derived and can
81
+ # fire on the passage of time alone
82
+ def time_fired?
83
+ time_operator? ||
84
+ TIME_SUBJECT_SUFFIXES.any? { |suffix| subject.to_s.end_with?(suffix) }
85
+ end
86
+
87
+ # Evaluates the predicate against a facts document (a Hash with
88
+ # dotted-path-resolvable keys, e.g. {"object" => {"age_years" => 120}}).
89
+ #
90
+ # @param facts [Hash]
91
+ # @return [Boolean]
92
+ def matches?(facts)
93
+ actual = TriggerPredicate.resolve_path(facts, subject)
94
+ return operator == "exists" ? !actual.nil? : false if actual.nil?
95
+
96
+ case operator
97
+ when "exists" then true
98
+ when "eq" then compare_eq(actual)
99
+ when "neq" then !compare_eq(actual)
100
+ when "contains" then contains_match?(actual)
101
+ when "gt", "gte", "lt", "lte" then numeric_compare(actual)
102
+ when "before", "after" then time_compare(actual)
103
+ else false
104
+ end
105
+ end
106
+
107
+ # @return [String] human-readable summary, for drift/conformance
108
+ # reports
109
+ def description
110
+ "#{subject} #{operator} #{value}"
111
+ end
112
+
113
+ def self.resolve_path(facts, path)
114
+ path.to_s.split(".").reduce(facts) do |node, key|
115
+ break nil unless node.is_a?(Hash) || node.is_a?(Array)
116
+
117
+ node.is_a?(Array) ? node.map { |e| e[key] if e.is_a?(Hash) }.compact : node[key]
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def time_operator?
124
+ %w[before after].include?(operator)
125
+ end
126
+
127
+ def compare_eq(actual)
128
+ if numeric?(actual) && numeric?(value)
129
+ actual.to_f == value.to_f
130
+ else
131
+ actual.to_s == value.to_s
132
+ end
133
+ end
134
+
135
+ # contains-match against array or scalar facts (distinct from the
136
+ # lutaml-generated +contains?+ enum predicate on the operator).
137
+ def contains_match?(actual)
138
+ if actual.is_a?(Array)
139
+ actual.map(&:to_s).include?(value.to_s)
140
+ else
141
+ actual.to_s.include?(value.to_s)
142
+ end
143
+ end
144
+
145
+ def numeric_compare(actual)
146
+ return false unless numeric?(actual) && numeric?(value)
147
+
148
+ a = actual.to_f
149
+ v = value.to_f
150
+ case operator
151
+ when "gt" then a > v
152
+ when "gte" then a >= v
153
+ when "lt" then a < v
154
+ when "lte" then a <= v
155
+ end
156
+ end
157
+
158
+ def time_compare(actual)
159
+ a = to_time(actual)
160
+ v = to_time(value)
161
+ return false if a.nil? || v.nil?
162
+
163
+ operator == "before" ? a < v : a > v
164
+ end
165
+
166
+ def numeric?(v)
167
+ Float(v.to_s) != nil
168
+ rescue ArgumentError, TypeError
169
+ false
170
+ end
171
+
172
+ def to_time(v)
173
+ return v if v.is_a?(Time) || v.is_a?(DateTime) || v.is_a?(Date)
174
+
175
+ DateTime.parse(v.to_s)
176
+ rescue ArgumentError, TypeError
177
+ nil
178
+ end
179
+ end
180
+
181
+ # Binding of a cryptographic suite to a profile (I9/I10: jurisdictional
182
+ # crypto suites are part of the profile, not the platform). The same
183
+ # payload carries multiple signatures; verifiers check only what their
184
+ # profile-scoped acceptance policy requires.
185
+ class CryptoSuiteBinding < Lutaml::Model::Serializable
186
+ USES = %w[sign hash kem transport].freeze
187
+ PHASES = %w[primary composite post-quantum].freeze
188
+
189
+ attribute :suite_id, :string # registry item id (crypto-suite subregister)
190
+ attribute :jurisdiction, :string
191
+ attribute :use, :string, values: USES
192
+ attribute :phase, :string, values: PHASES, default: -> { "primary" }
193
+
194
+ json do
195
+ map "suite_id", to: :suite_id
196
+ map "jurisdiction", to: :jurisdiction
197
+ map "use", to: :use
198
+ map "phase", to: :phase
199
+ end
200
+
201
+ yaml do
202
+ map "suite_id", to: :suite_id
203
+ map "jurisdiction", to: :jurisdiction
204
+ map "use", to: :use
205
+ map "phase", to: :phase
206
+ end
207
+
208
+ def post_quantum?
209
+ phase == "post-quantum"
210
+ end
211
+ end
212
+
213
+ # The profile manifest (L2 / I10): a profile is a registered, versioned
214
+ # lens placed on the neutral core. It binds axes (jurisdiction x sector
215
+ # x characteristic), trigger predicates, the minimum subject capability
216
+ # class it can be served for, its crypto-suite bindings, the data points
217
+ # it requires (references into the semantic registry), languages, and
218
+ # its legal effective window. Registered transforms and trust
219
+ # requirements ride the registry items referenced here.
220
+ #
221
+ # (De)serialization is generated by lutaml-model from the attribute +
222
+ # mapping declarations below — no hand-rolled to_h / from_h.
223
+ class ProfileManifest < Lutaml::Model::Serializable
224
+ CAPABILITY_CLASSES = CapabilityClass::ORDER
225
+
226
+ attribute :profile_id, :string # registry item identifier
227
+ attribute :version, :string # registry item version pin
228
+ attribute :register, :string # FERIN register that holds the profile
229
+ attribute :owner, :string # regulator / profile owner
230
+ attribute :legal_basis, :string # citation URI of the applicable law
231
+ attribute :axes, ProfileAxes
232
+ attribute :triggers, TriggerPredicate, collection: true, default: -> { [] }
233
+ attribute :capability_class, :string, values: CAPABILITY_CLASSES,
234
+ default: -> { "S0" }
235
+ attribute :crypto_suites, CryptoSuiteBinding, collection: true,
236
+ default: -> { [] }
237
+ # Data points required by the profile: (register, item, version)
238
+ # references into the semantic registry, as resolvable URIs.
239
+ attribute :data_points, :string, collection: true, default: -> { [] }
240
+ attribute :languages, :string, collection: true, default: -> { [] }
241
+ attribute :effective_from, "Lutaml::Model::Type::DateTime"
242
+ attribute :effective_until, "Lutaml::Model::Type::DateTime"
243
+ # Resolution / traversal exposure (I12): confidential profiles are
244
+ # non-public register items; resolution scope limits the resolver.
245
+ attribute :resolution, :string,
246
+ values: %w[none national restricted public],
247
+ default: -> { "public" }
248
+ attribute :confidential, :boolean, default: -> { false }
249
+
250
+ json do
251
+ map "profile_id", to: :profile_id
252
+ map "version", to: :version
253
+ map "register", to: :register, render_nil: false
254
+ map "owner", to: :owner, render_nil: false
255
+ map "legal_basis", to: :legal_basis, render_nil: false
256
+ map "axes", to: :axes, render_nil: false
257
+ map "triggers", to: :triggers, render_nil: false
258
+ map "capability_class", to: :capability_class
259
+ map "crypto_suites", to: :crypto_suites, render_nil: false
260
+ map "data_points", to: :data_points, render_nil: false
261
+ map "languages", to: :languages, render_nil: false
262
+ map "effective_from", to: :effective_from, render_nil: false
263
+ map "effective_until", to: :effective_until, render_nil: false
264
+ map "resolution", to: :resolution
265
+ map "confidential", to: :confidential
266
+ end
267
+
268
+ yaml do
269
+ map "profile_id", to: :profile_id
270
+ map "version", to: :version
271
+ map "register", to: :register, render_nil: false
272
+ map "owner", to: :owner, render_nil: false
273
+ map "legal_basis", to: :legal_basis, render_nil: false
274
+ map "axes", to: :axes, render_nil: false
275
+ map "triggers", to: :triggers, render_nil: false
276
+ map "capability_class", to: :capability_class
277
+ map "crypto_suites", to: :crypto_suites, render_nil: false
278
+ map "data_points", to: :data_points, render_nil: false
279
+ map "languages", to: :languages, render_nil: false
280
+ map "effective_from", to: :effective_from, render_nil: false
281
+ map "effective_until", to: :effective_until, render_nil: false
282
+ map "resolution", to: :resolution
283
+ map "confidential", to: :confidential
284
+ end
285
+
286
+ # A profile with no trigger predicates applies to every subject of
287
+ # its axes; otherwise every predicate must match (conjunction).
288
+ #
289
+ # @param facts [Hash] twin facts document
290
+ # @return [Boolean]
291
+ def applies_to?(facts)
292
+ triggers.all? { |predicate| predicate.matches?(facts) }
293
+ end
294
+
295
+ # @param subject_capability [String] S0..S3 of the subject
296
+ # @return [Boolean] satisfiability of this profile for that class
297
+ def satisfiable_for?(subject_capability)
298
+ CapabilityClass.satisfies?(capability_class, subject_capability)
299
+ end
300
+
301
+ # Legal effective window at time +t+.
302
+ # @param t [Time, DateTime]
303
+ # @return [Boolean]
304
+ def effective_at?(t)
305
+ t = to_datetime(t)
306
+ (!effective_from || t >= effective_from) &&
307
+ (!effective_until || t < effective_until)
308
+ end
309
+
310
+ def jurisdiction_profile?
311
+ !axes.jurisdictions.empty?
312
+ end
313
+
314
+ def characteristic_profile?
315
+ !axes.characteristics.empty?
316
+ end
317
+
318
+ def time_triggered?
319
+ triggers.any?(&:time_fired?)
320
+ end
321
+
322
+ private
323
+
324
+ def to_datetime(t)
325
+ t.is_a?(DateTime) ? t : DateTime.parse(t.to_s)
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module Unidpp
6
+ # Quantity = value + registered unit URI (the units chain: ISO 80000 ->
7
+ # UnitsDB/UnitsML -> OpenCDD mirror). Units are *registered URIs*, not
8
+ # bare symbols; unit conversion is a registered transform (deterministic
9
+ # factors from the registry), so this class never converts implicitly —
10
+ # arithmetic requires identical unit URIs and raises on mismatch. The
11
+ # twin stores canonical measured values in canonical units only; no
12
+ # pre-transformed duplicates.
13
+ #
14
+ # Measurement uncertainty is mandatory metadata on measured values
15
+ # (published MTF): it is carried as a standard (k=1) uncertainty with
16
+ # its coverage factor, and conformity decisions near a boundary follow
17
+ # the ILAC-G8/JCGM-106 shared-risk rule (measured values agree when
18
+ # |difference| <= expanded combined uncertainty).
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 Quantity < Lutaml::Model::Serializable
23
+ class UnitMismatchError < Unidpp::Error; end
24
+
25
+ # Standard uncertainty with coverage factor.
26
+ class Uncertainty < Lutaml::Model::Serializable
27
+ attribute :value, :decimal
28
+ attribute :coverage_factor, :decimal, default: -> { BigDecimal("2") }
29
+
30
+ json do
31
+ map "value", to: :value
32
+ map "coverage_factor", to: :coverage_factor
33
+ end
34
+
35
+ yaml do
36
+ map "value", to: :value
37
+ map "coverage_factor", to: :coverage_factor
38
+ end
39
+
40
+ # @return [BigDecimal] expanded uncertainty U = k * u
41
+ def expanded
42
+ value * coverage_factor
43
+ end
44
+ end
45
+
46
+ attribute :value, :decimal
47
+ # Registered unit URI, e.g. "https://unitsml.org/unit#kWh" or the
48
+ # UnitsDB identifier URI for the unit.
49
+ attribute :unit, :string
50
+ attribute :uncertainty, Uncertainty
51
+
52
+ json do
53
+ map "value", to: :value
54
+ map "unit", to: :unit
55
+ map "uncertainty", to: :uncertainty, render_nil: false
56
+ end
57
+
58
+ yaml do
59
+ map "value", to: :value
60
+ map "unit", to: :unit
61
+ map "uncertainty", to: :uncertainty, render_nil: false
62
+ end
63
+
64
+ # -- strict-unit arithmetic ------------------------------------------------
65
+
66
+ def +(other)
67
+ assert_same_unit(other)
68
+ Quantity.new(value: value + other.value, unit: unit,
69
+ uncertainty: combine_uncertainty(other))
70
+ end
71
+
72
+ def -(other)
73
+ assert_same_unit(other)
74
+ Quantity.new(value: value - other.value, unit: unit,
75
+ uncertainty: combine_uncertainty(other))
76
+ end
77
+
78
+ # Scalar multiple (unit unchanged); scaling does not apply to
79
+ # uncertainties.
80
+ def *(factor)
81
+ Quantity.new(value: value * factor, unit: unit)
82
+ end
83
+
84
+ def zero?
85
+ value.zero?
86
+ end
87
+
88
+ def negative?
89
+ value.negative?
90
+ end
91
+
92
+ def positive?
93
+ value.positive?
94
+ end
95
+
96
+ # @param other [Quantity]
97
+ # @return [Boolean] same registered unit URI
98
+ def compatible_with?(other)
99
+ unit == other.unit
100
+ end
101
+
102
+ # ILAC-G8 shared-risk conformity decision: two measured values agree
103
+ # when their absolute difference is within the expanded combined
104
+ # uncertainty (U = k * sqrt(u1^2 + u2^2), k = the larger coverage
105
+ # factor). Values without uncertainties agree only on exact equality.
106
+ #
107
+ # @param other [Quantity]
108
+ # @return [Boolean]
109
+ def agrees_with?(other)
110
+ assert_same_unit(other)
111
+ return value == other.value unless uncertainty && other.uncertainty
112
+
113
+ (value - other.value).abs <= expanded_combined_uncertainty(other)
114
+ end
115
+
116
+ # @param other [Quantity]
117
+ # @return [BigDecimal] k * sqrt(u1^2 + u2^2), nil when uncertainless
118
+ def expanded_combined_uncertainty(other)
119
+ return nil unless uncertainty && other.uncertainty
120
+
121
+ k = [uncertainty.coverage_factor,
122
+ other.uncertainty.coverage_factor].max
123
+ sum_of_squares = uncertainty.value * uncertainty.value +
124
+ other.uncertainty.value * other.uncertainty.value
125
+ k * BigDecimal(sum_of_squares.sqrt(10))
126
+ end
127
+
128
+ private
129
+
130
+ def assert_same_unit(other)
131
+ return if compatible_with?(other)
132
+
133
+ raise UnitMismatchError,
134
+ "unit mismatch: #{unit} vs #{other.unit} — convert through a " \
135
+ "registered transform, never implicitly"
136
+ end
137
+
138
+ def combine_uncertainty(other)
139
+ return nil unless uncertainty && other.uncertainty
140
+
141
+ Uncertainty.new(
142
+ value: BigDecimal(Math.sqrt(
143
+ (uncertainty.value * uncertainty.value +
144
+ other.uncertainty.value * other.uncertainty.value).to_f
145
+ )),
146
+ coverage_factor: uncertainty.coverage_factor
147
+ )
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unidpp
4
+ module Registry
5
+ # Applicability binding (source invariant 9): profile-set growth is
6
+ # dated binding, not new identity. Adding a profile to an existing
7
+ # subject is a registry applicability event with an effective window
8
+ # and a retroactivity flag; manifest history is as-of-reconstructable.
9
+ #
10
+ # Retroactivity semantics: a retroactive binding legally backdates —
11
+ # it applies from its effective_from even though registered later
12
+ # (end-of-waste style re-qualification, corrected as-of state). A
13
+ # non-retroactive binding cannot impose obligations for times before
14
+ # the authority declared it (registered_at).
15
+ class ApplicabilityBinding < Lutaml::Model::Serializable
16
+ attribute :subject, :string # canonical product/type identity
17
+ attribute :profile_item, :string # registry item identifier
18
+ attribute :register, :string # register holding the profile
19
+ attribute :profile_version, :string
20
+ attribute :effective_from, "Lutaml::Model::Type::DateTime"
21
+ attribute :effective_until, "Lutaml::Model::Type::DateTime"
22
+ attribute :registered_at, "Lutaml::Model::Type::DateTime"
23
+ attribute :retroactive, :boolean, default: -> { false }
24
+
25
+ json do
26
+ map "subject", to: :subject
27
+ map "profile_item", to: :profile_item
28
+ map "register", to: :register, render_nil: false
29
+ map "profile_version", to: :profile_version, render_nil: false
30
+ map "effective_from", to: :effective_from, render_nil: false
31
+ map "effective_until", to: :effective_until, render_nil: false
32
+ map "registered_at", to: :registered_at, render_nil: false
33
+ map "retroactive", to: :retroactive
34
+ end
35
+
36
+ yaml do
37
+ map "subject", to: :subject
38
+ map "profile_item", to: :profile_item
39
+ map "register", to: :register, render_nil: false
40
+ map "profile_version", to: :profile_version, render_nil: false
41
+ map "effective_from", to: :effective_from, render_nil: false
42
+ map "effective_until", to: :effective_until, render_nil: false
43
+ map "registered_at", to: :registered_at, render_nil: false
44
+ map "retroactive", to: :retroactive
45
+ end
46
+
47
+ # Legal as-of semantics.
48
+ #
49
+ # @param t [Time, DateTime]
50
+ # @return [Boolean] the binding applied to the subject at +t+
51
+ def applies_at?(t)
52
+ t = t.is_a?(DateTime) ? t : DateTime.parse(t.to_s)
53
+ return false if effective_from && t < effective_from
54
+ return false if effective_until && t >= effective_until
55
+ return false if !retroactive? && registered_at && t < registered_at
56
+
57
+ true
58
+ end
59
+
60
+ # @return [Boolean]
61
+ def retroactive?
62
+ retroactive == true
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unidpp
4
+ module Registry
5
+ # Registry client: item lookup with version+status, supersession
6
+ # resolution, and applicability as-of queries over any Store. The
7
+ # client is read-only — registration (proposals, public review,
8
+ # control-body decisions) belongs to the register operator's own
9
+ # tooling; consumers resolve references.
10
+ class Client
11
+ class ItemNotFoundError < Unidpp::Error; end
12
+ class NoValidVersionError < Unidpp::Error; end
13
+
14
+ attr_reader :store
15
+
16
+ # @param store [Store]
17
+ def initialize(store:)
18
+ @store = store
19
+ end
20
+
21
+ # @return [Register, nil]
22
+ def register
23
+ store.register
24
+ end
25
+
26
+ # @return [Array<Item>]
27
+ def items
28
+ store.items
29
+ end
30
+
31
+ # @param register [String, nil] optional register scope
32
+ # @param identifier [String] item identifier
33
+ # @return [Item, nil]
34
+ def item(identifier, register: nil)
35
+ store.items.find do |i|
36
+ i.identifier == identifier && (register.nil? || i.register == register)
37
+ end
38
+ end
39
+
40
+ # @param identifier [String]
41
+ # @return [Item]
42
+ # @raise [ItemNotFoundError]
43
+ def item!(identifier, register: nil)
44
+ scope = register ? " in register #{register.inspect}" : ""
45
+ item(identifier, register: register) ||
46
+ raise(ItemNotFoundError,
47
+ "no registry item #{identifier.inspect}#{scope}")
48
+ end
49
+
50
+ # Version-pinned lookup: the exact version when given, otherwise the
51
+ # version currently in force.
52
+ #
53
+ # @param identifier [String]
54
+ # @param version [String, nil]
55
+ # @return [ItemVersion]
56
+ # @raise [ItemNotFoundError, Item::UnknownVersionError]
57
+ def version_of(identifier, version: nil, register: nil)
58
+ found = item!(identifier, register: register)
59
+ version ? found.version!(version) : current_version_of(found)
60
+ end
61
+
62
+ # @param item [Item]
63
+ # @return [ItemVersion]
64
+ # @raise [NoValidVersionError] when no version is in force
65
+ def current_version_of(item)
66
+ item.current_version ||
67
+ raise(NoValidVersionError, "#{item.identifier} has no valid version")
68
+ end
69
+
70
+ # Supersession resolution: the terminal version of the chain.
71
+ #
72
+ # @param identifier [String]
73
+ # @return [ItemVersion]
74
+ # @raise [ItemNotFoundError]
75
+ def resolve(identifier, register: nil)
76
+ supersession_chain(identifier, register: register).last
77
+ end
78
+
79
+ # @param identifier [String]
80
+ # @param from [String, nil] chain start version
81
+ # @return [Array<ItemVersion>] chain in supersession order
82
+ # @raise [ItemNotFoundError]
83
+ def supersession_chain(identifier, from: nil, register: nil)
84
+ item!(identifier, register: register).supersession_chain(from: from)
85
+ end
86
+
87
+ # Point-in-time item state: the version in force at +t+.
88
+ #
89
+ # @param identifier [String]
90
+ # @param as_of [Time, DateTime]
91
+ # @return [ItemVersion, nil]
92
+ # @raise [ItemNotFoundError]
93
+ def version_as_of(identifier, as_of:, register: nil)
94
+ item!(identifier, register: register).in_force_at(as_of)
95
+ end
96
+
97
+ # -- applicability --------------------------------------------------
98
+
99
+ # @param subject [String] canonical product/type identity
100
+ # @return [Array<ApplicabilityBinding>]
101
+ def bindings_for(subject)
102
+ store.bindings.select { |b| b.subject == subject }
103
+ end
104
+
105
+ # The legal as-of query: which profiles applied to +subject+ at +t+.
106
+ # Retroactive bindings apply from their effective_from even when
107
+ # registered later; non-retroactive ones only from registered_at.
108
+ #
109
+ # @param subject [String]
110
+ # @param as_of [Time, DateTime]
111
+ # @return [Array<ApplicabilityBinding>]
112
+ def applicable_profiles(subject, as_of:)
113
+ bindings_for(subject).select { |b| b.applies_at?(as_of) }
114
+ end
115
+
116
+ # The same query resolved to version-pinned profile items.
117
+ #
118
+ # @param subject [String]
119
+ # @param as_of [Time, DateTime]
120
+ # @return [Array<Item>] profile items applicable at +t+
121
+ def applicable_profile_items(subject, as_of:)
122
+ applicable_profiles(subject, as_of: as_of).map do |binding|
123
+ item(binding.profile_item, register: binding.register)
124
+ end.compact
125
+ end
126
+
127
+ # The manifests of the profiles applicable at +t+ (nil entries are
128
+ # skipped for bindings whose item carries no embedded manifest).
129
+ #
130
+ # @param subject [String]
131
+ # @param as_of [Time, DateTime]
132
+ # @return [Array<ProfileManifest>]
133
+ def applicable_manifests(subject, as_of:)
134
+ applicable_profiles(subject, as_of: as_of).filter_map do |binding|
135
+ found = item(binding.profile_item, register: binding.register)
136
+ found&.manifest
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end