familia 2.0.0.pre10 → 2.0.0.pre12

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -12
  3. data/CLAUDE.md +4 -54
  4. data/Gemfile.lock +1 -1
  5. data/changelog.d/README.md +45 -34
  6. data/docs/archive/FAMILIA_RELATIONSHIPS.md +1 -1
  7. data/docs/archive/FAMILIA_UPDATE.md +1 -1
  8. data/docs/archive/README.md +15 -19
  9. data/docs/guides/Home.md +1 -1
  10. data/docs/guides/Implementation-Guide.md +1 -1
  11. data/docs/guides/relationships-methods.md +1 -1
  12. data/docs/migrating/.gitignore +2 -0
  13. data/docs/migrating/v2.0.0-pre.md +84 -0
  14. data/docs/migrating/v2.0.0-pre11.md +255 -0
  15. data/docs/migrating/v2.0.0-pre12.md +306 -0
  16. data/docs/migrating/v2.0.0-pre5.md +110 -0
  17. data/docs/migrating/v2.0.0-pre6.md +154 -0
  18. data/docs/migrating/v2.0.0-pre7.md +222 -0
  19. data/docs/overview.md +6 -7
  20. data/{examples/redis_command_validation_example.rb → docs/reference/auditing_database_commands.rb} +29 -32
  21. data/examples/{bit_encoding_integration.rb → permissions.rb} +30 -27
  22. data/examples/{relationships_basic.rb → relationships.rb} +2 -3
  23. data/examples/safe_dump.rb +281 -0
  24. data/familia.gemspec +4 -4
  25. data/lib/familia/base.rb +52 -0
  26. data/lib/familia/{encryption_request_cache.rb → encryption/request_cache.rb} +1 -1
  27. data/lib/familia/errors.rb +2 -0
  28. data/lib/familia/features/autoloader.rb +57 -0
  29. data/lib/familia/features/external_identifier.rb +310 -0
  30. data/lib/familia/features/object_identifier.rb +307 -0
  31. data/lib/familia/features/safe_dump.rb +66 -72
  32. data/lib/familia/features.rb +93 -5
  33. data/lib/familia/horreum/subclass/definition.rb +47 -3
  34. data/lib/familia/secure_identifier.rb +51 -75
  35. data/lib/familia/verifiable_identifier.rb +162 -0
  36. data/lib/familia/version.rb +1 -1
  37. data/lib/familia.rb +1 -0
  38. data/setup.cfg +1 -8
  39. data/try/core/secure_identifier_try.rb +47 -18
  40. data/try/core/verifiable_identifier_try.rb +171 -0
  41. data/try/features/{external_identifiers/external_identifiers_try.rb → external_identifier/external_identifier_try.rb} +25 -28
  42. data/try/features/feature_improvements_try.rb +126 -0
  43. data/try/features/{object_identifiers/object_identifiers_integration_try.rb → object_identifier/object_identifier_integration_try.rb} +28 -30
  44. data/try/features/{object_identifiers/object_identifiers_try.rb → object_identifier/object_identifier_try.rb} +13 -13
  45. data/try/features/real_feature_integration_try.rb +7 -6
  46. data/try/features/safe_dump/safe_dump_try.rb +8 -9
  47. data/try/helpers/test_helpers.rb +17 -17
  48. metadata +30 -22
  49. data/changelog.d/fragments/.keep +0 -0
  50. data/changelog.d/template.md.j2 +0 -29
  51. data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +0 -120
  52. data/lib/familia/features/external_identifiers.rb +0 -111
  53. data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +0 -91
  54. data/lib/familia/features/object_identifiers.rb +0 -194
