Skip to main content

Unified Branding Service (Whitelabel) — Task Breakdown

RFC: rfc-unified-branding-service-be.md Source: rfc-unified-branding-service-be.md (§4.C Agent Execution Plan, chunks 1–8; T8 additionally realizes the email/PDF "server-rendered brand" consumer from RFC §1 UI/Consumer Surface Coverage) Services: hub_core (in-repo, this breakdown) · hub-service (endpoint, cross-repo) · FE (hub-chat, crm-fe-v3, cross-squad) Slicing: vertical — one task per execution chunk. Backend-only (mailers live in hub_core); the GET /branding HTTP endpoint and FE consumers are out of repo (see Skipped stories). Execute in order — each task's acceptance criteria must pass before the next.


Effort Summary

TaskFE daysBE daysQA daysTotal
T1 — Migrations (3 tables)11
T2 — Models (3) + validations1.51.5
T3 — Entity + Builder + token/URL validators1.51.5
T4 — Redis cache services (set/get/invalidate)1.51.5
T5 — Repository + Resolve interactor (Plan 1/2 + fallback)2.50.53
T6 — Write interactor + cache invalidation + audit20.52.5
T7 — Feature flag registration0.50.5
T8 — Whitelabel customer-email header logo (mailers)2.50.53
Grand total131.514.5

Confidence: medium–high. The build itself is high-confidence — every layer has a verified in-repo pattern to copy. Medium overall because four open questions can move T1/T5/T6: citext availability (T1), Plan-1 identifier company_id vs sso_id (T5), audit sink choice (T6), and default-branding source (T5). None blocks starting; each has a sensible default in RFC §5. T8 scope note: ~20+ mailers exist; T8 builds the shared branding-aware header + migrates the customer-facing mailers that hardcode the Qontak logo, with a Qontak fallback — remaining internal/admin mailers are a listed follow-on.


Task 1: [BE] Branding persistence — 3 migrations (B1)

A tenant's branding, resolvable domains, and assets have a place to live in the core chat DB.

Status: ✅ Actionable (confirm citext extension — RFC §5.4; falls back to string + lowercased unique index)

What to build

Three Rails migrations under database/core/db/migrate/ creating branding_configs, tenant_domains, branding_assets with UUID PKs and the unique indexes the resolver relies on.

Implementation Plan

ActionFileWhat changes
createdatabase/core/db/migrate/20260724000001_create_branding_configs.rbbranding_configsid: :uuid, organization_id, product_name default 'Qontak', color_tokens jsonb, font_family, font_css_url, support_url, legal_url; unique index on organization_id
createdatabase/core/db/migrate/20260724000002_create_tenant_domains.rbtenant_domainsid: :uuid, organization_id, host (citext), is_primary; unique index on host, index on organization_id; enable_extension 'citext' if absent
createdatabase/core/db/migrate/20260724000003_create_branding_assets.rbbranding_assetsid: :uuid, organization_id, kind, cdn_url; unique index on (organization_id, kind)

Implementation steps

  1. Explore — open database/core/db/migrate/20260624000001_create_direct_send_message_histories.rb for the exact template (< ActiveRecord::Migration[6.1], create_table …, id: :uuid, t.uuid :organization_id, null: false, t.jsonb, t.timestamps null: false, add_index).
  2. Write migrations — create the three files above following that template; add enable_extension 'citext' guarded by unless extension_enabled?('citext') in the tenant_domains migration.
  3. MigrateRAILS_ENV=test bundle exec rails app:db:migrate.
  4. Verify rollbackRAILS_ENV=test bundle exec rails app:db:rollback STEP=3 reverts cleanly, then migrate up again.

Acceptance criteria

  • Three tables exist with UUID PKs defaulting to gen_random_uuid().
  • Unique index on branding_configs.organization_id, on tenant_domains.host, and on branding_assets (organization_id, kind).
  • db:rollback STEP=3 reverses all three without error.

Effort estimate

DisciplineDays
Backend1
QA
Total1

Assumptions: standard Rails migrator (database/core/db/migrate/ registered at lib/hub_core/engine.rb:122); no backfill (greenfield).

Run to verify

RAILS_ENV=test bundle exec rails app:db:migrate && RAILS_ENV=test bundle exec rails app:db:rollback STEP=3

Depends on

  • None (first task).

Task 2: [BE] Branding models + validations (B1)

