Org Web Adapter

hungryroot/brand/tdd-canonical-brand-entity.org

TDD: Canonical Brand Entity

Overview

CUSTOM_ID
overview

Promote brand from a denormalized text field on =Product= to a canonical =Brand= entity that discovery, search, merchandising, and client surfaces build against. A new standalone =brands= app carries the model (slug, display name, marketing copy, visual assets, activation state); a =Product → Brand= FK replaces string reads via an expand → migrate → contract rollout with read-fallback to the legacy field throughout the transition.

=Product.brand_name= is today a free-text =CharField(max_length=50, null=True, blank=True)= (=app/models/product.py:105=) --- the platform's only brand representation. It is searchable and boosted in both Algolia and OpenSearch, baked into the semantic-embedding text, and drives business logic by raw string compare.

*Status:* =DRAFT= --- Epic [[https://hungryroot.atlassian.net/browse/EP-667][EP-667]]

*Prior art:* supersedes =first-class-brand-model-plan.org= (same directory). Key change from that plan: single =ForeignKey= instead of M2M + =ProductBrand= through model --- current data is strictly one brand per product, and that plan itself flagged FK as the simpler choice pending cardinality confirmation. See Alternatives Considered.

Problem

CUSTOM_ID
problem

Brand and growth teams want brand-led merchandising and discovery experiences (brand pages, brand-filtered browse, brand-aware ranking). Today:

- No canonical identity --- spelling variants ("Banza", "banza", "BANZA ") read as distinct brands; no stable ID, no shared assets, dirty cardinality

- Only edit path is per-product Django admin --- =brand_name= isn't even in the =BulkProductUpdate._TEXT_FIELDS= allowlist, so there is no bulk tooling and no place for brand-level metadata (logo, description, activation)

- Business logic keys off the raw string --- =Product.is_third_party= compares =brand_name.lower() != "hungryroot"= (=app/models/product.py:430=); a hardcoded Banza name override lives at =app/services/api/product.py:210=

- Search leans on the free text --- =brand_name^2= multi-match boost plus a 6.0 exact-match =constant_score= in OpenSearch, =searchableAttributes= position 2 in Algolia --- but there is no brand facet or filter anywhere

- Every new brand-flavored surface requires a one-off integration against the string

People

CUSTOM_ID
people

| Role | Name |

|------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|

| Technical Author | Colin Powell |

| Jira | [[https://hungryroot.atlassian.net/browse/EP-667][EP-667]] (stories [[https://hungryroot.atlassian.net/browse/BE-8109][BE-8109]] -- [[https://hungryroot.atlassian.net/browse/BE-8118][BE-8118]]) |

Design

CUSTOM_ID
design

Canonical Brand Model

CUSTOM_ID
canonical-brand-model

New standalone =brands= Django app (mirrors =bonus_bites= skeleton). Model mirrors =DishType= for slug/images/description and =CookbookAuthor= for =is_active=:

| Field | Type | Purpose |

|------------------+-------------------------------------------------------------------+-----------------------------------------------------------------------------------------|

| =name= | =CharField(max_length=200)= | Canonical display name |

| =slug= | =SlugField(max_length=100, unique=True)= | Stable URL identity; auto-generated in =clean()=+=save()=; =UniqueConstraint= in =Meta= |

| =description= | =TextField(blank=True)= | Marketing copy |

| =logo_image= | =FileField(upload_to=unique_upload_path, storage=public_storage)= | Admin-uploaded; served via CloudFront (=build_cdn_url=) |

| =hero_image= | =FileField(...)= (same pattern) | Brand-page hero asset |

| =sort_order= | =SmallIntegerField(default=0)= | Merchandising order |

| =is_active= | =BooleanField(default=False)= | Activation state; inactive brands hidden from client surfaces |

| =is_first_party= | =BooleanField(default=False)= | Replaces the =brand_name.lower() != "hungryroot"= string compare |

=BrandAdmin= (SimpleHistoryAdmin) is the ops management surface: create/edit brands, upload logos, toggle activation --- replacing per-product free-text edits.

Product Relationship

CUSTOM_ID
product-relationship

#+begin_src python

# app/models/product.py (proposed)

brand = ForeignKey("brands.Brand", null=True, blank=True, on_delete=PROTECT, related_name="products")

#+end_src

- Cardinality: one brand per product (FK, not M2M) --- matches current data shape

- =on_delete=PROTECT=: a brand with live products cannot be deleted; deactivate via =is_active= instead

- Nullable during the transition; flipped to non-null in the contract phase after backfill is verified

Backfill & Normalization

CUSTOM_ID
backfill--normalization

Backfill collapses =brand_name= variants to canonical rows via =get_or_create= on a normalized name (rule finalized in BE-8109; baseline: lowercase, strip, collapse internal whitespace):

#+begin_src python

# app/migrations/XXXX_backfill_product_brand.py (proposed sketch, follows 0621 precedent)

def backfill_brands(apps, _schema_editor):

Product = apps.get_model("app", "Product")

Brand = apps.get_model("brands", "Brand")

for product in Product.objects.exclude(brand_name__isnull=True).exclude(brand_name=""):

normalized = normalize_brand_name(product.brand_name)

brand, _ = Brand.objects.get_or_create(

slug=slugify(normalized),

defaults={"name": normalized.title(), "is_first_party": normalized == "hungryroot"},

)

product.brand = brand

product.save(update_fields=["brand"])

#+end_src

Read-Fallback During Transition

CUSTOM_ID
read-fallback-during-transition

Every canonical read falls back to the legacy field while FKs may be null:

#+begin_src python

# Product (proposed)

@property

def canonical_brand_name(self) -> str | None:

return self.brand.name if self.brand_id else self.brand_name

#+end_src

Both search indexes and all serializers read through this fallback until the contract phase, so search/discovery quality is preserved even mid-backfill.

Search Indexing

CUSTOM_ID
search-indexing

| Index | Change |

|--------------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|

| OpenSearch =ProductSearch= (=hrsearch/search.py=) | Add =brand_id= as =KeywordField= (facetable/filterable) + canonical brand name with fallback. Semantic-embedding template (=hrsearch/utils.py= → =product_search.html=) switches to canonical brand; nightly =rebuild_product_search_index= picks up the mapping. |

| Algolia =ProductIndex= (=app/index.py=) | Canonical brand field with fallback; add brand to =attributesForFaceting= --- the first brand facet. Settings applied via existing =post_migrate= receiver. |

| Query layer (=hrsearch/open_search/query_builders/specs.py=) | Brand facet/aggregation + brand filter on =brand_id=; move =brand_name^2= and the 6.0 =product_brand_exact_boost= onto the canonical field, weights still via =SEARCH_DEFAULT_WEIGHTS= dynamic config. |

Feature Flag

CUSTOM_ID
feature-flag

| Config key | Type | Default | Purpose |

|---------------------------------------+-----------------------------------+---------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|

| =api.brand_entity.serializer_enabled= | Boolean (=BoolDynamicConfigEnum=) | ="0"= | Gates the removal of the legacy =brand_name= field from API responses (mirrors the =COOKBOOK_LIST_REMOVE_DEPRECATED_FIELDS= pattern). Nested =brand= object ships unconditionally; the flag only controls dropping the legacy field. |

Alternatives Considered

CUSTOM_ID
alternatives-considered

| Approach | Rejected Because |

|---------------------------------------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|

| Brand model in the =app= monolith | Standalone app gives clean ownership (CODEOWNERS), isolated migrations, and its own test/service layout; app-creation cost is 5 config files, paid once |

| Ops-curated variant→canonical mapping table | Auto =get_or_create= on a normalized name covers case/whitespace variants with zero ops burden; residual near-dupes ("Ben & Jerry's" vs "Ben and Jerrys") are fixable post-hoc in =BrandAdmin= (merge = repoint FKs) |

| Enum/choices field instead of a model | Brands carry metadata (logo, copy, activation) and change at merchandising cadence --- data, not code |

| M2M + =ProductBrand= through model (=is_primary=) --- the prior plan's recommendation | Current data is strictly one brand per product; M2M complicates every serializer and index for a cardinality we don't have. Revisit only if product confirms multi-brand SKUs (sub-brands, co-branding) |

| Keep the string, add validation | Solves spelling only; no stable ID, no assets, no activation state, no brand pages |

| Big-bang cutover (drop =brand_name= immediately) | =brand_name= is embedded in the OpenSearch semantic vectors, Algolia searchable attributes, ~12 serializers, Braze/Segment events, and pick tickets --- fallback dual-read is the only safe path |

Implementation

CUSTOM_ID
implementation

Files Changed

CUSTOM_ID
files-changed

| File | Change | Story |

|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------------------------------------------------------+---------|

| =brands/= (new app: models, admin, migrations, tests) | =Brand= model + =BrandAdmin= | BE-8110 |

| =hrplatform/settings/components/common.py=, =.pre-commit-config.yaml=, =Dockerfile-seed=, =pyproject.toml=, =CODEOWNERS= | New-app registration (5 config files) | BE-8110 |

| =app/models/product.py= | =brand= FK + =canonical_brand_name= fallback property | BE-8111 |

| =app/migrations/= | AddField + backfill data migration | BE-8111 |

| =hrsearch/search.py=, =hrsearch/utils.py=, =hrsearch/templates/hrsearch/product_search.html= | =brand_id= KeywordField, canonical name w/ fallback, embedding template | BE-8112 |

| =app/index.py= | Algolia canonical brand field + brand facet | BE-8113 |

| =hrsearch/open_search/query_builders/specs.py= | Brand facet/filter; boosts moved to canonical field | BE-8114 |

| =app/rest/product.py=, =app/rest/v3/product.py=, =app/rest/pairing.py=, =app/rest/pairing_swaps.py=, =discovery_home/views/v2/collection.py=, =scsort/views/foryou_products.py=, ... | Nested =brand= object; legacy field behind flag | BE-8115 |

| =brands/views.py= + URL conf | =GET /brands/=, =GET /brands/<slug>/= | BE-8116 |

| =app/models/product.py:430=, =app/services/api/product.py:210=, =app/services/braze_helpers.py=, =app/services/ops/pick_ticket_service.py=, =app/rest/cart.py= | Move string-keyed logic to =brand.is_first_party= / brand-driven rules | BE-8117 |

| Everything above + =app/django_admin/product_admin.py=, =app/admin/stock.py= | FK non-null; remove =brand_name= everywhere | BE-8118 |

Brand Model (proposed sketch)

CUSTOM_ID
brand-model-proposed-sketch

#+begin_src python

# brands/models.py — proposed; mirrors DishType + CookbookAuthor patterns

class Brand(BaseModelV1):

name = models.CharField(max_length=200)

slug = models.SlugField(max_length=100, unique=True)

description = models.TextField(blank=True)

logo_image = models.FileField(upload_to=utils.unique_upload_path, storage=utils.public_storage, blank=True, null=True)

hero_image = models.FileField(upload_to=utils.unique_upload_path, storage=utils.public_storage, blank=True, null=True)

sort_order = models.SmallIntegerField(default=0)

is_active = models.BooleanField(default=False)

is_first_party = models.BooleanField(default=False)

class Meta:

constraints = [models.UniqueConstraint(fields=["slug"], name="brand_uniq_slug")]

def clean(self) -> None:

super().clean()

if not self.slug:

self.slug = slugify(self.name)

def save(self, *args, **kwargs) -> None:

self.clean()

super().save(*args, **kwargs)

@property

def logo_image_url(self) -> str | None:

return utils.build_cdn_url(self.logo_image.name if self.logo_image else "")

#+end_src

Rollout

CUSTOM_ID
rollout

- [ ] BE-8109 --- audit prod =brand_name= values; lock normalization rule (fills the data table below)

- [ ] BE-8110 --- deploy =brands= app + =BrandAdmin= (no behavior change; new empty table)

- [ ] BE-8111 --- deploy FK + backfill migration; verify cardinality diff on prod snapshot before merge

- [ ] BE-8112 / BE-8113 --- index canonical brand in OpenSearch + Algolia with fallback; confirm nightly rebuild picks up mapping; spot-check search quality pre/post

- [ ] BE-8114 --- enable brand facet/filter; move boosts to canonical field

- [ ] BE-8115 --- ship nested =brand= object in APIs (legacy field still present, flag off)

- [ ] BE-8116 --- launch =GET /brands/= + brand pages

- [ ] BE-8117 --- cut business logic over to =brand.is_first_party=

- [ ] Clients confirm migration to nested =brand= object → flip =api.brand_entity.serializer_enabled= to ="1"=

- [ ] BE-8118 --- FK non-null, remove =brand_name= from indexes/serializers/admin, =RemoveField=

Current brand_name Data

CUSTOM_ID
current-brand_name-data

/Pending the BE-8109 audit --- populate before implementation starts./

| Metric | Value |

|------------------------------------------+-------|

| Products with non-empty =brand_name= | TBD |

| Distinct raw values | TBD |

| Distinct after normalization | TBD |

| Variant clusters (case/whitespace dupes) | TBD |

| First-party ("Hungryroot") share | TBD |

Risks

CUSTOM_ID
risks

| Risk | Mitigation |

|--------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------|

| Changing the embedding text forces a full OpenSearch semantic reindex | Nightly =rebuild_product_search_index= already rebuilds the full index; ship the template change with the mapping change so one rebuild covers both |

| Auto-normalization mis-merges two genuinely distinct brands | BE-8109 spot-checks the collision set before backfill; residual fixes are FK repoints in =BrandAdmin= |

| Search quality regression while FKs are partially populated | Read-fallback to legacy =brand_name= in both indexes and all serializers; legacy field untouched until BE-8118 |

| =on_delete=PROTECT= blocks brand deletion with live products | Intentional --- deactivate via =is_active=; prevents orphaning product data |

| 5-min DynamicConfig cache = up to 5-min flag-flip lag | Same SLA as all existing flags; acceptable |

| =RemoveField brand_name= also alters the large =HistoricalProduct= table | Standard simple-history migration; run in a low-traffic window like migration 0696 did |

| Clients break when legacy =brand_name= is dropped from responses | Field removal is flag-gated per the cookbook deprecated-fields precedent; flip only after client confirmation, roll back in ≤5 min |