Repository navigation
feat: rank groups of records by rulesets, named copies of the model-wide and global rules - #19
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Concurrent writes can restore removed rules or reject source creation, and rollback caching and selector normalization can produce incorrect rankings.
4 open issues
What changed in this PR
Adds named rulesets so groups of records can rank shared sources differently while retaining shared entries and record-level overrides. Applications without a selector continue using base rules.
Changes:
- Adds app-wide and model-specific selectors, ruleset-aware ranking, and separate cache keys.
- Introduces copy-on-first-write, fork/reset APIs, and source placement within rulesets.
- Adds migration support, documentation, and ruleset behavior tests.
| File | Description |
|---|---|
spec/support/association_capabilities.rb |
Adds a region field for selector tests. |
spec/spec_helper.rb |
Adds ruleset-aware test schema and indexes. |
spec/lib/usage_spec.rb |
Updates expectations for ranking metadata. |
spec/lib/rulesets_spec.rb |
Tests selectors, copying, ranking, lifecycle, and migration. |
Readme.md |
Documents rulesets and upgrade instructions. |
lib/laforge/rulesets.rb |
Implements ruleset configuration and lifecycle APIs. |
lib/laforge/precedence_rule.rb |
Adds ruleset scopes, validation, and resolver support. |
lib/laforge/precedence_cache.rb |
Updates comments to describe ruleset caching. |
lib/laforge/data_source.rb |
Adds ruleset placement and global-order updates. |
lib/laforge/activerecord.rb |
Integrates selectors and ruleset-aware model APIs. |
lib/laforge.rb |
Loads the ruleset implementation. |
lib/generators/laforge/templates/rulesets_migration.rb.tt |
Adds a reversible ruleset upgrade migration. |
lib/generators/laforge/templates/precedence_rules_migration.rb.tt |
Includes rulesets when creating precedence rules. |
lib/generators/laforge/templates/migration.rb.tt |
Includes rulesets in fresh-install schemas. |
lib/generators/laforge/rulesets_generator.rb |
Generates the ruleset upgrade migration. |
Review effort: Balanced
ac12420 to
edaec66
Compare
edaec66 to
73a950e
Compare
73a950e to
1c58532
Compare
1c58532 to
7c9e649
Compare
| def forge_diff(**forge_options) | ||
| laforge_with_one_ruleset { laforge_forge_diff(**forge_options) } |
There was a problem hiding this comment.
Fixed in 82cb907: a forge forges a record's tracked associations before assigning its attributes, so a member whose selector reads its owner selects its ruleset from the owner as the forge found it, as forge_diff previews. A spec moves a place from north to south in the same forge that updates its tenant.
| # Returns { membership: source, listed_by: [sources], excluded_by: [sources], attributes: { attribute => source } } | ||
| # for one member | ||
| def member_sources(name, **member) | ||
| @laforge_precedence_resolver = nil # ranked by the ruleset selected now |
There was a problem hiding this comment.
Fixed in 82cb907: the reset in Inspection#member_sources skips a resolver held by an enclosing forge, and a standalone call still selects afresh.
7c9e649 to
41cedca
Compare
92d1fe1 to
fa5e587
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Resolver lifecycle defects remain, and transactional ruleset changes and destructive rollback require final human validation.
7 open issues
Refresh selected member resolver before association inspection · New Preserve held resolver during region reset · New Refresh member resolver before ranking cached attribute sources Validate record-level ruleset on updates Refresh member ruleset before ranking attribute sources Preserve held resolver during member source resets Keep nested previews consistent with owner-dependent selectors
Review effort: Balanced
| # Returns { membership: source, listed_by: [sources], excluded_by: [sources], attributes: { attribute => source } } | ||
| # for one member | ||
| def member_sources(name, **member) | ||
| reset_resolver # selects the ruleset and reads the rules again |
There was a problem hiding this comment.
Fixed in 82cb907: TrackedAssociation#member_sources calls reset_resolver_unless_held on the member before ranking its attributes, so a loaded member whose selector value changed reports its new ruleset's winners.
fa5e587 to
d7e2b65
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Mixed-case ruleset names under case-insensitive database collations can produce incorrect source placement or duplicate insertion attempts.
8 open issues
Handle case-insensitive ruleset names consistently · New Preserve held resolver during region reset Refresh selected member resolver before association inspection Refresh member resolver before ranking cached attribute sources Validate record-level ruleset on updates Refresh member ruleset before ranking attribute sources Preserve held resolver during member source resets Keep nested previews consistent with owner-dependent selectors
Review effort: Balanced
| unplaced = rulesets - PrecedenceRule.global.in_ruleset(rulesets).where(source_id: source.id).pluck(:ruleset) | ||
| lowest = PrecedenceRule.global.in_ruleset(unplaced).group(:ruleset).minimum(:precedence) | ||
| unplaced.each { |ruleset| PrecedenceRule.create!(source_id: source.id, ruleset: ruleset, precedence: lowest.fetch(ruleset, 1) - 1) } | ||
| PrecedenceCache.clear if unplaced.any? |
There was a problem hiding this comment.
Fixed in 82cb907: the ruleset column uses utf8mb4_bin, with its utf8mb4 charset, on install and on upgrade, so names are case-sensitive in SQL as in Ruby.
Issue: `Rulesets` explained when the selector is called, how a source created later is placed, and what happens when two processes set a ruleset up at once in its opening paragraph and bullets, the first in one long sentence, so those cases read as part of normal use. The section now opens with what a selector is and the rules of the common case, and those cases follow in `When the selector is called`, `Sources created or destroyed later` and `Setting rulesets up while the app runs`. Refs #19 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d7e2b65 to
c77befa
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The cross-cutting ranking, cache, and migration changes warrant final human verification before approval.
8 open issues
Handle case-insensitive ruleset names consistently Preserve held resolver during region reset Refresh selected member resolver before association inspection Refresh member resolver before ranking cached attribute sources Validate record-level ruleset on updates Refresh member ruleset before ranking attribute sources Preserve held resolver during member source resets Keep nested previews consistent with owner-dependent selectors
Review effort: Balanced
Issue: `Rulesets` explained when the selector is called, how a source created later is placed, and what happens when two processes set a ruleset up at once in its opening paragraph and bullets, the first in one long sentence, so those cases read as part of normal use. The section now opens with what a selector is and the rules of the common case, and those cases follow in `When the selector is called`, `Sources created or destroyed later` and `Setting rulesets up while the app runs`. Refs #19 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c77befa to
9995513
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The schema migration and cross-cutting transactional ranking and cache behavior warrant final human validation.
8 open issues
Handle case-insensitive ruleset names consistently Preserve held resolver during region reset Refresh selected member resolver before association inspection Refresh member resolver before ranking cached attribute sources Validate record-level ruleset on updates Refresh member ruleset before ranking attribute sources Preserve held resolver during member source resets Keep nested previews consistent with owner-dependent selectors
Review effort: Balanced
…ide and global rules Issue: LaForge's sources are shared by every record, so a field, model or global order applied to all of them, and groups of records that need their sources in different orders, such as records published to different domains, could not have them. Filtering the rules by a default scope would have chosen rules by the current thread rather than by the record, could not fall back to the base rules, and would have collided in the unique indexes and the precedence cache. A ruleset is a named copy of the model-wide and global rules, stored in a new `ruleset` column on `laforge_precedence_rules` (`''` for the base rules) that both unique indexes now include. `LaForge.ruleset_selector = :method_name` or a Proc given the record names a record's ruleset app-wide, and `laforged ruleset_selector:` replaces it for one model, `false` keeping its records on the base rules. The selector is evaluated only when a record is forged or ranked, never at boot; its value is turned into a String with surrounding whitespace removed, as `ruleset:` is, and nil or blank selects the base rules. Below a record's own rules, a record whose ruleset has rules ranks by that ruleset's attribute, model-wide and global rules, and any other record by the base rules; `PrecedenceRule::Resolver` never mixes the two, and caches each ruleset under its own `PrecedenceCache` keys; `forge` and `forge_diff` select the record's ruleset once, before any attribute is assigned, and keep it throughout, while a standalone `forge_attributes`, `forge_associations` or `member_sources` selects it afresh. Members of a tracked association are ranked by their own model's selector. `Model.update_precedence_order`, `Model.update_attribute_source_precedence`, `Model.update_source_precedence`, `LaForge.update_precedence_order` and the new `LaForge.update_source_precedence` take an optional `ruleset:`, nil meaning the base rules, where `update_source_precedence` sets the source's own precedence as `source.update!(precedence:)` does, and `Model.precedence_order` and `Model.source_precedences` read a ruleset too. The first rule written to a ruleset copies every model-wide and global base rule into it in the same transaction, through `LaForge.fork_ruleset`, which does only the copy and nothing for a ruleset that has rules; the copy uses `insert_all`, so the unique indexes skip the rows a concurrent first write copied already. Removing an order in a ruleset falls back to the ruleset's next level. `LaForge.reset_ruleset` deletes a ruleset's rules and `LaForge.rulesets` lists those with rules; a copy or reset that rolls back clears the precedence cache again. The README says to set rulesets up from one writer at a time, since two concurrent first writes, or two concurrent source creations, are not serialised. `precedence_order` now reports the ruleset each source's level comes from. Record rules are never copied and belong to no ruleset. Every source keeps a global precedence in each ruleset, as in the base rules, since the unique `global_precedence` index is what keeps forging free of ties: a source created later is placed below each existing ruleset's lowest global precedence in the same transaction, a ruleset that has placed it already is left alone, and `update_source_precedence` with a nil precedence raises, with or without `ruleset:`. A ruleset name that is not a String or Symbol, one over 100 characters, `ruleset:` given to a record-level method, a selector of the wrong kind, a Symbol selector the record does not respond to, and a ruleset rule created directly before its ruleset is forked each raise an `ArgumentError` saying what to pass instead. The column is limited to 100 characters so the unique index on the scope, source and ruleset stays within MySQL's 3072-byte index limit under utf8mb4 alongside two 255-character strings. `rails generate laforge:rulesets` adds it to existing installs with a reversible migration, whose down deletes every ruleset's rules; `laforge:install` creates it. `laforge:precedence_rules` still creates the precedence rules table as it was before rulesets, so on an upgraded install the rulesets migration alone adds the column and rebuilds the indexes, and alone takes them away. A spec runs the README's upgrade sequence from the 0.0.1.alpha schema up to the schema `laforge:install` creates, and back down to the 0.0.1.alpha schema with its priorities. The README documents rulesets as an advanced option under "Precedence", with an upgrade note. Further decisions: - A forked ruleset's copies keep each base rule's `recorded_at`, since forking changes no ranking and the copied rule was last set when its base rule was. - `member_sources` selects the ruleset again for the member it reports on as well as for the owner, so a loaded member whose selector value changed since it last ranked reports the winners of its new ruleset, as `precedence_order` does. - `member_sources` called during a forge, such as from a setter, keeps the ruleset the forge selected, so the forge ranks attributes and associations by one ruleset and matches `forge_diff`. - A rule's ruleset is checked whenever its ruleset or record changes, not only when it is created, so `update!` cannot move a record's own rule into a ruleset, or a base rule into a ruleset that was never forked. LaForge's own updates change neither. - The `ruleset` column has the `utf8mb4_bin` collation, given with its `utf8mb4` charset so a utf8mb3 table accepts it, in `laforge:install`, `laforge:rulesets` and the spec schema. Ruleset names are then case-sensitive in SQL as they are in Ruby, so `North` and `north` are two rulesets and never one ruleset stored under two spellings. The column keeps its 100-character limit, its `''` default and NOT NULL, and both unique indexes still build. - A forge forges a record's tracked associations before assigning its attributes, so each member selects its ruleset from its owner as the forge found it, ranking as `forge_diff` previews even when the forge changes what a member's selector reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: `Rulesets` explained when the selector is called, how a source created later is placed, and what happens when two processes set a ruleset up at once in its opening paragraph and bullets, the first in one long sentence, so those cases read as part of normal use. The section now opens with what a selector is and the rules of the common case, and those cases follow in `When the selector is called`, `Sources created or destroyed later` and `Setting rulesets up while the app runs`. Refs #19 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9995513 to
3a6bb98
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Association construction can use stale owner attributes, and nested member inspection can lose the enclosing forge's ruleset selection.
8 open issues
Build associations using forged owner attributes · New Preserve member selections through enclosing owner forge · New Handle case-insensitive ruleset names consistently Refresh selected member resolver before association inspection Refresh member resolver before ranking cached attribute sources Refresh member ruleset before ranking attribute sources Preserve held resolver during member source resets Keep nested previews consistent with owner-dependent selectors
2 resolved since last review
Review effort: Balanced
| forge_associations(associations: attributes) | ||
| record.attributes = forged |
| key = config.key_of(member) | ||
| record = config.members_of(owner).find { |candidate| config.record_key(candidate) == key } | ||
| candidates = record ? record.laforge.forging_entries.reject { |entry| entry.marked_for_destruction? || config.nested_config(entry.attribute_name) } : [] | ||
| record&.laforge&.reset_resolver_unless_held # a loaded member's selector value may have changed since it last ranked |