The three tables are usable through domain models that enforce branding's integrity rules.

Status: ✅ Actionable

What to build

Models::BrandingConfig, Models::TenantDomain, Models::BrandingAsset (all < Models::AbstractModel) with associations to Models::Organization, uniqueness/format validations, and the kind enum — plus co-located specs.

Implementation Plan

ActionFileWhat changes
createapp/core/domains/models/branding_config.rbbelongs_to :organization; validate uniqueness of organization_id; color_tokens hash; https validation for *_url; both-or-neither for font pair
createapp/core/domains/models/tenant_domain.rbbelongs_to :organization; validate uniqueness of host (case-insensitive), hostname format, lowercase-on-write
createapp/core/domains/models/branding_asset.rbbelongs_to :organization; kind inclusion in %w[logo favicon apple_touch_icon font]; uniqueness of kind scoped to organization_id; https cdn_url
createapp/core/domains/models/branding_config_spec.rb, tenant_domain_spec.rb, branding_asset_spec.rbvalidation happy/failure paths incl. wrong organization_id

Implementation steps

  1. Explore — open app/core/domains/models/organization.rb (< Models::AbstractModel, associations, store_accessor) and a nearby model spec such as app/core/domains/models/agent_participant_spec.rb for the co-located spec style.
  2. Write failing specs (red) — create the three *_spec.rb beside the models; assert uniqueness, kind enum, https URL, host lowercasing. Run and confirm red.
  3. Implement models — add associations + validations; enforce host lowercasing in a before_validation.
  4. Go greenbundle exec rspec app/core/domains/models/branding_config_spec.rb app/core/domains/models/tenant_domain_spec.rb app/core/domains/models/branding_asset_spec.rb.
  5. Quality gatebundle exec rubocop --no-color.

Acceptance criteria

  • Each model is < Models::AbstractModel and belongs_to :organization.
  • Duplicate organization_id on branding_configs, duplicate host, and duplicate (organization_id, kind) are rejected.
  • kind outside the enum and non-https URLs are rejected.
  • Specs cover happy path, validation failure, and wrong organization_id.

Effort estimate

DisciplineDays
Backend1.5
QA
Total1.5

Assumptions: reuses Models::AbstractModel (chat DB); validations only (no callbacks/business logic, per AGENTS.md).

Run to verify

bundle exec rspec app/core/domains/models/branding_config_spec.rb app/core/domains/models/tenant_domain_spec.rb app/core/domains/models/branding_asset_spec.rb && bundle exec rubocop --no-color

Depends on

  • Task 1 (tables must exist).

Task 3: [BE] Payload entity + builder + token/URL validators (B2)

The branding data assembles into one immutable payload object, with only verified color tokens and safe URLs allowed in.

Status: ✅ Actionable

What to build

Entities::Branding (Dry::Struct, the public payload shape), Builders::Branding (AR config + assets → entity), and a token allow-list constant + hex/URL validators shared by the builder and models.

Implementation Plan

ActionFileWhat changes
createapp/core/domains/entities/branding.rbDry::Struct: tenant, product_name, colors (hash), assets (hash), font (hash, optional), links (hash); plus a default class method returning Qontak defaults
createapp/core/domains/builders/branding.rbmaps Models::BrandingConfig + Models::BrandingAsset collection → Entities::Branding (plain entity, not a monad)
createapp/apps/whitelabel/constants/color_tokens.rbALLOWED_TOKENS = the 7 verified --mp-colors-* keys (RFC §4 / Decision 8); hex regex
createapp/core/domains/entities/branding_spec.rb, app/core/domains/builders/branding_spec.rbbuilder mapping + default fallback + token rejection

Implementation steps

  1. Explore — read an existing Dry::Struct entity under app/core/domains/entities/ and a builder under app/core/domains/builders/ (builder returns a plain entity, repository wraps in Success — per AGENTS.md).
  2. Write failing specs (red) — assert builder maps config+assets to the entity, Entities::Branding.default returns Qontak brand, and an unknown token key / bad hex is rejected.
  3. Implement — the constant allow-list, the entity struct, the builder; keep the builder monad-free.
  4. Go greenbundle exec rspec app/core/domains/entities/branding_spec.rb app/core/domains/builders/branding_spec.rb.
  5. Quality gatebundle exec rubocop --no-color.

