familia 2.0.0.pre10 → 2.0.0.pre13

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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +2 -3
  3. data/CHANGELOG.rst +507 -0
  4. data/CLAUDE.md +5 -55
  5. data/Gemfile +1 -6
  6. data/Gemfile.lock +13 -7
  7. data/changelog.d/README.md +45 -34
  8. data/changelog.d/scriv.ini +5 -0
  9. data/docs/archive/FAMILIA_RELATIONSHIPS.md +1 -1
  10. data/docs/archive/FAMILIA_UPDATE.md +1 -1
  11. data/docs/archive/README.md +15 -19
  12. data/docs/guides/Feature-System-Autoloading.md +228 -0
  13. data/docs/guides/Home.md +1 -1
  14. data/docs/guides/Implementation-Guide.md +1 -1
  15. data/docs/guides/relationships-methods.md +1 -1
  16. data/docs/guides/time-utilities.md +221 -0
  17. data/docs/migrating/.gitignore +2 -0
  18. data/docs/migrating/v2.0.0-pre.md +84 -0
  19. data/docs/migrating/v2.0.0-pre11.md +253 -0
  20. data/docs/migrating/v2.0.0-pre12.md +306 -0
  21. data/docs/migrating/v2.0.0-pre13.md +329 -0
  22. data/docs/migrating/v2.0.0-pre5.md +110 -0
  23. data/docs/migrating/v2.0.0-pre6.md +154 -0
  24. data/docs/migrating/v2.0.0-pre7.md +222 -0
  25. data/docs/overview.md +6 -7
  26. data/{examples/redis_command_validation_example.rb → docs/reference/auditing_database_commands.rb} +29 -32
  27. data/examples/autoloader/mega_customer/safe_dump_fields.rb +6 -0
  28. data/examples/autoloader/mega_customer.rb +17 -0
  29. data/examples/{bit_encoding_integration.rb → permissions.rb} +30 -27
  30. data/examples/{relationships_basic.rb → relationships.rb} +2 -3
  31. data/examples/safe_dump.rb +281 -0
  32. data/familia.gemspec +5 -4
  33. data/lib/familia/autoloader.rb +53 -0
  34. data/lib/familia/base.rb +57 -0
  35. data/lib/familia/data_type.rb +4 -0
  36. data/lib/familia/encryption/encrypted_data.rb +4 -4
  37. data/lib/familia/encryption/manager.rb +6 -4
  38. data/lib/familia/{encryption_request_cache.rb → encryption/request_cache.rb} +1 -1
  39. data/lib/familia/encryption.rb +1 -1
  40. data/lib/familia/errors.rb +5 -0
  41. data/lib/familia/features/autoloadable.rb +113 -0
  42. data/lib/familia/features/encrypted_fields/concealed_string.rb +4 -2
  43. data/lib/familia/features/expiration.rb +4 -0
  44. data/lib/familia/features/external_identifier.rb +310 -0
  45. data/lib/familia/features/object_identifier.rb +307 -0
  46. data/lib/familia/features/quantization.rb +5 -0
  47. data/lib/familia/features/safe_dump.rb +74 -73
  48. data/lib/familia/features.rb +109 -17
  49. data/lib/familia/field_type.rb +2 -0
  50. data/lib/familia/horreum/core/serialization.rb +3 -3
  51. data/lib/familia/horreum/subclass/definition.rb +50 -7
  52. data/lib/familia/horreum.rb +2 -0
  53. data/lib/familia/json_serializer.rb +70 -0
  54. data/lib/familia/logging.rb +12 -10
  55. data/lib/familia/refinements/logger_trace.rb +57 -0
  56. data/lib/familia/refinements/snake_case.rb +40 -0
  57. data/lib/familia/refinements/time_utils.rb +248 -0
  58. data/lib/familia/refinements.rb +3 -49
  59. data/lib/familia/secure_identifier.rb +51 -75
  60. data/lib/familia/utils.rb +2 -0
  61. data/lib/familia/validation/{test_helpers.rb → validation_helpers.rb} +2 -2
  62. data/lib/familia/validation.rb +1 -1
  63. data/lib/familia/verifiable_identifier.rb +162 -0
  64. data/lib/familia/version.rb +1 -1
  65. data/lib/familia.rb +15 -2
  66. data/try/core/autoloader_try.rb +112 -0
  67. data/try/core/extensions_try.rb +38 -21
  68. data/try/core/familia_extended_try.rb +4 -3
  69. data/try/core/secure_identifier_try.rb +47 -18
  70. data/try/core/time_utils_try.rb +130 -0
  71. data/try/core/verifiable_identifier_try.rb +171 -0
  72. data/try/data_types/datatype_base_try.rb +3 -2
  73. data/try/features/autoloadable/autoloadable_try.rb +61 -0
  74. data/try/features/encrypted_fields/concealed_string_core_try.rb +8 -3
  75. data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +59 -17
  76. data/try/features/encrypted_fields/universal_serialization_safety_try.rb +36 -12
  77. data/try/features/{external_identifiers/external_identifiers_try.rb → external_identifier/external_identifier_try.rb} +25 -28
  78. data/try/features/feature_improvements_try.rb +127 -0
  79. data/try/features/{object_identifiers/object_identifiers_integration_try.rb → object_identifier/object_identifier_integration_try.rb} +28 -30
  80. data/try/features/{object_identifiers/object_identifiers_try.rb → object_identifier/object_identifier_try.rb} +13 -13
  81. data/try/features/real_feature_integration_try.rb +8 -7
  82. data/try/features/safe_dump/safe_dump_autoloading_try.rb +111 -0
  83. data/try/features/safe_dump/safe_dump_try.rb +8 -9
  84. data/try/helpers/test_helpers.rb +41 -17
  85. data/try/integration/cross_component_try.rb +3 -1
  86. metadata +61 -26
  87. data/CHANGELOG.md +0 -184
  88. data/changelog.d/fragments/.keep +0 -0
  89. data/changelog.d/template.md.j2 +0 -29
  90. data/lib/familia/core_ext.rb +0 -135
  91. data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +0 -120
  92. data/lib/familia/features/external_identifiers.rb +0 -111
  93. data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +0 -91
  94. data/lib/familia/features/object_identifiers.rb +0 -194
  95. data/setup.cfg +0 -12