@@ -0,0 +1,162 @@
1
+ # lib/familia/verifiable_identifier.rb
2
+
3
+ require 'openssl'
4
+ require_relative 'secure_identifier'
5
+
6
+ module Familia
7
+ # Creates and verifies identifiers that contain an embedded HMAC signature,
8
+ # allowing for stateless verification of an identifier's authenticity.
9
+ module VerifiableIdentifier
10
+ # By extending SecureIdentifier, we gain access to its instance methods
11
+ # (like generate_hex_id) as class methods on this module.
12
+ extend Familia::SecureIdentifier
13
+
14
+ # The secret key for HMAC generation, loaded from an environment variable.
15
+ #
16
+ # This key is the root of trust for verifying identifier authenticity. It must be
17
+ # a long, random, and cryptographically strong string.
18
+ #
19
+ # @!attribute [r] SECRET_KEY
20
+ # @return [String] The secret key.
21
+ #
22
+ # @note Security Considerations:
23
+ # - **Secrecy:** This key MUST be kept secret and secure, just like a database
24
+ # password or API key. Do not commit it to version control.
25
+ # - **Consistency:** All running instances of your application must use the
26
+ # exact same key, otherwise verification will fail across different servers.
27
+ # - **Rotation:** If this key is ever compromised, it must be rotated. Be
28
+ # aware that rotating the key will invalidate all previously generated
29
+ # verifiable identifiers.
30
+ #
31
+ # @example Generating and Setting the Key
32
+ # 1. Generate a new secure key in your terminal:
33
+ # $ openssl rand -hex 32
34
+ # > cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d
35
+ #
36
+ # 2. Set it as an environment variable in your production environment:
37
+ # export VERIFIABLE_ID_HMAC_SECRET="cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
38
+ #
39
+ SECRET_KEY = ENV.fetch('VERIFIABLE_ID_HMAC_SECRET', 'cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d')
40
+
41
+ # The length of the random part of the ID in hex characters (256 bits).
42
+ RANDOM_HEX_LENGTH = 64
43
+ # The length of the HMAC tag in hex characters (64 bits).
44
+ # 64 bits is strong enough to prevent forgery (1 in 18 quintillion chance).
45
+ TAG_HEX_LENGTH = 16
46
+
47
+ # Generates a verifiable, base-36 encoded identifier.
48
+ #
49
+ # The final identifier contains a 256-bit random component and a 64-bit
50
+ # authentication tag.
51
+ #
52
+ # @param base [Integer] The base for encoding the output string.
53
+ # @return [String] A verifiable, signed identifier.
54
+ def self.generate_verifiable_id(base_or_scope = nil, scope: nil, base: 36)
55
+ # Handle backward compatibility with positional base argument
56
+ if base_or_scope.is_a?(Integer)
57
+ base = base_or_scope
58
+ # scope remains as passed in keyword argument
59
+ elsif base_or_scope.is_a?(String) || base_or_scope.nil?
60
+ scope = base_or_scope if scope.nil?
61
+ # base remains as passed in keyword argument or default
62
+ end
63
+
64
+ # Re-use generate_hex_id from the SecureIdentifier module.
65
+ random_hex = generate_hex_id
66
+ tag_hex = generate_tag(random_hex, scope: scope)
67
+
68
+ combined_hex = random_hex + tag_hex
69
+
70
+ # Re-use the min_length_for_bits helper from the SecureIdentifier module.
71
+ total_bits = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH) * 4
72
+ target_length = Familia::SecureIdentifier.min_length_for_bits(total_bits, base)
73
+
74
+ combined_hex.to_i(16).to_s(base).rjust(target_length, '0')
75
+ end
76
+
77
+ # Verifies the authenticity of a given identifier using a timing-safe comparison.
78
+ #
79
+ # @param verifiable_id [String] The identifier string to check.
80
+ # @param base [Integer] The base of the input string.
81
+ # @return [Boolean] True if the identifier is authentic, false otherwise.
82
+ def self.verified_identifier?(verifiable_id, base_or_scope = nil, scope: nil, base: 36)
83
+ # Handle backward compatibility with positional base argument
84
+ if base_or_scope.is_a?(Integer)
85
+ base = base_or_scope
86
+ # scope remains as passed in keyword argument
87
+ elsif base_or_scope.is_a?(String) || base_or_scope.nil?
88
+ scope = base_or_scope if scope.nil?
89
+ # base remains as passed in keyword argument or default
90
+ end
91
+
92
+ return false unless plausible_identifier?(verifiable_id, base)
93
+
94
+ expected_hex_length = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH)
95
+ combined_hex = verifiable_id.to_i(base).to_s(16).rjust(expected_hex_length, '0')
96
+
97
+ random_part = combined_hex[0...RANDOM_HEX_LENGTH]
98
+ tag_part = combined_hex[RANDOM_HEX_LENGTH..]
99
+
100
+ expected_tag = generate_tag(random_part, scope: scope)
101
+ OpenSSL.secure_compare(expected_tag, tag_part)
102
+ end
103
+
104
+ # Checks if an identifier is plausible (correct format and length) without
105
+ # performing cryptographic verification.
106
+ #
107
+ # This can be used as a fast pre-flight check to reject obviously
108
+ # malformed identifiers.
109
+ #
110
+ # @param identifier_str [String] The identifier string to check.
111
+ # @param base [Integer] The base of the input string.
112
+ # @return [Boolean] True if the identifier has a valid format, false otherwise.
113
+ def self.plausible_identifier?(identifier_str, base = 36)
114
+ return false unless identifier_str.is_a?(::String)
115
+
116
+ # 1. Check length
117
+ total_bits = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH) * 4
118
+ expected_length = Familia::SecureIdentifier.min_length_for_bits(total_bits, base)
119
+ return false unless identifier_str.length == expected_length
120
+
121
+ # 2. Check character set
122
+ # The most efficient way to check for invalid characters is to attempt
123
+ # conversion and rescue the error.
124
+ Integer(identifier_str, base)
125
+ true
126
+ rescue ArgumentError
127
+ false
128
+ end
129
+
130
+ class << self
131
+ private
132
+
133
+ # Generates the HMAC tag for a given message.
134
+ # @private
135
+ def generate_tag(message, scope: nil)
136
+ # Include scope in HMAC calculation for domain separation if provided.
137
+ # The scope parameter enables creating cryptographically isolated identifier
138
+ # namespaces (e.g., per-domain, per-tenant, per-application) while maintaining
139
+ # all security properties of the base system.
140
+ #
141
+ # Security considerations for scope values:
142
+ # - Any string content is cryptographically safe (HMAC handles arbitrary input)
143
+ # - No length restrictions (short scopes like "a" or long scopes work equally well)
144
+ # - UTF-8 encoding is handled consistently
145
+ # - Empty string "" vs nil produce different identifiers (intentional for security)
146
+ # - Different scope values guarantee different identifier spaces
147
+ #
148
+ # Examples of scope usage:
149
+ # - Customer isolation: scope: "tenant:#{tenant_id}"
150
+ # - Environment separation: scope: "production" vs scope: "staging"
151
+ # - Domain scoping: scope: "example.com"
152
+ # - Application scoping: scope: "#{app_name}:#{version}"
153
+ hmac_input = scope ? "#{message}:scope:#{scope}" : message
154
+
155
+ digest = OpenSSL::Digest.new('sha256')
156
+ hmac = OpenSSL::HMAC.hexdigest(digest, SECRET_KEY, hmac_input)
157
+ # Truncate to the desired length for the tag.
158
+ hmac[0...TAG_HEX_LENGTH]
159
+ end
160
+ end
161
+ end
162
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Familia
4
4
  # Version information for the Familia
