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
| Task | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| T1 — Migrations (3 tables) | — | 1 | — | 1 |
| T2 — Models (3) + validations | — | 1.5 | — | 1.5 |
| T3 — Entity + Builder + token/URL validators | — | 1.5 | — | 1.5 |
| T4 — Redis cache services (set/get/invalidate) | — | 1.5 | — | 1.5 |
| T5 — Repository + Resolve interactor (Plan 1/2 + fallback) | — | 2.5 | 0.5 | 3 |
| T6 — Write interactor + cache invalidation + audit | — | 2 | 0.5 | 2.5 |
| T7 — Feature flag registration | — | 0.5 | — | 0.5 |
| T8 — Whitelabel customer-email header logo (mailers) | — | 2.5 | 0.5 | 3 |
| Grand total | — | 13 | 1.5 | 14.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:
citextavailability (T1), Plan-1 identifiercompany_idvssso_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
chatDB.
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
| Action | File | What changes |
|---|---|---|
| create | database/core/db/migrate/20260724000001_create_branding_configs.rb | branding_configs — id: :uuid, organization_id, product_name default 'Qontak', color_tokens jsonb, font_family, font_css_url, support_url, legal_url; unique index on organization_id |
| create | database/core/db/migrate/20260724000002_create_tenant_domains.rb | tenant_domains — id: :uuid, organization_id, host (citext), is_primary; unique index on host, index on organization_id; enable_extension 'citext' if absent |
| create | database/core/db/migrate/20260724000003_create_branding_assets.rb | branding_assets — id: :uuid, organization_id, kind, cdn_url; unique index on (organization_id, kind) |
Implementation steps
- Explore — open
database/core/db/migrate/20260624000001_create_direct_send_message_histories.rbfor 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). - Write migrations — create the three files above following that template; add
enable_extension 'citext'guarded byunless extension_enabled?('citext')in the tenant_domains migration. - Migrate —
RAILS_ENV=test bundle exec rails app:db:migrate. - Verify rollback —
RAILS_ENV=test bundle exec rails app:db:rollback STEP=3reverts 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, ontenant_domains.host, and onbranding_assets (organization_id, kind). -
db:rollback STEP=3reverses all three without error.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1 |
| QA | — |
| Total | 1 |
Assumptions: standard Rails migrator (
database/core/db/migrate/registered atlib/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
| Action | File | What changes |
|---|---|---|
| create | app/core/domains/models/branding_config.rb | belongs_to :organization; validate uniqueness of organization_id; color_tokens hash; https validation for *_url; both-or-neither for font pair |
| create | app/core/domains/models/tenant_domain.rb | belongs_to :organization; validate uniqueness of host (case-insensitive), hostname format, lowercase-on-write |
| create | app/core/domains/models/branding_asset.rb | belongs_to :organization; kind inclusion in %w[logo favicon apple_touch_icon font]; uniqueness of kind scoped to organization_id; https cdn_url |
| create | app/core/domains/models/branding_config_spec.rb, tenant_domain_spec.rb, branding_asset_spec.rb | validation happy/failure paths incl. wrong organization_id |
Implementation steps
- Explore — open
app/core/domains/models/organization.rb(< Models::AbstractModel, associations,store_accessor) and a nearby model spec such asapp/core/domains/models/agent_participant_spec.rbfor the co-located spec style. - Write failing specs (red) — create the three
*_spec.rbbeside the models; assert uniqueness,kindenum, https URL, host lowercasing. Run and confirm red. - Implement models — add associations + validations; enforce
hostlowercasing in abefore_validation. - Go green —
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. - Quality gate —
bundle exec rubocop --no-color.
Acceptance criteria
- Each model is
< Models::AbstractModelandbelongs_to :organization. - Duplicate
organization_idonbranding_configs, duplicatehost, and duplicate(organization_id, kind)are rejected. -
kindoutside the enum and non-https URLs are rejected. - Specs cover happy path, validation failure, and wrong
organization_id.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | — |
| Total | 1.5 |
Assumptions: reuses
Models::AbstractModel(chat DB); validations only (no callbacks/business logic, perAGENTS.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
| Action | File | What changes |
|---|---|---|
| create | app/core/domains/entities/branding.rb | Dry::Struct: tenant, product_name, colors (hash), assets (hash), font (hash, optional), links (hash); plus a default class method returning Qontak defaults |
| create | app/core/domains/builders/branding.rb | maps Models::BrandingConfig + Models::BrandingAsset collection → Entities::Branding (plain entity, not a monad) |
| create | app/apps/whitelabel/constants/color_tokens.rb | ALLOWED_TOKENS = the 7 verified --mp-colors-* keys (RFC §4 / Decision 8); hex regex |
| create | app/core/domains/entities/branding_spec.rb, app/core/domains/builders/branding_spec.rb | builder mapping + default fallback + token rejection |
Implementation steps
- Explore — read an existing
Dry::Structentity underapp/core/domains/entities/and a builder underapp/core/domains/builders/(builder returns a plain entity, repository wraps inSuccess— perAGENTS.md). - Write failing specs (red) — assert builder maps config+assets to the entity,
Entities::Branding.defaultreturns Qontak brand, and an unknown token key / bad hex is rejected. - Implement — the constant allow-list, the entity struct, the builder; keep the builder monad-free.
- Go green —
bundle exec rspec app/core/domains/entities/branding_spec.rb app/core/domains/builders/branding_spec.rb. - Quality gate —
bundle exec rubocop --no-color.
Acceptance criteria
-
Builders::Branding.new(config, assets).buildreturns a fully-populatedEntities::Branding. -
Entities::Branding.defaultreturns the Qontak default payload (productQontak, Qontak tokens/logo). - Token keys outside
ALLOWED_TOKENSand non-#RRGGBBvalues are rejected; removed--mp-colors-text-brand-on-surfaceis not accepted.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | — |
| Total | 1.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
| Action | File | What changes |
|---|---|---|
| create | app/core/domains/services/redis/branding/set_config.rb | key Branding::Config::<org_id>; TTL = 1.hour.to_i (private_constant); REDIS_W.setex(key, TTL, payload.to_json) |
| create | app/core/domains/services/redis/branding/get_config.rb | REDIS_R.get(key); JSON.parse(_, symbolize_names: true) or nil |
| create | app/core/domains/services/redis/branding/set_domain.rb | key Branding::Domain::<host> → organization_id, same TTL |
| create | app/core/domains/services/redis/branding/get_domain.rb | read host→org id |
| create | app/core/domains/services/redis/branding/invalidate.rb | DEL Branding::Config::<org> + DEL Branding::Domain::<host> for each tenant_domains of the org |
| create | co-located *_spec.rb for each | assert setex key+TTL, get parse/nil, invalidate deletes all keys |
Implementation steps
- Explore — open
app/core/domains/services/redis/organizations/set_owner_business_info.rbandget_owner_business_info.rb; copy the class shape,private_constant :TTL, and theCustomLogFormatrescue verbatim. - Write failing specs (red) — stub
REDIS_W/REDIS_R; assertsetex(key, 3600, json)and key format; assertInvalidateenumerates the org'stenant_domainsand deletes each domain key. - Implement — the five services.
- Go green —
bundle exec rspec app/core/domains/services/redis/branding/. - Quality gate —
bundle exec rubocop --no-color.
Acceptance criteria
-
SetConfigwritesBranding::Config::<org_id>viaREDIS_W.setexwith TTL 3600. -
GetConfig/GetDomainreturn the parsed value ornilon miss. -
Invalidatedeletes the config key and everyBranding::Domain::<host>key for the org. - Redis errors are logged via
CustomLogFormatand don't raise.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | — |
| Total | 1.5 |
Assumptions: reuses
REDIS_R/REDIS_Wglobals; 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
SetConfigserializes).
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
| Action | File | What changes |
|---|---|---|
| create | app/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 |
| create | app/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) |
| create | co-located find_branding_spec.rb, resolve_branding_spec.rb | resolve by host; by org id; unknown host→default; flag off→default; wrong-org isolation; cache-hit path |
Implementation steps
- Explore — open
app/core/domains/repositories/users/find_user.rb(repocall→success/failure+ builder) andapp/core/domains/interactors/admin_view_user.rb(contract DSL,def result,yield result_of_validating_params); openapp/core/domains/services/preference_v2.rbforServices::Preference.new.enabled?(:flag, organization_id:). - 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. - Implement repository — org resolution (host lookup via
Models::TenantDomain, else org id), load config + assets, build entity. - Implement interactor — validate params;
Services::Preference.new.enabled?(:whitelabel_branding, organization_id:);Services::Redis::Branding::GetConfig/GetDomain→ on miss call repo andSetConfig/SetDomain; fallback toEntities::Branding.default. - Go green —
bundle exec rspec app/apps/whitelabel/. - Quality gate —
bundle exec rubocop --no-color.
Acceptance criteria
-
ResolveBranding.new(host: "acme.com").resultreturnsSuccesswith the tenant's branding on both cache-hit and cache-miss paths. -
ResolveBranding.new(organization_id: <id>).resultresolves by org. - Unknown host, missing config, and flag-off all return
Success(Entities::Branding.default)(neverFailure). - A request scoped to org A never returns org B's config/assets.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 2.5 |
| QA | 0.5 |
| Total | 3 |
Assumptions: reuses
AbstractIteractor/AbstractRepository+Services::Preference; Plan-1 identifier iscompany_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
| Action | File | What changes |
|---|---|---|
| create | app/apps/whitelabel/repositories/upsert_branding.rb | < Repositories::AbstractRepository; transactional upsert of config + assets (+ optional domains); unique-host violation → failure('host already claimed') |
| create | app/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 |
| create | co-located upsert_branding_spec.rb, create_or_update_branding_spec.rb | create + update; unique-host conflict; cache busted post-commit; audit row written; invalid token/URL rejected |
Implementation steps
- Explore — open a transactional write repository (e.g. under
app/core/domains/repositories/users/,create.rb/update.rb) for thesuccess/failure+ transaction shape; confirm the audit sink (Models::Billing::AuditLogperAGENTS.md, or PaperTrail — §5.5). - Write failing specs (red) — create persists config+assets; update mutates; duplicate
host→Failure('host already claimed'); after commit the Redis config key is absent (invalidated); invalid token →Failure. - Implement repository — wrap upserts in a single AR transaction; rescue
ActiveRecord::RecordNotUnique→failure. - Implement interactor — validate via contract (reuse
ALLOWED_TOKENS+ URL validator from Task 3); callInvalidateonly after a successful commit; write the audit row. - Go green —
bundle exec rspec app/apps/whitelabel/. - Quality gate —
bundle 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
hostgetsFailure('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
| Discipline | Days |
|---|---|
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: single AR transaction (RFC §2.A); audit via
Models::Billing::AuditLogpending §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
| Action | File | What changes |
|---|---|---|
| create | database/core/db/migrate/20260724000004_register_whitelabel_branding_flag.rb | Services::Preference.new.add(:whitelabel_branding, title: 'Whitelabel branding', target: 'feature', author: '<owner>', expires_in: …) |
| extend | app/apps/whitelabel/interactors/resolve_branding_spec.rb | assert flag OFF → default branding, flag ON (org) → resolved branding |
Implementation steps
- Explore — read the "Register a flag" example in
AGENTS.mdandapp/core/domains/services/preference_v2.rb(add/enableAPI). - Write the registration migration — call
Services::Preference.new.add(...)for:whitelabel_branding(targetfeature). - Extend resolver spec — enable/disable via
Flipper.enable(:whitelabel_branding)/disableinbefore/after; assert branch behavior. - Migrate + test —
RAILS_ENV=test bundle exec rails app:db:migratethenbundle exec rspec app/apps/whitelabel/. - Quality gate —
bundle exec rubocop --no-color.
Acceptance criteria
-
whitelabel_brandingflag is registered and toggleable per organization. - Resolver spec proves flag OFF → default branding and flag ON (org) → tenant branding.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | — |
| Total | 0.5 |
Assumptions: reuses
Services::Preference(Flipper + Redis); flag registration lives in a migration/admin task (not app code), perAGENTS.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
| Action | File | What changes |
|---|---|---|
| create | app/mailers/concerns/brandable.rb | mailer concern: given organization_id, sets @branding = Interactors::Whitelabel::ResolveBranding.new(organization_id:).result value (always a payload — default Qontak on miss) |
| create | app/views/layouts/_branding_header.html.erb | shared header partial: renders @branding.assets[:logo] as the <img> with alt = @branding.product_name; falls back to Qontak logo if @branding absent |
| extend | app/views/forgot_password_mailer/send_forgot_pass_mail.html.erb (L15) | replace hardcoded <img … alt="Qontak Logo"> with render 'layouts/branding_header' |
| extend | app/views/onboarding_request_mailer/activation_reminder.html.erb (L110-112), onboarding_request_mailer/client_reminder.html.erb (L145-146) | same replacement |
| extend | remaining 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 |
| extend | the owning mailers in app/mailers/*.rb | include Brandable; pass the recipient's organization_id when composing the mail |
| create | co-located mailer specs (e.g. spec beside each mailer, per repo convention) | assert branded logo when config present; Qontak fallback when absent |
Implementation steps
- Explore — open
app/views/forgot_password_mailer/send_forgot_pass_mail.html.erb:15(thealt="Qontak Logo"<img>), the bare shared layoutapp/views/layouts/mailer.html.erb, and one mailer (e.g.app/mailers/forgot_password_mailer.rb) to see howorganization_id/recipient context is available. - Write failing specs (red) — for a representative mailer, assert the rendered HTML contains the tenant
logoURL + product name when branding is configured, and the Qontak default logo when not. - Build the concern + partial —
Brandableresolves branding (reusing T5'sResolveBranding, which always returns a payload incl. default);_branding_header.html.erbrenders logo + product name. - Migrate views — replace each hardcoded Qontak-logo
<img>in the customer-facing mailers withrender 'layouts/branding_header'; keep inline styles. - Go green —
bundle exec rspec specfor the touched mailers. - Quality gate —
bundle exec rubocop --no-color.
Acceptance criteria
- A shared
_branding_headerpartial renders@branding.assets[:logo]withalt= 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
| Discipline | Days |
|---|---|
| Backend | 2.5 |
| QA | 0.5 |
| Total | 3 |
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 recipientorganization_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 (
ResolveBrandinginteractor). 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 /brandingendpoint 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_coreand 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 recipientorganization_idbefore migrating it, and leave internal/ops mail (which may intentionally stay Qontak-branded) to the follow-on. - Push externally in parallel: the
hub_serviceGET /brandingendpoint 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
| Story | Reason |
|---|---|
B7 — GET /branding HTTP endpoint | Out 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 consumption | Out 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 UI | Out of scope (RFC §1) — this breakdown builds the write path (T6), not its screens. |
| Asset upload UX | Out of scope (RFC §1) — assets referenced by CDN URL; reuses the existing uploader. |