Acceptance criteria

  • Builders::Branding.new(config, assets).build returns a fully-populated Entities::Branding.
  • Entities::Branding.default returns the Qontak default payload (product Qontak, Qontak tokens/logo).
  • Token keys outside ALLOWED_TOKENS and non-#RRGGBB values are rejected; removed --mp-colors-text-brand-on-surface is not accepted.

Effort estimate

DisciplineDays
Backend1.5
QA
Total1.5

Assumptions: entity is the security boundary (no PII fields — RFC Decision 6); allow-list is the 7 tokens verified in the design doc §4.

Run to verify

bundle exec rspec app/core/domains/entities/branding_spec.rb app/core/domains/builders/branding_spec.rb && bundle exec rubocop --no-color

Depends on

  • Task 2 (models supply the builder input).

Task 4: [BE] Redis cache services — set/get/invalidate (B3)

Resolved branding is cached per tenant so repeat lookups never hit the DB, and edits bust the cache immediately.

Status: ✅ Actionable

What to build

Five small single-responsibility services under app/core/domains/services/redis/branding/, copying the verified Set/GetOwnerBusinessInfo pattern (org-scoped key, REDIS_W.setex / REDIS_R.get, CustomLogFormat rescue).

Implementation Plan

ActionFileWhat changes
createapp/core/domains/services/redis/branding/set_config.rbkey Branding::Config::<org_id>; TTL = 1.hour.to_i (private_constant); REDIS_W.setex(key, TTL, payload.to_json)
createapp/core/domains/services/redis/branding/get_config.rbREDIS_R.get(key); JSON.parse(_, symbolize_names: true) or nil
createapp/core/domains/services/redis/branding/set_domain.rbkey Branding::Domain::<host>organization_id, same TTL
createapp/core/domains/services/redis/branding/get_domain.rbread host→org id
createapp/core/domains/services/redis/branding/invalidate.rbDEL Branding::Config::<org> + DEL Branding::Domain::<host> for each tenant_domains of the org
createco-located *_spec.rb for eachassert setex key+TTL, get parse/nil, invalidate deletes all keys

Implementation steps

  1. Explore — open app/core/domains/services/redis/organizations/set_owner_business_info.rb and get_owner_business_info.rb; copy the class shape, private_constant :TTL, and the CustomLogFormat rescue verbatim.
  2. Write failing specs (red) — stub REDIS_W/REDIS_R; assert setex(key, 3600, json) and key format; assert Invalidate enumerates the org's tenant_domains and deletes each domain key.
  3. Implement — the five services.
  4. Go greenbundle exec rspec app/core/domains/services/redis/branding/.
  5. Quality gatebundle exec rubocop --no-color.

Acceptance criteria

  • SetConfig writes Branding::Config::<org_id> via REDIS_W.setex with TTL 3600.
  • GetConfig/GetDomain return the parsed value or nil on miss.
  • Invalidate deletes the config key and every Branding::Domain::<host> key for the org.
  • Redis errors are logged via CustomLogFormat and don't raise.

Effort estimate

DisciplineDays
Backend1.5
QA
Total1.5

Assumptions: reuses REDIS_R/REDIS_W globals; mirrors the existing owner-business-info cache idiom 1:1.

Run to verify

bundle exec rspec app/core/domains/services/redis/branding/ && bundle exec rubocop --no-color

Depends on

  • Task 3 (payload entity is what SetConfig serializes).

Task 5: [BE] Repository + Resolve interactor — Plan 1/2 + fallback + flag (B4)

Given a Host or a company/org identifier, the system returns that tenant's branding — falling back to default Qontak branding when unresolved or flag-off — with the whole thing cached.

Status: ✅ Actionable (Plan-1 identifier assumed company_id — RFC §5.3)

What to build

Repositories::Whitelabel::FindBranding (returns Success(Entities::Branding) / Failure) and Interactors::Whitelabel::ResolveBranding (validates input, checks the whitelabel_branding flag, resolves via cache→DB, applies default fallback), following the AbstractRepository / AbstractIteractor patterns.

Implementation Plan

ActionFileWhat changes
createapp/apps/whitelabel/repositories/find_branding.rb< Repositories::AbstractRepository; call resolves org (by organization_id or by tenant_domains.host), loads config + assets, returns success Builders::Branding.new(...).build or failure
createapp/apps/whitelabel/interactors/resolve_branding.rb< Interactors::AbstractIteractor; contract accepts host: or organization_id:/company_id:; def result → flag check → cache get → repo on miss → cache set → Success(entity); unknown/flag-off → Success(Entities::Branding.default)
createco-located find_branding_spec.rb, resolve_branding_spec.rbresolve by host; by org id; unknown host→default; flag off→default; wrong-org isolation; cache-hit path

