familia 2.3.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cfb3f3aa06c0b96493a86fbe2e3c475e81b9a84d69d7b88e06025adbb597ad8e
4
- data.tar.gz: c567f15b73c06a67baad7b1aff36e23d0d544de1a25308716f6edbac235a3ca9
3
+ metadata.gz: 9dfc0a904fa0458350fcdc3098b01e4ecbbe12846b9ddcb2265929d1f05f3a3d
4
+ data.tar.gz: ecd74f3b069bde39291fa5c750ed7a4d6f38938f4514563fd87309380b711cba
5
5
  SHA512:
6
- metadata.gz: 66028adaf4e3004b57bb17b39a99745ad771b2af915253cce02a6ca130e028cdf39e0fc11d90c40729ffa85cc5b5fa8c2c2f1b0320a7c3e21ea74858d173c6b6
7
- data.tar.gz: aa3c991ea06b0c2268034784b83fb2dfb5ab6507902de3bca2345149dba3bdb287829769abbb15350c093551c79419c3e2e79558cacac2e589161ebdadd9dd4f
6
+ metadata.gz: ad072a725504dfe12f19ebe23ff8d257174ae431f8a42bf654e17e14db55f0361811e896b49d9f48eeb137272bcce991ddb65c813b0591379eddf9fffc6dd566
7
+ data.tar.gz: c74fb2bda18b647f1c32d81e669f275c1a93c88aa0123ff69258262cd3ef31bb14b9fda8b31f57fbd9f96eb188f89bed0435a05d844e2f5cab77bd7ff7e1c822
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,87 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.4.0:
11
+
12
+ 2.4.0 — 2026-04-06
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Added ``staged:`` option to ``participates_in`` for invitation workflows where
19
+ through models must exist before participants. Creates a staging sorted set
20
+ alongside the active membership set with three new operations:
21
+ ``stage_members_instance``, ``activate_members_instance``, ``unstage_members_instance``.
22
+ Staged models use UUID keys; activated models use composite keys.
23
+ (`#237 <https://github.com/delano/familia/issues/237>`_)
24
+
25
+ - Added ``StagedOperations`` module in ``lib/familia/features/relationships/participation/``
26
+ for staging lifecycle management with lazy cleanup for ghost entries.
27
+
28
+ - Added ``staged?`` and ``staging_collection_name`` methods to ``ParticipationRelationship``.
29
+
30
+ Changed
31
+ -------
32
+
33
+ - **Breaking change**: Through models in staged relationships use UUID keys during staging,
34
+ composite keys after activation. The staged model is destroyed during activation --
35
+ any references to it become invalid. Application code calling ``accept!`` on
36
+ staged memberships should capture and use the returned activated model rather
37
+ than the original staged model.
38
+
39
+ - Extended ``participates_in`` signature to accept ``staged:`` option (Symbol or nil).
40
+ Validation ensures ``staged:`` requires ``through:`` option.
41
+
42
+ AI Assistance
43
+ -------------
44
+
45
+ - Claude assisted with architecture design, identifying the impedance mismatch between
46
+ relational ORM patterns and Redis's materialized indexes, analyzing transaction
47
+ boundaries, and designing the separation between ``StagedOperations`` and
48
+ ``ThroughModelOperations`` modules.
49
+
50
+ .. _changelog-2.3.3:
51
+
52
+ 2.3.3 — 2026-03-30
53
+ ==================
54
+
55
+ Added
56
+ -----
57
+
58
+ - Encrypt now records the original plaintext encoding in the EncryptedData
59
+ envelope (``encoding`` field), completing the Phase 2 encoding round-trip
60
+ fix. Decrypt (Phase 1, #228) already falls back to UTF-8 when the field
61
+ is absent, so this change is backward-compatible. PR #229
62
+
63
+ Fixed
64
+ -----
65
+
66
+ - ``build_aad`` now produces consistent AAD (Additional Authenticated Data)
67
+ regardless of whether the record has been persisted. Previously, encrypted
68
+ fields with ``aad_fields`` used different AAD computation paths before and
69
+ after save, making ``reveal`` fail on any record created via ``create!``.
70
+ Issue #232, PR #234. No migration needed — the previous behavior was
71
+ broken (AAD mismatch prevented decryption), so no valid ciphertexts
72
+ exist under the old inconsistent paths.
73
+
74
+ - ``build_aad`` no longer uses ``.compact`` on AAD field values. Previously,
75
+ nil fields were silently dropped, shifting later values left and producing
76
+ a different hash once the field was populated. Now each field is coerced
77
+ via ``.to_s`` so that nil and empty string both occupy a fixed position
78
+ in the joined AAD string. Issue #232, PR #234.
79
+
80
+ AI Assistance
81
+ -------------
82
+
83
+ - Implementation and test authoring delegated to backend-dev agents, with
84
+ orchestration and Phase 1 test fixups handled in the main session. Claude
85
+ Opus 4.6.
86
+
87
+ - Claude assisted with implementing the fix, updating affected tests, and
88
+ writing the round-trip regression test. The issue analysis, root cause
89
+ identification, and suggested fix were provided in the issue by the author.
90
+
10
91
  .. _changelog-2.3.2:
11
92
 
12
93
  2.3.2 — 2026-03-12
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.3.2)
4
+ familia (2.4.0)
5
5
  concurrent-ruby (~> 1.3)