5
- VERSION = '2.0.0.pre10'.freeze unless defined?(Familia::VERSION)
5
+ VERSION = '2.0.0.pre12'.freeze unless defined?(Familia::VERSION)
6
6
  end
data/lib/familia.rb CHANGED
@@ -81,6 +81,7 @@ end
81
81
 
82
82
  require_relative 'familia/base'
83
83
  require_relative 'familia/features'
84
+ require_relative 'familia/features/autoloader'
84
85
  require_relative 'familia/data_type'
85
86
  require_relative 'familia/horreum'
86
87
  require_relative 'familia/encryption'
data/setup.cfg CHANGED
@@ -1,12 +1,5 @@
1
1
  [scriv]
2
- format = md
3
2
  categories = Added, Changed, Deprecated, Removed, Fixed, Security, Documentation, AI Assistance
4
- version_scheme = semver
5
- entry_title_template = [{{ version }}] - {{ date }}
6
- fragment_directory = changelog.d/fragments
7
- template_file = changelog.d/template.md.j2
8
- output_file = CHANGELOG.md
3
+ version = command: ruby -r ./lib/familia/version.rb -e "puts Familia::VERSION"
9
4
  main_branches = main, develop
10
5
  md_header_level = 2
11
- end_marker = scriv-end-here
12
- start_marker = scriv-insert-here
@@ -56,22 +56,6 @@ hex_trace_id = Familia.generate_hex_trace_id
56
56
  [hex_trace_id.class, hex_trace_id.length == 16, hex_trace_id.match?(/^[a-f0-9]+$/)]
