Skip to content

feat: track association membership across sources - #16

Open
njakobsen wants to merge 58 commits into
feat/precedence-rulesfrom
feat/association-tracking
Open

njakobsen wants to merge 58 commits into
feat/precedence-rulesfrom
feat/association-tracking

Conversation

@njakobsen

@njakobsen njakobsen commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #15. Association tracking is experimental; the README says so.

Changes

  • belongs_to: recording an association with a record (or nil) stores its foreign key, and type when polymorphic, so it resolves across sources like any attribute. Values JSON cannot hold, such as objects, now raise LaForge::InvalidValue when recorded instead of coming back as hashes.

  • has_many and has_one: laforged associations: { … } declares tracked associations (a has_and_belongs_to_many raises), with keys derived from the reflection where possible. A polymorphic reference adds its type column to a derived key. Member order is written to the integer column the scope orders by, in the scope's direction, never one that identifies the member or its owner, or to the column order_column: names; order_column: false writes none.

  • Membership per member: record_data_entries(..., mode:) says what a source's list is: :complete (the default), the source's whole member list; :partial, the members it includes or excludes; or :edit, changes to the members it names, the rest of its report kept; forgetting a member of a complete list raises, since it would exclude it. The highest-precedence report with an opinion on a member decides it. A source that stops including a member withdraws it, and rows no report has named, such as ones from before tracking, are left alone.

  • Editing one member: mode: :edit takes { include: [...], exclude: [...], forget: [...] }, as an editor makes changes, and accepts the shapes a Rails form submits: *_attributes for a tracked association, and *_ids. Where the owner declares accepts_nested_attributes_for, its reject_if: and allow_destroy: apply to the rows as they do in Rails, a method-based reject_if: called on the owner itself, new or not; limit: and update_only: raise, as LaForge does not apply them.

  • Removed members reported again: excluding a member keyed by id destroys it and keeps a tombstone, which tombstone_matcher: uses to recognise the member when a source reports it again, so it stays out until a source that outranks the exclusion includes it. LaForge.tombstone_blocking lets code that creates members itself check first.

  • Orphan sweeps: withdraw_source withdraws everything a source reported, instead of destroying its entries.

  • Member attributes: a list names its members by key, and keeps what the source says about each one as pending until a forge of the owner includes that member. That forge records them on the member as that source's entries, as if the source had recorded them on the member directly, nested tracked associations included. Nothing is created for a member membership excludes; its attributes wait in the report until membership includes it or the source's next report replaces them, so an owner can be saved and forged later. A newer value the member has from the same source is not overwritten, judged by a new recorded_at column on data entries (updated_at keeps its usual meaning). Existing installs add it with rails generate laforge:recorded_at, which fills it from updated_at. A saved record can be listed in place of a hash; an unsaved one raises.

  • Keys: members need a value for each key column. Rows missing one are left alone; rows sharing a key raise LaForge::DuplicateMemberKey at forge, and a list naming a member twice raises.

  • Members that can't be created: a member included that no report describes is built from its key, or skipped with a warning and reported under skipped: in forge_diff when invalid with its key alone.

  • Precedence: a precedence: given with a list is given to everything the payload records, nested lists included. Sources rank by level first, a tie at a source's most specific level falling to the sources' own precedence, and update_precedence_order sets the order of sources for one record's attribute, a model's attribute, a whole model, or globally.

  • Attribute groups, inactive sources and change events: laforged attribute_groups: keeps attributes such as an address's lines from one source together; a source's active flag (rails generate laforge:active_sources) leaves its entries out of forging while keeping them; forge_attributes(except_sources:) answers what would be forged without a source; and data_entry_changed.laforge is published for each entry saved with a changed value or destroyed, once the write is committed, and not for a write that rolls back, with the values as they were saved. Change events need Rails 7.2 or later.

  • Merging duplicates: LaForge.merge and LaForge.reverse_merge move a duplicate's entries, precedence rules and tracked members into the survivor.

  • Removal: members no longer included are removed as assigning the collection would remove them, per dependent:: destroyed for :destroy, deleted without callbacks for :delete_all, detached without validation otherwise, once the owner is saved. A replaced has_one member is removed when the owner is saved, and a member a later forge includes again before the save, has_one or not, is kept. Destroying records is otherwise out of scope.

  • Inspection: source_values returns only the members the association's scope includes, and forge_diff(attributes:) previews only the associations given.

  • Specs: spec/lib/association_capabilities_spec.rb covers each capability by id, and spec/lib/association_importer_scenarios_spec.rb walks through importer runs.

  • Structure: eight refactor: commits at the top restructure association tracking around LaForge::TrackedAssociation (one object per owner and association) and MemberList::Complete/MemberList::Partial reports with methods for each change, hold the forge's writes in PendingWrites, and move LaForge's methods onto LaForge::Record. Each laforged model has its own subclass, Model.laforge, holding the model-level methods, and each record an instance, record.laforge, holding its methods, pending writes and precedence resolver. The concerns AttributeGroups, Recording, Forging, Precedence and Inspection make it up, and the model delegates the documented API to it, so place.forge! is place.laforge.forge!. Behaviour is unchanged; the suite proves it at each commit.

Follow-ups

  • A reactivated source's exclusion doesn't apply to a member the application created itself while the source was inactive, after LaForge.tombstone_blocking allowed it, since later reports name it by its id rather than a tombstone. Matching saved members against tombstones would change tombstone_matcher:'s contract and could exclude a legitimate row; the README now describes the narrower behaviour.
  • forge(sources:) filters attribute entries only; association membership is still decided by every active report. Limiting membership to some sources needs its own semantics for the reports left out.
  • forge_diff doesn't preview what withdrawing a source from retained members changes, down nested lists.
  • recorded_at advances only when a value changes, so re-recording an unchanged value doesn't protect it from an older report kept pending on an owner; refreshing it for every unchanged attribute would write a row per attribute on each import.
  • Replacing a keyed has_one member whose owner foreign key has a unique index: the old member is removed after the save, so autosave's insert of the new one fails first.
  • Key columns whose type JSON doesn't keep, such as dates, compare as strings once a report is reloaded.
  • A saved record listed in a member's nested list is kept in the pending JSON as its attributes rather than its key.
  • LaForge.merge doesn't remap entries recorded on moved members that point back at the duplicate owner through a belongs_to.
  • A has_one member's own nested lists are recorded as complete reports in edit mode, not as edits.
  • LaForge.merge keeps both records' un-forged pending attributes for the same member, so the newer one wins rather than the merge winner's. Forging both records before merging avoids it.

UAT Steps

  1. bundle exec rspec: 309 examples, 0 failures, 2 pending.

Closes

Closes #10

Special Handling

  • This PR requires user testing
  • Include this PR in the changelog

🤖 Generated with Claude Code

@njakobsen
njakobsen force-pushed the feat/association-tracking branch 2 times, most recently from 70a442a to fa6006d Compare October 16, 2026 23:25
@njakobsen
njakobsen added this pull request to stack #17 October 16, 2026 23:28
@njakobsen
njakobsen force-pushed the feat/association-tracking branch 7 times, most recently from e7eebe0 to a5b06c6 Compare October 19, 2026 21:33
Comment thread lib/laforge/data_entry.rb
@timtait

timtait commented Oct 22, 2026 •

Copy link
Copy Markdown

Checking the upgrade against client data

We tested whether moving Sparkle from the LaForge it pins today (dbc77faa) to this stack changes what any record forges.