Implementation steps

  1. Explore — open app/core/domains/repositories/users/find_user.rb (repo callsuccess/failure + builder) and app/core/domains/interactors/admin_view_user.rb (contract DSL, def result, yield result_of_validating_params); open app/core/domains/services/preference_v2.rb for Services::Preference.new.enabled?(:flag, organization_id:).
  2. Write failing specs (red) — cover: resolve by host (cache miss→DB→cache set), resolve by org id, unknown host → Success(default), flag OFF → Success(default), and that a wrong org never returns another tenant's rows.
  3. Implement repository — org resolution (host lookup via Models::TenantDomain, else org id), load config + assets, build entity.
  4. Implement interactor — validate params; Services::Preference.new.enabled?(:whitelabel_branding, organization_id:); Services::Redis::Branding::GetConfig/GetDomain → on miss call repo and SetConfig/SetDomain; fallback to Entities::Branding.default.
  5. Go greenbundle exec rspec app/apps/whitelabel/.
  6. Quality gatebundle exec rubocop --no-color.

Acceptance criteria

  • ResolveBranding.new(host: "acme.com").result returns Success with the tenant's branding on both cache-hit and cache-miss paths.
  • ResolveBranding.new(organization_id: <id>).result resolves by org.
  • Unknown host, missing config, and flag-off all return Success(Entities::Branding.default) (never Failure).
  • A request scoped to org A never returns org B's config/assets.

Effort estimate

DisciplineDays
Backend2.5
QA0.5
Total3

Assumptions: reuses AbstractIteractor/AbstractRepository + Services::Preference; Plan-1 identifier is company_id (open question §5.3); QA validates resolution behavior once the endpoint (out of repo) is wired.

Run to verify

bundle exec rspec app/apps/whitelabel/ && bundle exec rubocop --no-color

Depends on

  • Tasks 2, 3, 4 (models, entity/builder, cache services). Task 7 (flag) must exist for the flag-off spec.

Task 6: [BE] Write interactor + cache invalidation + audit (B5)

An admin can create or update a tenant's branding, and the change is live on the next request.

Status: ✅ Actionable (audit sink to confirm — RFC §5.5)

What to build

Interactors::Whitelabel::CreateOrUpdateBranding (+ its repository) that upserts branding_configs + branding_assets in one transaction, writes an audit row, and busts the cache via Services::Redis::Branding::Invalidate.

Implementation Plan

ActionFileWhat changes
createapp/apps/whitelabel/repositories/upsert_branding.rb< Repositories::AbstractRepository; transactional upsert of config + assets (+ optional domains); unique-host violation → failure('host already claimed')
createapp/apps/whitelabel/interactors/create_or_update_branding.rb< Interactors::AbstractIteractor; contract validates organization_id + token allow-list + https URLs; on commit calls Services::Redis::Branding::Invalidate; writes audit row
createco-located upsert_branding_spec.rb, create_or_update_branding_spec.rbcreate + update; unique-host conflict; cache busted post-commit; audit row written; invalid token/URL rejected

Implementation steps

  1. Explore — open a transactional write repository (e.g. under app/core/domains/repositories/users/, create.rb/update.rb) for the success/failure + transaction shape; confirm the audit sink (Models::Billing::AuditLog per AGENTS.md, or PaperTrail — §5.5).
  2. Write failing specs (red) — create persists config+assets; update mutates; duplicate hostFailure('host already claimed'); after commit the Redis config key is absent (invalidated); invalid token → Failure.
  3. Implement repository — wrap upserts in a single AR transaction; rescue ActiveRecord::RecordNotUniquefailure.
  4. Implement interactor — validate via contract (reuse ALLOWED_TOKENS + URL validator from Task 3); call Invalidate only after a successful commit; write the audit row.
  5. Go greenbundle exec rspec app/apps/whitelabel/.
  6. Quality gatebundle exec rubocop --no-color.

Acceptance criteria

  • Create and update both persist config + assets atomically (rollback on any failure; cache NOT busted on rollback).
  • A second org claiming an existing host gets Failure('host already claimed').
  • After a successful write, Branding::Config::<org> and the org's domain keys are deleted.
  • An audit row is written for each write.
  • Unknown token / non-https URL is rejected before persistence.

