Repository: artsy/metaphysics
Pull request #6677: feat(homeView): introduce HomeViewMixer and use it for for boosting content to new users
Author: login: anandaroop
Created: 2025-04-24T21:18:10Z | Merged: 2025-04-25T18:30:07Z

https://artsyproduct.atlassian.net/browse/ONYX-1674

This implements the proposal sketched out here — [slides](https://docs.google.com/presentation/d/1UdeoZ5XSZSdylohK9xwlyvp0Ak-1bq8Y61KRfvxtDrg/edit?usp=sharing) and [discussion](https://artsy.slack.com/archives/C05EQL4R5N0/p1745488800435159) — and presented to Onyx today.

The big idea here is the `HomeViewMixer`, explained in [this slide](https://docs.google.com/presentation/d/1UdeoZ5XSZSdylohK9xwlyvp0Ak-1bq8Y61KRfvxtDrg/edit#slide=id.g34fb28ebba3_0_82):

<img src="https://github.com/user-attachments/assets/f87a9bc6-b825-4949-97df-a1fbc4f99461" width="50%" />

This PR:

- introduces `HomeViewMixer` 
- adds a `BoostHeroUnitsForNewUsersFilter` — which (unsurprisingly) boosts the `HeroUnit` section for newly created accounts
- puts this new logic behind a `onyx_enable-home-view-mixer` [Unleash flag](https://unleash.artsy.net/projects/default/features/onyx_enable-home-view-mixer) (currently enabled in dev only)

We discussed many possible refinements, but the version here satisfies the requirements for the NY Art Week marketing campaign, which is the first production use of this pattern.


Diff:
diff --git a/src/lib/featureFlags.ts b/src/lib/featureFlags.ts
index 87bd810eef..f4a8a0ae54 100644
--- a/src/lib/featureFlags.ts
+++ b/src/lib/featureFlags.ts
@@ -12,6 +12,7 @@ const FEATURE_FLAGS_LIST = [
   "onyx_enable-home-view-section-featured-fairs",
   "onyx_experiment_home_view_test",
   "emerald_clientside-collector-signals",
+  "onyx_enable-home-view-mixer",
 ] as const
 
 export type FeatureFlag = typeof FEATURE_FLAGS_LIST[number]
diff --git a/src/schema/v2/homeView/mixer/HomeViewMixer.ts b/src/schema/v2/homeView/mixer/HomeViewMixer.ts
new file mode 100644
index 0000000000..e0cbdf23aa
--- /dev/null
+++ b/src/schema/v2/homeView/mixer/HomeViewMixer.ts
@@ -0,0 +1,37 @@
+import { HomeViewSection } from "schema/v2/homeView/sections"
+import { ResolverContext } from "types/graphql"
+import { HomeViewMixerRule } from "./HomeViewMixerRule"
+
+/**
+ * Orchestrates the application of multiple rules to a list of sections.
+ */
+export class HomeViewMixer {
+  private rules: HomeViewMixerRule[]
+
+  /**
+   * Create a new HomeViewMixer with the specified rules.
+   * @param rules Array of section rules to apply in sequence
+   */
+  constructor(rules: HomeViewMixerRule[]) {
+    this.rules = rules
+  }
+
+  /**
+   * Apply all rules sequentially to the provided sections.
+   * @param initialSections Initial list of sections to process
+   * @param context GraphQL resolver context
+   * @returns Final list of sections after all rules have been applied
+   */
+  async mix(
+    initialSections: HomeViewSection[],
+    context: ResolverContext
+  ): Promise<HomeViewSection[]> {
+    let sections = [...initialSections]
+
+    for (const rule of this.rules) {
+      sections = await rule.apply(sections, context)
+    }
+
+    return sections
+  }
+}
diff --git a/src/schema/v2/homeView/mixer/HomeViewMixerRule.ts b/src/schema/v2/homeView/mixer/HomeViewMixerRule.ts
new file mode 100644
index 0000000000..2beacd0b38
--- /dev/null
+++ b/src/schema/v2/homeView/mixer/HomeViewMixerRule.ts
@@ -0,0 +1,21 @@
+import { HomeViewSection } from "schema/v2/homeView/sections"
+import { ResolverContext } from "types/graphql"
+
+/**
+ * Abstract base class for mixer rules.
+ *
+ * Each rule may transform the list of sections, based on its own logic
+ * and the provided GraphQL request context.
+ */
+export abstract class HomeViewMixerRule {
+  /**
+   * Transforms a list of incoming sections
+   * @param sections List of sections to transform
+   * @param context GraphQL resolver context
+   * @returns Transformed list of sections
+   */
+  abstract apply(
+    sections: HomeViewSection[],
+    context: ResolverContext
+  ): Promise<HomeViewSection[]>
+}
diff --git a/src/schema/v2/homeView/mixer/__tests__/HomeViewMixer.test.ts b/src/schema/v2/homeView/mixer/__tests__/HomeViewMixer.test.ts
new file mode 100644
index 0000000000..ccfe88c65e
--- /dev/null
+++ b/src/schema/v2/homeView/mixer/__tests__/HomeViewMixer.test.ts
@@ -0,0 +1,80 @@
+import { ResolverContext } from "types/graphql"
+import { HomeViewSection } from "../../sections"
+import { HomeViewMixer } from "../HomeViewMixer"
+import { HomeViewMixerRule } from "../HomeViewMixerRule"
+
+describe("HomeViewMixer", () => {
+  it("should apply all rules to produce the final section list", async () => {
+    const context = {} as ResolverContext
+
+    const INITIAL_SECTIONS = [
+      BadArtworks, // should be removed
+      OkayArtworks,
+      GreatArtworks, // should be boosted to the top
+    ]
+
+    const mixer = new HomeViewMixer([
+      new RemoveBadArtworksRule(),
+      new BoostGreatArtworksRule(),
+    ])
+
+    const finalSections = await mixer.mix(INITIAL_SECTIONS, context)
+
+    expect(finalSections).toEqual([
+      GreatArtworks,
+      OkayArtworks,
+      // and BadArtworks has been removed
+    ])
+  })
+})
+
+// Mock rules
+
+class RemoveBadArtworksRule extends HomeViewMixerRule {
+  async apply(
+    sections: HomeViewSection[],
+    _context: ResolverContext
+  ): Promise<HomeViewSection[]> {
+    return sections.filter((section) => section.id !== BadArtworks.id)
+  }
+}
+
+class BoostGreatArtworksRule extends HomeViewMixerRule {
+  async apply(
+    sections: HomeViewSection[],
+    _context: ResolverContext
+  ): Promise<HomeViewSection[]> {
+    const greatArtworksSectionIndex = sections.findIndex(
+      (section) => section.id === GreatArtworks.id
+    )
+
+    // move it to the top
+    if (greatArtworksSectionIndex !== -1) {
+      const greatArtworksSection = sections[greatArtworksSectionIndex]
+      sections.splice(greatArtworksSectionIndex, 1)
+      sections.unshift(greatArtworksSection)
+    }
+
+    return sections
+  }
+}
+
+// Mock sections
+
+const BadArtworks: HomeViewSection = {
+  id: "home-view-section-bad",
+  requiresAuthentication: false,
+  type: "HomeViewSectionArtworks",
+}
+
+const OkayArtworks: HomeViewSection = {
+  id: "home-view-section-okay",
+  requiresAuthentication: false,
+  type: "HomeViewSectionArtworks",
+}
+
+const GreatArtworks: HomeViewSection = {
+  id: "home-view-section-great",
+  requiresAuthentication: false,
+  type: "HomeViewSectionArtworks",
+}
diff --git a/src/schema/v2/homeView/mixer/__tests__/rules/BoostHeroUnitsForNewUsersRule.test.ts b/src/schema/v2/homeView/mixer/__tests__/rules/BoostHeroUnitsForNewUsersRule.test.ts
new file mode 100644
index 0000000000..fa3c784bd3
--- /dev/null
+++ b/src/schema/v2/homeView/mixer/__tests__/rules/BoostHeroUnitsForNewUsersRule.test.ts
@@ -0,0 +1,77 @@
+import { HomeViewSection } from "schema/v2/homeView/sections"
+import { BoostHeroUnitsForNewUsersRule } from "../../rules/BoostHeroUnitsForNewUsersRule"
+import moment from "moment"
+import { HeroUnits } from "schema/v2/homeView/sections/HeroUnits"
+import { ResolverContext } from "types/graphql"
+
+describe("BoostHeroUnitsForNewUsersRule", () => {
+  it("moves HeroUnits near the top if user is new", async () => {
+    const mockContext: Partial<ResolverContext> = {
+      userID: "123",
+      userByIDLoader: jest.fn().mockResolvedValue(NEW_USER),
+    }
+
+    const inputSections: Partial<HomeViewSection>[] = [
+      { id: "quick-links-section" },
+      { id: "tasks-section" },
+      { id: "some-section" },
+      { id: "another-section" },
+      HeroUnits,
+    ]
+
+    const boostHeroUnits = new BoostHeroUnitsForNewUsersRule()
+
+    const outputSections = await boostHeroUnits.apply(
+      inputSections as HomeViewSection[],
+      mockContext as ResolverContext
+    )
+
+    expect(outputSections).toEqual([
+      { id: "quick-links-section" },
+      { id: "tasks-section" },
+      HeroUnits,
+      { id: "some-section" },
+      { id: "another-section" },
+    ])
+  })
+
+  it("leaves HeroUnits alone if user is not new", async () => {
+    const mockContext: Partial<ResolverContext> = {
+      userID: "123",
+      userByIDLoader: jest.fn().mockResolvedValue(OLD_USER),
+    }
+
+    const inputSections: Partial<HomeViewSection>[] = [
+      { id: "quick-links-section" },
+      { id: "tasks-section" },
+      { id: "some-section" },
+      { id: "another-section" },
+      HeroUnits,
+    ]
+
+    const boostHeroUnits = new BoostHeroUnitsForNewUsersRule()
+
+    const outputSections = await boostHeroUnits.apply(
+      inputSections as HomeViewSection[],
+      mockContext as ResolverContext
+    )
+
+    expect(outputSections).toEqual([
+      { id: "quick-links-section" },
+      { id: "tasks-section" },
+      { id: "some-section" },
+      { id: "another-section" },
+      HeroUnits,
+    ])
+  })
+})
+
+const NEW_USER = {
+  email: "new.user@example.com",
+  created_at: moment().subtract(1, "day").toISOString(),
+}
+
+const OLD_USER = {
+  email: "old.user@example.com",
+  created_at: moment().subtract(1, "year").toISOString(),
+}
diff --git a/src/schema/v2/homeView/mixer/rules/BoostHeroUnitsForNewUsersRule.ts b/src/schema/v2/homeView/mixer/rules/BoostHeroUnitsForNewUsersRule.ts
new file mode 100644
index 0000000000..882e96d0be
--- /dev/null
+++ b/src/schema/v2/homeView/mixer/rules/BoostHeroUnitsForNewUsersRule.ts
@@ -0,0 +1,47 @@
+import { HomeViewSection } from "schema/v2/homeView/sections"
+import { ResolverContext } from "types/graphql"
+import { HomeViewMixerRule } from "../HomeViewMixerRule"
+import { HeroUnits } from "../../sections/HeroUnits"
+
+const THRESHOLD_IN_HOURS = 2 * 7 * 24 // equals two weeks
+
+/**
+ * Rule that moves the HeroUnits section to the top of the list if the user is new.
+ */
+export class BoostHeroUnitsForNewUsersRule extends HomeViewMixerRule {
+  async apply(
+    sections: HomeViewSection[],
+    context: ResolverContext
+  ): Promise<HomeViewSection[]> {
+    const { userID, userByIDLoader } = context
+
+    if (userID && userByIDLoader) {
+      const user = await userByIDLoader(userID)
+      const age = getAccountAgeInHours(user)
+
+      if (age < THRESHOLD_IN_HOURS) {
+        // find the HeroUnits section
+        const heroUnitsIndex = sections.findIndex(
+          (section) => section.id === HeroUnits.id
+        )
+
+        // move it to the top (after Quick Links and Tasks, i.e. index 2)
+        if (heroUnitsIndex !== -1) {
+          const heroUnitsSection = sections[heroUnitsIndex]
+          sections.splice(heroUnitsIndex, 1)
+          sections.splice(2, 0, heroUnitsSection)
+        }
+      }
+    }
+
+    return sections
+  }
+}
+
+function getAccountAgeInHours(user) {
+  const createdAt = user.created_at
+  const accountAgeMillis = new Date().getTime() - new Date(createdAt).getTime()
+  const accountAgeHours = accountAgeMillis / (1000 * 60 * 60)
+
+  return accountAgeHours
+}
diff --git a/src/schema/v2/homeView/mixer/rules/DisplayableRule.ts b/src/schema/v2/homeView/mixer/rules/DisplayableRule.ts
new file mode 100644
index 0000000000..7be9826682
--- /dev/null
+++ b/src/schema/v2/homeView/mixer/rules/DisplayableRule.ts
@@ -0,0 +1,16 @@
+import { HomeViewSection } from "schema/v2/homeView/sections"
+import { ResolverContext } from "types/graphql"
+import { isSectionDisplayable } from "../../helpers/isSectionDisplayable"
+import { HomeViewMixerRule } from "../HomeViewMixerRule"
+
+/**
+ * Rule that removes sections based on the isSectionDisplayable helper.
+ */
+export class DisplayableRule extends HomeViewMixerRule {
+  async apply(
+    sections: HomeViewSection[],
+    context: ResolverContext
+  ): Promise<HomeViewSection[]> {
+    return sections.filter((section) => isSectionDisplayable(section, context))
+  }
+}
diff --git a/src/schema/v2/homeView/zones/default.ts b/src/schema/v2/homeView/zones/default.ts
index 74820bf078..2f9704cabe 100644
--- a/src/schema/v2/homeView/zones/default.ts
+++ b/src/schema/v2/homeView/zones/default.ts
@@ -1,6 +1,7 @@
 import { HomeViewSection } from "schema/v2/homeView/sections"
 import { ResolverContext } from "types/graphql"
-import { isSectionDisplayable } from "../helpers/isSectionDisplayable"
+import { DisplayableRule } from "../mixer/rules/DisplayableRule"
+import { HomeViewMixer } from "../mixer/HomeViewMixer"
 import { AuctionLotsForYou } from "../sections/AuctionLotsForYou"
 import { Auctions } from "../sections/Auctions"
 import { CuratorsPicksEmerging } from "../sections/CuratorsPicksEmerging"
@@ -25,6 +26,9 @@ import { TrendingArtists } from "../sections/TrendingArtists"
 import { ViewingRooms } from "../sections/ViewingRooms"
 import { InfiniteDiscovery } from "../sections/InfiniteDiscovery"
 import { QuickLinks } from "../sections/QuickLinks"
+import { BoostHeroUnitsForNewUsersRule } from "../mixer/rules/BoostHeroUnitsForNewUsersRule"
+import { isSectionDisplayable } from "../helpers/isSectionDisplayable"
+import { isFeatureFlagEnabled } from "lib/featureFlags"
 
 const SECTIONS: HomeViewSection[] = [
   QuickLinks,
@@ -57,6 +61,25 @@ const SECTIONS: HomeViewSection[] = [
  * Assemble the list of sections that can be displayed
  */
 export async function getSections(context: ResolverContext) {
+  const isMixerEnabled = isFeatureFlagEnabled("onyx_enable-home-view-mixer")
+
+  if (isMixerEnabled) {
+    return await getSectionsViaMixer(context)
+  } else {
+    return await getSectionsLegacy(context)
+  }
+}
+
+async function getSectionsViaMixer(context: ResolverContext) {
+  const mixer = new HomeViewMixer([
+    new DisplayableRule(),
+    new BoostHeroUnitsForNewUsersRule(),
+  ])
+
+  return await mixer.mix(SECTIONS, context)
+}
+
+async function getSectionsLegacy(context: ResolverContext) {
   const displayableSections: HomeViewSection[] = []
 
   SECTIONS.forEach((section) => {


Reviews:
user: login: mzikherman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T22:06:22Z
user: login: anandaroop
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T22:08:38Z
user: login: mzikherman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T22:11:23Z
user: login: damassi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T22:28:05Z
user: login: damassi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T22:29:57Z
user: login: nickskalkin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T15:42:29Z
user: login: anandaroop
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T18:26:10Z
user: login: anandaroop
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T18:26:14Z

Review comments:
diff_hunk: @@ -57,6 +61,25 @@ const SECTIONS: HomeViewSection[] = [
  * Assemble the list of sections that can be displayed
  */
 export async function getSections(context: ResolverContext) {
+  const isMixerEnabled = isFeatureFlagEnabled("onyx_enable-home-view-mixer")
+
+  if (isMixerEnabled) {
+    return await getSectionsViaMixer(context)
+  } else {
+    return await getSectionsLegacy(context)
+  }
+}
+
+async function getSectionsViaMixer(context: ResolverContext) {
+  const mixer = new HomeViewMixer([
path: src/schema/v2/homeView/zones/default.ts
user: login: mzikherman
body: I'm really into this DSL! Except - the 'filter' lingo - the boosting hero units one doesn't actually 'filter' - it more reorders.

And then the displayable one - it's feeling middleware-ish - and I'd imagine order specified in the mixer _may_ even  matter.

I wonder if instead of settling on the base 'SectionFilter' naming - instead maybe something like 'Step' or 'Rule' or 'Policy'?
created_at: 2025-04-24T22:06:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,6 +61,25 @@ const SECTIONS: HomeViewSection[] = [
  * Assemble the list of sections that can be displayed
  */
 export async function getSections(context: ResolverContext) {
+  const isMixerEnabled = isFeatureFlagEnabled("onyx_enable-home-view-mixer")
+
+  if (isMixerEnabled) {
+    return await getSectionsViaMixer(context)
+  } else {
+    return await getSectionsLegacy(context)
+  }
+}
+
+async function getSectionsViaMixer(context: ResolverContext) {
+  const mixer = new HomeViewMixer([
path: src/schema/v2/homeView/zones/default.ts
user: login: anandaroop
body: Yeah I took inspiration from (god help me) Elasticsearch and its notion of "[token filters](https://www.elastic.co/docs/reference/text-analysis/token-filter-reference)", which have the ability to reorder, modify, drop or even add tokens. (Also Rails controller "[filters](https://guides.rubyonrails.org/v2.3/action_controller_overview.html#filters)")

But I could roll with another name. `Rule` sounds nice and neutral.
created_at: 2025-04-24T22:08:38Z
author_association: MEMBER
diff_hunk: @@ -57,6 +61,25 @@ const SECTIONS: HomeViewSection[] = [
  * Assemble the list of sections that can be displayed
  */
 export async function getSections(context: ResolverContext) {
+  const isMixerEnabled = isFeatureFlagEnabled("onyx_enable-home-view-mixer")
+
+  if (isMixerEnabled) {
+    return await getSectionsViaMixer(context)
+  } else {
+    return await getSectionsLegacy(context)
+  }
+}
+
+async function getSectionsViaMixer(context: ResolverContext) {
+  const mixer = new HomeViewMixer([
path: src/schema/v2/homeView/zones/default.ts
user: login: mzikherman
body: (it just feels like 'rule' or 'policy' is a touch more accurate and up front with intention than 'filter'. For that same reason, _maybe_ even instead of mixer something like 'composer'?)
created_at: 2025-04-24T22:11:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { HomeViewSection } from "schema/v2/homeView/sections"
+import { ResolverContext } from "types/graphql"
+import { SectionFilter } from "./SectionFilter"
+
+/**
+ * Orchestrates the application of multiple filters to a list of sections.
+ */
+export class HomeViewMixer {
path: src/schema/v2/homeView/mixer/HomeViewMixer.ts
user: login: damassi
body: This all looks great design wise 👌 I just wonder if it can be achieved without any use of classes, instead leveraging simple functions without lingo. The JS world just doesn't intersect OOP much, with its hidden behaviors. 
created_at: 2025-04-24T22:28:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+import { HomeViewSection } from "schema/v2/homeView/sections"
+import { ResolverContext } from "types/graphql"
+import { SectionFilter } from "./SectionFilter"
+
+/**
+ * Orchestrates the application of multiple filters to a list of sections.
+ */
+export class HomeViewMixer {
path: src/schema/v2/homeView/mixer/HomeViewMixer.ts
user: login: damassi
body: (The last time I encountered an 'abstract class' in the wild was back in my ActionScript 3 days, about 15-20 years ago!)
created_at: 2025-04-24T22:29:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,6 +61,25 @@ const SECTIONS: HomeViewSection[] = [
  * Assemble the list of sections that can be displayed
  */
 export async function getSections(context: ResolverContext) {
+  const isMixerEnabled = isFeatureFlagEnabled("onyx_enable-home-view-mixer")
+
+  if (isMixerEnabled) {
+    return await getSectionsViaMixer(context)
+  } else {
+    return await getSectionsLegacy(context)
+  }
+}
+
+async function getSectionsViaMixer(context: ResolverContext) {
+  const mixer = new HomeViewMixer([
path: src/schema/v2/homeView/zones/default.ts
user: login: nickskalkin
body: I agree with Matt that Filter might not be the best lingo here. Maybe Ranker, since these classes basically re-rank sections?
created_at: 2025-04-25T15:42:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,6 +61,25 @@ const SECTIONS: HomeViewSection[] = [
  * Assemble the list of sections that can be displayed
  */
 export async function getSections(context: ResolverContext) {
+  const isMixerEnabled = isFeatureFlagEnabled("onyx_enable-home-view-mixer")
+
+  if (isMixerEnabled) {
+    return await getSectionsViaMixer(context)
+  } else {
+    return await getSectionsLegacy(context)
+  }
+}
+
+async function getSectionsViaMixer(context: ResolverContext) {
+  const mixer = new HomeViewMixer([
path: src/schema/v2/homeView/zones/default.ts
user: login: anandaroop
body: I think "ranker" downplays the potential of these filters to remove or add sections as well.

So, based on that and some [further discussion](https://artsy.slack.com/archives/C05EQL4R5N0/p1745595474594619?thread_ts=1745488800.435159&cid=C05EQL4R5N0) I've decided to split the difference and keep "mixer" but change "filter" to "rule" per Matt's suggestion above.
created_at: 2025-04-25T18:26:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,37 @@
+import { HomeViewSection } from "schema/v2/homeView/sections"
+import { ResolverContext } from "types/graphql"
+import { SectionFilter } from "./SectionFilter"
+
+/**
+ * Orchestrates the application of multiple filters to a list of sections.
+ */
+export class HomeViewMixer {
path: src/schema/v2/homeView/mixer/HomeViewMixer.ts
user: login: anandaroop
body: Idk we make use of OO JS all the time, particularly in MP because of all the [OO interfaces](https://github.com/search?q=repo%3Agraphql%2Fgraphql-js+%22export+class%22&type=code) in `graphql-js`.

There's really nothing exotic going on here. The DX for adding a new rule is simple as [this](https://github.com/artsy/metaphysics/blob/c2bc8b3242fcff6b66d9608966a28d7b1c588fab/src/schema/v2/homeView/mixer/__tests__/HomeViewMixer.test.ts#L33-L40).

Furthermore there's another reason I want these rules to be stateful. In a follow-up I aim to add the ability for each rule to contribute an explanation for if/how it was applied in a given request. I think this will be helpful for debugging/inspecting once the home view becomes highly variable across users.
created_at: 2025-04-25T18:26:14Z
author_association: MEMBER

PR comments:
user: login: nickskalkin
created_at: 2025-04-25T15:44:39Z
body: This looks good to me! Let's write a short doc explaining how it works and how to add & test new "Filters"?
author_association: CONTRIBUTOR
user: login: anandaroop
created_at: 2025-04-25T18:26:25Z
body: Thanks for the feedback. Decided to lean away from the "filter" naming in favor of "rule" and to keep the existing interface.

Good call on adding docs @nickskalkin, I will follow up with that in another PR 👍🏽

Gonna merge and check it out in staging.

author_association: MEMBER