How

  1. For each client database, copied laforge_data_sources and laforge_data_entries into two scratch databases ("before" and "after"). The source databases were only read.
  2. Ran the upgrade migrations on the "after" copy in the order Sparkle will: precedence_rules (feat!: precedence rules at four levels, and rename priority to precedence #15), then recorded_at and active_sources (feat: track association membership across sources #16).
  3. Forged every record in both copies without saving: "before" with LaForge at dbc77faa, "after" with this PR's head (838a041), on the same Rails 8.1 bundle. Each record type gets a stand-in model with a setter for every attribute its entries name, so the gem's own forge_attributes picks the winners.
  4. Diffed the winning value of every record and attribute.

Results

Client Copy as of Forged values Changed
Atrium 2026-10-01 (production data) 499,843 0
Scripps 2026-08-23 202,345 0
UCSF 2026-05-28 372,142 0
AtlantiCare 2025-11-12 53,361 0
Westchester 2025-01-10 46,418 0

Control. Before upgrading, we added one Sparkle entry to both Atrium copies: an admin edit of department_id on an address where Epic also has one. The diff showed exactly that change, with the edit winning before and Epic's value winning after. So the harness does catch a changed winner, and level-first ranking is the cause. The cause is EpicDepartmentImporter recording Epic's address fields with priority: 100. The migration turns those into per-record rules (19,092 on Atrium), which now outrank Sparkle's global precedence.

No current Atrium data hits this, but future admin edits to department_id, exists_in_ehr and scheduling_enabled on Epic addresses would be overwritten. We reran the control with a candidate Sparkle-side data migration added: it deletes those record rules and sets Address.update_precedence_order([sparkle, epic], attribute: ...) for each of the three fields. The diff was back to zero, with the edit holding. We'll ship that conversion, and drop priority: 100 from the importer, in the same Sparkle deploy as the gem bump. Scripps has the same Epic rules (about 2,100) but no Sparkle source, and Epic already outranked MDStaff and UHC there, so nothing changes.

Caveats

  • Only Atrium's copy is recent. The Scripps, UCSF, AtlantiCare and Westchester copies are local snapshots that are months old, and Westchester's is from January 2025. We'll rerun the check against fresh copies before upgrading.
  • Clients without a local copy (UMMS, Christus and others) haven't been checked yet.
  • This covers attribute forging only, which is all Sparkle records through LaForge today. No associations are tracked yet. Sparkle's own model code, such as setters with side effects, isn't exercised.

(Generated using Claude Code)

@timtait timtait left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for the inline findings. Each one has a repro on 838a041 and a suggested fix that keeps the full suite green (269 examples, 0 failures, 2 pending):

  1. High: derived_order_column can choose the primary key, so forge rewrites member ids, or raises RecordNotUnique. Sparkle's Address hits this, and should also be declared with key: :id.
  2. Payload ignores reject_if: and allow_destroy:. A blank form row fails the save, and _destroy deletes members that Rails would keep.
  3. forge_diff leaves a precedence rule in memory, and the next owner save persists it.
  4. Minor: precedence_order with no attribute reports a model-wide rule as :attribute.

Change events for rolled-back writes are covered in the earlier comment: #16 (comment)

The PR description is also out of date. It says partial: true, but the code uses mode: :partial/mode: :edit. It also says 221 examples; it's 269 now.

(Generated using Claude Code)

Comment thread lib/laforge/association_config.rb Outdated
end
end

columns.find { |column| !key.include?(column) && klass.columns_hash[column]&.type == :integer }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forging a tracked association can rewrite its members' primary keys

derived_order_column excludes key columns, but not the primary key. That's only safe when members are keyed by id. Once a member has a second belongs_to, derived_key makes that foreign key the key. Then id becomes "an integer column the scope orders by", and forge runs record[config.order_column] = index on it.

Sparkle's Address is shaped like this. It belongs to locale (the owner) plus location, address_group and department. The core AddressBehavior scope for addresses ends in .order(virtual: :asc, id: :asc), and the position ordering is a raw CASE, so derivation doesn't see it. laforged associations: { addresses: {} } would therefore derive key: [location_id, address_group_id, department_id] and order_column: "id".

Here's what forge issued for an owner with addresses 2 and 3:

UPDATE `pz_addresses` SET `pz_addresses`.`id` = 0 WHERE `pz_addresses`.`id` = 2
UPDATE `pz_addresses` SET `pz_addresses`.`id` = 1 WHERE `pz_addresses`.`id` = 3

There are two outcomes:

  • If the new id is free, the row moves silently. Its own laforge_data_entries still point at the old id, and so does anything else that references it (appointments, Kyruus ids, caches).
  • If the new id is taken, forge! raises ActiveRecord::RecordNotUnique: Duplicate entry '0' for key 'repro_addresses.PRIMARY'.

Repro. Drop this in spec/lib/. All 3 examples fail on 838a041:

spec
require 'spec_helper'

describe 'association tracking on a member with a second belongs_to' do
  before(:all) do
    ActiveRecord::Schema.define do
      create_table(:repro_owners, force: true) { |t| t.string :name }
      create_table(:repro_departments, force: true) { |t| t.string :name }
      create_table(:repro_addresses, force: true) { |t| t.bigint :repro_owner_id; t.bigint :repro_department_id; t.string :city }
    end
    Object.const_set(:ReproDepartment, Class.new(ActiveRecord::Base))
    Object.const_set(:ReproAddress, Class.new(ActiveRecord::Base))
    Object.const_set(:ReproOwner, Class.new(ActiveRecord::Base))
    ReproAddress.class_eval { belongs_to :repro_owner; belongs_to :repro_department, optional: true; laforged }
    ReproOwner.class_eval do
      has_many :repro_addresses, -> { order(id: :asc) }
      laforged associations: { repro_addresses: {} }
    end
  end
  after(:all) { %w[repro_owners repro_departments repro_addresses].each { |t| ActiveRecord::Base.connection.drop_table(t, if_exists: true) } }

  let(:source) { LaForge::DataSource.find_or_create_by!(name: 'Import') { |s| s.precedence = 1 } }
  let(:department) { ReproDepartment.create!(name: 'Cardiology') }

  it 'does not use the primary key as the order column' do
    expect(LaForge::AssociationConfig.new(ReproOwner, :repro_addresses).order_column).not_to eq('id')
  end

  it 'does not change the id of the address it matches' do
    owner = ReproOwner.create!
    address = owner.repro_addresses.create!(repro_department: department, city: 'Charlotte')
    address.record_data_entries({ city: 'Charlotte' }, source)
    address.save!

    owner.reload.record_data_entries({ repro_addresses: [{ 'repro_department_id' => department.id }] }, source)
    owner.forge!

    expect(ReproAddress.exists?(address.id)).to be(true)
    expect(LaForge::DataEntry.where(record_type: 'ReproAddress').pluck(:record_id)).to all(eq(address.id))
  end

  it 'forges when another row already holds the position number' do
    ReproAddress.create!(city: 'unrelated')
    owner = ReproOwner.create!
    owner.repro_addresses.create!(repro_department: department, city: 'Charlotte')
    owner.repro_addresses.create!(repro_department: ReproDepartment.create!(name: 'Oncology'), city: 'Concord')

    owner.reload.record_data_entries({ repro_addresses: owner.repro_addresses.map { |a| { 'repro_department_id' => a.repro_department_id } } }, source)
    expect { owner.forge! }.not_to raise_error
  end
end
1) does not use the primary key as the order column
   expected: value != "id"
        got: "id"
2) does not change the id of the address it matches
   expected true
        got false
3) forges when another row already holds the position number
   expected no Exception, got #<ActiveRecord::RecordNotUnique: Mysql2::Error: Duplicate entry '0' for key 'repro_addresses.PRIMARY'>

Suggested fix. Never derive a column that identifies the member or its owner:

-    # An integer column the association scope orders by, other than a key column, is where member order is written
+    # An integer column the association scope orders by is where member order is written, unless it identifies the
+    # member or its owner: the primary key, a key column, or the foreign key back to the owner
     def derived_order_column
@@
-      columns.find { |column| !key.include?(column) && klass.columns_hash[column]&.type == :integer }
+      identifying = key + [klass.primary_key, owner_foreign_key, owner_type_column].compact
+      columns.find { |column| identifying.exclude?(column) && klass.columns_hash[column]&.type == :integer }

With that change, the repro passes 3/3 and the full suite passes (269 examples, 0 failures, 2 pending).

Separately, for Sparkle: the README (line 379) says an owned model that happens to belong to another record needs key: :id. Address is exactly that. Without it, addresses would be keyed by [location_id, address_group_id, department_id], and any address missing one of those values can't be identified at all. The upgrade-path doc's addresses: { tombstone_matcher: … } example raises at declaration for the same reason. It should read addresses: { key: :id, tombstone_matcher: … }.

A follow-up worth considering: since derivation can't see position inside Sparkle's CASE, an explicit order_column: (and order_column: false) option would let an owner name its order column or opt out. That's better than relying on whatever the scope happens to mention.

(Generated using Claude Code)

@njakobsen njakobsen Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0203833: the derived order column skips the primary key, key columns and owner columns, and order_column: (a column or false) names it outright.

Comment thread lib/laforge/payload.rb Outdated
raise ArgumentError, "#{model.name}##{field} describes edits, as a Rails form submits them; record it with mode: :edit" unless edit
end