Effort estimate

DisciplineDays
Backend2
QA0.5
Total2.5

Assumptions: single AR transaction (RFC §2.A); audit via Models::Billing::AuditLog pending §5.5; validators reused from Task 3.

Run to verify

bundle exec rspec app/apps/whitelabel/ && bundle exec rubocop --no-color

Depends on

  • Tasks 2, 3, 4 (models, validators, Invalidate).

Task 7: [BE] Register whitelabel_branding feature flag (B6)

Branding resolution is opt-in per tenant via a feature flag, so rollout is org-scoped and instantly reversible.

Status: ✅ Actionable

What to build

A migration/rake task that registers the whitelabel_branding flag through Services::Preference, plus a spec asserting the resolver honors the org-scoped flag.

Implementation Plan

ActionFileWhat changes
createdatabase/core/db/migrate/20260724000004_register_whitelabel_branding_flag.rbServices::Preference.new.add(:whitelabel_branding, title: 'Whitelabel branding', target: 'feature', author: '<owner>', expires_in: …)
extendapp/apps/whitelabel/interactors/resolve_branding_spec.rbassert flag OFF → default branding, flag ON (org) → resolved branding

Implementation steps

  1. Explore — read the "Register a flag" example in AGENTS.md and app/core/domains/services/preference_v2.rb (add / enable API).
  2. Write the registration migration — call Services::Preference.new.add(...) for :whitelabel_branding (target feature).
  3. Extend resolver spec — enable/disable via Flipper.enable(:whitelabel_branding) / disable in before/after; assert branch behavior.
  4. Migrate + testRAILS_ENV=test bundle exec rails app:db:migrate then bundle exec rspec app/apps/whitelabel/.
  5. Quality gatebundle exec rubocop --no-color.

Acceptance criteria

  • whitelabel_branding flag is registered and toggleable per organization.
  • Resolver spec proves flag OFF → default branding and flag ON (org) → tenant branding.

Effort estimate

DisciplineDays
Backend0.5
QA
Total0.5

Assumptions: reuses Services::Preference (Flipper + Redis); flag registration lives in a migration/admin task (not app code), per AGENTS.md.

Run to verify

RAILS_ENV=test bundle exec rails app:db:migrate && bundle exec rspec app/apps/whitelabel/ && bundle exec rubocop --no-color

Depends on

  • Task 5 (resolver reads the flag).

Final gate (RFC §4.C chunk 8), after Tasks 1–7: run the full pre-PR check — bin/overcommit_run (bundle + rspec app + brakeman + rubocop --parallel) — and confirm the full suite is green with no new Brakeman high warnings.


Task 8: [BE] Whitelabel the customer-email header logo (mailers)

A customer who receives a Qontak Chat email (password reset, onboarding, chat-history download, etc.) sees their tenant's logo and product name in the header — not the hardcoded Qontak logo.

Status: ✅ Actionable (depends on T5 resolver; recipient organization_id must be in scope for each migrated mailer — verify per mailer)

What to build

A branding-aware shared email header (partial + mailer concern) that resolves the recipient organization's branding via ResolveBranding and renders the tenant logo + product name, then migration of the customer-facing mailer views that currently hardcode the Qontak logo <img> to use it — with a Qontak default fallback (so unbranded orgs are unchanged).

Implementation Plan