@@ -1,7 +1,7 @@
1
1
  # lib/familia/encryption.rb
2
2
 
3
3
  require 'base64'
4
- require 'json'
4
+ require 'oj'
5
5
  require 'openssl'
6
6
 
7
7
  # Provider system components
@@ -5,6 +5,11 @@ module Familia
5
5
  class NoIdentifier < Problem; end
6
6
  class NonUniqueKey < Problem; end
7
7
 
8
+ class FieldTypeError < Problem; end
9
+ class AutoloadError < Problem; end
10
+
11
+ class SerializerError < Problem; end
12
+
8
13
  class HighRiskFactor < Problem
9
14
  attr_reader :value
10
15
 
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../refinements/snake_case'
4
+
5
+ module Familia
6
+ module Features
7
+ # Enables automatic loading of feature-specific files when a feature is included in a user class.
8
+ #
9
+ # When included in a feature module, adds ClassMethods that detect when the feature is
10
+ # included in user classes, derives the feature name, and autoloads files matching
11
+ # conventional patterns in the user class's directory structure.
12
+ module Autoloadable
13
+ using Familia::Refinements::SnakeCase
14
+
15
+ # Sets up a feature module with autoloading capabilities.
16
+ #
17
+ # Extends the feature module with ClassMethods to handle post-inclusion autoloading.
18
+ #
19
+ # @param feature_module [Module] the feature module being enhanced
20
+ def self.included(feature_module)
21
+ feature_module.extend(ClassMethods)
22
+ end
23
+
24
+ # Methods added to feature modules that include Autoloadable.
25
+ module ClassMethods
26
+ # Triggered when the feature is included in a user class.
27
+ #
28
+ # Sets up for post-inclusion autoloading. The actual autoloading
29
+ # is deferred until after feature setup completes.
30
+ #
31
+ # @param base [Class] the user class including this feature
32
+ def included(base)
33
+ # Call parent included method if it exists (defensive programming for mixed-in contexts)
34
+ super if defined?(super)
35
+
36
+ # No autoloading here - it's deferred to post_inclusion_autoload
37
+ # to ensure the feature is fully set up before extension files are loaded
38
+ end
39
+
40
+ # Called by the feature system after the feature is fully included.
41
+ #
42
+ # Uses const_source_location to determine where the base class is defined,
43
+ # then autoloads feature-specific extension files from that location.
44
+ #
45
+ # @param base [Class] the class that included this feature
46
+ # @param feature_name [Symbol] the name of the feature
47
+ # @param options [Hash] feature options (unused but kept for compatibility)
48
+ def post_inclusion_autoload(base, feature_name, options)
49
+ Familia.trace :FEATURE, nil, "[Autoloadable] post_inclusion_autoload called for #{feature_name} on #{base.name || base}", caller(1..1) if Familia.debug?
50
+
51
+ # Get the source location via Ruby's built-in introspection
52
+ source_location = nil
53
+
54
+ # Check for named classes that can be looked up via const_source_location
55
+ # Class#name always returns String or nil, so type check is redundant
56
+ if base.name && !base.name.empty?
57
+ begin
58
+ location_info = Module.const_source_location(base.name)
59
+ source_location = location_info&.first
60
+ Familia.trace :FEATURE, nil, "[Autoloadable] Source location for #{base.name}: #{source_location}", caller(1..1) if Familia.debug?
61
+ rescue NameError => e
62
+ # Handle cases where the class name is not a valid constant name
63
+ # This can happen in test environments with dynamically created classes
64
+ Familia.trace :FEATURE, nil, "[Autoloadable] Cannot resolve source location for #{base.name}: #{e.message}", caller(1..1) if Familia.debug?
65
+ end
66
+ else
67
+ Familia.trace :FEATURE, nil, "[Autoloadable] Skipping source location detection - base.name=#{base.name.inspect}", caller(1..1) if Familia.debug?
68
+ end
69
+
70
+ # Autoload feature-specific files if we have a valid source location
71
+ if source_location && !source_location.include?('-e') # Skip eval/irb contexts
72
+ Familia.trace :FEATURE, nil, "[Autoloadable] Calling autoload_feature_files with #{source_location}", caller(1..1) if Familia.debug?
73
+ autoload_feature_files(source_location, base, feature_name.to_s.snake_case)
74
+ else
75
+ Familia.trace :FEATURE, nil, "[Autoloadable] Skipping autoload - no valid source location", caller(1..1) if Familia.debug?
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ # Autoloads feature-specific files from conventional directory patterns.
82
+ #
83
+ # Searches for files matching patterns like:
84
+ # - model_name/feature_name_*.rb
85
+ # - model_name/features/feature_name_*.rb
86
+ # - features/feature_name_*.rb
87
+ #
88
+ # @param location_path [String] path where the user class is defined
89
+ # @param base [Class] the user class including the feature
90
+ # @param feature_name [String] snake_case name of the feature
91
+ def autoload_feature_files(location_path, base, feature_name)
92
+ base_dir = File.dirname(location_path)
93
+
94
+ # Handle anonymous classes gracefully
95
+ model_name = base.name ? base.name.snake_case : "anonymous_#{base.object_id}"
96
+
97
+ # Look for feature-specific files in conventional locations
98
+ patterns = [
99
+ File.join(base_dir, model_name, "#{feature_name}_*.rb"),
100
+ File.join(base_dir, model_name, 'features', "#{feature_name}_*.rb"),
101
+ File.join(base_dir, 'features', "#{feature_name}_*.rb"),
102
+ ]
103
+
104
+ # Use Autoloader's shared method for consistent file loading
105
+ Familia::Autoloader.autoload_files(
106
+ patterns,
107
+ log_prefix: "Autoloadable(#{feature_name})"
108
+ )
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -37,6 +37,8 @@
37
37
  # user.to_json # Safe - contains [CONCEALED]