def nested_attributes(config, value, owner)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Payload ignores accepts_nested_attributes_for's reject_if: and allow_destroy:

A Rails form for an owner that declares accepts_nested_attributes_for :tenants, reject_if: :all_blank, allow_destroy: false behaves differently when recorded through LaForge than when assigned directly:

params Rails assign_attributes LaForge record_data_entries(params, mode: :edit) + forge!
a blank template row { 'name' => '', 'unit' => '' } rejected, 0 new tenants included, so forge! raises RecordInvalid: Tenants name can't be blank
{ 'id' => cafe.id, '_destroy' => '1' } ignored, tenant kept excluded, so the tenant is destroyed

Sparkle's addresses use accepts_nested_attributes_for :addresses, allow_destroy: true, reject_if: :all_blank_with_exceptions, so admin forms with an empty "add address" row would hit the first case.

Suggested fix: where the owner declares nested attributes, apply them the way Rails does. Otherwise keep the current behaviour, which the existing specs rely on.

     def nested_attributes(config, value, owner)
+      options = config.owner_class.nested_attributes_options[config.name.to_sym] # nil where the owner does not accept nested attributes
       unless config.collection?
         attributes = value.to_h.stringify_keys
-        return destroy?(attributes) ? nil : member(config, attributes.except(DESTROY), owner)
+        return destroy?(attributes, options) ? nil : member(config, attributes.except(DESTROY), owner)
       end
 ...
       Array(items).map { |attributes| attributes.to_h.stringify_keys }.each do |attributes|
-        if !destroy?(attributes)
-          include << member(config, attributes.except(DESTROY), owner)
+        if !destroy?(attributes, options)
+          include << member(config, attributes.except(DESTROY), owner) unless reject?(config, attributes, owner, options)
 ...
-    def destroy?(attributes)
-      ::ActiveModel::Type::Boolean.new.cast(attributes[DESTROY]) == true
+    # Where the owner accepts nested attributes, `_destroy` counts only if they allow it, as in Rails
+    def destroy?(attributes, options)
+      (options.nil? || options[:allow_destroy]) && ::ActiveModel::Type::Boolean.new.cast(attributes[DESTROY]) == true
+    end
+
+    # Whether the association's `reject_if:` drops the row, as assigning it through Rails would
+    def reject?(config, attributes, owner, options)
+      case (reject_if = options&.dig(:reject_if))
+      when Symbol
+        target = owner || config.owner_class.new
+        target.method(reject_if).arity.zero? ? target.send(reject_if) : target.send(reject_if, attributes)
+      when Proc
+        reject_if.call(attributes.with_indifferent_access)
+      end
     end

(:all_blank is stored as a Proc by Rails, so the Proc branch covers it.) With this change, both rows match Rails: the blank row is dropped and the tenant is kept. The full suite passes (269 examples, 0 failures, 2 pending). Rails' limit: isn't handled either; I left it out because it's rarely used.

(Generated using Claude Code)

@njakobsen njakobsen Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 71b0c41: reject_if: and allow_destroy: apply as Rails applies them; limit: and update_only: raise.

Comment thread lib/laforge/activerecord.rb Outdated
# @api private
# The changes forging would make to this record if what its owner's reports say about it, as [[source id, attributes,
# recorded at, edit, precedence], ...], were recorded on it, without recording it
def laforge_forge_diff_with(reported)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forge_diff can leave a precedence rule behind that the next save persists

laforge_forge_diff_with puts data_entries back after the preview, but not precedence_rules. When a pending report carries a precedence, laforge_record_reported → write_data_entry builds a rule on the member, and it stays in memory. precedence_rules is autosave: true, so any later save of the owner writes it, even though nothing was forged.

Repro on 838a041, using the spec fixtures:

place = Place.create!(name: 'P')
place.record_data_entries({ tenants: [{ name: 'Cafe' }] }, low); place.forge!
tenant = place.tenants.first
place.reload
place.record_data_entries({ tenants: [{ id: tenant.id, unit: '2B' }] }, low, precedence: 50)
place.forge_diff              # a preview
place.update!(name: 'P2')     # an unrelated save, no forge
LaForge::PrecedenceRule.where(record_type: 'Tenant').pluck(:attribute_name, :precedence)
# => [["unit", 50]], but the tenant's unit is still nil and it has no "unit" entry

Suggested fix: snapshot and restore the rules alongside the entries.

       entries = data_entries.to_a
       values = entries.to_h { |entry| [entry, [entry.value, entry.recorded_at]] }
+      rules = precedence_rules.to_a
+      precedences = rules.to_h { |rule| [rule, [rule.precedence, rule.marked_for_destruction?]] }
       reported.each { |source_id, attributes, recorded_at, edit, precedence| laforge_record_reported(attributes, source_id, recorded_at, edit: edit, precedence: precedence) }
       forge_diff
     ensure
       data_entries.target.replace(entries)
       values.each { |entry, (value, recorded_at)| entry.value = value; entry.recorded_at = recorded_at }
+      precedence_rules.target.replace(rules)
+      precedences.each { |rule, (precedence, destroyed)| rule.precedence = precedence; rule.instance_variable_set(:@marked_for_destruction, destroyed) }

With this change, the same steps leave no Tenant rules after the save, and a real forge! afterwards still writes ["unit", 50] and sets unit to "2B". The full suite passes (269 examples, 0 failures, 2 pending).

(Generated using Claude Code)

@njakobsen njakobsen Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5336613: the preview snapshots and restores the member's rules, with their precedence and destruction mark.

Comment thread lib/laforge/precedence_rule.rb Outdated
# level, most specific first, with -Infinity where it has no rule
def rank(record, attribute_name, source_id)
attribute_name = rule_name(attribute_name)
levels = [record_rule(record, attribute_name, source_id), @model_rules[[ANY_ID, attribute_name, source_id]], @model_rules[[ANY_ID, ANY_ATTRIBUTE, source_id]]]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: with no attribute, precedence_order labels a model-wide rule :attribute

Place.update_source_precedence(low, 5)
Place.precedence_order                     # => [{ source: low, level: :attribute }]   expected :model
Place.precedence_order(attribute: :name)   # => [{ source: low, level: :model }]

With attribute: nil, rank looks up [ANY_ID, '', source] at the model-attribute level, which is the model-wide rule. The order is unaffected, because the same value appears at both levels. The label is wrong, though, and anything that shows it (an admin "why does this source win" view) would mislead.

-        levels = [record_rule(record, attribute_name, source_id), @model_rules[[ANY_ID, attribute_name, source_id]], @model_rules[[ANY_ID, ANY_ATTRIBUTE, source_id]]]
+        attribute_rule = @model_rules[[ANY_ID, attribute_name, source_id]] unless attribute_name == ANY_ATTRIBUTE
+        levels = [record_rule(record, attribute_name, source_id), attribute_rule, @model_rules[[ANY_ID, ANY_ATTRIBUTE, source_id]]]

With this change it returns :model, and the full suite passes.

(Generated using Claude Code)

@njakobsen njakobsen Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cf6d4aa: with no attribute, the model-attribute level is empty, so a model-wide rule is labelled :model.

njakobsen added a commit that referenced this pull request Oct 22, 2026
… and accept `order_column:`

Root cause: `AssociationConfig#derived_order_column` took the first integer column the association scope orders by, skipping only key columns. When a member's key is derived from a second `belongs_to`, its primary key is no longer a key column, so a scope ending in `order(:id)` made `id` the order column and `forge` wrote list positions into it: a member moved to another id, leaving its entries pointing at the old one, or `forge!` raised `ActiveRecord::RecordNotUnique` when the id was taken. A scope that orders by an expression, such as `Arel.sql(...)`, names no column, so its order column could not be found at all.

