Skip to main content

RFC: Unified Branding Service (Whitelabel) — hub_core backend

Document Conventions (do not remove)

This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory. Mark sections N/A — reason when truly inapplicable rather than deleting them.

It is also agent-execution-ready: the §1 PRD-to-Schema Derivation, §2 Repo Reading Guide (Detail 2.0), mermaid diagrams, and §4 Agent Execution Plan + Verification & Rollback Recipe must be complete before §7 Ready for agent execution: yes.

The YAML frontmatter is the machine-readable index; the Metadata table is the human-readable governance record. Both agree on every shared field.

Metadata

FieldValueNotes
StatusIDEAIDEA / RFC / ABANDON / AGREED
OwnerQontak Chat — Chat Panel 2Team owning the RFC (service-metadata.yaml:1 teamName: "Chat panel 2")
Author(s)A. Firdha Shafridhi (Saf)Author of the source design doc
Reviewershub_core · hub_service · FE (to assign)Tech reviewers across affected squads
Approver(s)Tech lead + infosec (to assign)Public unauthenticated endpoint requires infosec sign-off
Submitted Date2026-07-23ISO-8601
Last Updated2026-07-23ISO-8601; bump on every material edit
Target Release2026-Q3 (proposed)Confirm in §5
Related DocumentsWhitelabel — Unified Branding Service (Design)Source design doc (Confluence)
DiscussionTBASlack thread to be linked

Type: backend Sub-type: new-feature