38
38
  #
39
39
  class ConcealedString
40
+ REDACTED = '[CONCEALED]'.freeze
41
+
40
42
  # Create a concealed string wrapper
41
43
  #
42
44
  # @param encrypted_data [String] The encrypted JSON data
@@ -264,9 +266,9 @@ class ConcealedString
264
266
  { concealed: true }
265
267
  end
266
268
 
267
- # Prevent exposure in JSON serialization
269
+ # Prevent exposure in JSON serialization - fail closed for security
268
270
  def to_json(*)
269
- '"[CONCEALED]"'
271
+ raise Familia::SerializerError, "ConcealedString cannot be serialized to JSON"
270
272
  end
271
273
 
272
274
  # Prevent exposure in Rails serialization (as_json -> to_json)
@@ -149,6 +149,8 @@ module Familia
149
149
  module Expiration
150
150
  @default_expiration = nil
151
151
 
152
+ using Familia::Refinements::TimeUtils
153
+
152
154
  def self.included(base)
153
155
  Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
154
156
  base.extend ClassMethods
@@ -160,6 +162,8 @@ module Familia
160
162
  base.instance_variable_set(:@default_expiration, @default_expiration)
161
163
  end
162
164
 
165
+ # Familia::Expiration::ClassMethods
166
+ #
163
167
  module ClassMethods