Derivation now skips the primary key, the key columns, and the foreign key and type column pointing back at the owner. `laforged associations: { name: { order_column: :position } }` names the column outright, and `order_column: false` writes no order; a column that does not exist, is not an integer, or identifies the member or its owner raises an `ArgumentError` saying which and what to pass instead. The README documents both under "Declaring has_many and has_one associations".

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
njakobsen added a commit that referenced this pull request Oct 22, 2026
…` previews a report

Root cause: `laforge_forge_diff_with` records what an owner's pending report says on a member to preview the changes, then puts the member's `data_entries` back, but not its `precedence_rules`. When the report carries a precedence, `write_data_entry` builds or changes a rule on the member, and since `precedence_rules` is `autosave: true` the owner's next save wrote it, though nothing was forged.

The preview now snapshots the member's rules, with each one's precedence and whether it is marked for destruction, and restores them alongside the entries, so a later `forge!` still writes the rule.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
njakobsen added a commit that referenced this pull request Oct 22, 2026
…ttribute

Root cause: with no attribute, `PrecedenceRule::Resolver#rank` looks up the rule for the attribute `''` at the model-attribute level, and `''` is how a model-wide rule for every attribute is stored, so the same rule counted at both levels and `Model.precedence_order` labelled it `:attribute`. The order was right, since the precedence appeared at both levels, but a screen showing why a source wins would have shown the wrong level.

With no attribute, the model-attribute level is now empty, so a model-wide rule ranks and is labelled `:model`, as it is when an attribute is given.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 22, 2026 18:37
njakobsen added a commit that referenced this pull request Oct 22, 2026
…, and not for a rolled-back write

Root cause: `DataEntry` published `CHANGED` from `after_save` and `after_destroy`, and the owner's `laforge_after_save` published the entries a forge withdrew from members as it deleted them, all inside the transaction. A write that then rolled back had already been announced, so a subscriber that enqueues a job, notifies someone or updates another store acted on a change that never happened.

`DataEntry.publish_change` now takes the payload when the write is made, which keeps `value_was` right when one transaction saves an entry more than once, and instruments it from the current transaction's `after_commit`, which drops it if the transaction rolls back. Every event goes through it, the withdrawn entries' included. A write outside an explicit transaction is published as it is saved. This uses `ActiveRecord::Base.current_transaction`, so the gemspec now requires Rails 7.2 or later. The README's "Following changes" says the event fires once the write is committed.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
njakobsen added a commit that referenced this pull request Oct 22, 2026
…orm's nested rows

Root cause: `Payload#nested_attributes` translated every `*_attributes` row into an edit without reading the owner's `accepts_nested_attributes_for` options. A blank template row that `reject_if: :all_blank` drops when Rails assigns it was included, so `forge!` raised `RecordInvalid` for the empty member, and a row with `_destroy` excluded its member even where `allow_destroy: false` makes Rails ignore the flag, so the member was destroyed.

Where the owner declares nested attributes for the association, a row its `reject_if:` rejects (a Proc, `:all_blank`, or a method on the owner, called on an unsaved instance for a new owner) is left out, new and existing rows alike but never a row being destroyed, and `_destroy` counts only with `allow_destroy: true`, as Rails does. A rejected has_one row records nothing. `limit:` and `update_only:` are not applied, so recording `*_attributes` for an association that declares them raises an `ArgumentError` naming the model, the association and the option. Owners without nested attributes are recorded as before. The README's "Recording a Rails form" describes it.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@njakobsen

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — each point is fixed in its own commit:

  1. d98725f: the derived order column now skips the primary key, the key columns, and the owner's foreign key and type column. order_column: (a column name, or false) states it explicitly, and a bad column raises naming what to pass. Your repro is C107; the option and its errors are C108.
  2. 419d7ce: Payload applies the owner's reject_if: (Proc, Symbol or :all_blank) and allow_destroy: to nested rows as Rails does. That includes existing rows, a row being destroyed is never rejected, and a new owner's rows are checked on an unsaved instance. limit: and update_only: raise rather than being silently ignored. Your repro is C111; C112 and C113 cover the rest.
  3. c58cfa6: forge_diff snapshots and restores the member's precedence rules, with their precedence and destruction flags. Your repro is C109.
  4. bdd02bb: a model-wide rule is labelled :model.
  5. 42f1b05: we went with your preference. The payload is captured at save time and published after commit, and dropped on rollback, withdrawn-entry events included. Specs cover a save outside a transaction, inside one, a rollback, and a rolled-back forge (C110). This uses current_transaction, so the gemspec now requires Rails 7.2 or later.
  6. The description is updated: mode:, the example count (278), and a few other claims that had gone stale.

The expiry work that builds on this is stacked above it in #18.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved correctness issues can remove intended members, apply the wrong merge values, and produce inaccurate previews.

15 open issues
What changed in this PR

Adds experimental association tracking to LaForge’s source-based attribute resolution, addressing association support requested in #10 and building on #15’s precedence rules.

Changes:

  • Tracks association membership, deferred member attributes, nested form edits, withdrawals, and tombstones.
  • Adds duplicate merging, attribute groups, inactive sources, precedence ordering, and committed change events.
  • Adds migration generators and extensive capability/importer specs; raises the Rails minimum to 7.2.
File Description
spec/​support/​association_capabilities.rb Defines association test models and schema.
spec/​spec_helper.rb Adds reference fields and tracking columns.
spec/​lib/​usage_spec.rb Covers references, groups, sources, events, and precedence orders.
spec/​lib/​association_importer_scenarios_spec.rb Exercises multi-run importer scenarios.
spec/​lib/​association_capabilities_spec.rb Covers association tracking capabilities.
spec/​lib/​activerecord_spec.rb Updates precedence-ranking expectations.
Readme.md Documents experimental APIs and upgrade steps.
lib/​laforge/​tombstone.rb Represents exclusions and exposes blocking checks.
lib/​laforge/​precedence_rule.rb Adds level-first ranking and record-rule queries.
lib/​laforge/​precedence_cache.rb Updates cache documentation for inactive sources.
lib/​laforge/​payload.rb Translates Rails form payloads into association edits.
lib/​laforge/​merge.rb Merges duplicate records’ tracking data and members.
lib/​laforge/​member_list.rb Represents membership reports and pending attributes.
lib/​laforge/​data_source.rb Adds source activation and global ordering.
lib/​laforge/​data_entry.rb Adds recording timestamps, events, and value checks.
lib/​laforge/​association_tracking.rb Resolves membership, member attributes, and previews.
lib/​laforge/​association_config.rb Configures association keys, ordering, and validation.
lib/​laforge/​activerecord.rb Integrates tracking into recording and forging APIs.
lib/​laforge.rb Loads the new tracking components.
lib/​generators/​laforge/​templates/​recorded_at_migration.rb.tt Adds and backfills recording timestamps.
lib/​generators/​laforge/​templates/​migration.rb.tt Includes new columns in fresh installs.
lib/​generators/​laforge/​templates/​active_sources_migration.rb.tt Adds source activation to existing installs.
lib/​generators/​laforge/​recorded_at_generator.rb Generates the timestamp upgrade migration.
lib/​generators/​laforge/​active_sources_generator.rb Generates the source-activation migration.
laforge.gemspec Requires Rails 7.2 or later.
Gemfile.lock Aligns the locked Rails dependency requirement.

Review effort: Balanced

Comment thread lib/laforge/association_config.rb Outdated
Comment thread lib/laforge/association_config.rb Outdated
Comment thread lib/laforge/association_tracking.rb Outdated
Comment thread lib/laforge/merge.rb Outdated
Comment thread lib/laforge/activerecord.rb Outdated
Comment thread lib/laforge/association_tracking.rb Outdated
Comment thread lib/laforge/association_tracking.rb Outdated
Comment thread lib/laforge/data_entry.rb Outdated
Comment thread lib/laforge/data_source.rb
Comment thread lib/laforge/precedence_rule.rb Outdated
njakobsen added a commit that referenced this pull request Oct 23, 2026
… and accept `order_column:`

Root cause: `AssociationConfig#derived_order_column` took the first integer column the association scope orders by, skipping only key columns. When a member's key is derived from a second `belongs_to`, its primary key is no longer a key column, so a scope ending in `order(:id)` made `id` the order column and `forge` wrote list positions into it: a member moved to another id, leaving its entries pointing at the old one, or `forge!` raised `ActiveRecord::RecordNotUnique` when the id was taken. A scope that orders by an expression, such as `Arel.sql(...)`, names no column, so its order column could not be found at all.