Stacked on #18. Lets groups of records rank their sources differently while sharing the same sources, entries and base rules. It's an advanced option: an app that never sets a ruleset selector sees no change.
Changes
rulesetcolumn onlaforge_precedence_rules(''for the base rules). Both unique indexes include it. Record rules, entries and sources are not per ruleset.LaForge.ruleset_selector(a method name or a Proc given the record) names a record's ruleset app-wide, andlaforged ruleset_selector:replaces it for one model,falsekeeping that model on the base rules. It's evaluated only when a record is forged or ranked, never at boot; nil or blank means the base rules, and surrounding whitespace is removed, as forruleset:.forgeandforge_diffselect the record's ruleset once, before any attribute is assigned, and a standaloneforge_associationsormember_sourcesselects it afresh.LaForge.fork_rulesetdoes the copy ahead of time,LaForge.reset_rulesetdeletes a ruleset's rules, andLaForge.rulesetslists them. A copy or reset that rolls back is forgotten by the precedence cache too. Rulesets are meant to be set up from one writer at a time; the README says why.update_precedence_order,update_attribute_source_precedenceandupdate_source_precedence(models andLaForge) take an optionalruleset:;precedence_orderreports the ruleset each level comes from.rails generate laforge:rulesetsadds the column to existing installs, reversibly. The column is case-sensitive (utf8mb4_bin), soNorthandnorthare two rulesets, and limited to 100 characters to keep the unique index within MySQL's 3072-byte limit.laforge:precedence_rules's rollback restores each source's priority from its base global rule only.Follow-ups
precedence_orderandmember_sources, such aseffective_precedenceandDataEntry.in_precedence_order, reuse a record's last ruleset selection until it is forged again or reloaded; the README says so.UAT Steps
bundle exec rspec: 403 examples, 0 failures, 2 pending.Closes
Unreported
Special Handling
🤖 Generated with Claude Code