164
168
  # Set the default expiration time for instances of this class
165
169
  #
@@ -0,0 +1,310 @@
1
+ # lib/familia/features/external_identifier.rb
2
+
3
+ module Familia
4
+ module Features
5
+ # Familia::Features::ExternalIdentifier
6
+ #
7
+ module ExternalIdentifier
8
+ def self.included(base)
9
+ Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
10
+ base.extend ClassMethods
11
+
12
+ # Ensure default prefix is set in feature options
13
+ base.add_feature_options(:external_identifier, prefix: 'ext')
14
+
15
+ # Add class-level mapping for extid -> id lookups
16
+ base.class_hashkey :extid_lookup
17
+
18
+ # Register the extid field using our custom field type
19
+ base.register_field_type(ExternalIdentifierFieldType.new(:extid, as: :extid, fast_method: false))
20
+ end
21
+
22
+ # Error classes
23
+ class ExternalIdentifierError < FieldTypeError; end
24
+
25
+ # ExternalIdentifierFieldType - Fields that derive deterministic external identifiers
26
+ #
27
+ # External identifier fields derive shorter, public-facing identifiers that are
28
+ # deterministically derived from object identifiers. These IDs are safe for use
29
+ # in URLs, APIs, and other external contexts where shorter IDs are preferred.
30
+ #
31
+ # Key characteristics:
32
+ # - Deterministic generation from objid ensures consistency
33
+ # - Shorter than objid (128-bit vs 256-bit) for external use
34
+ # - Base-36 encoding for URL-safe identifiers
35
+ # - 'ext_' prefix for clear identification as external IDs
36
+ # - Lazy generation preserves values from initialization
37
+ #
38
+ # @example Using external identifier fields
39
+ # class User < Familia::Horreum
40
+ # feature :object_identifier
41
+ # feature :external_identifier
42
+ # field :email
43
+ # end
44
+ #
45
+ # user = User.new(email: 'user@example.com')
46
+ # user.objid # => "01234567-89ab-7def-8000-123456789abc"
47
+ # user.extid # => "ext_abc123def456ghi789" (deterministic from objid)
48
+ #
49
+ # # Same objid always produces same extid
50
+ # user2 = User.new(objid: user.objid, email: 'user@example.com')
51
+ # user2.extid # => "ext_abc123def456ghi789" (identical to user.extid)
52
+ #
53
+ class ExternalIdentifierFieldType < Familia::FieldType
54
+ # Override getter to provide lazy generation from objid
55
+ #
56
+ # Derives the external identifier deterministically from the object's
57
+ # objid. This ensures consistency - the same objid will always produce
58
+ # the same extid. Only derives when objid is available.
59
+ #
60
+ # @param klass [Class] The class to define the method on
61
+ #
62
+ def define_getter(klass)
63
+ field_name = @name
64
+ method_name = @method_name
65
+
66
+ handle_method_conflict(klass, method_name) do
67
+ klass.define_method method_name do
68
+ # Check if we already have a value (from initialization or previous generation)
69
+ existing_value = instance_variable_get(:"@#{field_name}")
70
+ return existing_value unless existing_value.nil?
71
+
72
+ # Derive external identifier from objid if available
73
+ derived_extid = derive_external_identifier
74
+ return unless derived_extid
75
+
76
+ instance_variable_set(:"@#{field_name}", derived_extid)
77
+
78
+ # Update mapping if we have an identifier
79
+ self.class.extid_lookup[derived_extid] = identifier if respond_to?(:identifier) && identifier
80
+
81
+ derived_extid
82
+ end
83
+ end
84
+ end
85
+
86
+ # Override setter to preserve values during initialization
87
+ #
88
+ # This ensures that values passed during object initialization
89
+ # (e.g., when loading from Redis) are preserved and not overwritten
90
+ # by the lazy generation logic.
91
+ #
92
+ # @param klass [Class] The class to define the method on
93
+ #
94
+ def define_setter(klass)
95
+ field_name = @name
96
+ method_name = @method_name
97
+
98
+ handle_method_conflict(klass, :"#{method_name}=") do
99
+ klass.define_method :"#{method_name}=" do |value|
100
+ # Remove old mapping if extid is changing
101
+ old_value = instance_variable_get(:"@#{field_name}")
102
+ self.class.extid_lookup.del(old_value) if old_value && old_value != value && respond_to?(:identifier)
103
+
104
+ # Set the new value
105
+ instance_variable_set(:"@#{field_name}", value)
106
+
107
+ # Update mapping if we have both extid and identifier
108
+ return unless value && respond_to?(:identifier) && identifier
109
+
110
+ self.class.extid_lookup[value] = identifier
111
+ end
112
+ end
113
+ end
114
+
115
+ # External identifier fields are persisted to database
116
+ #
117
+ # @return [Boolean] true - external identifiers are always persisted
118
+ #
119
+ def persistent?
120
+ true
121
+ end
122
+
123
+ # Category for external identifier fields
124
+ #
125
+ # @return [Symbol] :external_identifier
126
+ #
127
+ def category
128
+ :external_identifier
129
+ end
130
+ end
131
+
132
+ # ExternalIdentifier::ClassMethods
133
+ #
134
+ module ClassMethods
135
+ # Find an object by its external identifier
136
+ #
137
+ # @param extid [String] The external identifier to search for
138
+ # @return [Object, nil] The object if found, nil otherwise
139
+ #
140
+ def find_by_extid(extid)
141
+ return nil if extid.to_s.empty?
142
+
143
+ if Familia.debug?
144
+ reference = caller(1..1).first
145
+ Familia.trace :FIND_BY_EXTID, Familia.dbclient, extid, reference
146
+ end
147
+
148
+ # Look up the primary ID from the external ID mapping
149
+ primary_id = extid_lookup[extid]
150
+ return nil if primary_id.nil?
151
+
152
+ # Find the object by its primary ID
153
+ find_by_id(primary_id)
154
+ rescue Familia::NotFound
155
+ # If the object was deleted but mapping wasn't cleaned up
156
+ extid_lookup.del(extid)
157
+ nil
158
+ end
159
+ end
160
+
161
+ # Derives a deterministic, public-facing external identifier from the object's
162
+ # internal `objid`.
163
+ #
164
+ # This method uses the `objid`'s high-quality randomness to seed a
165
+ # pseudorandom number generator (PRNG). The PRNG then acts as a complex,
166
+ # deterministic function to produce a new identifier that has no discernible
167
+ # mathematical correlation to the `objid`. This is a security measure to
168
+ # prevent leaking information (like timestamps from UUIDv7) from the internal
169
+ # identifier to the public one.
170
+ #
171
+ # The resulting identifier is always deterministic: the same `objid` will
172
+ # always produce the same `extid`, which is crucial for lookups.
173
+ #
174
+ # @return [String, nil] A prefixed, base36-encoded external identifier, or nil
175
+ # if the `objid` is not present.
176
+ # @raise [ExternalIdentifierError] if the `objid` provenance is unknown.
177
+ def derive_external_identifier
178
+ raise ExternalIdentifierError, 'Missing objid field' unless respond_to?(:objid)
179
+
180
+ current_objid = objid
181
+ return nil if current_objid.nil? || current_objid.to_s.empty?
182
+
183
+ # Validate objid provenance for security guarantees
184
+ validate_objid_provenance!
185
+
186
+ # Normalize the objid to a consistent hex representation first.
187
+ normalized_hex = normalize_objid_to_hex(current_objid)
188
+
189
+ # Use the objid's randomness to create a deterministic, yet secure,
190
+ # external identifier. We do not use SecureRandom here because the output
191
+ # must be deterministic.
192
+ #
193
+ # The process is as follows:
194
+ # 1. The objid (a high-entropy value) is hashed to create a uniform seed.
195
+ # 2. The seed initializes a standard PRNG (Random.new).
196
+ # 3. The PRNG acts as a deterministic function to generate a sequence of
197
+ # bytes that appears random, obscuring the original objid.
198
+
199
+ # 1. Create a high-quality, uniform seed from the objid's entropy.
200
+ seed = Digest::SHA256.digest(normalized_hex)
201
+
202
+ # 2. Initialize a PRNG with the seed. The same seed will always produce
203
+ # the same sequence of "random" numbers.
204
+ prng = Random.new(seed.unpack1('Q>'))
205
+
206
+ # 3. Generate 16 bytes (128 bits) of deterministic output.
207
+ random_bytes = prng.bytes(16)
208
+
209
+ # Encode as a base36 string for a compact, URL-safe identifier.
210
+ # 128 bits is approximately 25 characters in base36.
211
+ external_part = random_bytes.unpack1('H*').to_i(16).to_s(36).rjust(25, '0')
212
+
213
+ # Get prefix from feature options, default to "ext"
214
+ options = self.class.feature_options(:external_identifier)
215
+ prefix = options[:prefix] || 'ext'
216
+
217
+ "#{prefix}_#{external_part}"
218
+ end
219
+
220
+ # Full-length alias for extid for clarity when needed
221
+ #
222
+ # @return [String] The external identifier
223
+ #
224
+ def external_identifier
225
+ extid
226
+ end
227
+
228
+ # Full-length alias setter for extid
229
+ #
230
+ # @param value [String] The external identifier to set
231
+ #
232
+ def external_identifier=(value)
233
+ self.extid = value
234
+ end
235
+
236
+ def destroy!
237
+ # Clean up extid mapping when object is destroyed
238
+ current_extid = instance_variable_get(:@extid)
239
+ self.class.extid_lookup.del(current_extid) if current_extid
240
+
241
+ super if defined?(super)
242
+ end
243
+
244
+ private
245
+
246
+ # Validate that objid comes from a known secure ObjectIdentifier generator
247
+ #
248
+ # This ensures we only derive external identifiers from objid values that
249
+ # have known provenance and security properties. External identifiers derived
250
+ # from objid values of unknown origin cannot provide security guarantees.
251
+ #
252
+ # @raise [ExternalIdentifierError] if objid has unknown provenance
253
+ #
254
+ def validate_objid_provenance!
255
+ # Check if we have provenance information about the objid generator
256
+ generator_used = objid_generator_used
257
+
258
+ if generator_used.nil?
259
+ error_msg = <<~MSG.strip
260
+ Cannot derive external identifier: objid provenance unknown.
261
+ External identifiers can only be derived from objid values created
262
+ by the ObjectIdentifier feature to ensure security guarantees.
263
+ MSG
264
+ raise ExternalIdentifierError, error_msg
265
+ end
266
+
267
+ # Additional validation: ensure the ObjectIdentifier feature is active
268
+ return if self.class.features_enabled.include?(:object_identifier)
269
+
270
+ raise ExternalIdentifierError,
271
+ 'ExternalIdentifier requires ObjectIdentifier feature for secure provenance.'
272
+ end
273
+
274
+ # Normalize objid to hex format based on the known generator type
275
+ #
276
+ # Since we track which generator was used, we can safely normalize the objid
277
+ # to hex format without relying on string pattern matching. This eliminates
278
+ # the ambiguity between uuid7, uuid4, and hex formats.
279
+ #
280
+ # @param objid_value [String] The objid to normalize
281
+ # @return [String] Hex string suitable for SecureIdentifier processing
282
+ #
283
+ def normalize_objid_to_hex(objid_value)
284
+ generator_used = objid_generator_used
285
+
286
+ case generator_used
287
+ when :uuid_v7, :uuid_v4
288
+ # UUID formats: remove hyphens to get 128-bit hex string
289
+ objid_value.delete('-')
290
+ when :hex
291
+ # Already in hex format (256-bit)
292
+ objid_value
293
+ else
294
+ # Custom generator: attempt to normalize, but we can't guarantee format
295
+ normalized = objid_value.to_s.delete('-')
296
+ unless normalized.match?(/\A[0-9a-fA-F]+\z/)
297
+ error_msg = <<~MSG.strip
298
+ Cannot normalize objid from custom generator #{generator_used}:
299
+ value must be in hexadecimal format, got: #{objid_value}
300
+ MSG
301
+ raise ExternalIdentifierError, error_msg
302
+ end
303
+ normalized
304
+ end
305
+ end
306
+
307
+ Familia::Base.add_feature self, :external_identifier, depends_on: [:object_identifier]
308
+ end
309
+ end
310
+ end