Derivation now skips the primary key, the key columns, and the foreign key and type column pointing back at the owner. `laforged associations: { name: { order_column: :position } }` names the column outright, and `order_column: false` writes no order; a column that does not exist, is not an integer, or identifies the member or its owner raises an `ArgumentError` saying which and what to pass instead. The README documents both under "Declaring has_many and has_one associations".

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
njakobsen added a commit that referenced this pull request Oct 23, 2026
…` previews a report

Root cause: `laforge_forge_diff_with` records what an owner's pending report says on a member to preview the changes, then puts the member's `data_entries` back, but not its `precedence_rules`. When the report carries a precedence, `write_data_entry` builds or changes a rule on the member, and since `precedence_rules` is `autosave: true` the owner's next save wrote it, though nothing was forged.

The preview now snapshots the member's rules, with each one's precedence and whether it is marked for destruction, and restores them alongside the entries, so a later `forge!` still writes the rule.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
njakobsen added a commit that referenced this pull request Oct 23, 2026
…ttribute

Root cause: with no attribute, `PrecedenceRule::Resolver#rank` looks up the rule for the attribute `''` at the model-attribute level, and `''` is how a model-wide rule for every attribute is stored, so the same rule counted at both levels and `Model.precedence_order` labelled it `:attribute`. The order was right, since the precedence appeared at both levels, but a screen showing why a source wins would have shown the wrong level.

With no attribute, the model-attribute level is now empty, so a model-wide rule ranks and is labelled `:model`, as it is when an attribute is given.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
njakobsen added a commit that referenced this pull request Oct 23, 2026
…, and not for a rolled-back write

Root cause: `DataEntry` published `CHANGED` from `after_save` and `after_destroy`, and the owner's `laforge_after_save` published the entries a forge withdrew from members as it deleted them, all inside the transaction. A write that then rolled back had already been announced, so a subscriber that enqueues a job, notifies someone or updates another store acted on a change that never happened.

`DataEntry.publish_change` now takes the payload when the write is made, which keeps `value_was` right when one transaction saves an entry more than once, and instruments it from the current transaction's `after_commit`, which drops it if the transaction rolls back. Every event goes through it, the withdrawn entries' included. A write outside an explicit transaction is published as it is saved. This uses `ActiveRecord::Base.current_transaction`, so the gemspec now requires Rails 7.2 or later. The README's "Following changes" says the event fires once the write is committed.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
njakobsen added a commit that referenced this pull request Oct 23, 2026
…orm's nested rows

Root cause: `Payload#nested_attributes` translated every `*_attributes` row into an edit without reading the owner's `accepts_nested_attributes_for` options. A blank template row that `reject_if: :all_blank` drops when Rails assigns it was included, so `forge!` raised `RecordInvalid` for the empty member, and a row with `_destroy` excluded its member even where `allow_destroy: false` makes Rails ignore the flag, so the member was destroyed.

Where the owner declares nested attributes for the association, a row its `reject_if:` rejects (a Proc, `:all_blank`, or a method on the owner, called on an unsaved instance for a new owner) is left out, new and existing rows alike but never a row being destroyed, and `_destroy` counts only with `allow_destroy: true`, as Rails does. A rejected has_one row records nothing. `limit:` and `update_only:` are not applied, so recording `*_attributes` for an association that declares them raises an `ArgumentError` naming the model, the association and the option. Owners without nested attributes are recorded as before. The README's "Recording a Rails form" describes it.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
njakobsen and others added 13 commits October 23, 2026 13:07
…orm payloads need mode: :edit

`record_data_entry` wrote its value with `laforge_record_attribute` directly, skipping `Payload.translate`. A Rails form field such as `tenant_ids` or `tenants_attributes` was accepted wherever its setter existed and stored as an attribute entry, and forging that entry called Rails' collection setter, which writes to the database during `forge` and bypasses tracked membership. A tracked association raised a misleading "is not a tracked association".

`record_data_entry` now records through `record_data_entries` in its default mode, so form shapes raise `ArgumentError` asking for `mode: :edit`, and a tracked association is recorded as a complete report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gain

`write_data_entry` reused an entry marked for destruction without clearing the mark. A partial report that a forge emptied and `prune_withdrawn` marked, or an attribute entry `remove_data_entries` marked, kept its mark when the source recorded it again before the owner was saved. Since a forge leaves marked entries out, the next forge ignored the new value, falling back to other sources or withdrawing the members the report included again, and the save then destroyed the entry.

`write_data_entry` now clears the mark when it writes onto an entry, as `write_precedence_rule` does for rules, so recording again undoes the unsaved removal for reports and attribute entries alike. `laforge_forge_diff_with` restores each entry's mark along with its value, since it records onto the member's entries only to compute the diff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… before the owner was saved

When a forge built a new has_one member and a second forge before the owner's save dropped it, `forge` scheduled that unsaved member for removal like a saved one. For an association with no `dependent:` option, `laforge_after_save` then nullified its owner key and called `save!`, inserting an orphan row, or failing where that column is NOT NULL.

`laforge_after_save` now skips scheduled members that were never saved, so they are discarded, and only persisted members are detached, deleted or destroyed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erent keys

`merge_members` moved every duplicate member the survivor had no member with the same key for. In a keyed has_one, such as `marker: { key: :label }`, a duplicate's marker with another label moved to the survivor alongside its own, leaving two rows behind a reader that shows one, or failing on a unique owner index after the entries had already moved.

`merge_records` now checks each tracked has_one first and raises `ArgumentError` when both records have a member and their keys differ, before anything moves, naming both keys and asking for one to be removed or given the same key. Members with the same key are still merged into the survivor's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…other source reported as new meanwhile

Issue: "Deactivating a source" said a tenant created while the excluding source was inactive is removed by the first forge after it is activated again. That holds for a tenant another source reported as new, which the forge matches against the tombstone, but not for one the application created itself after `LaForge.tombstone_blocking` allowed it: later reports name it by its id, which the tombstone does not.

The sentence now covers only tenants reported as new, and says the exclusion does not apply to one the application created itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r`, with no level

Root cause: `LaForge::ActiveRecord.order_by_rank` named each source's level by the first precedence in its rank that was not `-Float::INFINITY`. A source with no global rule now ranks as `-Float::INFINITY` there too, as an upgrade can leave for a source deleted before the precedence rules migration, so `precedence_order` raised `TypeError` indexing the levels with `nil`.

Such a source is now listed last with a `nil` level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…LE_TYPES`

Issue: the types `DataEntry.storable?` accepts were listed inside its `case`, halfway down the file, so finding what a source can record meant searching for the method.

`DataEntry::STORABLE_TYPES` lists them at the top of the class, and `storable?` matches against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y there

Issue: the `MemberList` class comment opened with a fragment ("One source's report of …") rather than saying what the class is for, and wove the terms of a report (complete, partial, include, exclude, withdrawn, pending, tombstones) and its stored shapes through prose, leaving the stored shape of a complete report with both withdrawn and pending items undescribed. The method comments described their results without saying what they return, left out branches (exclusions and tombstones given for a complete report are dropped by `MemberList.value`, and `marked: true` also drops pending items and tombstones), and `reported_keys` claimed keys the report "has ever" had an opinion on, though withdrawn keys are pruned by the next forge.

The class comment now says it is used to read and build the value of a source's `DataEntry` for a tracked association, then defines complete and partial reports, each list a report holds, the fields of a pending item, and the stored shapes, as structured lists. The method comments open with what they return and cover their branches, `renamed` gains an example, and the comment on `reported_keys`, which restated its one line, is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: the comments in `AssociationTracking`, `AssociationConfig` and `Tombstone` opened with nuances or situations instead of what each method returns, left out raise and nil branches, re-derived membership and withdrawal in every method that touched them, used colons and semicolons to splice ideas together, and in places misstated the code: an excluded member is removed per `dependent:` rather than always destroyed, `reported_columns` and `tombstone_attributes` drop the primary key rather than the key, and `resolve` leaves alone rows no report names now rather than ever. A comment on removing members sat above `private`, attached to nothing.

The `AssociationTracking` module comment now defines membership, a forge's removal of members, pending attributes and withdrawing a source from a member, and `AssociationConfig` lists how a member's key is chosen and defines placeholder and tombstone keys and `tombstone_matcher:`, so method comments use those terms without re-deriving them. Each method comment now says first what it returns or does and covers its raise and nil branches, multi-part returns and options are lists, the stray comment is deleted, and `AssociationTracking.tombstone_blocking` points at the public `LaForge.tombstone_blocking` instead of repeating it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… payloads