Sections at a Glance

  1. Overview (incl. §1 PRD-to-Schema Derivation — entities, business rules, contracts; no Figma)
  2. Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture & Service Map → Sequence Diagrams → DDL → APIs → integrity / concurrency / async specs)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (incl. §4 Agent Execution Plan + Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

1. Overview

Qontak Chat's frontends (hub-chat, crm-fe-v3) and its backends (hub_core / hub_service / qontak.com) currently hard-code Qontak brand — colors, product name, logos, favicons, support/legal links — across config files, i18n strings, and literals (design doc §8: hub-chat alone carries 159 Qontak literals). Whitelabel resale and per-tenant branding are impossible without per-service code changes.

This RFC specifies the backend half of a Unified Branding Service: a single source of truth that, given a tenant, returns that tenant's brand (color tokens, logo/favicon URLs, product name, font, links). Every consumer asks one contract — GET /branding — and applies the answer. The design doc's key insight is that hub-chat and crm-fe-v3 both render through the same @mekari/pixel3 --mp-colors-* CSS variables, so one set of token overrides themes both apps with no per-component logic; unification is therefore a thin consumption layer over what already exists, not a rewrite.

Scope boundary this RFC commits to (grounded in the repo): hub_core is a Rails Engine / domain library with no HTTP layer of its ownconfig/routes.rb is empty and config.api_only = true is set at lib/hub_core/engine.rb:118, with no Grape or controllers present. Therefore this RFC's verifiable, in-repo work is the hub_core domain layer: the persistence schema, models, the tenant→branding resolver (interactor + repository), the Redis cache service, and the payload entity/builder. The HTTP endpoint GET /branding (routing, public/unauth gate, edge allow-listing) is owned by the separate hub_service repo (API::Core::V1 / API::Internal::V1, per the workspace migrate-core-to-iag convention) which is not checked out here — it is tracked as a cross-repo dependency (§2.D, §5), not designed line-by-line.

Success Criteria

  1. Given a resolvable tenant (by Host or by company/org identifier), the resolver returns a complete branding payload (productName, colors token map, assets, font, links) in < 20 ms p99 on a cache hit and < 60 ms p99 on a cache miss (single indexed DB read + Redis write), measured in hub_core interactor benchmarks.
  2. A tenant with no branding config resolves to default Qontak branding (policy fallback, not an error) — zero 5xx for unknown/unbranded tenants.
  3. An admin branding update is reflected on the next request after cache bust (staleness window ≤ TTL = 1 h, or immediate on invalidation).
  4. Color tokens returned are verified real --mp-colors-* Pixel3 tokens (design doc §4 table); unknown token keys are rejected at write time.
  5. Zero PII in the payload (color, logo URL, product name, links only) — enabling the public/unauthenticated contract (infosec-approved).

Out of Scope

  • The GET /branding HTTP endpoint implementation and edge/ingress allow-listing — owned by hub_service (cross-repo; see §2.D and §5).
  • The frontend applyBranding consumers (hub-chat, crm-fe-v3, legacy hub), the --mp-colors-* injection, favicon/font swap, and the Pixel3 data-panda-theme=next rollout — separate FE RFC(s) / squads (design doc §4b, §5). This RFC only defines the contract they consume.
  • Legacy hub (bootstrap-vue + @mekari/pixel v1) theming — not driven by --mp-colors-*; explicitly not themeable by this service (design doc §4b).
  • Admin UI to author branding — a follow-up; this RFC provides the write path (create/update + invalidation) but not its screens.
  • Asset upload UX — assets are referenced by CDN URL; this RFC reuses the existing uploader/CDN mechanism but does not build an upload flow.
  • Source design: Whitelabel — Unified Branding Service (Design) — Confluence, space QON.
  • hub_core AGENTS.md (architecture, layers, conventions, feature flags, Redis globals).
  • hub_core docs/architecture/flows/core/multi-tenancy/README.md (org-scoping).

Assumptions

  1. hub_service (or qontak.com) will expose GET /branding and call the hub_core ResolveBranding interactor — the standard "HTTP in hub_service, logic in hub_core" split (per migrate-core-to-iag). Unverified in this checkout → §5 Open Question.
  2. The existing Models::Organization (table organizations, UUID PK — organization.rb:3, spec/dummy/db/schema.rb:1282) is the canonical tenant. Branding keys off organization_id; no new tenant table is introduced (aligns with design doc Decision #2).
  3. Plan 2 (Host-based resolution) requires a Host → organization mapping; the design doc models this as tenant_domain. No such mapping exists today → new table.
  4. Assets (logo/favicon/apple-touch-icon/font CSS) are hosted on the existing S3 (AWS_BUCKET) / Alibaba OSS CDN (OSS_CDN_URL, env.example) already used by Repositories::Uploaders::OrganizationAvatarUploader.
  5. Redis (REDIS_R/REDIS_W, config/initializers/redis.rb:5,11) is the cache tier.

Dependencies

DependencyOwner / repoAvailabilityNotes
GET /branding HTTP endpoint + public/unauth routehub_service (API squad)needs buildingGrape API::Core::V1/API::Internal::V1; calls hub_core interactor
Edge/ingress allow-list of /branding unauthenticated on every tenant domainPlatform / DevOpsneeds buildingDesign doc Decision #3 follow-through
applyBranding FE composable + --mp-colors-* injectionFE squads (hub-chat, crm-fe-v3)needs buildingSeparate FE RFC; consumes this contract
Pixel3 data-panda-theme=next rollout on hub-chat migrated routesFE squadpartialPrerequisite for full component theming on hub-chat (design doc §4b)
Models::Organizationhub_core (this repo)existsapp/core/domains/models/organization.rb:3
Redis globals REDIS_R/REDIS_Whub_core (this repo)existsconfig/initializers/redis.rb:5,11
S3/OSS CDN + AbstractUploaderhub_core (this repo)existsapp/core/domains/repositories/uploaders/organization_avatar_uploader.rb
Feature flags Services::Preferencehub_core (this repo)existsapp/core/domains/services/preference_v2.rb:20-76

PRD-to-Schema Derivation (backend-specific — required)

The source is a design doc, not a numbered PRD with §13b user stories. The table below derives persistence/exposure/enforcement from the design doc's described entities and rules. Sections cited are the design doc's numbered sections.

Design-doc entity / attribute / rulePersisted as (table.column)Exposed via (endpoint / interactor)Enforced whereSource
A tenant maps to an existing organization (organization_id ties to organization)branding_configs.organization_id uuid (FK→organizations.id, unique)ResolveBranding interactor → GET /branding (hub_service)FK + unique index; interactor validates organization_iddoc §3, Decision #2
A tenant can own several domains (Plan 2 Host resolution)tenant_domains(organization_id uuid, host citext unique, is_primary bool)resolver: host → organization_idunique index on host; resolver lookupdoc §2, §3
Product name per tenantbranding_configs.product_name stringpayload productNamenot-null default = 'Qontak'doc §4, §6
Brand color token map (--mp-colors-*, returned verbatim)branding_configs.color_tokens jsonbpayload colorswrite-time allow-list of verified token keys + hex-value validationdoc §4, Decision #1
Assets: logo, favicon, appleTouchIcon (CDN URLs)branding_assets(organization_id, kind, cdn_url); unique (organization_id, kind)payload assets.{logo,favicon,appleTouchIcon}kind enum check; URL https-only validationdoc §3, §4
Optional per-tenant fontbranding_configs.font_family string, branding_configs.font_css_url stringpayload font.{family,cssUrl}https-only URL validation; both-or-neitherdoc §4, §5
Support / legal linksbranding_configs.support_url, branding_configs.legal_urlpayload links.{support,legal}https-only URL validationdoc §4
Resolved payload cached per tenant; O(1) lookupsRedis Branding::Config::<org_id> (+ Branding::Domain::<host>)cache service set/getTTL constant; bust on writedoc §2, §7
Cache invalidated on brand update(no column) cache-bust side effectInvalidateBranding servicecalled by write interactordoc §7
Payload is public, no PII(design constraint — no column)endpoint unauth (hub_service)infosec review; validation forbids new sensitive fieldsdoc §4, Decision #3
Unknown/unbranded tenant → default Qontak brand(absence of row)resolver returns default entityresolver fallback branchdoc §6 (implied), §8

Every §2.3 DDL row and every resolver behavior traces to a row above.

Detail 1.A — PRD Traceability Matrix

Forward (design doc → RFC):

Design-doc requirementService / interactor / jobRFC section
§1 One service owns branding; consumers ask "who is this tenant & their brand?"ResolveBranding interactor (hub_core) + GET /branding (hub_service)§2 Topology, §2.1, §2.4
§2 Two resolution signals (Host / company code) one code pathResolveBranding (host: or organization_id:/company_id:)§2.2 seq, §2.4
§3 Data model (tenant→config→assets, domains)3 tables§2.3 DDL
§4 GET /branding contract + verified color tokenspayload entity/builder + endpoint§2.4, §2.E
§4b Theming reaches components only under data-panda-theme=nextFE prerequisite (cross-squad)§2.D, §5
§5 One applyBranding composableFE (out of scope)§1 Out of Scope
§7 Caching & invalidationRedis cache service + invalidation§2 Decision 3, §2.C
§8 Replaces per-service brand literalscontract consumers (FE/BE)§1 Overview
Decisions #1–#3 (tokens verbatim / owner / public-no-auth)ADRs§2 Decisions 1, 8, 9, 10

Reverse (RFC → design doc):

New table / interactor / decisionDesign-doc need it serves
tenant_domains tablePlan 2 Host→tenant resolution (§2, §3)
branding_configs.color_tokens jsonbverbatim --mp-colors-* payload (§4, Decision #1)
ResolveBranding interactorsingle resolution code path (§2)
InvalidateBranding cache serviceinvalidate on brand update (§7)
Default-branding fallback branchunbranded tenants keep working (§6/§8)

UI / Consumer Surface Coverage

Consumer surfaceConsumerRequired readsRequired writesStatus surface
App boot (any tenant domain)web (hub-chat, crm-fe-v3)GET /brandingn/acolors/assets/productName in payload
Legacy SPA bootweb (hub)GET /branding (payload ignored for theming)n/an/a — not themeable (doc §4b)
Server-rendered brand (email / PDF templates)backendResolveBranding interactor (in-process) or GET /brandingn/apayload fields
Admin branding editorsupport/admin tool (future)read configCreateOrUpdateBranding (write path)config fields

Role Coverage

RoleAuthorization mechanismEndpoints permittedCross-tenant?Audit trail
Anonymous / pre-login visitornone (public endpoint, no PII)GET /branding (read)no — resolves to exactly one tenant by Host/companyrequest log only (no PII)
Backend service (email/PDF renderer)in-process interactor call (no HTTP auth)ResolveBrandingscoped by passed organization_idCustomLogFormat structured log
Admin / support (branding author)hub_service JWT scope (out of repo)write path (CreateOrUpdateBranding)own tenant onlyModels::Billing::AuditLog or PaperTrail (see §3)

The admin authorization mechanism is enforced in hub_service (not verifiable here) → §5.

PRD Section Coverage

Design-doc sectionTitleWhere covered
1Architecture§2 Infrastructure Topology, §2.1
2Tenant resolution & theming§2.2 sequence diagrams
3Data model§2.3 DDL + §2.1 erDiagram
4API contract GET /branding§2.4 APIs, §2.E State Surface
4bOverride reaches components (next-theme)§2.D Responsibility Boundary, §5 (FE prerequisite)
5Consumer composable applyBrandingn/a — frontend (out of scope §1)
6Proof: two tenants, same code§2.2 (resolution), §2.4 (payload examples)
7Caching & invalidation§2 Decision 3, §2.C async/invalidation
8What this replaces per service§1 Overview (context only)
DecisionsResolved decisions #1–#3§2 Technical Decisions (1, 8, 9, 10)

Detail 1.B — Key Decisions Summary (full ADR treatment in §2)

#DecisionChosen option§2 block
1Storage: branding schema shapeNormalized tables in core chat DB, keyed off existing organizationsDecision 1
2Sync vs async on readSynchronous, cache-first read; no workerDecision 2
3Caching + invalidationRedis setex (config + host→org keys), TTL 1 h, bust on writeDecision 3
4Asset hosting / third-partyReuse existing S3/OSS CDN + AbstractUploader; store CDN URL onlyDecision 4
5Consistency modelEventual (bounded by TTL); write busts cacheDecision 5
6Multi-tenancy isolationResolve to single org; public payload has no PIIDecision 6
7Reuse vs newNew tables + app/apps/whitelabel module; reuse Organization/Redis/uploader/PreferenceDecision 7
8Color token storagecolor_tokens jsonb stored verbatim (vs discrete columns + derive)Decision 8
9Where the HTTP endpoint liveshub_service Grape endpoint calling hub_core interactorDecision 9
10Endpoint authPublic / unauthenticatedDecision 10

Detail 1.C — Per-Story Change Map (RFC-derived; no formal PRD §13b)

The source is a design doc without a user-story list. Stories below are RFC-derived milestones covering the in-repo (hub_core) scope; cross-repo work (hub_service endpoint, FE consumers) is marked Cross-squad.

Story #Story titleLayer scopeChanges (concrete BE artifacts)Acceptance criteria (verifiable)RFC anchors
B1Persistence schemaBE-onlymigrations create_branding_configs, create_tenant_domains, create_branding_assets in database/core/db/migrate/; models Models::BrandingConfig, Models::TenantDomain, Models::BrandingAssetRAILS_ENV=test rails app:db:migrate succeeds; tables exist with UUID PK + unique indexes (organization_id, host, (organization_id,kind)); model specs green§2.3 · §4.C chunk 1–2 · §1 PRD-to-Schema rows 1-8
B2Payload entity + builder + token validationBE-onlyEntities::Branding (Dry::Struct), Builders::Branding, token allow-list constant + hex validatorrspec: builder maps config+assets→entity; invalid token key rejected; hex regex enforced§2.4 · §2.E · §4.C chunk 3
B3Redis cache service (set/get/invalidate)BE-only + RuntimeServices::Redis::Branding::SetConfig, GetConfig, SetDomain, GetDomain, Invalidate following SetOwnerBusinessInfo patternrspec: setex key + TTL asserted; get returns parsed hash / nil; invalidate deletes config + all domain keys for org§2 Decision 3 · §2.C · §4.C chunk 4
B4Resolve interactor (Plan 1 + Plan 2 + fallback)BE-onlyInteractors::Whitelabel::ResolveBranding + Repositories::Whitelabel::FindBranding (Dry::Monads Success/Failure)rspec: resolves by host; resolves by organization_id; unknown host → Success(default_branding); wrong org isolation§2.1 · §2.2 · §4.C chunk 5
B5Write path + cache invalidationBE-onlyInteractors::Whitelabel::CreateOrUpdateBranding + repo; calls Invalidate after commitrspec: create/update persists; audit row written; cache busted (Redis key absent post-write)§2.C · §3 · §4.C chunk 6
B6Feature flag gateConfigregister whitelabel_branding flag via Services::Preference; resolver honors org-scoped flagrspec: flag off → default branding; flag on (org) → resolved branding§2 Decision 6 · §4.A · §4.C chunk 7
B7GET /branding HTTP endpoint (public)Cross-squadGrape endpoint in hub_service API::Core::V1/API::Internal::V1 calling ResolveBranding; edge allow-listn/a — covered in hub_service RFC (to be created; §5 Open Question)§2.4 · §2.D
B8FE applyBranding consumptionCross-squadapplyBranding composable, --mp-colors-* injection, next-theme rolloutn/a — covered in FE RFC (out of scope §1)§2.D · §5

Coverage rule satisfied: every derived story has exactly one row; cross-repo stories say n/a — covered in <other RFC>.


2. Technical Design

Infrastructure Topology (start here)

Deployment topology

flowchart TB
internet([Browser on tenant domain]) -->|HTTPS| edge["Edge / Ingress\n(maps Host→tenant; allow-lists /branding unauth)"]
edge -->|HTTP| svc["hub_service API pods ×N\n(Grape API::Core::V1 / API::Internal::V1)\n[separate repo]"]
svc -->|in-process gem call| core["hub_core engine\n(ResolveBranding interactor)"]
core -->|GET cache| redis[("Redis (REDIS_R/REDIS_W)\nRedis::Namespace")]
core -->|read on miss| dbR[("Postgres replica\n(AbstractModelReplica)")]
core -->|write path only| dbW[("Postgres primary 'chat'\n(AbstractModel)")]
internet -->|GET logo/favicon/font| cdn[("Asset CDN\nS3 AWS_BUCKET / Alibaba OSS_CDN_URL")]
beRender["Backend renderers\n(email / PDF templates)"] -->|in-process| core

Nodes correspond to real infra: hub_service (separate repo, per migrate-core-to-iag), hub_core engine (this repo), Redis (config/initializers/redis.rb), Postgres primary + replica (AbstractModel / AbstractModelReplica, AGENTS.md "Six databases"), CDN (OSS_CDN_URL / AWS_BUCKET, env.example).

Per-service responsibility

flowchart LR
subgraph hs["hub_service (API squad — separate repo)"]
ep1["GET /branding\n(public, unauth)\n(use case: serve brand)"]
ep2["POST/PUT /admin/branding\n(authz'd — future)\n(use case: author brand)"]
end

subgraph hc["hub_core (this repo — domain logic)"]
uc1["Interactors::Whitelabel::ResolveBranding\n(resolve + cache + fallback)"]
uc2["Interactors::Whitelabel::CreateOrUpdateBranding\n(persist + invalidate)"]
cache["Services::Redis::Branding::*\n(set/get/invalidate)"]
repo["Repositories::Whitelabel::FindBranding"]
end

ep1 --> uc1
ep2 --> uc2
uc1 --> cache
uc1 --> repo
uc2 --> repo
uc2 --> cache
repo -->|"ActiveRecord"| db[(Postgres 'chat')]
cache -->|"Redis::Namespace"| redis[(Redis)]
uc1 -->|"asset CDN URLs (stored strings)"| cdn([Asset CDN])

Protocols: browser→edge HTTPS; edge→hub_service HTTP; hub_service→hub_core in-process Ruby (gem); hub_core→Postgres AR; hub_core→Redis redis-namespace.


Technical Decisions (ADR-format — the engineering heart)


Decision 1: Branding storage — normalized tables keyed off organizations

Context Branding must persist per tenant: a 1:1 config, a 1:N set of resolvable domains (Plan 2), and a small set of assets. The org model already carries a ~120-key settings jsonb (organization.rb:20-190). Do we add branding into that jsonb or create dedicated tables?

Options considered

  • Option A — Dedicated normalized tables (branding_configs, tenant_domains, branding_assets), FK → organizations.id.
    • Pros: tenant_domains.host needs a unique index for O(1) Host resolution — natural as a table; 1:N domains/assets don't fit one jsonb; clean validation & querying; no bloat on the hot organizations row.
    • Cons: 3 new migrations + models; a join/2-3 reads on cache miss.
  • Option B — Store branding inside organizations.settings jsonb (store_accessor :settings, :branding).
    • Pros: no new tables; reuses existing model.
    • Cons: cannot index host for reverse lookup (Plan 2 would full-scan jsonb across all orgs); mixes public branding with sensitive settings on a hot, widely-loaded row; 1:N domains awkward; every org read pays branding weight.

Decision: Option A.

Rationale Plan 2 resolution is host → organization_id, which demands an indexed unique lookup by host — impossible to do efficiently inside a per-org jsonb. Keeping branding off the hot organizations row also avoids inflating a record read on nearly every request path. New tables live in the core chat DB via the registered migrator (lib/hub_core/engine.rb:122, database/core/db/migrate/).

Consequences Three new tables/models to maintain; cache-miss path reads config + assets + (for Plan 2) a domain row. Mitigated by caching the fully-assembled payload (Decision 3).

Reversibility Moderate. Collapsing into jsonb later is a backfill + model change; the resolver interface (ResolveBranding) stays stable, so consumers are unaffected. Cost: one migration + backfill job.


Decision 2: Read path is synchronous and cache-first (no async worker)

Context GET /branding is on the app-boot critical path for every tenant page load. Does resolution need any async/background processing?

Options considered

  • Option A — Synchronous, cache-first read.
    • Pros: simplest; sub-ms on hit; the only work on miss is a couple of indexed reads + a Redis write; no queue infra; no eventual-visibility surprises for a read.
    • Cons: cache-miss latency is in the request path (bounded, small).
  • Option B — Precompute/warm payloads via a Sidekiq worker on write.
    • Pros: cache-miss becomes rare.
    • Cons: added worker + idempotency + failure modes for a read that is already cheap; premature.

Decision: Option A (sync). Async precompute marked n/a — not needed for reads; the only background-ish action is cache invalidation on write, done inline (Decision 3), not queued.

Rationale The miss cost is one indexed host/organization_id read plus one branding_configs + branding_assets read — well under the 60 ms p99 target. Queuing adds operational surface for no measurable gain (design doc §7 already frames this as O(1) cache lookups).

Consequences First request per tenant after a cache expiry/bust pays the DB read. Acceptable and bounded.

Reversibility Trivial — add a warm-cache worker later without changing the read contract.


Decision 3: Caching strategy — Redis setex, two key families, bust-on-write

Context Design doc §7 mandates per-tenant caching with invalidation on update, plus an HTTP Cache-Control at the edge. We must pick keys, TTL, invalidation, and stampede handling, consistent with the repo's Redis conventions.

Options considered

  • Option A — Two Redis key families following the repo's Set/GetOwnerBusinessInfo pattern (app/core/domains/services/redis/organizations/set_owner_business_info.rb): Branding::Config::<organization_id> → serialized payload; Branding::Domain::<host>organization_id. REDIS_W.setex(key, TTL, json); TTL = 1 h. Bust both on write.
    • Pros: matches existing idiom exactly; O(1) Host resolution and O(1) payload fetch; namespaced per env automatically (Redis::Namespace).
    • Cons: two keys to invalidate; must enumerate an org's hosts on bust.
  • Option B — Single payload key only (Branding::Config::<org>), resolve Host→org from DB every request.
    • Pros: one key.
    • Cons: Host resolution hits DB on every request (defeats §7); worse p99.

Decision: Option A. TTL = 1.hour.to_i (constant, private_constant like SetOwnerBusinessInfo::TTL). Invalidation: Services::Redis::Branding::Invalidate deletes Branding::Config::<org> and each Branding::Domain::<host> for the org's tenant_domains. Edge Cache-Control: public, max-age=300 on the endpoint (hub_service). FE may also keep last payload in localStorage (design doc §7) — FE concern.

Rationale Directly implements design doc §7 with the repo's proven Redis idiom (org-scoped string key + setex). Stampede risk is low (cheap read, short repopulate); if a hot tenant ever thundering-herds, add a set nx: guard as in services/uploader/broadcast_media.rb:29 — noted, not built.

Consequences Invalidation must enumerate tenant_domains for the org (bounded, small). Two key families to reason about. A missed invalidation self-heals within TTL (1 h).

Reversibility Trivial — change TTL constant or collapse to one key; no schema/contract impact.


Decision 4: Asset hosting — reuse existing S3/OSS CDN; store CDN URL only

Context Payload returns assets.{logo,favicon,appleTouchIcon} and font.cssUrl as CDN URLs (design doc §3/§4). How are assets hosted?

Options considered

  • Option A — Reuse the existing uploader + CDN (Repositories::Uploaders::OrganizationAvatarUploader < AbstractUploader, S3 AWS_BUCKET / Alibaba OSS_CDN_URL). Store only the resulting cdn_url string in branding_assets.
    • Pros: zero new infra; org avatars already ship this way; DB stays a pointer store (design doc §3: "adding a customer never needs a rebuild").
    • Cons: upload flow (admin) is separate work (out of scope).
  • Option B — New dedicated asset service / bucket.
    • Pros: isolation.
    • Cons: new infra + credentials for no benefit; duplicates a working mechanism.

Decision: Option A — store CDN URLs; reuse the existing uploader/CDN for producing them.

Rationale The repo already hosts org assets on S3/OSS with a CDN URL (organization_avatar_uploader.rb, OSS_CDN_URL). Branding assets are the same shape.

Consequences branding_assets.cdn_url is a plain string; validation must enforce https + allowed host. The admin upload UX is a follow-up.

Reversibility Trivial — swap CDN host or move to a new uploader without contract change.


Decision 5: Consistency model — eventual, bounded by TTL, write busts cache

Context Branding is read-heavy, write-rare. What consistency do consumers get after an admin edit?

Options considered

  • Option A — Eventual, bounded by TTL, with bust-on-write.
    • Pros: cheap; near-immediate after bust; stale window ≤ TTL if a bust is missed.
    • Cons: not strictly transactional across cache + DB.
  • Option B — Strong (no cache / write-through only).
    • Pros: always fresh.
    • Cons: every read hits DB; contradicts §7; higher p99.

Decision: Option A.

Rationale Branding changes are infrequent and non-critical to correctness of money/security flows; a sub-hour, usually-sub-second staleness is acceptable (Success Criteria #3). Matches design doc §7.

Consequences A dropped invalidation shows stale brand for ≤ 1 h. Acceptable; self-healing.

Reversibility Trivial — lower TTL toward 0 for stronger freshness at a read-cost tradeoff.


Decision 6: Multi-tenancy isolation — resolve to one org; public payload carries no PII

Context The endpoint is public/unauth (Decision 10). How do we guarantee one tenant never sees another's brand, and that "public" is safe?

Options considered

  • Option A — Resolution maps a request to exactly one organization_id (Host→domain→org, or explicit org/company id); the resolver only ever returns that org's rows, gated by an org-scoped feature flag; payload restricted to non-PII brand fields by the entity/builder allow-list.
    • Pros: structural isolation (one row set per resolved org); "public" is safe because the entity cannot carry PII.
    • Cons: relies on correct resolution + entity discipline.
  • Option B — Return brand for any org id passed by the client.
    • Pros: simpler.
    • Cons: enumerable; still no PII but leaks tenant existence/branding arbitrarily.

Decision: Option A. Isolation enforced at resolution (single org) + the Entities::Branding allow-list (only productName, colors, assets, font, links). New sensitive fields are forbidden by validation (Success Criteria #5, design doc Decision #3 follow-through).

Rationale Design doc Decision #3 states the payload is world-readable by design; safety comes from what the entity can contain, not from auth. Structurally binding the response to one resolved org prevents cross-tenant mixing.

Consequences The Entities::Branding shape is a security boundary — reviews must reject any PII addition. Documented in §3 Security.

Reversibility N/A conceptually; if branding ever needs auth, add it at the hub_service layer without changing hub_core.


Decision 7: Reuse vs new — new module + tables; reuse platform primitives

Context Where does the code live and what is reused?

Decision

  • New: app/apps/whitelabel/ module (interactors/, repositories/, services/, builders/, entities/) per the repo's app-slice convention (e.g. app/apps/launchpad); 3 new models under app/core/domains/models/; Redis services under app/core/domains/services/redis/branding/.
  • Reuse: Models::Organization; Interactors::AbstractIteractor + Repositories::AbstractRepository (Dry::Monads); REDIS_R/REDIS_W; AbstractUploader/CDN; Services::Preference flags; CustomLogFormat logging.

Rationale / Consequences / Reversibility: standard for this codebase (AGENTS.md file-placement tree); no alternative seriously considered — no alternative considered — repo conventions are fixed.


Decision 8: Color token storage — color_tokens jsonb stored verbatim

Context Design doc §4 returns --mp-colors-* tokens verbatim ("no mapping table per app", Decision #1). Several tokens equal the brand color, but background-brand-hovered is a distinct darker shade (#6A1FE0 vs brand #7A2FF2) — i.e. not purely derivable from one column. The doc's ER used discrete color columns; the API uses a token map. Which do we store?

Options considered

  • Option A — Store the token map verbatim as color_tokens jsonb, validated at write time against an allow-list of the verified token keys (design doc §4 table).
    • Pros: DB == API (no lossy derive); handles the non-derivable hover/bold shades; adding a token key later needs no migration; matches Decision #1 ("as-is").
    • Cons: jsonb is schemaless → must validate keys+values on write.
  • Option B — Discrete semantic columns (color_brand, color_accent, color_on_brand) + derive the token map in the builder.
    • Pros: typed columns; matches the doc's ER literally.
    • Cons: hover/bold shades aren't deterministically derivable (would need a shade algorithm and a place to store overrides anyway); more migrations to add tokens.

Decision: Option A — color_tokens jsonb, with a write-time allow-list of verified keys and a hex-color value validator. This deviates from the design doc's discrete-column ER — justified because the hover/bold shades are independent values the doc itself hardcodes, so discrete columns would be lossy or require a shade algorithm the doc doesn't specify.

Rationale Keeps the store 1:1 with the verbatim contract (Decision #1) while making validation the safety net that jsonb requires (also the public-payload guard, Decision 6).

Consequences Validation logic (allow-list + hex regex) is mandatory and security-relevant. The allow-list is the verified token set: --mp-colors-brand-qontak, --mp-colors-background-brand, --mp-colors-background-brand-hovered, --mp-colors-background-brand-bold, --mp-colors-border-brand, --mp-colors-icon-brand, --mp-colors-text-inverse (design doc §4; the removed --mp-colors-text-brand-on-surface is not allowed).

Reversibility Moderate — normalizing into columns later is a backfill; the builder/entity shape stays constant, so the payload contract is unaffected.


Decision 9: HTTP endpoint lives in hub_service, logic in hub_core

Context Design doc Decision #2 says the service is "owned by qontak.com". But hub_core (this repo) is a domain engine with no HTTP layer (config/routes.rb empty; config.api_only = true at lib/hub_core/engine.rb:118; no Grape/controllers). Where does GET /branding get served?

Options considered

  • Option A — HTTP endpoint in hub_service (API::Core::V1 / API::Internal::V1) calling the hub_core ResolveBranding interactor in-process — the established split (migrate-core-to-iag).
    • Pros: matches how every other HTTP surface works here; keeps hub_core a pure library; testable via interactor specs in this repo.
    • Cons: work spans two repos; hub_service not checked out → not verifiable in this RFC.
  • Option B — Introduce an HTTP layer into hub_core.
    • Pros: single repo.
    • Cons: violates the engine's api_only library design; no controller/Grape scaffolding exists; contradicts every existing pattern.

Decision: Option A.

Rationale The repo is unambiguous — hub_core exposes no routes and is mounted as a gem. The branding logic belongs here; the endpoint belongs in the API service.

Consequences This RFC's in-repo deliverables stop at the interactor boundary. The endpoint (routing, public gate, edge allow-list) is a cross-repo dependency (§2.D, §5) requiring a companion hub_service change (and likely its own RFC). The interactor is designed to be endpoint-agnostic (accepts host: or organization_id:/company_id:).

Reversibility N/A — reflects a fixed architectural boundary.


Decision 10: Endpoint is public / unauthenticated

Context Branding must render before login on a customer domain (design doc Decision #3).

Options considered

  • Option A — Public, unauthenticated GET /branding (no PII in payload).
    • Pros: works pre-login; cacheable at edge; no token needed.
    • Cons: world-readable → payload must never carry sensitive data.
  • Option B — Require a token / session.
    • Pros: access-controlled.
    • Cons: impossible pre-login; defeats the whole "theme the login page" goal.

Decision: Option A (public), gated by the no-PII entity boundary (Decision 6) and infosec review.

Rationale / Consequences / Reversibility Directly per design doc Decision #3. Consequence: infosec approval required (Metadata Approvers); edge must allow-list /branding unauthenticated on every tenant domain (§2.D dependency). Reversible by adding auth at hub_service if the payload scope ever changes.


Detail 2.0 — Repo Reading Guide (read this first)

Repo Map (mermaid)

flowchart LR
subgraph apps["app/apps/whitelabel/ (NEW)"]
inter["interactors/resolve_branding.rb\ninteractors/create_or_update_branding.rb"]
repo["repositories/find_branding.rb"]
end
subgraph core["app/core/domains/"]
models["models/branding_config.rb\nmodels/tenant_domain.rb\nmodels/branding_asset.rb (NEW)"]
ent["entities/branding.rb (NEW)"]
bld["builders/branding.rb (NEW)"]
redis["services/redis/branding/*.rb (NEW)"]
org["models/organization.rb (EXISTING anchor)"]
end
subgraph infra["infra"]
db[(Postgres 'chat')]
rds[(Redis REDIS_R/REDIS_W)]
cdn[(S3/OSS CDN)]
end
inter --> repo --> models --> db
inter --> redis --> rds
repo --> bld --> ent
models -. FK .-> org
ent -. asset urls .-> cdn

Existing Code Anchors

PathWhy the agent reads itWhat pattern it teaches
app/core/domains/models/organization.rbThe tenant anchor branding keys offModels::X < Models::AbstractModel, store_accessor :settings, mount_uploader :avatar, ES mappings, flipper_id
spec/dummy/db/schema.rb (L1282-1314)Authoritative organizations shapeUUID PK gen_random_uuid(), settings jsonb default {}, company_id unique, sso_id uuid
database/core/db/migrate/20200417051101_create_organizations.rbHow the org table was createdcreate_table :organizations, id: :uuid
database/core/db/migrate/20260624000001_create_direct_send_message_histories.rbRecent migration templateActiveRecord::Migration[6.1], id: :uuid, t.uuid :organization_id, null: false, t.jsonb, t.timestamps, add_index
app/core/domains/services/redis/organizations/set_owner_business_info.rbCache-write idiom to copyTTL = 24.hours.to_i (private_constant); org-scoped key; REDIS_W.setex(key, TTL, json); CustomLogFormat rescue
app/core/domains/services/redis/organizations/get_owner_business_info.rbCache-read idiom to copyREDIS_R.get(key); JSON.parse(result, symbolize_names: true); nil on miss
app/core/domains/interactors/admin_view_user.rbInteractor template< Interactors::AbstractIteractor; contract do params { required(:organization_id).filled(:string) } end; def result; yield result_of_validating_params; delegates to repo
app/core/domains/repositories/users/find_user.rbRepository template< Repositories::AbstractRepository; def call; returns success Builders::X.new(...).build / failure 'msg'
app/core/domains/repositories/uploaders/organization_avatar_uploader.rbAsset/CDN pattern< AbstractUploader; store_dir; extension/content-type whitelist; S3/OSS-backed URL
app/core/domains/services/preference_v2.rb (L20-76)Feature-flag gateServices::Preference.new.enabled?(:flag, organization_id:)
lib/hub_core/engine.rb (L118, 122-123)Engine wiringconfig.api_only = true; migrate paths database/core/db/migrate, database/log/db/migrate

Existing Contracts to Reuse, Extend, or Replace

ContractStatusJustificationOwner
Models::Organization (organizations)reuseCanonical tenant; branding FKs to organizations.idhub_core
REDIS_R/REDIS_W globalsreuseExisting cache tier + namespacehub_core
AbstractUploader + S3/OSS CDNreuseExisting asset hostinghub_core
Services::Preference flagsreuseExisting rollout gatehub_core
branding_configs / tenant_domains / branding_assets tablesnew-with-justificationNo branding/domain-mapping schema exists (greenfield; grep -ri branding app lib database → none); Host resolution needs an indexed host tablehub_core
Interactors::Whitelabel::ResolveBranding / repo / redis servicesnew-with-justificationNo branding logic exists; new use casehub_core
GET /branding HTTP endpointnew-with-justificationNo HTTP layer in hub_core; must be built in hub_servicehub_service (cross-repo)

Patterns to Follow (and where to find them)

ConcernPattern in repoReference fileDeviation in this RFC?
HTTP handler shapeN/A in hub_core — no HTTP layerconfig/routes.rb (empty), lib/hub_core/engine.rb:118endpoint built in hub_service (Decision 9)
Interactor / use caseAbstractIteractor + Dry::Monads, def result, contract DSLapp/core/domains/interactors/admin_view_user.rb:3,6-7,15none
Repository / DB accessAbstractRepository, def call, returns success/failure monad + builderapp/core/domains/repositories/users/find_user.rb:3,11,21-23none
Redis cache producer/consumerSingle-responsibility service, org-scoped key, setex/get, CustomLogFormat rescueapp/core/domains/services/redis/organizations/set_owner_business_info.rb:4,14-15none
Error response shapeFailure('message') monad; CustomLogFormat.new(...).error for logsfind_user.rb:22, set_owner_business_info.rb rescuenone — HTTP error shape owned by hub_service
Logging / tracingCustomLogFormat structured JSON (lib/custom_log_format.rb)set_owner_business_info.rb rescue blocknone
MigrationActiveRecord::Migration[6.1], id: :uuid, in database/core/db/migrate/database/core/db/migrate/20260624000001_create_direct_send_message_histories.rb:3,5,6none

Reading Order for the Agent

  1. AGENTS.md — architecture, layers, feature flags, Redis globals, conventions.
  2. app/core/domains/models/organization.rb — the tenant anchor.
  3. spec/dummy/db/schema.rb (L1282-1314) — organizations authoritative columns.
  4. database/core/db/migrate/20260624000001_create_direct_send_message_histories.rb — migration template.
  5. app/core/domains/services/redis/organizations/set_owner_business_info.rb + get_owner_business_info.rb — cache idiom.
  6. app/core/domains/interactors/admin_view_user.rb — interactor template.
  7. app/core/domains/repositories/users/find_user.rb — repository template.
  8. app/core/domains/repositories/uploaders/organization_avatar_uploader.rb — asset/CDN.
  9. app/core/domains/services/preference_v2.rb (L20-76) — feature flag.
  10. lib/hub_core/engine.rb (L118, 122-123) — engine wiring / migrate paths.

Source Verification (anti-hallucination — required)

Anchor / pattern / contractVerified byEvidence
Models::Organizationreadclass Models::Organization < Models::AbstractModel at organization.rb:3; mount_uploader :avatar, ...OrganizationAvatarUploader L14; ~120 store_accessor :settings L20-190
organizations schemaread (agent)spec/dummy/db/schema.rb:1282 create_table "organizations", id: :uuid, default: -> { "gen_random_uuid()" }; settings jsonb, company_id unique index L1308, sso_id uuid L1298
org table migrationread (agent)database/core/db/migrate/20200417051101_create_organizations.rb:5 create_table :organizations, id: :uuid
recent migration templateread (agent)20260624000001_create_direct_send_message_histories.rb:3 < ActiveRecord::Migration[6.1]; L5 create_table ..., id: :uuid; L6 t.uuid :organization_id, null: false; L16 add_index
Redis globalsreadconfig/initializers/redis.rb:5 REDIS_R = ... Redis::Namespace.new(Rails.env.to_sym, redis: Redis.new(url: ENV['REDIS_R_URL'])); L11 REDIS_W = ...
cache write idiomreadset_owner_business_info.rb:4 TTL = 24.hours.to_i; L14 key = "OwnerBusinessInfo::#{@organization_id}::#{@waba_id}"; L15 REDIS_W.setex(key, TTL, @owner_business_info.to_json)
cache read idiomreadget_owner_business_info.rb:11 result = REDIS_R.get(key); L12 JSON.parse(result, symbolize_names: true)
interactor base + exampleread (agent)abstract_iteractor.rb:6 class Interactors::AbstractIteractor < CleanArchitecture::UseCases::AbstractUseCase; admin_view_user.rb:3,6-7,15 (def result, contract required(:organization_id).filled(:string))
repository base + exampleread (agent)abstract_repository.rb:3 class Repositories::AbstractRepository; users/find_user.rb:11,22-23 (def call, failure 'User not found' / success Builders::User...build)
uploader / CDNreadorganization_avatar_uploader.rb:3 < Repositories::Uploaders::AbstractUploader; store_dir uploads/organization/avatar/#{model.id}; env.example OSS_CDN_URL, AWS_BUCKET=qontak-hub-test
feature flag serviceread (AGENTS.md)Services::Preference at preference_v2.rb:20-76; enabled?(:flag, organization_id:)
no HTTP layerreadconfig/routes.rb = Rails.application.routes.draw do end; lib/hub_core/engine.rb:118 config.api_only = true; agent grep: no Grape::API, no *controller*.rb
migrate path registrationread (agent)lib/hub_core/engine.rb:122 appends /database/core/db/migrate to paths['db/migrate']
greenfield (no branding code)grepgrep -ril "branding|whitelabel|white_label" app lib database → no matches
test/lint commandsreadbitbucket-pipelines.yml:166 RAILS_ENV=test bundle exec rspec app ...; AGENTS.md bundle exec rubocop --no-color, bundle exec rspec, bin/overcommit_run; README RAILS_ENV=test rails app:db:drop app:db:create app:db:migrate
host-app API layer (hub_service)not verifiable herehub_service not checked out → §5 Open Question; convention from migrate-core-to-iag skill (API::Core::V1 /api/core/v1, API::Internal::V1 /api/internal/v1)

Detail 2.1 — Architecture (mermaid)

Component diagram

flowchart TB
caller([hub_service endpoint / in-process renderer]) --> uc[Interactors::Whitelabel::ResolveBranding]
uc --> cacheGet[Services::Redis::Branding::GetConfig / GetDomain]
cacheGet --> redis[(Redis)]
uc -->|cache miss| repo[Repositories::Whitelabel::FindBranding]
repo --> mTD[(Models::TenantDomain)]
repo --> mBC[(Models::BrandingConfig)]
repo --> mBA[(Models::BrandingAsset)]
mTD --> db[(postgres.chat)]
mBC --> db
mBA --> db
repo --> bld[Builders::Branding] --> ent[Entities::Branding]
uc -->|cache set| cacheSet[Services::Redis::Branding::SetConfig / SetDomain]
cacheSet --> redis
ucW[Interactors::Whitelabel::CreateOrUpdateBranding] --> repo
ucW --> inv[Services::Redis::Branding::Invalidate] --> redis

Service use cases & third-party connections

flowchart LR
subgraph hc["hub_core (domain)"]
r1["ResolveBranding\n(read + cache + fallback)"]
r2["CreateOrUpdateBranding\n(write + invalidate)"]
end
r1 -->|"Redis (redis-namespace)"| rds[(Redis REDIS_R/REDIS_W)]
r1 -->|"AR read (replica)"| db[(Postgres 'chat')]
r1 -->|"stored CDN URL strings"| cdn(["Asset CDN (S3/OSS)"])
r2 -->|"AR write (primary)"| db
r2 -->|"flag check"| pref(["Services::Preference (Flipper+Redis)"])

Data model (mermaid erDiagram)

erDiagram
ORGANIZATIONS ||--o| BRANDING_CONFIGS : "has (1:0..1)"
ORGANIZATIONS ||--o{ TENANT_DOMAINS : "maps (1:N)"
ORGANIZATIONS ||--o{ BRANDING_ASSETS : "includes (1:N)"

ORGANIZATIONS {
uuid id PK "existing — gen_random_uuid()"
string company_id "existing — unique"
jsonb settings "existing"
}
BRANDING_CONFIGS {
uuid id PK
uuid organization_id FK "unique, not null"
string product_name "not null, default 'Qontak'"
jsonb color_tokens "validated --mp-colors-* map"
string font_family "nullable"
string font_css_url "nullable, https"
string support_url "nullable, https"
string legal_url "nullable, https"
datetime created_at
datetime updated_at
}
TENANT_DOMAINS {
uuid id PK
uuid organization_id FK "not null"
citext host "unique, not null"
boolean is_primary "default false"
datetime created_at
datetime updated_at
}
BRANDING_ASSETS {
uuid id PK
uuid organization_id FK "not null"
string kind "enum: logo|favicon|apple_touch_icon|font"
string cdn_url "not null, https"
datetime created_at
datetime updated_at
}

State machine — branding resolution flow

branding_configs has no status enum (create/update only; no soft-delete — Decision 5/8), so there is no DB-status state diagram. The non-trivial flow is resolution, modeled here.

stateDiagram-v2
[*] --> resolving: request (host or org id)
resolving --> flag_check: identify organization_id
flag_check --> default_fallback: whitelabel_branding OFF for org
flag_check --> cache_lookup: flag ON
cache_lookup --> resolved: cache hit
cache_lookup --> db_lookup: cache miss
db_lookup --> resolved: config found (cache set)
db_lookup --> default_fallback: no config / unknown host
resolved --> [*]
default_fallback --> [*]: return default Qontak branding

Branch & skip flow (non-error policy branches)

flowchart TD
req([resolve request]) --> known{organization resolved?}
known -- no (unknown host/company) --> def[return default Qontak branding + audit]
known -- yes --> flag{whitelabel_branding enabled for org?}
flag -- no --> def
flag -- yes --> cfg{branding_config exists?}
cfg -- no --> def
cfg -- yes --> serve[return tenant branding]
def --> done([resolver returns Success])
serve --> done

Detail 2.2 — Sequence (mermaid, end-to-end across infra layers)

Happy path — Plan 2 (Host) resolution, cache miss then hit

sequenceDiagram
actor U as Browser (tenant domain)
participant LB as Edge / Ingress
participant API as hub_service GET /branding
participant UC as hub_core ResolveBranding
participant Cache as Redis (REDIS_R/W)
participant DB_R as Postgres replica
participant CDN as Asset CDN

U->>LB: GET /branding (Host: acme.com)
LB->>API: HTTP (allow-listed, unauth)
API->>UC: call(host: "acme.com")
UC->>Cache: GET Branding::Domain::acme.com
alt domain cache miss
Cache-->>UC: nil
UC->>DB_R: SELECT tenant_domains WHERE host='acme.com'
DB_R-->>UC: organization_id
UC->>Cache: SETEX Branding::Domain::acme.com TTL org_id
UC->>Cache: GET Branding::Config::<org_id>
Cache-->>UC: nil
UC->>DB_R: SELECT branding_configs + branding_assets WHERE organization_id
DB_R-->>UC: config + assets
UC->>Cache: SETEX Branding::Config::<org_id> TTL payload_json
else config cache hit
Cache-->>UC: payload_json
end
UC-->>API: Success(Entities::Branding)
API-->>U: 200 JSON (Cache-Control: public, max-age=300)
U->>CDN: GET logoUrl / faviconUrl / fontUrl
CDN-->>U: assets
Note over U: FE writes --mp-colors-* to :root (separate FE RFC)

Failure path — unknown host (policy fallback, not 5xx)

sequenceDiagram
participant API as hub_service GET /branding
participant UC as hub_core ResolveBranding
participant Cache as Redis
participant DB_R as Postgres replica

API->>UC: call(host: "unknown.example")
UC->>Cache: GET Branding::Domain::unknown.example
Cache-->>UC: nil
UC->>DB_R: SELECT tenant_domains WHERE host='unknown.example'
DB_R-->>UC: (none)
UC-->>API: Success(default Qontak branding)
API-->>API: 200 default payload (no error)

Write path — admin update busts cache

sequenceDiagram
actor A as Admin (authz in hub_service)
participant API as hub_service PUT /admin/branding
participant UC as hub_core CreateOrUpdateBranding
participant DB_W as Postgres primary
participant Cache as Redis

A->>API: PUT branding (organization_id, tokens, assets)
API->>UC: call(attrs)
UC->>DB_W: BEGIN; UPSERT branding_configs; UPSERT branding_assets; COMMIT
DB_W-->>UC: committed
UC->>Cache: DEL Branding::Config::<org>
UC->>Cache: DEL Branding::Domain::<host> (each tenant_domain of org)
UC-->>API: Success(Entities::Branding)
API-->>A: 200

Every external/boundary hop (Redis, DB, CDN) has a happy + failure/branch path above. No external third-party API is called in the read path (assets are stored URLs).

Detail 2.3 — Database Model (DDL)

Rails migrations (ActiveRecord::Migration[6.1]) in database/core/db/migrate/, Postgres, UUID PKs — matching 20260624000001_create_direct_send_message_histories.rb. citext requires the citext extension (verify enabled; else use string + normalized lowercase + unique index — see §5).

-- 20260724000001_create_branding_configs.rb
CREATE TABLE branding_configs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
product_name varchar NOT NULL DEFAULT 'Qontak',
color_tokens jsonb NOT NULL DEFAULT '{}',
font_family varchar,
font_css_url varchar,
support_url varchar,
legal_url varchar,
created_at timestamp NOT NULL,
updated_at timestamp NOT NULL
);
CREATE UNIQUE INDEX idx_branding_configs_org ON branding_configs (organization_id); -- 1:1 per org; O(1) fetch

-- 20260724000002_create_tenant_domains.rb
CREATE TABLE tenant_domains (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
host citext NOT NULL,
is_primary boolean NOT NULL DEFAULT false,
created_at timestamp NOT NULL,
updated_at timestamp NOT NULL
);
CREATE UNIQUE INDEX idx_tenant_domains_host ON tenant_domains (host); -- Plan 2 O(1) Host→org
CREATE INDEX idx_tenant_domains_org ON tenant_domains (organization_id); -- enumerate hosts on invalidation

-- 20260724000003_create_branding_assets.rb
CREATE TABLE branding_assets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
kind varchar NOT NULL, -- logo | favicon | apple_touch_icon | font
cdn_url varchar NOT NULL,
created_at timestamp NOT NULL,
updated_at timestamp NOT NULL
);
CREATE UNIQUE INDEX idx_branding_assets_org_kind ON branding_assets (organization_id, kind); -- one asset per kind per org
  • Cardinality / growth: branding_configs ≈ 1 row per branded org (≤ #organizations, low thousands); tenant_domains ≈ 1–3 rows per branded org; branding_assets ≤ 4 per org. Growth is bounded by tenant count — negligible.
  • Example rows: branding_configs: {organization_id: <acme>, product_name: 'Acme Chat', color_tokens: {"--mp-colors-brand-qontak":"#7A2FF2","--mp-colors-background-brand-hovered":"#6A1FE0", ...}}; tenant_domains: {organization_id: <acme>, host: 'acme.com', is_primary: true}; branding_assets: {organization_id: <acme>, kind:'logo', cdn_url:'https://cdn.../acme/logo.svg'}.
  • PII classification: none — all columns are public brand metadata (color, product name, public URLs). No user PII (Decision 6). host is a customer's public domain.
  • Retention: retained for the tenant's lifetime; deleting a row reverts the tenant to default branding. No time-based purge.
  • Per-status lifecycle: n/a — no status enum on any branding table. Branding rows are create/update; "disabled" = feature flag off or row absent → default fallback (Decision 5/8). is_primary is a boolean flag (not a lifecycle enum), used only to pick a canonical host.

Detail 2.4 — APIs

The HTTP endpoint is served by hub_service (Decision 9), not by hub_core. hub_core exposes the in-process interactor contract. Both are documented; the HTTP row is tagged new-with-justification and owned cross-repo.

Outbound endpoints (consumers call us)

EndpointMethodAuthN/AuthZRequestResponseStatus codesIdempotencyVersioningReuse?
/branding (hub_service)GETnone (public) — edge allow-listResolution via Host header or ?company=<code> / auth claimbranding JSON (below)200 (always, incl. default fallback); 400 only on malformed paramspure read — naturally idempotent/api/core/v1 (or internal /api/internal/v1)new-with-justification — no HTTP layer in hub_core
Interactors::Whitelabel::ResolveBranding (in-process)callnone (caller passes organization_id/host){ host: } or { organization_id: } / { company_id: }Success(Entities::Branding) (never Failure for unknown tenant → default)Success only (Failure reserved for validation/infra error)idempotentn/anew

GET /branding response schema (verbatim --mp-colors-* per Decision 1/8):

{
"tenant": "acme",
"productName": "Acme Chat",
"colors": {
"--mp-colors-brand-qontak": "#7A2FF2",
"--mp-colors-background-brand": "#7A2FF2",
"--mp-colors-background-brand-hovered": "#6A1FE0",
"--mp-colors-background-brand-bold": "#7A2FF2",
"--mp-colors-border-brand": "#7A2FF2",
"--mp-colors-icon-brand": "#7A2FF2",
"--mp-colors-text-inverse": "#FFFFFF"
},
"assets": {
"logo": "https://cdn.brand.example/acme/logo.svg",
"favicon": "https://cdn.brand.example/acme/favicon.ico",
"appleTouchIcon": "https://cdn.brand.example/acme/apple-touch-icon.png"
},
"font": { "family": "Inter", "cssUrl": "https://cdn.brand.example/acme/fonts/inter.css" },
"links": { "support": "https://help.acme.com", "legal": "https://acme.com/terms" }
}

Default-fallback response (unknown/unbranded/flag-off): the same shape populated with Qontak defaults (productName: "Qontak", Qontak color tokens, Qontak logo/favicon), HTTP 200.

  • Rate limits / payload size: read-only, cacheable; edge rate-limit recommended (hub_service); payload ≈ < 2 KB.
  • Pagination: n/a (single object).
  • Backward compatibility: additive-only. New payload keys are additive; the color allow-list may grow (no breaking removals).

Inbound webhooks (other services call us)

N/A — the branding service consumes no inbound webhooks.

Detail 2.A — Data Integrity Matrix

Write pathTransaction scopePartial failure behaviorIdempotency keyConsistencyDuplicate-event handlingStale-read handling
CreateOrUpdateBranding (config + assets)Single AR transaction over branding_configs + branding_assets (+ tenant_domains if provided)Transaction rolls back; cache NOT busted on failure (avoids serving a half-written brand)organization_id (upsert semantics — unique index)strong within txn; eventual to consumers (TTL)Upsert on unique (organization_id) / (organization_id, kind) → repeat writes convergeCache bust after commit; stale ≤ TTL if a bust is dropped
Cache set on readsingle SETEX per keyon Redis error: log via CustomLogFormat, still return DB-resolved payload (cache best-effort)key = org/hosteventualoverwrite (last-writer)TTL-bounded

Detail 2.B — Concurrency Collision Map

ResourceWritersCollision scenarioResolutionBehavior on failure
branding_configs row (per org)admin write pathTwo admins update same org concurrentlyUnique index on organization_id + AR upsert; last-writer-wins within short windowSecond write overwrites; both bust cache; no lock needed (rare, low-value)
tenant_domains.hostadmin write pathTwo orgs claim the same hostUnique index on host → 2nd insert raises ActiveRecord::RecordNotUniqueReturn Failure('host already claimed'); no cross-tenant hijack
Branding::* cache keysresolver (set) + write path (del)Read repopulates a key the writer just deleted (repopulate-after-invalidate race)Bust happens after commit; a racing read repopulates from fresh committed data → correct valueWorst case a redundant set of the correct value; self-heals at TTL

Detail 2.C — Async Job / Event Consumer Spec

N/A — no background worker, cron, queue consumer, or event handler is introduced. The read path is synchronous cache-first (Decision 2); cache invalidation runs inline in the write interactor (Decision 3). If a warm-cache worker is added later it would follow include Sidekiq::Worker (idempotent, delegating to the interactor) per AGENTS.md.

Detail 2.D — Responsibility Boundary Matrix (≥ 2 services / squads)

Step (execution order)Owning squad / serviceInbound triggerOutbound effectFailure handlerAnchor
1. Route Host→tenant, allow-list /branding unauthPlatform / DevOps (edge)Browser HTTPS on tenant domainForwards to hub_service without auth gateMisroute → wrong/absent Host; hub_service defaultsdoc Decision #3 follow-through
2. Serve GET /branding (public)hub_service (API squad)Edge HTTPCalls ResolveBranding; sets Cache-ControlInteractor Failure/exception → 200 default or 5xx-safe fallback (hub_service decides)Decision 9, 10
3. Resolve + cache + fallbackhub_core (this RFC)in-process call(host:/org:)Success(Entities::Branding); Redis setunknown tenant → default (not error); Redis error → DB value§2.1, §2.2
4. Persist + invalidate (admin)hub_service (authz) → hub_coreadmin PUT/POSTUPSERT + cache busttxn rollback; unique-violation Failure§2.2 write path
5. Consume payload, theme appFE squads (hub-chat, crm-fe-v3)app boot fetchapplyBranding writes --mp-colors-*, swaps favicon/fontpayload fetch fail → last localStorage/default (FE)doc §5, §4b
6. Complete data-panda-theme=next rollout (hub-chat)FE squadbrand tokens reach all componentslegacy routes stay hardcoded blue.400doc §4b

Disagreement to reconcile (Open Question §5): the design doc says the service is "owned by qontak.com", but the repo shows hub_core has no HTTP layer — so ownership of the endpoint is hub_service, while the logic is hub_core. Confirm this split with the API squad.

Detail 2.E — State Surface Contract

EntityState field / eventDefault valuesUpdated byRead viaStale window
Branding (per tenant)productName, colors map, assets, font, linksQontak defaults (product Qontak, Qontak tokens/logo)CreateOrUpdateBrandingResolveBrandingGET /branding≤ 1 h (cache TTL); ~0 after invalidation
Tenant domain mappingtenant_domains.host → organization_id, is_primarynone (unmapped host → default)admin write pathresolver (Branding::Domain::<host>)≤ 1 h

3. High-Availability & Security

HA narrative. The read path is stateless (hub_service pods scale horizontally; hub_core is a library). Availability rests on Redis + Postgres. Degradation ladder: (1) Redis unavailable → resolver logs via CustomLogFormat and reads Postgres directly (cache is best-effort — see §2.A); (2) Postgres replica lag/unavailable → read from primary or serve default branding; (3) branding data missing → default Qontak branding (never a hard failure for a read). Full-restart recovery: tables are small and cache repopulates lazily on first request per tenant.

Performance Requirement

  • Sustained: modest — one GET /branding per app boot per session; expect ≤ tens of RPS/pod. Targets: p99 < 20 ms cache hit, < 60 ms cache miss (single indexed reads).
  • Scalability: HPA on hub_service (existing); Redis absorbs the hot path; tenant_domains.host unique index keeps Plan 2 O(1). Stampede protection: cheap repopulate; optional set nx: guard (pattern at services/uploader/broadcast_media.rb:29) if a single tenant ever herds.
  • Load test: k6/JMeter against hub_service GET /branding with a warm and cold cache mix (hub_service RFC owns the harness).

Monitoring & Alerting

  • RED (hub_service, endpoint-owned): request rate, error rate, p50/p99 duration for GET /branding.
  • Cache metrics: reuse the repo's :redis_cache_hit / :redis_cache_miss metric names (app/core/domains/services/redis/abstract_models.rb:62) for branding cache → Datadog (service-metadata.yaml golden-metric dashboard).
  • Resolver logs: CustomLogFormat structured JSON (class_name, method_name, args) on error, matching set_owner_business_info.rb.
  • Alert: fallback-rate (default branding served for a branded org) crossing a threshold indicates a resolution/cache regression.
  • SLO: GET /branding availability ≥ 99.9%; p99 within targets over 30 d (hub_service).

Logging

  • Fields: organization_id/host (host is public), cache_hit, resolution_plan (host/company), fell_back (bool). Level: error on infra failure (CustomLogFormat.error), else quiet.
  • PII scrubbed: none present — no user PII in this domain. host and brand fields are public.

Security Implications

  • Threat model: (a) tenant-hijack via host collision → prevented by unique index on tenant_domains.host (§2.B); (b) injection of a sensitive field into a world-readable payload → prevented by the Entities::Branding allow-list (Decision 6) — reviews must reject PII additions; (c) malicious CDN/URL values (SSRF/XSS via injected URLs) → https-only + host allow-list validation on cdn_url/*_url; (d) invalid color tokens → write-time allow-list + hex regex (Decision 8); (e) DoS on a public endpoint → edge rate-limit + Redis cache + Cache-Control (hub_service/edge).
  • Input validation: color_tokens keys ∈ verified allow-list; values match /\A#[0-9A-Fa-f]{6}\z/; host matches a hostname regex, lowercased; *_url/cdn_url must be https:// with an allow-listed host; kind{logo, favicon, apple_touch_icon, font}; product_name length-bounded, no HTML.
  • Injection: ActiveRecord parameterized queries (no raw SQL); URL validation guards SSRF on any outbound fetch (none in read path); Brakeman scan in CI (bitbucket-pipelines.yml brakeman step).
  • Secrets: none new; CDN/AWS/OSS creds already in env; no secret in the public payload.
  • Audit logging: admin writes recorded (see below); reads not audited (public, no PII).
  • Tenancy isolation: enforced at resolution (one org) + entity allow-list (§ Decision 6).
  • Static analysis: Brakeman (CI). Public-exposure: infosec approval required (Metadata); payload is intentionally world-readable — documented invariant.

Role × Endpoint Authorization Matrix

RoleEndpoint(s)Permitted methodsTenant scopeAdditional constraintAudit trail
Anonymous visitorGET /brandingGETresolved single tenant (own domain)read-only; no PII returnededge/request log (no PII)
Backend renderer (email/PDF)ResolveBranding (in-process)callscoped by passed organization_idread-onlyCustomLogFormat
Admin / support (branding author)write path (CreateOrUpdateBranding)create/updateown tenant only (enforced in hub_service authz)JWT scope (hub_service)audit row (Models::Billing::AuditLog or PaperTrail) — see §5

No PRD role is left without a row. Admin authz mechanism is a hub_service concern (§5).

Detail 3.A — Failure Mode & Retry Catalog

External callTimeoutRetriesCircuit breakerDLQCaller behavior on persistent failure
Redis GET/SETEX (cache)Redis client default (short)none (best-effort)n/an/aLog CustomLogFormat; fall through to DB and still return payload
Postgres read (config/domain/assets)DATABASE_TIMEOUT (env, 5000 ms)nonen/an/aOn replica failure read primary; on total DB failure return default branding (200) rather than 5xx
Postgres write (admin upsert)DATABASE_TIMEOUTnone (admin retries)n/an/aTransaction rollback → Failure(message); cache not busted
CDN asset fetchn/a (browser→CDN, not in resolver)n/an/an/aResolver returns URLs only; asset availability is a browser/CDN concern

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerWhere checkedDownstream effectAudit trailUser-visible?
Unknown host / unmapped companyResolveBranding (hub_core)return default Qontak brandinglog fell_back=trueyes (default brand shown)
whitelabel_branding flag OFF for orgResolveBranding (Services::Preference)return default Qontak brandinglog fell_back=trueyes
Branding config row absent for resolved orgResolveBrandingreturn default Qontak brandinglog fell_back=trueyes
Legacy hub app (bootstrap-vue)FE (design doc §4b)payload fetched but not applied (not themeable)n/a (FE)no (stays Qontak-styled)
hub-chat legacy (non-next-theme) routeFE (design doc §4b)brand tokens don't reach hardcoded blue.400 componentsn/a (FE)partial theming

Detail 3.B — Error Response Catalog

HTTP error shaping is owned by hub_service; hub_core returns Dry::Monads Failure(message). The read endpoint is designed to avoid errors (default fallback → 200).

EndpointError codeHTTP statusMessageWhenUser-facing?
GET /brandingINVALID_PARAMS400"Invalid branding request"malformed ?company= with no Hostno (rare)
GET /branding(none)200default branding payloadunknown/unbranded/flag-off tenantno (transparent)
write pathHOST_TAKEN409"host already claimed"tenant_domains.host unique violationyes (admin)
write pathINVALID_TOKEN422"unknown color token / invalid hex"token not in allow-list / bad hexyes (admin)
write pathINVALID_URL422"URL must be https and allow-listed host"bad cdn_url/*_urlyes (admin)

Detail 3.C — Compliance & Data Governance

N/A — no compliance trigger; verified no PII / payment / health / auth / audit-of-users data is touched. Branding data (colors, product name, public logo/domain/URLs) is world-readable by design (Decision 6/10). Admin write actions are audited (§3 matrix) but contain no personal data.


4. Backwards Compatibility and Rollout Plan

Compatibility

  • Existing endpoints/shapes changed: none — purely additive (new tables, new module, new cross-repo endpoint). No change to organizations or any existing contract.
  • Compatibility window: n/a (additive).
  • Consumer notification: FE squads + API squad coordinate on the new GET /branding contract.
  • API version strategy: additive; new payload keys and new allow-listed tokens are non-breaking.

Rollout Strategy

  • Migration sequence: add 3 tables (no backfill needed — empty until admins add branding). No intermediate dual-write; no data migration.
  • Backfill: none (greenfield).
  • Feature flag: whitelabel_branding via Services::Preference (Flipper + Redis), org-scoped. Default OFF → all orgs resolve to default Qontak branding (identical to today's behavior).
  • Rollout stages:
    1. internal: migrations deployed; flag ON for one internal test org with seeded branding + a tenant_domains row on a staging host; verify resolution + cache + fallback.
    2. 1 tenant (pilot): enable for one real whitelabel customer (e.g. acme.com); go/no-go = p99 within target, fallback-rate ~0 for the branded org, zero cross-tenant leakage.
    3. N tenants: enable per onboarded tenant (org-scoped flag) — branding is opt-in per tenant, so "100%" = every whitelabel customer, not every org.
  • Stop conditions: GET /branding error rate > 0.5% or p99 > 100 ms sustained 15 min, or any cross-tenant branding leak → disable flag.
  • Rollback: flag OFF (instant → default branding for all). Data (tables) can remain; no destructive rollback needed. Mid-session users: next boot fetches default branding.
  • Blast radius: worst case a resolution bug serves default Qontak branding to a whitelabel tenant (cosmetic degradation, no data risk) — flag-off reverts.
  • PIC + timeline: per stage — to assign (§5).

Detail 4.A — Configuration Contract

Env var / config / flagTypeDefaultRequiredProvisionerSecret?
whitelabel_branding (Flipper flag)feature flagOFFyesServices::Preference (migration/admin task)no
BRANDING_CACHE_TTL_SECONDS (optional)integer3600noenv (defaults to constant if unset)no
OSS_CDN_URL / AWS_BUCKET / AWS_REGIONstringexistingyesenv (existing)key/secret yes (existing)
REDIS_R_URL / REDIS_W_URLstringexistingyesenv (existing)no
Edge allow-list rule for /branding (unauth)ingress confignoneyesPlatform/DevOps (hub_service repo/infra)no

Detail 4.B — Test Plan (commands the agent will run)

Sourced from the repo: AGENTS.md "Safe commands", bitbucket-pipelines.yml, README.md.

LayerCommand (source)What it must prove
DB migrate (test)RAILS_ENV=test bundle exec rails app:db:migrate (bitbucket-pipelines.yml:164, README)3 tables + indexes created; reversible
Unit — modelsbundle exec rspec spec/core/domains/models/branding_config_spec.rb spec/core/domains/models/tenant_domain_spec.rb spec/core/domains/models/branding_asset_spec.rb (AGENTS.md spec map: core → spec/core/domains/)validations, associations, uniqueness
Unit — redis cachebundle exec rspec spec/core/domains/services/redis/branding/setex key+TTL; get parse; invalidate deletes keys
Unit — interactor/repobundle exec rspec spec/apps/whitelabel/ (co-located per AGENTS.md)resolve by host / org; fallback; wrong-org isolation; write + invalidate
Full suitebundle exec rspec (AGENTS.md)no regressions
Lintbundle exec rubocop --no-color (AGENTS.md)style clean
Securitybundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -q (AGENTS.md)no new high warnings
Pre-PR gatebin/overcommit_run (AGENTS.md)bundle + rspec + brakeman + rubocop
Contract / E2E GET /brandingn/a — owned by hub_service repo (§5)endpoint + public gate

Detail 4.C — Agent Execution Plan

Migrations before models before repos before interactors. Co-locate specs (app/apps/whitelabel/*) and mirror core specs under spec/core/domains/. Every new interactor/repo/service needs a spec covering happy path, validation failure, wrong organization_id, and external failure (AGENTS.md Definition of Done).

OrderChunkFiles to modify/createCommands to runAcceptance criteria
1Migrations: 3 branding tablesdatabase/core/db/migrate/20260724000001_create_branding_configs.rb, ..._000002_create_tenant_domains.rb, ..._000003_create_branding_assets.rbRAILS_ENV=test bundle exec rails app:db:migratetables exist; unique indexes on organization_id, host, (organization_id,kind); db:rollback reverts cleanly
2Modelsapp/core/domains/models/branding_config.rb, tenant_domain.rb, branding_asset.rb + specs under spec/core/domains/models/bundle exec rspec spec/core/domains/models/branding_config_spec.rb ...< Models::AbstractModel; belongs_to organization; validations (uniqueness, kind enum, https URL, token allow-list, hex); specs green
3Entity + builder + token/URL validatorsapp/core/domains/entities/branding.rb, app/core/domains/builders/branding.rb, app/apps/whitelabel/constants/* (allow-list) + specsbundle exec rspec spec/core/domains/builders/branding_spec.rbbuilder maps config+assets→Entities::Branding; allow-list rejects unknown token; hex regex enforced
4Redis cache servicesapp/core/domains/services/redis/branding/{set_config,get_config,set_domain,get_domain,invalidate}.rb + specsbundle exec rspec spec/core/domains/services/redis/branding/SetConfig asserts REDIS_W.setex(key, TTL, json); GetConfig returns parsed/nil; Invalidate deletes config + all domain keys for org
5Repository + Resolve interactor (Plan 1/2 + fallback + flag)app/apps/whitelabel/repositories/find_branding.rb, app/apps/whitelabel/interactors/resolve_branding.rb + co-located specsbundle exec rspec spec/apps/whitelabel/interactors/resolve_branding_spec.rb (or co-located)resolves by host; by org id; unknown host → Success(default); flag OFF → default; wrong-org returns only own rows; cache hit path asserted
6Write interactor + invalidationapp/apps/whitelabel/interactors/create_or_update_branding.rb + repo + specsbundle exec rspec spec/apps/whitelabel/upsert config+assets in one txn; unique host violation → Failure('host already claimed'); cache busted post-commit; audit row written
7Register feature flagmigration/rake task calling Services::Preference.new.add(:whitelabel_branding, target: 'feature', ...) + specbundle exec rspec spec/apps/whitelabel/flag registered; resolver honors org-scoped enable/disable
8Full gatebin/overcommit_run (or bundle exec rubocop --no-color + bundle exec rspec + brakeman)rubocop 0; full suite green; no new brakeman high

Detail 4.D — Verification & Rollback Recipe

  • Pre-merge verification (in order):
    1. RAILS_ENV=test bundle exec rails app:db:create app:db:migrate (README; resets test DB if duplicate-migration error)
    2. bundle exec rubocop --no-color
    3. bundle exec rspec spec/apps/whitelabel spec/core/domains/models/branding_config_spec.rb spec/core/domains/services/redis/branding (feature specs)
    4. bundle exec rspec (full suite)
    5. bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -q
  • Post-deploy verification signals:
    • Datadog Qontak Chat golden-metric dashboard (service-metadata.yaml): GET /branding p99 within target, error rate < 0.5% over 15 min.
    • Cache metrics :redis_cache_hit / :redis_cache_miss (services/redis/abstract_models.rb:62) show a healthy hit ratio after warm-up.
    • Structured log query (CustomLogFormat): fell_back=true rate ≈ 0 for orgs that have branding.
  • Rollback recipe (in order):
    1. Services::Preference.new.disable(:whitelabel_branding) (or disable per-org) → all tenants serve default Qontak branding immediately.
    2. If the migration must be reverted: RAILS_ENV=<env> bundle exec rails app:db:rollback STEP=3 (tables are empty/greenfield — safe).
    3. Confirm GET /branding error rate returns to baseline and fell_back behavior is the pre-rollout default on the Datadog dashboard within 15 min.

Detail 4.E — Resource & Cost Notes (advisory)

  • Compute: negligible — no new pods; logic runs in existing hub_service/hub_core processes.
  • DB: 3 tiny tables (bounded by tenant count); connection delta ~0.
  • Network egress: none new server-side (asset fetch is browser→CDN).
  • Storage growth: kilobytes/month scale.
  • New infra: none — reuses Redis, Postgres, CDN, feature-flag service.

5. Concern, Questions, or Known Limitations

  1. [BLOCKER — cross-repo ownership] The design doc says the service is "owned by qontak.com", but hub_core has no HTTP layer. Confirm with the API squad that GET /branding (public gate + edge allow-list) is built in hub_service (API::Core::V1 vs API::Internal::V1?) calling the hub_core ResolveBranding interactor. A companion hub_service RFC is needed (endpoint routing, auth-skip, Cache-Control, rate-limit, E2E). hub_service is not checked out at ../backend/hub_core, so its exact patterns are unverified here.
  2. [BLOCKER — approvers] Assign reviewers (hub_core, hub_service, FE) and approvers including an infosec approver — required because GET /branding is public/unauthenticated (Decision 10).
  3. Resolution signal for Plan 1: which identifier does the FE/login send — company_id (exists on organizations, unique) or sso_id/unified_sso_id? This RFC assumes company_id for the ?company= path; confirm and adjust the resolver contract.
  4. citext extension: tenant_domains.host uses citext for case-insensitive uniqueness. Confirm the citext extension is enabled in the chat DB; if not, migration must enable_extension 'citext' or fall back to string + lowercased-on-write + unique index.
  5. Audit sink for admin writes: use Models::Billing::AuditLog, PaperTrail, or a new branding audit table? (AGENTS.md cites Models::Billing::AuditLog for billing events.) Decide before B5.
  6. Default branding source: hardcode Qontak defaults in a constant, or seed a branding_configs row for a "default" org? This RFC assumes a code constant (Entities::Branding.default).
  7. hub-chat next-theme prerequisite (design doc §4b): full component theming on hub-chat depends on completing the data-panda-theme=next rollout — track as an FE dependency; branding for non-migrated hub-chat routes will be partial. Legacy hub is not themeable at all.
  8. Neutral token alias (design doc §4): consider introducing --mp-colors-brand-primary so the payload isn't named after Qontak (--mp-colors-brand-qontak). FE/design-system decision; the color_tokens jsonb already stores whatever keys are allow-listed, so this is a validation-list change, not a schema change.
  9. Target release 2026-Q3 is proposed — confirm.

Known limitations: staleness ≤ 1 h if a cache invalidation is dropped (Decision 5); no stampede lock by default (added only if a hot tenant herds); admin authoring UI is out of scope.


6. Comment logs

DateComment(s) FromAction Item(s)
2026-07-23RFC drafted (rfc-starter) from Confluence design doc, grounded in hub_coreAssign reviewers/approvers (incl. infosec); confirm hub_service endpoint ownership (§5.1–5.2)

7. Ready for agent execution

  • yes — for the hub_core in-repo scope (chunks 1–8 in §4.C), which is fully grounded and verifiable. An autonomous agent can build the schema, models, entity/builder, Redis cache services, resolver + write interactors, and feature flag against this repo without a clarification meeting.

  • Conditional / blocked for the cross-repo scope: the GET /branding HTTP endpoint, public/unauth edge allow-listing (hub_service), and the FE applyBranding consumers are out of this repo and require companion RFCs/work (§1 Out of Scope, §5.1). Two governance blockers remain open and do not affect the hub_core build but must be closed before production rollout:

    • §5.1 — confirm hub_service endpoint ownership + which API namespace (companion RFC).
    • §5.2 — assign reviewers + infosec approver for the public endpoint.

    Minor confirmations that can be resolved in-flight (sensible defaults chosen, see §5): Plan-1 identifier (company_id), citext availability, audit sink, default-branding source.

Optional: hand this RFC to rfc-reviewer for a second-pass score now that the §7 gate is yes for the hub_core scope.