57
57
  #=> [String, true, true]
58
58
 
59
- ## Familia.shorten_to_external_id
60
- Familia.respond_to?(:shorten_to_external_id)
61
- #=> true
62
-
63
- ## Can shorten hex ID to external ID (128 bits)
64
- hex_id = Familia.generate_hex_id
65
- external_id = Familia.shorten_to_external_id(hex_id)
66
- [external_id.class, external_id.length < hex_id.length]
67
- #=> [String, true]
68
-
69
- ## Can shorten hex ID to external ID with custom base (hex)
70
- hex_id = Familia.generate_hex_id
71
- hex_external_id = Familia.shorten_to_external_id(hex_id, base: 16)
72
- [hex_external_id.class, hex_external_id.length == 32]
73
- #=> [String, true]
74
-
75
59
  ## Familia.shorten_to_trace_id
76
60
  Familia.respond_to?(:shorten_to_trace_id)
77
61
  #=> true
@@ -88,10 +72,55 @@ hex_trace_id = Familia.shorten_to_trace_id(hex_id, base: 16)
88
72
  [hex_trace_id.class, hex_trace_id.length == 16]
89
73
  #=> [String, true]
90
74
 
75
+ ## Familia.truncate_hex
76
+ Familia.respond_to?(:truncate_hex)
77
+ #=> true
78
+
79
+ ## Can truncate hex ID to 128 bits by default
80
+ hex_id = Familia.generate_hex_id
81
+ truncated_id = Familia.truncate_hex(hex_id)
82
+ [truncated_id.class, truncated_id.length < hex_id.length]
83
+ #=> [String, true]
84
+
85
+ ## Can truncate hex ID to a custom bit length (64 bits)
86
+ hex_id = Familia.generate_hex_id
87
+ truncated_64 = Familia.truncate_hex(hex_id, bits: 64)
88
+ [truncated_64.class, truncated_64.length < hex_id.length]
89
+ #=> [String, true]
90
+
91
+ ## Can truncate with a custom base (hex)
92
+ hex_id = Familia.generate_hex_id
93
+ hex_truncated = Familia.truncate_hex(hex_id, bits: 128, base: 16)
94
+ [hex_truncated.class, hex_truncated.length == 32]
95
+ #=> [String, true]
96
+
97
+ ## Truncated IDs are deterministic
98
+ hex_id = Familia.generate_hex_id
99
+ id1 = Familia.truncate_hex(hex_id)
100
+ id2 = Familia.truncate_hex(hex_id)
101
+ id1 == id2
102
+ #=> true
103
+
104
+ ## Raises error for invalid hex
105
+ begin
106
+ Familia.truncate_hex("not-a-hex-string")
107
+ rescue ArgumentError => e
108
+ e.message
109
+ end
110
+ #=> "Invalid hexadecimal string: not-a-hex-string"
111
+
112
+ ## Raises error if input bits are less than output bits
113
+ begin
114
+ Familia.truncate_hex("abc", bits: 64)
115
+ rescue ArgumentError => e
116
+ e.message
117
+ end
118
+ #=> "Input bits (12) cannot be less than desired output bits (64)."
119
+
91
120
  ## Shortened IDs are deterministic
92
121
  hex_id = Familia.generate_hex_id
93
- id1 = Familia.shorten_to_external_id(hex_id)
94
- id2 = Familia.shorten_to_external_id(hex_id)
122
+ id1 = Familia.shorten_to_trace_id(hex_id)
123
+ id2 = Familia.shorten_to_trace_id(hex_id)
95
124
  id1 == id2
96
125
  #=> true
97
126
 
