Skip to content

feat: rank groups of records by rulesets, named copies of the model-wide and global rules - #19

Merged
njakobsen merged 2 commits into
feat/expiring-changesfrom
feat/rulesets
Oct 26, 2026
Merged

njakobsen merged 2 commits into
feat/expiring-changesfrom
feat/rulesets

Conversation

@njakobsen

@njakobsen njakobsen commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Rulesets: 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). Both unique indexes include it. Record rules, entries and sources are not per ruleset.
  • Choosing one: LaForge.ruleset_selector (a 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 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 for ruleset:.
  • Ranking: below a record's own rules, a record whose ruleset has rules ranks by that ruleset's attribute, model-wide and global rules; any other record by the base rules. The two are never mixed, so changing the base rules can't reach a ruleset. Each ruleset has its own precedence cache keys, forge and forge_diff select the record's ruleset once, before any attribute is assigned, and a standalone forge_associations or member_sources selects it afresh.
  • Copy on first write: the first rule written to a ruleset copies every model-wide and global base rule into it in the same transaction, then applies the change, so nothing else changes. LaForge.fork_ruleset does the copy ahead of time, LaForge.reset_ruleset deletes a ruleset's rules, and LaForge.rulesets lists 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.
  • Writing: update_precedence_order, update_attribute_source_precedence and update_source_precedence (models and LaForge) take an optional ruleset:; precedence_order reports the ruleset each level comes from.
  • No ties: every source keeps a global precedence in each ruleset, as in the base rules. A source created later is placed below each ruleset's lowest global precedence, and removing a source's global precedence raises.
  • Migration: rails generate laforge:rulesets adds the column to existing installs, reversibly. The column is case-sensitive (utf8mb4_bin), so North and north are 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

  • Ranking helpers other than the forge methods, precedence_order and member_sources, such as effective_precedence and DataEntry.in_precedence_order, reuse a record's last ruleset selection until it is forged again or reloaded; the README says so.
  • Serialise a ruleset's first write and source placement across processes, if setting rulesets up from one writer at a time turns out not to be enough.

UAT Steps

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

Closes

Unreported

Special Handling

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

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings October 23, 2026 06:52
@njakobsen
njakobsen added this pull request to stack #17 October 23, 2026 06:52

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

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

Comment thread lib/laforge/rulesets.rb
Comment thread lib/laforge/rulesets.rb
Comment thread lib/laforge/rulesets.rb Outdated
Comment thread lib/laforge/rulesets.rb Outdated

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 resolver-lifetime and rollback correctness issues, together with transaction-sensitive ruleset behavior, require fixes and final human validation.

2 open issues
4 resolved since last review

Review effort: Balanced

Comment thread lib/generators/laforge/templates/precedence_rules_migration.rb.tt Outdated
Comment thread lib/laforge/activerecord.rb Outdated
Copilot AI balanced review requested due to automatic review settings October 23, 2026 08:07

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

The unresolved ranking-cache issue and combined changes to transactional caching and reversible migrations warrant final human review.

1 open issue
2 resolved since last review

Review effort: Balanced

Comment thread lib/laforge/activerecord.rb Outdated
Copilot AI balanced review requested due to automatic review settings October 23, 2026 08:22

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

A full forge can switch rulesets between scalar and association ranking, causing applied membership changes to differ from the preview.

1 open issue
1 resolved since last review

Review effort: Balanced

Comment thread lib/laforge/activerecord.rb Outdated
Copilot AI balanced review requested due to automatic review settings October 23, 2026 08:31

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 selector-consistency issues and transaction-sensitive schema and ranking changes require fixes and final human validation.

2 open issues
1 resolved since last review

Review effort: Balanced

Comment thread lib/laforge/activerecord.rb Outdated
Comment on lines +241 to +242
def forge_diff(**forge_options)
laforge_with_one_ruleset { laforge_forge_diff(**forge_options) }

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 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.

Comment thread lib/laforge/activerecord.rb Outdated
# 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

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 82cb907: the reset in Inspection#member_sources skips a resolver held by an enclosing forge, and a standalone call still selects afresh.

Copilot AI balanced review requested due to automatic review settings October 23, 2026 18:46

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

The documented legacy upgrade sequence creates the ruleset column twice and has conflicting index operations, blocking migration.

3 open issues

Review effort: Balanced

Comment thread lib/generators/laforge/templates/rulesets_migration.rb.tt Outdated
Copilot AI balanced review requested due to automatic review settings October 23, 2026 19:33
Copilot AI balanced review requested due to automatic review settings October 24, 2026 01:27

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/inspection.rb Outdated
# 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

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 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.

Comment thread lib/laforge/inspection.rb Outdated
Copilot AI balanced review requested due to automatic review settings October 24, 2026 01:35

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/rulesets.rb
Comment on lines +73 to +76
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?

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 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.

njakobsen added a commit that referenced this pull request Oct 24, 2026
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>
Copilot AI balanced review requested due to automatic review settings October 24, 2026 08:14

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.

njakobsen added a commit that referenced this pull request Oct 26, 2026
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>
Copilot AI balanced review requested due to automatic review settings October 26, 2026 18:11

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.

njakobsen and others added 2 commits October 26, 2026 15:06
…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>
Copilot AI balanced review requested due to automatic review settings October 26, 2026 22:11

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
Comment on lines +24 to +25
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
@njakobsen
njakobsen merged commit bb41c7e into master Oct 26, 2026
3 checks passed
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.

2 participants