6
6
  connection_pool (>= 2.4, < 4.0)
7
7
  csv (~> 3.3)
@@ -622,7 +622,7 @@ Extend current TTL by additional time.
622
622
 
623
623
  **Returns:** Boolean indicating success
624
624
 
625
- #### `persist!`
625
+ #### `persist!` / `clear_expiration!`
626
626
  Remove TTL, making object persistent.
627
627
 
628
628
  **Returns:** Boolean indicating success
@@ -5,6 +5,7 @@ Indexing provides O(1) field-to-object lookups using Redis data structures, enab
5
5
  ## Core Concepts
6
6
 
7
7
  Indexing creates fast lookups for finding objects by field values:
8
+
8
9
  - **O(1) performance** - Hash/Set-based constant-time access
9
10
  - **Automatic management** - Class indexes update on save/destroy
10
11
  - **Flexible scoping** - Global or parent-scoped uniqueness
@@ -12,12 +13,12 @@ Indexing creates fast lookups for finding objects by field values:
12
13
 
13
14
  ## Index Types
14
15
 
15
- | Type | Scope | Use Case | Structure |
16
- |------|-------|----------|-----------|
17
- | `unique_index` | Class | Global unique fields | Redis HashKey |
18
- | `unique_index` | Instance | Parent-scoped unique | Redis HashKey |
19
- | `multi_index` | Class (default) | Global non-unique groupings | Redis Set per value |
20
- | `multi_index` | Instance | Parent-scoped groupings | Redis Set per value |
16
+ | Type | Scope | Use Case | Structure |
17
+ | -------------- | --------------- | --------------------------- | ------------------- |
18
+ | `unique_index` | Class | Global unique fields | Redis HashKey |
19
+ | `unique_index` | Instance | Parent-scoped unique | Redis HashKey |
20
+ | `multi_index` | Class (default) | Global non-unique groupings | Redis Set per value |
21
+ | `multi_index` | Instance | Parent-scoped groupings | Redis Set per value |
21
22
 
22
23
  ## Class-Level Unique Indexing
23
24
 
@@ -47,12 +48,12 @@ User.find_by_email('alice.smith@example.com') # => nil
47
48
 
48
49
  ### Generated Methods
49
50
 
50
- | Method | Description |
51
- |--------|-------------|
52
- | `User.find_by_email(email)` | O(1) lookup |
53
- | `User.index_email_for(user)` | Manual index |
51
+ | Method | Description |
52
+ | ------------------------------ | ----------------- |
53
+ | `User.find_by_email(email)` | O(1) lookup |
54
+ | `User.index_email_for(user)` | Manual index |
54
55
  | `User.unindex_email_for(user)` | Remove from index |
55
- | `User.reindex_email_for(user)` | Update index |
56
+ | `User.reindex_email_for(user)` | Update index |
56
57
 
57
58
  ## Instance-Scoped Unique Indexing
58
59
 
@@ -139,20 +140,20 @@ Customer.role_index_for('user').dbkey # => "customer:role_index:user"
139
140
 
140
141
  ### Generated Class Methods
141
142
 