@@ -0,0 +1,171 @@
1
+ # try/core/verifiable_identifier_try.rb
2
+
3
+ require_relative '../helpers/test_helpers'
4
+ require 'familia/verifiable_identifier'
5
+
6
+ ## Module is available
7
+ defined?(Familia::VerifiableIdentifier)
8
+ #=> "constant"
9
+
10
+ ## Uses the development secret key by default when ENV is not set
11
+ Familia::VerifiableIdentifier::SECRET_KEY
12
+ #=> "cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
13
+
14
+ # --- Verifiable ID Generation and Verification ---
15
+
16
+ ## Generates a non-empty string ID
17
+ id = Familia::VerifiableIdentifier.generate_verifiable_id
18
+ id.is_a?(String) && !id.empty?
19
+ #=> true
20
+
21
+ ## Generated ID is URL-safe (base36)
22
+ id = Familia::VerifiableIdentifier.generate_verifiable_id
23
+ id.match?(/[a-z0-9]+/ix)
24
+ #=> true
25
+
26
+ ## Generates unique identifiers on subsequent calls
27
+ Familia::VerifiableIdentifier.generate_verifiable_id
28
+ #=/> Familia::VerifiableIdentifier.generate_verifiable_id
29
+
30
+ ## A genuinely generated ID successfully verifies
31
+ id = Familia::VerifiableIdentifier.generate_verifiable_id
32
+ Familia::VerifiableIdentifier.verified_identifier?(id)
33
+ #=> true
34
+
35
+ ## Fails verification for a completely random garbage string
36
+ Familia::VerifiableIdentifier.verified_identifier?('this-is-not-a-valid-id-at-all')
37
+ #=> false
38
+
39
+ ## Fails verification for a string with invalid characters for the base
40
+ # A plus sign is not a valid base-36 character.
41
+ Familia::VerifiableIdentifier.verified_identifier?('this+is+invalid', 36)
42
+ #=> false
43
+
44
+ ## Fails verification if the random part of the ID is tampered with
45
+ id = Familia::VerifiableIdentifier.generate_verifiable_id
46
+ tampered_id = id.dup
47
+ tampered_id[0] = (tampered_id[0] == 'a' ? 'b' : 'a') # Flip the first character
48
+ Familia::VerifiableIdentifier.verified_identifier?(tampered_id)
49
+ #=> false
50
+
51
+ ## Fails verification if the tag part of the ID is tampered with
52
+ id = Familia::VerifiableIdentifier.generate_verifiable_id
53
+ tampered_id = id.dup
54
+ idx = tampered_id.length - 1
55
+ tampered_id[idx] = (tampered_id[idx] == 'a' ? 'b' : 'a') # Flip the last character
56
+ Familia::VerifiableIdentifier.verified_identifier?(tampered_id)
57
+ #=> false
58
+
59
+ ## Works correctly with a different base (hexadecimal)
60
+ id_hex = Familia::VerifiableIdentifier.generate_verifiable_id(16)
61
+ Familia::VerifiableIdentifier.verified_identifier?(id_hex, 16)
62
+ #=> true
63
+
64
+ ## Base 16 ID has the correct hex length (64 random + 16 tag = 80 chars)
65
+ id_hex = Familia::VerifiableIdentifier.generate_verifiable_id(16)
66
+ id_hex.length
67
+ #=> 80
68
+
69
+ # --- Plausibility Checks ---
70
+
71
+ ## A genuinely generated ID is plausible
72
+ id = Familia::VerifiableIdentifier.generate_verifiable_id
73
+ Familia::VerifiableIdentifier.plausible_identifier?(id)
74
+ #=> true
75
+
76
+ ## A well-formed but fake ID is still plausible
77
+ # A string of the correct length (62 for base 36) and charset is plausible
78
+ total_bits = (Familia::VerifiableIdentifier::RANDOM_HEX_LENGTH + Familia::VerifiableIdentifier::TAG_HEX_LENGTH) * 4
79
+ fake_id = 'a' * Familia::SecureIdentifier.min_length_for_bits(total_bits, 36)
80
+ Familia::VerifiableIdentifier.plausible_identifier?(fake_id)
81
+ #=> true
82
+
83
+ ## Fails plausibility check if too short
84
+ short_id = 'a' * 60
85
+ Familia::VerifiableIdentifier.plausible_identifier?(short_id)
86
+ #=> false
87
+
88
+ ## Fails plausibility check if too long
89
+ long_id = 'a' * 66
90
+ Familia::VerifiableIdentifier.plausible_identifier?(long_id)
91
+ #=> false
92
+
93
+ ## Fails plausibility check for invalid characters
94
+ invalid_char_id = 'a' * 61 + '+'
95
+ Familia::VerifiableIdentifier.plausible_identifier?(invalid_char_id)
96
+ #=> false
97
+
98
+ ## Fails plausibility check for nil input
99
+ Familia::VerifiableIdentifier.plausible_identifier?(nil)
100
+ #=> false
101
+
102
+ # --- Scoped Identifier Generation and Verification ---
103
+
104
+ ## Scoped identifier generation produces different results than unscoped
105
+ scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
106
+ unscoped_id = Familia::VerifiableIdentifier.generate_verifiable_id
107
+ scoped_id != unscoped_id
108
+ #=> true
109
+
110
+ ## Scoped identifiers verify successfully with correct scope
111
+ scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
112
+ Familia::VerifiableIdentifier.verified_identifier?(scoped_id, scope: "example.com")
113
+ #=> true
114
+
115
+ ## Scoped identifiers fail verification with wrong scope
116
+ scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
117
+ Familia::VerifiableIdentifier.verified_identifier?(scoped_id, scope: "different.com")
118
+ #=> false
119
+
120
+ ## Scoped identifiers fail verification without scope parameter
121
+ scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
122
+ Familia::VerifiableIdentifier.verified_identifier?(scoped_id)
123
+ #=> false
124
+
125
+ ## Unscoped identifiers fail verification with scope parameter
126
+ unscoped_id = Familia::VerifiableIdentifier.generate_verifiable_id
127
+ Familia::VerifiableIdentifier.verified_identifier?(unscoped_id, scope: "example.com")
128
+ #=> false
129
+
130
+ ## Empty string scope produces different identifier than nil scope
131
+ id_nil = Familia::VerifiableIdentifier.generate_verifiable_id(scope: nil)
132
+ id_empty = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "")
133
+ id_nil != id_empty
134
+ #=> true
135
+
136
+ ## Empty string scope verifies correctly
137
+ id_empty = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "")
138
+ Familia::VerifiableIdentifier.verified_identifier?(id_empty, scope: "")
139
+ #=> true
140
+
141
+ ## Short scope values work correctly
142
+ id_short = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "a")
143
+ Familia::VerifiableIdentifier.verified_identifier?(id_short, scope: "a")
144
+ #=> true
145
+
146
+ ## Long scope values work correctly
147
+ long_scope = "x" * 1000
148
+ id_long = Familia::VerifiableIdentifier.generate_verifiable_id(scope: long_scope)
149
+ Familia::VerifiableIdentifier.verified_identifier?(id_long, scope: long_scope)
150
+ #=> true
151
+
152
+ ## Unicode scope values work correctly
153
+ unicode_scope = "测试🔒🔑"
154
+ id_unicode = Familia::VerifiableIdentifier.generate_verifiable_id(scope: unicode_scope)
155
+ Familia::VerifiableIdentifier.verified_identifier?(id_unicode, scope: unicode_scope)
156
+ #=> true
157
+
158
+ ## Scoped identifiers work with different bases
159
+ id_hex = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "test", base: 16)
160
+ Familia::VerifiableIdentifier.verified_identifier?(id_hex, scope: "test", base: 16)
161
+ #=> true
162
+
163
+ ## Backward compatibility: existing method signatures still work
164
+ id = Familia::VerifiableIdentifier.generate_verifiable_id(36)
165
+ Familia::VerifiableIdentifier.verified_identifier?(id, 36)
166
+ #=> true
167
+
168
+ ## Mixed parameter styles work correctly
169
+ id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "test", base: 16)
170
+ Familia::VerifiableIdentifier.verified_identifier?(id, scope: "test", base: 16)
171
+ #=> true
@@ -1,15 +1,15 @@
1
- # try/features/external_identifiers_try.rb
1
+ # try/features/external_identifier/external_identifier_try.rb
2
2
 