Issue: the comments on recording, forging, precedence, merging and Rails form payloads re-derived vocabulary that `MemberList`, `AssociationTracking` and `PrecedenceRule` own, opened with nuances instead of saying what each method returns, left out branches such as the nil level, the attribute-group raises and members scheduled by `laforge_remove`, and argued against code that does not exist. Some had drifted from the code, naming `attributes` where `remove_data_entries` takes `attribute_names`, saying `record_data_entry` records a grouped attribute when it raises, and calling `effective_precedence` the way entries are ordered.

Levels-first ranking and precedence orders are now defined once in the `PrecedenceRule` overview, and attribute groups in the `laforged` docs. Method comments say what they return first, describe every branch, and use the report vocabulary without redefining it. Per-member facts (the `DataEntry::CHANGED` payload, the `LaForge.merge` report, `mode:` options, removal per dependent option, attribute group checks) are now lists, and lines that ran past 120 characters are rewrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: the association spec comments re-derived the vocabulary that `LaForge::MemberList`, `LaForge::AssociationConfig` and `LaForge::AssociationTracking` already define, in a header essay. Some comments made claims the code does not bear out, such as the importer sweep closing a record "or reports it in a list" and the registry helper keeping the tenant "only if another source still reports it". Others joined sentences with semicolons or mid-sentence colons, sat above the line they described, or named a process ("noted for later consideration"). Three spec names stacked clauses ("an earlier forge, not saved yet, removed") that were hard to parse.

The capability spec header now points to the modules that own the vocabulary, lists the experimental API names, and lists where a forge removes rows, defining the `# noted:` marker the examples use. Helper comments say what each helper does and returns, the inaccurate claims are gone, the misplaced comment sits on its line, and the stacked spec names read as one clause. Every `capability:` tag is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…odel's own method would hide LaForge's

Root cause: two errors misdescribed what happens. Excluding a member keyed by primary key without a `tombstone_matcher:` said "excluding it destroys it", but a removed member goes the way `dependent:` says, which can delete or nullify it. `laforged` raising for a method the model already defines said `laforged` "would replace" it, but a method the model defines hides the one LaForge includes.

The messages now say "excluding it removes it per `dependent:`" and "which would hide LaForge's of the same name", and the spec that removes a source's global rule says why such a source can exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: `AssociationTracking.record` and `.edit_member` built each new report by hand, passing every list of the previous report back into `MemberList.value` or `MemberList.partial` with a lambda to drop the member, a `previous&.` guard for a source without a report, and a branch for each action and report kind. Reading what one edit does to a report meant tracing five lists through two branches.