142
- | Method | Description |
143
- |--------|-------------|
144
- | `Customer.role_index_for(value)` | Factory returning `Familia::UnsortedSet` for the field value |
145
- | `Customer.find_all_by_role(value)` | Find all objects with that field value |
146
- | `Customer.sample_from_role(value, count)` | Random sample of objects |
147
- | `Customer.rebuild_role_index` | Rebuild the entire index from source data |
143
+ | Method | Description |
144
+ | ----------------------------------------- | ------------------------------------------------------------ |
145
+ | `Customer.role_index_for(value)` | Factory returning `Familia::UnsortedSet` for the field value |
146
+ | `Customer.find_all_by_role(value)` | Find all objects with that field value |
147
+ | `Customer.sample_from_role(value, count)` | Random sample of objects |
148
+ | `Customer.rebuild_role_index` | Rebuild the entire index from source data |
148
149
 
149
150
  ### Generated Instance Methods
150
151
 
151
- | Method | Description |
152
- |--------|-------------|
153
- | `customer.add_to_class_role_index` | Add this object to its field value's index |
154
- | `customer.remove_from_class_role_index` | Remove this object from its field value's index |
155
- | `customer.update_in_class_role_index(old_value)` | Move object from old index to new index |
152
+ | Method | Description |
153
+ | ------------------------------------------------ | ----------------------------------------------- |
154
+ | `customer.add_to_class_role_index` | Add this object to its field value's index |
155
+ | `customer.remove_from_class_role_index` | Remove this object from its field value's index |
156
+ | `customer.update_in_class_role_index(old_value)` | Move object from old index to new index |
156
157
 
157
158
  ### Update Operations
158
159
 
@@ -271,24 +272,28 @@ todays_events = user.find_all_by_daily_partition(today)
271
272
  ### Class vs Instance Scoping
272
273
 
273
274
  **Class-level unique (`unique_index :email, :email_lookup`):**
275
+
274
276
  - Automatic indexing on save/destroy
275
277
  - System-wide uniqueness
276
278
  - No parent context needed
277
279
  - Examples: emails, usernames, API keys
278
280
 
279
281
  **Class-level multi (`multi_index :role, :role_index`):**
282
+
280
283
  - Default behavior (no `within:` needed)
281
284
  - Groups all objects by field value at class level
282
285
  - Manual indexing via instance methods
283
286
  - Examples: roles, categories, statuses
284
287
 
285
288
  **Instance-scoped (`unique_index :badge, :badge_index, within: Company`):**
289
+
286
290
  - Manual indexing required
287
291
  - Unique within parent only
288
292
  - Requires parent context
289
293
  - Examples: employee IDs, project names per team
290
294
 
291
295
  **Instance-scoped multi (`multi_index :dept, :dept_index, within: Company`):**
296
+
292
297
  - Groups objects by field value within parent scope
293
298
  - Same field value allowed across different parents
294
299
  - Manual indexing with parent context
@@ -297,11 +302,13 @@ todays_events = user.find_all_by_daily_partition(today)
297
302
  ### Unique vs Multi Indexing
298
303
 
299
304
  **Unique index (`unique_index`):**
305
+
300
306
  - 1:1 field-to-object mapping
301
307
  - Returns single object or nil
302
308
  - Enforces uniqueness within scope
303
309
 
304
310
  **Multi index (`multi_index`):**
311
+
305
312
  - 1:many field-to-objects mapping
306
313
  - Returns array of objects
307
314
  - Allows duplicate values