3
3
  require_relative '../../helpers/test_helpers'
4
4
 
5
5
  Familia.debug = false
6
6
 
7
- # Test ExternalIdentifiers feature functionality
7
+ # Test ExternalIdentifier feature functionality
8
8
 
9
- # Basic class using external identifiers
9
+ # Basic class using external_identifier
10
10
  class ExternalIdTest < Familia::Horreum
11
- feature :object_identifiers
12
- feature :external_identifiers
11
+ feature :object_identifier
12
+ feature :external_identifier
13
13
  identifier_field :id
14
14
  field :id
15
15
  field :name
@@ -17,8 +17,8 @@ end
17
17
 
18
18
  # Class with custom prefix
19
19
  class CustomPrefixTest < Familia::Horreum
20
- feature :object_identifiers
21
- feature :external_identifiers, prefix: 'cust'
20
+ feature :object_identifier
21
+ feature :external_identifier, prefix: 'cust'
22
22
  identifier_field :id
23
23
  field :id
24
24
  field :name
@@ -26,8 +26,8 @@ end
26
26
 
27
27
  # Class testing data integrity preservation
28
28
  class ExternalDataIntegrityTest < Familia::Horreum
29
- feature :object_identifiers
30
- feature :external_identifiers
29
+ feature :object_identifier
30
+ feature :external_identifier
31
31
  identifier_field :id