`MemberList` now returns the stored value for each change to a report: `#including` (with the member's pending item), `#excluding`, `#forgetting`, `#replaced` (a new report that withdraws what it no longer names) and `#withdrawing_all`. `MemberList.none` is the report of a source without one, so callers no longer guard for nil. `edit_member` checks the member, asks the report for the change and writes it, and `record` and `withdraw` do the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread lib/laforge/activerecord.rb Outdated
(@laforge_marked_members ||= []) << record
else
data_entries.build(attribute_name: attribute_name, value: value, source_id: source)
laforge_remove_on_save(config, record)
Comment thread lib/laforge/merge.rb
kept.reload.forge! # no forge of the survivor reaches a member it has nothing pending for
(report[:members_merged][config.name] ||= []) << { kept: kept.id, removed: member.id }
else
member[config.owner_foreign_key] = survivor.id
Comment thread lib/laforge/activerecord.rb Outdated
raise InvalidValue, "#{self.class.name}##{reflection.name} must be a #{reflection.klass.name}, not #{target.class.name}"
end

primary_key = reflection.polymorphic? ? target&.class&.primary_key : reflection.association_primary_key
Comment thread lib/laforge/association_tracking.rb Outdated
Comment on lines +224 to +233
member_scope = config.klass.where(config.owner_foreign_key => owner_ids)
member_scope = member_scope.where(config.owner_type_column => owner_class.polymorphic_name) if config.owner_type_column
scope = config.reflection.scope
member_scope = member_scope.merge(config.klass.unscoped.instance_exec(&scope)) if scope&.arity&.zero? # a scope taking the owner cannot be applied to several owners at once
member_records = member_scope.to_a
member_entries = DataEntry.where(record_type: config.klass.base_class.name, record_id: member_records.map(&:id)).preload(:source).to_a
.reject { |entry| config.nested_config(entry.attribute_name) }
.group_by(&:record_id)

members_by_owner = member_records.group_by { |record| record[config.owner_foreign_key] }
Comment thread lib/laforge/association_tracking.rb Outdated
# Returns the source's report, or nil when it has none or its entry is marked for destruction
def report(owner, config, source)
entry = owner.send(:filter_data_entries_in_memory, attributes: config.name, sources: source).first
MemberList.new(entry.value) if entry && !entry.marked_for_destruction?
njakobsen and others added 4 commits October 23, 2026 13:47
…:Partial`

Issue: `MemberList` held both kinds of report in one class, so `initialize`, `.value`, `#decision` and every change method branched on `partial?`, and callers repeated the branch: `record` chose between `MemberList.value` and `MemberList.partial`, `prune_withdrawn` rebuilt each kind by hand, and `entomb` and `Merge.combined_report` passed `partial: report.partial?` back in. Each method had to be read twice, once per kind.

`MemberList.new` now returns a `MemberList::Complete` or a `MemberList::Partial`, which share one interface. Each kind reads and writes its own stored value, decides membership its own way and implements `#excluding`, `#vacant?` and `.replacing`; only a partial report can be forgotten from. Shared changes live on `MemberList`, and `#with` returns the stored value for a report of the same kind with some lists replaced, which `#renamed`, `clear_pending`, `prune_withdrawn`, `entomb` and `combined_report` use. The stored JSON is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: every `AssociationTracking` function took `(owner, config, ...)` and passed the reports, existing members, created members and pending items it read between a dozen helpers as arguments. `forge` was 42 lines that read the reports, built and claimed members, recorded pending items, wrote member order, withdrew sources, pruned the reports, entombed removed members and scheduled removals inline, and `diff` and `source_values` mixed their queries with the per-member logic.

`LaForge::TrackedAssociation.new(owner, config)` holds the owner and config, and reads the reports, existing members, created members, membership resolution and pending items when first needed. `record`, `edit`, `edit_member`, `forge`, `diff`, `name_created`, `tombstones`, `member_sources` and `unsaved_member` are instance methods, and `forge` reads as a sequence of named steps. Work across owners, `declare`, `source_values` and `tombstone_blocking`, are class methods, with `source_values` split into its queries and the per-owner and per-source member hashes. Callers in `activerecord.rb` and `tombstone.rb` build one per operation, and `withdraw_source` and `laforge_withdraw` call `MemberList#withdrawing_all` directly. `withdraw_from_members` is private, and `new_member` takes `attributes`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: `normalize_member` was 55 lines that turned a record, a `Tombstone` or a hash into a key hash, cast the key, gave a new member its placeholder key, then validated every field through a five-way `elsif` with the nested-list validation inline, the attribute groups and the key, so finding the check behind one error meant reading the whole method.

`normalize_member` now reads as its steps: `member_hash` turns each accepted shape into a hash, `named_tombstone` returns a tombstone's key, `keyed_member` casts the key and adds the placeholder key, and `assert_attribute!`, `assert_nested_list!`, `assert_whole_groups!` and `assert_key!` each validate one part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: `lib/laforge/activerecord.rb` held every method `laforged` adds in one 711-line file, so recording, forging, precedence rules and the bookkeeping for what forges schedule for the next save were interleaved in one `InstanceMethods` and one `ClassMethods`. `laforged` declared its associations and scopes inline, `laforge_after_save` finished three kinds of scheduled work in 26 lines, and four places reached into Active Record with `instance_variable_set(:@marked_for_destruction, ...)`.

`InstanceMethods` now includes `Recording`, `Forging`, `Precedence` and `ScheduledChanges`, each in its own file, and `ClassMethods` includes `Precedence::ClassMethods`, so `laforged` adds them as before. `assert_no_collisions!` reads the methods of the included modules too, so the check covers the same names. `laforged` calls `declare_data_associations` and `declare_scopes`, `laforge_after_save` calls a step for each kind of scheduled work, and `LaForge::ActiveRecord.set_marked_for_destruction` is the one place that sets the flag. Methods that called one defined above them now come after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Comment on lines +120 to +124
# Returns the withdrawn list without the member named by the key hash `member`, then with it at the end when this
# report includes it
def withdrawn_with(config, member)
key = config.key_of(member)
others(config, withdrawn, key) + (includes?(config, key) ? [config.key_hash(member)] : [])
Comment on lines +46 to +50
# Returns the stored value for this report leaving out the member named by the key hash `member`, without its
# pending items, and withdrawing it when this report included it
def excluding(config, member)
key = config.key_of(member)
with(include: others(config, include, key), withdrawn: withdrawn_with(config, member), pending: items_without(config, pending, key))
Comment on lines +42 to +43
owner_ids.each_with_object({}) do |owner_id, result|
reports = entries.select { |entry| entry.record_id == owner_id }.to_h { |entry| [entry.source, MemberList.new(entry.value)] }

@njakobsen njakobsen left a comment •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general there are a lot of methods in the ActiveRecord submodules and many we end up prefixing with laforge_. Are they all reading instance data, or could we avoid cluttering the model namespace and extract some of these into singleton methods on the module that do not get mixed in. Let's discuss.

Comment thread lib/laforge/scheduled_changes.rb Outdated
module LaForge
# Used to hold what forges of a laforged record schedule for its next save, and to carry it out once the record is
# saved
module ScheduledChanges

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Schedule" is the wrong word to use. It implies setting a date. How else could we describe these? Or do the belong with recording.rb since it's about recording that we want a specific change to happen. Or do they belong with forging, because these are for manipulating metadata used in forging, not the data we're storing on the record itself?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d8e3bf3: these are now pending writes, the writes a forge has decided on but not made, which the record's next save makes. They belong with forging, as its output. ScheduledChanges is replaced by LaForge::PendingWrites, one per record, reached through laforge_pending_writes, with remove, remove_on_save, removed_members, cancel_removal, delete_entry, created_members and write!. The model loses ten laforge_* methods and four instance variables, and no "schedule" wording is left.

…e::PendingWrites`

Issue: what forges decide on but leave for a record's next save lived on the model itself, in four ivars (`@laforge_marked_members`, `@laforge_replaced_members`, `@laforge_withdrawn_entries`, `@laforge_created_members`) and a dozen `laforge_` methods mixed in by `ScheduledChanges`, so a reader had to piece the concept together from the model's method list. "Scheduled" also implied the writes were set for a date, when they are made by the record's next save.

`LaForge::PendingWrites` now holds a record's pending writes, one instance per record reached through `laforge_pending_writes`, which `Forging` creates on first use and drops on `reload` and once `laforge_after_save` has called its `write!`. Its `remove`, `remove_on_save`, `removed_members`, `cancel_removal`, `delete_entry` and `created_members` replace `laforge_remove`, `laforge_remove_on_save`, `laforge_scheduled_members`, `laforge_cancel_removal`, `laforge_destroy_on_save` and `laforge_created_members`, and `TrackedAssociation` and `Recording` call them through the accessor. `write!` makes the writes in the order `laforge_after_save` did, and the `laforged` collision check now covers `laforge_pending_writes` in place of the removed methods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved association validation, membership lifecycle, and polymorphic reference issues can persist invalid or unintended records.

13 open issues
7 previously missed issues

In code that hasn't changed since last review

Medium severity Remove unsaved members from autosaved targets

lib/​laforge/​pending_writes.rb:27

For a natural-key collection without dependent:, forging a new member and then forging an empty list before saving leaves that unsaved member in the autosaved target. Autosave inserts it before the queued removal nullifies its owner, creating an orphan that the final report excludes. Remove never-persisted members from the autosaved target while preserving reuse if another unsaved forge includes their key again.

Medium severity Use the association primary key for polymorphic foreign keys

lib/​laforge/​recording.rb:155

With belongs_to :subject, polymorphic: true, primary_key: :uuid, foreign_key: :subject_uuid, this records the target's model primary key (id) instead of its uuid. Forging then produces a foreign key that cannot resolve to the intended target. Use the reflection's association primary key with the target class for polymorphic associations too.

Medium severity Register validation callbacks when enabling autosave

lib/​laforge/​tracked_association.rb:27

For a has_one declared without autosave: true or validate: true, setting autosave here does not install Rails' validation callbacks, yet autosave saves the member with validation disabled. Consequently, forge! can persist a member that fails its model validations instead of rolling back. Register the autosave validation callbacks when enabling autosave, as Rails' nested-attributes setup does.

Medium severity Use polymorphic_name for polymorphic entry queries

lib/​laforge/​tracked_association.rb:37

These polymorphic entry queries use the Ruby class name rather than Active Record's stored polymorphic name. With a namespaced model and store_full_class_name = false, source_values misses the owner's reports and the member-entry query below misses member attributes. Use polymorphic_name for both queries, as the owner-type filter already does.

Medium severity Reject conflicting first reports for keyed has_one associations

lib/​laforge/​tracked_association.rb:209

For a keyed has_one whose existing row has never been reported, a first report naming a different key appends the old row to records as undecided and removes it from the removal candidates. Only records.first becomes the target, so saving leaves both rows attached or fails a unique owner constraint. Reject this conflict to preserve the unreported row, or explicitly schedule its replacement according to dependent:.

Medium severity Destroy vacant partial reports without withdrawal keys

lib/​laforge/​tracked_association.rb:451

Forgetting a source's last exclusion or tombstone leaves a vacant partial report with no withdrawal keys, so this guard prevents its destruction even after forge!. The source remains in data_source_names and data_source? despite having no opinion, which can prevent orphan checks from recognizing an unclaimed record. Check for vacant reports even when there is nothing to withdraw.

Medium severity Preserve tombstone mappings until exclusions are saved

lib/​laforge/​tracked_association.rb:476

After a higher-precedence source excludes a saved tenant and forge runs without saving, the exclusion names only a tombstone while the original row still exists. If a lower source re-reports that row's id before forge!, the exclusion no longer matches and the tenant's pending destruction is cancelled. Preserve the id-to-tombstone mapping until removal is saved so the lower source cannot bypass the exclusion.

1 resolved since last review

Review effort: Balanced

…ecord.laforge`

Issue: a laforged model carried LaForge's internals alongside the API the application calls. `Recording`, `Forging` and `Precedence` defined `@api private` methods such as `laforge_record_reported`, `laforge_rank_of` and `laforge_pending_writes` on the model, `TrackedAssociation` reached private ones such as `write_data_entry` and `laforge_association_reports` with `send`, and the model held `@laforge_pending_writes` and `@laforge_precedence_resolver`, so every model exposed these names and `laforged`'s collision check guarded them as if they were API.

The new `LaForge::RecordState`, reached through `record.laforge` and made on first use, holds what LaForge keeps for one record, its pending writes and precedence resolver, and the low-level methods bound to it: `forge_diff_with`, `record_attribute`, `record_reported`, `withdraw`, `write_pending_writes!`, `pending_writes`, `association_reports`, `winning_entries`, `forging_entries`, `write_data_entry`, `write_precedence_rule`, `filter_data_entries_in_memory`, `precedence_of`, `rank_of`, `outranks?`, `resolver` and `reset_resolver`. `TrackedAssociation`, `DataEntry` and the model's own methods call them there, without `send`. The model keeps the API the README documents, its associations, scopes and callbacks, and `reload`, which drops the `RecordState`; `@laforge` is the one instance variable LaForge sets on it. `laforged`'s collision check covers `laforge` and leaves out the moved methods. The unused `laforge_association_lists` is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved membership fallback, has_one validation, and member ordering issues can cause incorrect persisted data.

14 open issues
2 previously missed issues

In code that hasn't changed since last review

Medium severity Register validation callback when enabling autosave

lib/​laforge/​tracked_association.rb:27

Enabling autosave after declaration does not install the associated-record validation callback that a plain has_one lacks by default. Its autosave path then saves the member without validation, allowing forge! to persist invalid reported attributes instead of raising and rolling back. Register the autosave validation callback when enabling autosave, as Rails does for nested attributes.

Medium severity Reapply association order after forging members

lib/​laforge/​tracked_association.rb:211

When an owner forge applies pending member attributes, the member's own forge can reapply a directly recorded order-column value after write_order assigns the list position. Saving then persists that value instead of the resolved position, so reloading can reorder the members. Assign the association's order after forging its members so the list remains authoritative.

Review effort: Balanced

Comment on lines +41 to +48
# Reads a report from its stored value, as a `Complete` or a `Partial`. `marked: true` reads the report of an entry
# marked for destruction, which includes nothing, has no pending items or tombstones, and withdraws every member it
# included. A complete one then excludes every member, and a partial one has no opinion on any.
def self.new(value, marked: false)
return super unless equal?(MemberList)

(value.is_a?(Hash) && value['partial'] ? Partial : Complete).new(value, marked: marked)
end
…ge`, which the model delegates to

Issue: `record.laforge` held only LaForge's low-level methods, so its API stayed spread over the model. `Recording`, `Forging` and `Precedence` were included into the model, which called into `record.laforge` for its internals, and `ClassMethods` added model-level helpers such as `laforged_associations`, `laforged_association`, `laforged_attribute_groups`, `laforge_with_groups`, `laforge_attribute_group`, `laforge_assert_whole_groups!` and `laforge_assert_rule_attribute!`. LaForge could not be used apart from the model's own methods.

`laforged` now gives each model its own subclass of the new `LaForge::Record`, reached through `Model.laforge`, and each record an instance of it, reached through `record.laforge`. `LaForge::Record` replaces `LaForge::RecordState` and includes the concerns `AttributeGroups`, `Recording`, `Forging`, `Precedence` and the new `Inspection`, whose instance methods act on the record and whose `class_methods` act on the model. Each concern lists its API in `API`, and `CLASS_API` where it has one, and `LaForge::ActiveRecord::Model` delegates them from the model and its records, so `place.forge!` is `place.laforge.forge!` and `Place.update_source_precedence` is `Place.laforge.update_source_precedence`. `Model.laforge` also holds `model`, `associations`, `association(name)` and `attribute_groups`, and `with_groups`, `group_of`, `assert_whole_groups!` and `assert_rule_attribute!` take the place of the `laforge_*` helpers. A subclass of a laforged model gets a subclass of its parent's `laforge`, so it keeps what the parent declared. `place.laforge.tombstones(:tenants)` joins `place.data_entries.tombstones(:tenants)`, which calls it. The model keeps its associations, scopes, after-save callback, `reload` and `initialize_dup`. The Readme notes the `laforge` form, and the Upgrading section says that `filter_data_entries_in_memory` is called on `record.laforge`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved association validation and merge data-loss issues prevent safe approval.

16 open issues
1 previously missed issue

In code that hasn't changed since last review

Medium severity Use collection deletion API to preserve counter-cache updates

lib/​laforge/​pending_writes.rb:98

For a collection with dependent: :delete_all and a belongs_to counter cache, member.delete bypasses both callbacks and Rails' association-level counter update. Removing a member through forge! therefore leaves the owner's cached count too high, unlike ordinary collection removal. Use the collection's deletion API here so it preserves callback-free deletion while maintaining the counter cache.

Review effort: Balanced

Comment thread lib/laforge/merge.rb
# includes is not withdrawn.
def combined_report(config, winning, losing)
by_key = ->(list) { list.uniq { |member| config.key_of(member) } }
included = by_key.(winning.include + losing.include.reject { |member| winning.partial? && winning.decision(config, config.key_of(member)) == :exclude })
# forge changes on its members.
def declare(owner_class, config)
config.klass.laforged unless config.klass.respond_to?(:laforge)
config.reflection.autosave = true

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread lib/laforge/forging.rb
def forge!(**forge_options, &block)
record.transaction do
block.call if block_given?
forge(**forge_options)
Comment thread lib/laforge/forging.rb
# sources' entries are left out unless `inactive` is true.
def association_reports(config, inactive: false)
entries = inactive ? filter_data_entries_in_memory(attributes: config.name) : forging_entries(attributes: config.name)
by_precedence(entries).map { |data_entry| [data_entry, MemberList.new(data_entry.value, marked: data_entry.marked_for_destruction?)] }
Comment thread lib/laforge/recording.rb
raise InvalidValue, "#{record.class.name}##{reflection.name} must be a #{reflection.klass.name}, not #{target.class.name}"
end

primary_key = reflection.polymorphic? ? target&.class&.primary_key : reflection.association_primary_key
# { include: [...], exclude: [...] }. The included are as `AssociationConfig#normalize_members` returns them, and
# the excluded are key hashes. Raises for an exclusion `AssociationConfig#assert_excludable!` rejects.
def partial_members(members)
include, exclude = members.is_a?(Hash) ? [members.fetch(:include) { members['include'] } || [], members.fetch(:exclude) { members['exclude'] } || []] : [members, []]
Comment thread Readme.md Outdated

Until a forge creates a new member, its source's report names it by a placeholder. Once the owner is saved, the placeholder is replaced by the new row's `id`. Forging again before saving reuses the member already built, rather than building another.

Sources rarely know the host application's ids, so an importer finds each record it reported before by its own means, such as the identifier its feed uses, and names it by `id`. Recording twice before a forge creates the member once, since a source's report replaces its previous one. A new member is created twice only when:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recording twice scenario is presented as though it was a common scenario, but maybe we should treat the first part of any subsection as the "golden path" and then talk about more complex scenarios in a subsubsection. I'm not sure if there are enough of these to make it a rule, but in this specific case it feels like the complex case is presented as if it was the natural continuation of the normal usage.

Let's discuss.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and made it the rule for the whole README: each section opens with the common case and its example, and situations that only arise in unusual cases follow in a subsection named for the situation. Here that's When a new member is created twice. The same pass moved edge cases in Edits (Several edits before a forge, What an edit can't do) and Recording a source's members (When a source also records on the member directly) in 4064ce1, Expiring a change on #18, and Rulesets on #19.

Issue: several association sections went straight from the common case into situations that only arise when something unusual happens, such as an importer reporting a member it already created, so the unusual case read as the natural continuation of normal use.

Each section now opens with the common case and its example, and the unusual situations follow in a subsection named for the situation. `Naming members` gains `When a new member is created twice`, `Edits` gains `Several edits before a forge` and `What an edit can't do` (which takes the exclusion rule from `Recording a Rails form`), and `Recording a source's members` gains `When a source also records on the member directly`.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved association identity, singleton replacement, inspection, and inherited form-option issues can produce incorrect results.

20 open issues
6 previously missed issues

In code that hasn't changed since last review

Medium severity Use subclass payload options for inherited associations

lib/​laforge/​payload.rb:174

Inherited tracked associations keep the parent as config.owner_class, so this lookup ignores accepts_nested_attributes_for options declared or overridden on the subclass. A subclass's allow_destroy: false, reject_if:, or unsupported-option checks can therefore be bypassed when recording its form. Pass the actual payload model through to the options lookup instead of using the class that originally declared tracking.

Medium severity Query members by the configured owner key

lib/​laforge/​tracked_association.rb:36

owner_ids contains record ids, but the member foreign key contains values of the association's configured primary_key:. When those differ, source_values queries the wrong members and can omit their recorded attributes or associate another owner's rows with this owner. Query and group members by the configured owner key, while keeping the returned results keyed by record id.

Medium severity Use polymorphic_name for association queries

lib/​laforge/​tracked_association.rb:37

These owner and member entry queries use base_class.name, but Active Record stores a polymorphic association's type using polymorphic_name. With a namespaced model and store_full_class_name = false, the stored type is demodulized, so source_values misses the reports and member attributes even though recording and forging work. Please use the polymorphic name in both queries, as PrecedenceRule.model_type already does.

Medium severity Apply owner-dependent association scopes per owner

lib/​laforge/​tracked_association.rb:69

Owner-dependent scopes are silently dropped here, so source_values can include attributes from members outside an association such as has_many :tenants, ->(place) { where(unit: place.name) }. The instance method uses this batch path too, so even a single-owner lookup is affected. Apply the association's scope separately for each owner when it cannot be shared across the batch.

Medium severity Handle conflicting keyed has_one reports

lib/​laforge/​tracked_association.rb:209

For a keyed has_one, a first report naming a different key retains the existing, unreported member as undecided while assigning the new member as the target. The old member is removed from existing, so it is never scheduled for removal; saving can leave both rows attached, and reloading can return the old one. Explicitly reject this singleton conflict or schedule the old member's replacement.

Medium severity Respect the association's configured owner primary key

lib/​laforge/​tracked_association.rb:537

When an association declares a different owner primary_key:, this overwrites the foreign key supplied by scope_for_create with owner.id. The unsaved member then carries the wrong owner reference during key-only validation and tombstone matching, even if autosave later corrects it. Use the owner key specified by the reflection here.

Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LaForge does not handle associations

3 participants