@@ -325,6 +332,7 @@ These methods work because **indexes are derived data** - they're computed from
325
332
  > **Important:** Participation data (like `@team.members`) cannot be rebuilt automatically because participations represent business decisions, not derived data. See [Why Participations Can't Be Rebuilt](../../lib/familia/features/relationships/participation/rebuild_strategies.md) for the critical distinction between indexes and participations.
326
333
 
327
334
  **When to rebuild indexes:**
335
+
328
336
  - After data migrations or bulk imports
329
337
  - Recovering from index corruption
330
338
  - Adding indexes to existing data
@@ -372,26 +380,29 @@ Index values (the object identifiers stored in hash keys and sets) are raw strin
372
380
 
373
381
  ## Redis Key Patterns
374
382
 
375
- | Type | Pattern | Example |
376
- |------|---------|---------|
377
- | Class unique | `{class}:{index_name}` | `user:email_lookup` |
378
- | Class multi | `{class}:{index_name}:{value}` | `customer:role_index:admin` |
379
- | Instance unique | `{scope}:{id}:{index_name}` | `company:123:badge_index` |
380
- | Instance multi | `{scope}:{id}:{index_name}:{value}` | `company:123:dept_index:engineering` |
383
+ | Type | Pattern | Example |
384
+ | --------------- | ----------------------------------- | ------------------------------------ |
385
+ | Class unique | `{class}:{index_name}` | `user:email_lookup` |
386
+ | Class multi | `{class}:{index_name}:{value}` | `customer:role_index:admin` |
387
+ | Instance unique | `{scope}:{id}:{index_name}` | `company:123:badge_index` |
388
+ | Instance multi | `{scope}:{id}:{index_name}:{value}` | `company:123:dept_index:engineering` |
381
389
 
382
390
  ## Troubleshooting
383
391
 
384
392
  ### Common Issues
385
393
 
386
394
  **Query methods not generated:**
395
+
387
396
  - Check `query: true` (default) or explicitly set
388
397
  - Verify `feature :relationships` declared
389
398
 
390
399
  **Index not updating:**
400
+
391
401
  - Class indexes: automatic on save/destroy
392
402
  - Instance indexes: require manual `add_to_*` calls
393
403
 
394
404
  **Duplicate key errors:**
405
+
395
406
  - Use `multi_index` for non-unique values
396
407
  - Consider instance-scoped for contextual uniqueness
397
408
 
@@ -189,6 +189,80 @@ def add_members_instance(user, score = nil)
189
189
  end
190
190
  ```
191
191
 
192
+ ## Staged Relationships (Invitation Workflows)
193
+
194
+ Staged relationships enable deferred activation where the through model exists before the participant. Common use case: invitations where a Membership record exists before the invited user accepts.
195
+
196
+ ### Setup
197
+
198
+ ```ruby
199
+ class Membership < Familia::Horreum
200
+ feature :object_identifier # Required for UUID-keyed staging
201
+ field :organization_objid
202
+ field :customer_objid
203
+ field :email # Invitation email
204
+ field :role
205
+ field :status
206
+ end
207
+
208
+ class Customer < Familia::Horreum
209
+ participates_in Organization, :members,
210
+ through: Membership,
211
+ staged: :pending_members # Enables staging API
212
+ end
213
+ ```
214
+
215
+ ### Lifecycle
216
+
217
+ ```ruby
218
+ # Stage (send invitation)
219
+ membership = org.stage_members_instance(
220
+ through_attrs: { email: 'invite@example.com', role: 'viewer' }
221
+ )
222
+ # → UUID-keyed Membership in pending_members sorted set
223
+
224
+ # Activate (accept invitation)
225
+ activated = org.activate_members_instance(
226
+ membership, customer,
227
+ through_attrs: { status: 'active' }
228
+ )
229
+ # → Composite-keyed Membership, staged model destroyed
230
+
231
+ # Unstage (revoke invitation)
232
+ org.unstage_members_instance(membership)
233
+ # → Membership destroyed, removed from staging set
234
+ ```
235
+
236
+ ### Attribute Handling on Activation
237
+
238
+ Activation intentionally does **not** auto-merge attributes from the staged model. The application controls what data carries over:
239
+
240
+ ```ruby
241
+ # Explicit attribute carryover
242
+ activated = org.activate_members_instance(
243
+ staged, customer,
244
+ through_attrs: staged.to_h.slice(:role, :invited_by).merge(status: 'active')
245
+ )
246
+ ```
247
+
248
+ This design supports workflows where:
249
+ - Staged data (invitation metadata) differs from activated data (membership settings)
250
+ - Certain fields should reset on activation (e.g., `status`, timestamps)
251
+ - Sensitive staging data should not leak to the activated record
252
+
253
+ ### Key Differences from Regular Participation
254
+
255
+ | Aspect | Regular | Staged |
256
+ |--------|---------|--------|
257
+ | Key type | Composite (target+participant) | UUID during staging |
258
+ | Participant | Required at creation | Set on activation |
259
+ | Through model | Optional | Required |
260
+ | Lifecycle | Single-phase | Two-phase (stage → activate) |
261
+
262
+ ### Ghost Entry Cleanup
263
+
264
+ Staged models that expire via TTL or are manually deleted leave "ghost entries" in the staging set. These are cleaned lazily when accessed via `load_staged` or enumeration methods.
265
+
192
266
  ## Performance Best Practices
193
267
 
194
268
  ### Bulk Operations
@@ -15,7 +15,8 @@ module Familia
15
15
  end
16
16
 
17
17
  def encrypt(plaintext, context:, additional_data: nil)
18
- return nil if plaintext.to_s.empty?
18
+ plaintext = plaintext.to_s
19
+ return nil if plaintext.empty?
19
20
 
20
21
  key = derive_key(context)
21
22
 
@@ -26,7 +27,8 @@ module Familia
26
27
  nonce: Base64.strict_encode64(result[:nonce]),
27
28
  ciphertext: Base64.strict_encode64(result[:ciphertext]),
28
29
  auth_tag: Base64.strict_encode64(result[:auth_tag]),
29
- key_version: current_key_version
30
+ key_version: current_key_version,
31
+ encoding: plaintext.encoding.name,
30
32
  ).to_h
31
33
 
32
34
  Familia::JsonSerializer.dump(encrypted_data)
@@ -35,7 +37,9 @@ module Familia
35
37
  end
36
38
 
37
39
  def decrypt(encrypted_json_or_hash, context:, additional_data: nil)
38
- return nil if encrypted_json_or_hash.nil? || (encrypted_json_or_hash.respond_to?(:empty?) && encrypted_json_or_hash.empty?)
40
+ if encrypted_json_or_hash.nil? || (encrypted_json_or_hash.respond_to?(:empty?) && encrypted_json_or_hash.empty?)
41
+ return nil
42
+ end
39
43
 
40
44
  # Increment counter immediately to track all decryption attempts, even failed ones
41
45
  Familia::Encryption.derivation_count.increment
@@ -221,16 +221,18 @@ module Familia
221
221
  if @aad_fields.empty?
222
222
  # When no AAD fields specified, use class:field:identifier
223
223
  base_components.join(':')
224
- elsif record.exists?
225
- # For unsaved records, don't enforce AAD fields since they can change
226
- # For saved records, include field values for tamper protection
227
- values = @aad_fields.map { |field| record.send(field) }
228
- all_components = [*base_components, *values].compact
229
- Digest::SHA256.hexdigest(all_components.join(':'))
230
- # Include specified field values in AAD for persisted records
231
224
  else
232
- # For unsaved records, only use class:field:identifier for context isolation
233
- base_components.join(':')
225
+ # Always include aad_field values regardless of persistence state.
226
+ # The field values are available on the record before save and must
227
+ # produce identical AAD at both encrypt and decrypt time.
228
+ #
229
+ # .to_s coerces nil to "" so that every declared AAD field occupies
230
+ # a fixed position in the join. Without this, a nil field would
231
+ # shift later values left and produce a different hash once the
232
+ # field is populated — making existing ciphertext undecryptable.
233
+ values = @aad_fields.map { |field| record.send(field).to_s }
234
+ all_components = [*base_components, *values]
235
+ Digest::SHA256.hexdigest(all_components.join(':'))
234
236
  end
235
237
  end
236
238
  end
@@ -385,14 +385,29 @@ module Familia
385
385
 
386
386
  # Remove expiration, making the object persist indefinitely
387
387
  #
388
+ # Cascades to all related fields by default, following the same
389
+ # pattern as update_expiration. Relations with `no_expiration: true`
390
+ # are skipped (they already persist independently).
391
+ #
388
392
  # @return [Boolean] Success of the operation
389
393
  #
390
394
  # @example Make session persistent
391
395
  # session.persist!
396
+ # session.clear_expiration! # alias
392
397
  #
393
398
  def persist!
399
+ # Cascade to relations first, mirroring update_expiration behavior
400
+ if self.class.relations?
401
+ self.class.related_fields.each do |name, definition|
402
+ next if definition.opts[:no_expiration]
403
+
404
+ send(name).persist
405
+ end
406
+ end
407
+
394
408
  dbclient.persist(dbkey)
395
409
  end
410
+ alias clear_expiration! persist!
396
411
 
397
412
  # Returns a report of TTL values for the main key and all relation keys.
398
413
  #