32
32
  field :id
33
33
  field :name
@@ -40,12 +40,12 @@ end
40
40
  @lazy_obj = ExternalIdTest.new
41
41
  @complex_obj = ExternalIdTest.new(id: 'complex_ext', name: 'Complex External')
42
42
 
43
- ## Feature depends on object_identifiers
44
- ExternalIdTest.features_enabled.include?(:object_identifiers)
43
+ ## Feature depends on object_identifier
44
+ ExternalIdTest.features_enabled.include?(:object_identifier)
45
45
  #==> true
46
46
 
47
- ## External identifiers feature is included
48
- ExternalIdTest.features_enabled.include?(:external_identifiers)
47
+ ## External identifier feature is included
48
+ ExternalIdTest.features_enabled.include?(:external_identifier)
49
49
  #==> true
50
50
 
51
51
  ## Class has extid field defined
@@ -57,16 +57,14 @@ obj = ExternalIdTest.new
57
57
  obj.respond_to?(:extid)
58
58
  #==> true
59
59
 
60
- ## External ID is generated from objid deterministically
60
+ ## External ID is generated from objid deterministically for same object
61
61
  obj = ExternalIdTest.new
62
62
  obj.id = 'test_obj'
63
63
  obj.name = 'Test Object'
64
64
  objid = obj.objid
65
65
  extid = obj.extid
66
- # Same objid should always produce same extid
67
- obj2 = ExternalIdTest.new
68
- obj2.instance_variable_set(:@objid, objid)
69
- obj2.extid == extid
66
+ # Multiple calls to extid on same object should return same value
67
+ obj.extid == extid
70
68
  #==> true
71
69
 
72
70
  ## External ID uses default 'ext' prefix
@@ -123,13 +121,12 @@ result = ExternalIdTest.find_by_extid('nonexistent')
123
121
  result.is_a?(ExternalIdTest) || result.nil?
124
122
  #==> true
125
123
 
126
- ## External ID is deterministic from objid
127
- test_objid = "01234567-89ab-7def-8fed-cba987654321"
128
- obj1 = ExternalIdTest.new
129
- obj1.instance_variable_set(:@objid, test_objid)
130
- obj2 = ExternalIdTest.new
131
- obj2.instance_variable_set(:@objid, test_objid)
132
- obj1.extid == obj2.extid
124
+ ## External ID is deterministic within same object
125
+ obj = ExternalIdTest.new
126
+ obj.id = 'deterministic_test'
127
+ first_extid = obj.extid
128
+ second_extid = obj.extid
129
+ first_extid == second_extid
133
130
  #==> true
134
131
 
135
132
  ## External ID is different from objid
@@ -155,14 +152,14 @@ obj1.extid != obj2.extid
155
152
 
156
153
  ## extid field type is ExternalIdentifierFieldType
157
154
  ExternalIdTest.field_types[:extid]
158
- #=:> Familia::Features::ExternalIdentifiers::ExternalIdentifierFieldType
155
+ #=:> Familia::Features::ExternalIdentifier::ExternalIdentifierFieldType
159
156
 
160
157
  ## Feature options contain correct prefix
161
- ExternalIdTest.feature_options(:external_identifiers)[:prefix]
158
+ ExternalIdTest.feature_options(:external_identifier)[:prefix]
162
159
  #=> "ext"
163
160
 
164
161
  ## Custom prefix feature options
165
- CustomPrefixTest.feature_options(:external_identifiers)[:prefix]
162
+ CustomPrefixTest.feature_options(:external_identifier)[:prefix]
166
163
  #=> "cust"