ActionFileWhat changes
createapp/mailers/concerns/brandable.rbmailer concern: given organization_id, sets @branding = Interactors::Whitelabel::ResolveBranding.new(organization_id:).result value (always a payload — default Qontak on miss)
createapp/views/layouts/_branding_header.html.erbshared header partial: renders @branding.assets[:logo] as the <img> with alt = @branding.product_name; falls back to Qontak logo if @branding absent
extendapp/views/forgot_password_mailer/send_forgot_pass_mail.html.erb (L15)replace hardcoded <img … alt="Qontak Logo"> with render 'layouts/branding_header'
extendapp/views/onboarding_request_mailer/activation_reminder.html.erb (L110-112), onboarding_request_mailer/client_reminder.html.erb (L145-146)same replacement
extendremaining customer-facing mailer views that hardcode the Qontak logo (e.g. create_trial_password_mailer, chat_history_mailer, request_old_chat_history_mailer, conversation_log_download_mailer)same replacement
extendthe owning mailers in app/mailers/*.rbinclude Brandable; pass the recipient's organization_id when composing the mail
createco-located mailer specs (e.g. spec beside each mailer, per repo convention)assert branded logo when config present; Qontak fallback when absent

Implementation steps

  1. Explore — open app/views/forgot_password_mailer/send_forgot_pass_mail.html.erb:15 (the alt="Qontak Logo" <img>), the bare shared layout app/views/layouts/mailer.html.erb, and one mailer (e.g. app/mailers/forgot_password_mailer.rb) to see how organization_id/recipient context is available.
  2. Write failing specs (red) — for a representative mailer, assert the rendered HTML contains the tenant logo URL + product name when branding is configured, and the Qontak default logo when not.
  3. Build the concern + partialBrandable resolves branding (reusing T5's ResolveBranding, which always returns a payload incl. default); _branding_header.html.erb renders logo + product name.
  4. Migrate views — replace each hardcoded Qontak-logo <img> in the customer-facing mailers with render 'layouts/branding_header'; keep inline styles.
  5. Go greenbundle exec rspec spec for the touched mailers.
  6. Quality gatebundle exec rubocop --no-color.

Acceptance criteria

  • A shared _branding_header partial renders @branding.assets[:logo] with alt = product name.
  • Migrated customer-facing mailers show the recipient org's logo + product name when branding is configured.
  • When the org has no branding (or flag off), the header falls back to the current Qontak logo — no regression for unbranded orgs.
  • Specs assert both the branded and fallback branches for at least the migrated mailers.
  • No remaining hardcoded alt="Qontak Logo" <img> in the migrated customer-facing views.

Effort estimate

DisciplineDays
Backend2.5
QA0.5
Total3

Assumptions: reuses T5 ResolveBranding (always returns a payload → clean fallback); scoped to customer-facing mailers that carry the header logo; each mailer already has (or can be given) the recipient organization_id. Remaining internal/admin mailers are a follow-on (below).

Run to verify

bundle exec rspec spec && bundle exec rubocop --no-color

Depends on

  • Task 5 (ResolveBranding interactor). Independent of T6/T7.

Follow-on (not in this task's estimate)

  • Internal/admin-facing mailers (e.g. billing reminders, freeze/unfreeze, template-notification) — migrate in a later pass once the customer-facing set is proven. List and confirm recipient identity (customer vs internal ops) before migrating, since internal ops mail may intentionally stay Qontak-branded.

Ordering rationale

  • Strict data-up dependency chain: migrations (T1) → models (T2) → entity/builder+validators (T3) → cache (T4) → read resolver (T5) → write path (T6). Each layer is the input to the next; this is the critical path.
  • T5 (resolver) is the highest-value, highest-effort task — it's the interactor the out-of-repo GET /branding endpoint will call, so it's the true "done" line for hub_core. Front-load review attention here.
  • T7 (flag) can be parallelized with T3/T4 by a second developer once T2 lands, but T5's flag-off spec needs it — cheapest to slot right after T5.
  • T6 (write path) is independent of T5 beyond shared models/validators, so it can run in parallel with T5 if two developers are available (saves ~2.5 days of wall-clock).
  • T8 (email header logo) is the first real branding consumer inside hub_core and depends only on T5 (the resolver). It can run in parallel with T6/T7 once T5 lands. It's scoped to customer-facing mailers with a Qontak fallback — verify each mailer actually carries a recipient organization_id before migrating it, and leave internal/ops mail (which may intentionally stay Qontak-branded) to the follow-on.
  • Push externally in parallel: the hub_service GET /branding endpoint RFC (RFC §5.1) and infosec sign-off for the public endpoint (RFC §5.2) — neither blocks hub_core work, but both gate production rollout, so start them now.

Skipped stories

StoryReason
B7 — GET /branding HTTP endpointOut of repo — owned by hub_service (RFC Decision 9 / §5.1); needs a companion RFC. hub_core exposes the ResolveBranding interactor it will call.
B8 — FE applyBranding consumptionOut of scope — cross-squad FE work (hub-chat, crm-fe-v3); depends on the hub-chat data-panda-theme=next rollout (design doc §4b).
Admin branding UIOut of scope (RFC §1) — this breakdown builds the write path (T6), not its screens.
Asset upload UXOut of scope (RFC §1) — assets referenced by CDN URL; reuses the existing uploader.