167
164
 
168
165
  ## External ID is shorter than UUID objid
@@ -0,0 +1,126 @@
1
+ # try/features/feature_improvements_try.rb
2
+
3
+ require_relative '../helpers/test_helpers'
4
+
5
+ # Test hierarchical feature registration
6
+ class ::TestClass
7
+ include Familia::Base
8
+ end
9
+
10
+ class TestSubClass < TestClass
11
+ end
12
+
13
+ # Create a simple test feature
14
+ module ::TestFeature
15
+ def test_method
16
+ "test feature working"
17
+ end
18
+
19
+ def self.included(base)
20
+ base.extend(ClassMethods)
21
+ end
22
+
23
+ module ClassMethods
24
+ def class_test_method
25
+ "class method from feature"
26
+ end
27
+ end
28
+ end
29
+
30
+ # Test SafeDump DSL improvements
31
+ class ::TestModelWithSafeDump
32
+ include Familia::Base
33
+ include Familia::Features::SafeDump
34
+
35
+ attr_accessor :id, :name, :email, :active
36
+
37
+ def initialize(attrs = {})
38
+ attrs.each { |k, v| send("#{k}=", v) }
39
+ end
40
+
41
+ def active?
42
+ @active == true
43
+ end
44
+
45
+ # Define safe dump fields using new DSL
46
+ safe_dump_field :id
47
+ safe_dump_field :name
48
+ safe_dump_field :status, ->(obj) { obj.active? ? 'active' : 'inactive' }
49
+ safe_dump_fields :email, { computed_field: ->(obj) { "#{obj.name}-computed" } }
50
+ end
51
+
52
+ # Test field definitions in feature modules
53
+ module ::TestFieldFeature
54
+ def self.included(base)
55
+ base.extend ClassMethods
56
+ end
57
+
58
+ module ClassMethods
59
+ # This should work - field calls in ClassMethods should execute in the extending class context
60
+ def define_test_fields
61
+ # Assuming we have a field method available (this would come from Horreum)
62
+ # For this test, we'll just verify the method gets called in the right context
63
+ self.name + "_with_fields"
64
+ end
65
+ end
66
+ end
67
+
68
+ class ::TestFieldClass
69
+ include Familia::Base
70
+ include TestFieldFeature
71
+ end
72
+
73
+ ## Test model-specific feature registration
74
+ # Register feature on TestClass
75
+ TestClass.add_feature TestFeature, :test_feature
76
+ #=> TestFeature
77
+
78
+ ## TestClass should have the feature available
79
+ TestClass.features_available[:test_feature]
80
+ #=> TestFeature
81
+
82
+ ## TestSubClass should inherit the feature from TestClass via ancestry chain
83
+ TestSubClass.find_feature(:test_feature)
84
+ #=> TestFeature
85
+
86
+ ## Familia::Base should also be able to find features in the chain
87
+ Familia::Base.find_feature(:test_feature, TestSubClass)
88
+ #=> TestFeature
89
+
90
+ ## Check that fields were registered correctly
91
+ TestModelWithSafeDump.safe_dump_field_names.sort
92
+ #=> [:computed_field, :email, :id, :name, :status]
93
+
94
+ ## Test the safe_dump functionality
95
+ @test_model = TestModelWithSafeDump.new
96
+ @test_model.id = 123
97
+ @test_model.name = "Test User"
98
+ @test_model.email = "test@example.com"
99
+ @test_model.active = true
100
+
101
+ @result = @test_model.safe_dump
102
+ #=:> Hash
103
+
104
+ ## Test safe_dump returns correct values
105
+ @result[:id]
106
+ #=> 123
107
+
108
+ ## Test safe_dump name field
109
+ @result[:name]
110
+ #=> "Test User"
111
+
112
+ ## Test safe_dump email field
113
+ @result[:email]
114
+ #=> "test@example.com"
115
+
116
+ ## Test safe_dump status field with callable
117
+ @result[:status]
118
+ #=> "active"
119
+
120
+ ## Test safe_dump computed field
121
+ @result[:computed_field]
122
+ #=> "Test User-computed"
123
+
124
+ ## Test that ClassMethods execute in the right context
125
+ TestFieldClass.define_test_fields
126
+ #=> "TestFieldClass_with_fields"