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 — reasonwhen 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
| Field | Value | Notes |
|---|---|---|
| Status | IDEA | IDEA / RFC / ABANDON / AGREED |
| Owner | Qontak Chat — Chat Panel 2 | Team owning the RFC (service-metadata.yaml:1 teamName: "Chat panel 2") |
| Author(s) | A. Firdha Shafridhi (Saf) | Author of the source design doc |
| Reviewers | hub_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 Date | 2026-07-23 | ISO-8601 |
| Last Updated | 2026-07-23 | ISO-8601; bump on every material edit |
| Target Release | 2026-Q3 (proposed) | Confirm in §5 |
| Related Documents | Whitelabel — Unified Branding Service (Design) | Source design doc (Confluence) |
| Discussion | TBA | Slack thread to be linked |
Type: backend Sub-type: new-feature
Sections at a Glance
- Overview (incl. §1 PRD-to-Schema Derivation — entities, business rules, contracts; no Figma)
- Technical Design (Infrastructure Topology → Technical Decisions [ADR] → Repo Reading Guide → Architecture & Service Map → Sequence Diagrams → DDL → APIs → integrity / concurrency / async specs)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (incl. §4 Agent Execution Plan + Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- 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 own — config/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
- Given a resolvable tenant (by
Hostor by company/org identifier), the resolver returns a complete branding payload (productName,colorstoken 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 inhub_coreinteractor benchmarks. - A tenant with no branding config resolves to default Qontak branding (policy fallback, not an error) — zero 5xx for unknown/unbranded tenants.
- An admin branding update is reflected on the next request after cache bust (staleness window ≤ TTL = 1 h, or immediate on invalidation).
- Color tokens returned are verified real
--mp-colors-*Pixel3 tokens (design doc §4 table); unknown token keys are rejected at write time. - Zero PII in the payload (color, logo URL, product name, links only) — enabling the public/unauthenticated contract (infosec-approved).
Out of Scope
- The
GET /brandingHTTP endpoint implementation and edge/ingress allow-listing — owned byhub_service(cross-repo; see §2.D and §5). - The frontend
applyBrandingconsumers (hub-chat,crm-fe-v3, legacyhub), the--mp-colors-*injection, favicon/font swap, and the Pixel3data-panda-theme=nextrollout — separate FE RFC(s) / squads (design doc §4b, §5). This RFC only defines the contract they consume. - Legacy
hub(bootstrap-vue +@mekari/pixelv1) 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.
Related Documents
- Source design: Whitelabel — Unified Branding Service (Design) — Confluence, space QON.
hub_coreAGENTS.md(architecture, layers, conventions, feature flags, Redis globals).hub_coredocs/architecture/flows/core/multi-tenancy/README.md(org-scoping).
Assumptions
hub_service(orqontak.com) will exposeGET /brandingand call thehub_coreResolveBrandinginteractor — the standard "HTTP inhub_service, logic inhub_core" split (permigrate-core-to-iag). Unverified in this checkout → §5 Open Question.- The existing
Models::Organization(tableorganizations, UUID PK —organization.rb:3,spec/dummy/db/schema.rb:1282) is the canonical tenant. Branding keys offorganization_id; no newtenanttable is introduced (aligns with design doc Decision #2). - Plan 2 (Host-based resolution) requires a
Host → organizationmapping; the design doc models this astenant_domain. No such mapping exists today → new table. - 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 byRepositories::Uploaders::OrganizationAvatarUploader. - Redis (
REDIS_R/REDIS_W,config/initializers/redis.rb:5,11) is the cache tier.
Dependencies
| Dependency | Owner / repo | Availability | Notes |
|---|---|---|---|
GET /branding HTTP endpoint + public/unauth route | hub_service (API squad) | needs building | Grape API::Core::V1/API::Internal::V1; calls hub_core interactor |
Edge/ingress allow-list of /branding unauthenticated on every tenant domain | Platform / DevOps | needs building | Design doc Decision #3 follow-through |
applyBranding FE composable + --mp-colors-* injection | FE squads (hub-chat, crm-fe-v3) | needs building | Separate FE RFC; consumes this contract |
Pixel3 data-panda-theme=next rollout on hub-chat migrated routes | FE squad | partial | Prerequisite for full component theming on hub-chat (design doc §4b) |
Models::Organization | hub_core (this repo) | exists | app/core/domains/models/organization.rb:3 |
Redis globals REDIS_R/REDIS_W | hub_core (this repo) | exists | config/initializers/redis.rb:5,11 |
S3/OSS CDN + AbstractUploader | hub_core (this repo) | exists | app/core/domains/repositories/uploaders/organization_avatar_uploader.rb |
Feature flags Services::Preference | hub_core (this repo) | exists | app/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 / rule | Persisted as (table.column) | Exposed via (endpoint / interactor) | Enforced where | Source |
|---|---|---|---|---|
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_id | doc §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_id | unique index on host; resolver lookup | doc §2, §3 |
| Product name per tenant | branding_configs.product_name string | payload productName | not-null default = 'Qontak' | doc §4, §6 |
Brand color token map (--mp-colors-*, returned verbatim) | branding_configs.color_tokens jsonb | payload colors | write-time allow-list of verified token keys + hex-value validation | doc §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 validation | doc §3, §4 |
| Optional per-tenant font | branding_configs.font_family string, branding_configs.font_css_url string | payload font.{family,cssUrl} | https-only URL validation; both-or-neither | doc §4, §5 |
| Support / legal links | branding_configs.support_url, branding_configs.legal_url | payload links.{support,legal} | https-only URL validation | doc §4 |
| Resolved payload cached per tenant; O(1) lookups | Redis Branding::Config::<org_id> (+ Branding::Domain::<host>) | cache service set/get | TTL constant; bust on write | doc §2, §7 |
| Cache invalidated on brand update | (no column) cache-bust side effect | InvalidateBranding service | called by write interactor | doc §7 |
| Payload is public, no PII | (design constraint — no column) | endpoint unauth (hub_service) | infosec review; validation forbids new sensitive fields | doc §4, Decision #3 |
| Unknown/unbranded tenant → default Qontak brand | (absence of row) | resolver returns default entity | resolver fallback branch | doc §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 requirement | Service / interactor / job | RFC 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 path | ResolveBranding (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 tokens | payload entity/builder + endpoint | §2.4, §2.E |
§4b Theming reaches components only under data-panda-theme=next | FE prerequisite (cross-squad) | §2.D, §5 |
§5 One applyBranding composable | FE (out of scope) | §1 Out of Scope |
| §7 Caching & invalidation | Redis cache service + invalidation | §2 Decision 3, §2.C |
| §8 Replaces per-service brand literals | contract 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 / decision | Design-doc need it serves |
|---|---|
tenant_domains table | Plan 2 Host→tenant resolution (§2, §3) |
branding_configs.color_tokens jsonb | verbatim --mp-colors-* payload (§4, Decision #1) |
ResolveBranding interactor | single resolution code path (§2) |
InvalidateBranding cache service | invalidate on brand update (§7) |
| Default-branding fallback branch | unbranded tenants keep working (§6/§8) |
UI / Consumer Surface Coverage
| Consumer surface | Consumer | Required reads | Required writes | Status surface |
|---|---|---|---|---|
| App boot (any tenant domain) | web (hub-chat, crm-fe-v3) | GET /branding | n/a | colors/assets/productName in payload |
| Legacy SPA boot | web (hub) | GET /branding (payload ignored for theming) | n/a | n/a — not themeable (doc §4b) |
| Server-rendered brand (email / PDF templates) | backend | ResolveBranding interactor (in-process) or GET /branding | n/a | payload fields |
| Admin branding editor | support/admin tool (future) | read config | CreateOrUpdateBranding (write path) | config fields |
Role Coverage
| Role | Authorization mechanism | Endpoints permitted | Cross-tenant? | Audit trail |
|---|---|---|---|---|
| Anonymous / pre-login visitor | none (public endpoint, no PII) | GET /branding (read) | no — resolves to exactly one tenant by Host/company | request log only (no PII) |
| Backend service (email/PDF renderer) | in-process interactor call (no HTTP auth) | ResolveBranding | scoped by passed organization_id | CustomLogFormat structured log |
| Admin / support (branding author) | hub_service JWT scope (out of repo) | write path (CreateOrUpdateBranding) | own tenant only | Models::Billing::AuditLog or PaperTrail (see §3) |
The admin authorization mechanism is enforced in
hub_service(not verifiable here) → §5.
PRD Section Coverage
| Design-doc section | Title | Where covered |
|---|---|---|
| 1 | Architecture | §2 Infrastructure Topology, §2.1 |
| 2 | Tenant resolution & theming | §2.2 sequence diagrams |
| 3 | Data model | §2.3 DDL + §2.1 erDiagram |
| 4 | API contract GET /branding | §2.4 APIs, §2.E State Surface |
| 4b | Override reaches components (next-theme) | §2.D Responsibility Boundary, §5 (FE prerequisite) |
| 5 | Consumer composable applyBranding | n/a — frontend (out of scope §1) |
| 6 | Proof: two tenants, same code | §2.2 (resolution), §2.4 (payload examples) |
| 7 | Caching & invalidation | §2 Decision 3, §2.C async/invalidation |
| 8 | What this replaces per service | §1 Overview (context only) |
| Decisions | Resolved decisions #1–#3 | §2 Technical Decisions (1, 8, 9, 10) |
Detail 1.B — Key Decisions Summary (full ADR treatment in §2)
| # | Decision | Chosen option | §2 block |
|---|---|---|---|
| 1 | Storage: branding schema shape | Normalized tables in core chat DB, keyed off existing organizations | Decision 1 |
| 2 | Sync vs async on read | Synchronous, cache-first read; no worker | Decision 2 |
| 3 | Caching + invalidation | Redis setex (config + host→org keys), TTL 1 h, bust on write | Decision 3 |
| 4 | Asset hosting / third-party | Reuse existing S3/OSS CDN + AbstractUploader; store CDN URL only | Decision 4 |
| 5 | Consistency model | Eventual (bounded by TTL); write busts cache | Decision 5 |
| 6 | Multi-tenancy isolation | Resolve to single org; public payload has no PII | Decision 6 |
| 7 | Reuse vs new | New tables + app/apps/whitelabel module; reuse Organization/Redis/uploader/Preference | Decision 7 |
| 8 | Color token storage | color_tokens jsonb stored verbatim (vs discrete columns + derive) | Decision 8 |
| 9 | Where the HTTP endpoint lives | hub_service Grape endpoint calling hub_core interactor | Decision 9 |
| 10 | Endpoint auth | Public / unauthenticated | Decision 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 markedCross-squad.
| Story # | Story title | Layer scope | Changes (concrete BE artifacts) | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|
| B1 | Persistence schema | BE-only | migrations create_branding_configs, create_tenant_domains, create_branding_assets in database/core/db/migrate/; models Models::BrandingConfig, Models::TenantDomain, Models::BrandingAsset | RAILS_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 |
| B2 | Payload entity + builder + token validation | BE-only | Entities::Branding (Dry::Struct), Builders::Branding, token allow-list constant + hex validator | rspec: builder maps config+assets→entity; invalid token key rejected; hex regex enforced | §2.4 · §2.E · §4.C chunk 3 |
| B3 | Redis cache service (set/get/invalidate) | BE-only + Runtime | Services::Redis::Branding::SetConfig, GetConfig, SetDomain, GetDomain, Invalidate following SetOwnerBusinessInfo pattern | rspec: 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 |
| B4 | Resolve interactor (Plan 1 + Plan 2 + fallback) | BE-only | Interactors::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 |
| B5 | Write path + cache invalidation | BE-only | Interactors::Whitelabel::CreateOrUpdateBranding + repo; calls Invalidate after commit | rspec: create/update persists; audit row written; cache busted (Redis key absent post-write) | §2.C · §3 · §4.C chunk 6 |
| B6 | Feature flag gate | Config | register whitelabel_branding flag via Services::Preference; resolver honors org-scoped flag | rspec: flag off → default branding; flag on (org) → resolved branding | §2 Decision 6 · §4.A · §4.C chunk 7 |
| B7 | GET /branding HTTP endpoint (public) | Cross-squad | Grape endpoint in hub_service API::Core::V1/API::Internal::V1 calling ResolveBranding; edge allow-list | n/a — covered in hub_service RFC (to be created; §5 Open Question) | §2.4 · §2.D |
| B8 | FE applyBranding consumption | Cross-squad | applyBranding composable, --mp-colors-* injection, next-theme rollout | n/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, permigrate-core-to-iag),hub_coreengine (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.hostneeds 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 hotorganizationsrow. - Cons: 3 new migrations + models; a join/2-3 reads on cache miss.
- Pros:
- Option B — Store branding inside
organizations.settingsjsonb (store_accessor :settings, :branding).- Pros: no new tables; reuses existing model.
- Cons: cannot index
hostfor 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/GetOwnerBusinessInfopattern (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.
- Pros: matches existing idiom exactly; O(1) Host resolution and O(1) payload fetch;
namespaced per env automatically (
- 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, S3AWS_BUCKET/ AlibabaOSS_CDN_URL). Store only the resultingcdn_urlstring inbranding_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 underapp/core/domains/models/; Redis services underapp/core/domains/services/redis/branding/. - Reuse:
Models::Organization;Interactors::AbstractIteractor+Repositories::AbstractRepository(Dry::Monads);REDIS_R/REDIS_W;AbstractUploader/CDN;Services::Preferenceflags;CustomLogFormatlogging.
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 thehub_coreResolveBrandinginteractor 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_onlylibrary 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
| Path | Why the agent reads it | What pattern it teaches |
|---|---|---|
app/core/domains/models/organization.rb | The tenant anchor branding keys off | Models::X < Models::AbstractModel, store_accessor :settings, mount_uploader :avatar, ES mappings, flipper_id |
spec/dummy/db/schema.rb (L1282-1314) | Authoritative organizations shape | UUID PK gen_random_uuid(), settings jsonb default {}, company_id unique, sso_id uuid |
database/core/db/migrate/20200417051101_create_organizations.rb | How the org table was created | create_table :organizations, id: :uuid |
database/core/db/migrate/20260624000001_create_direct_send_message_histories.rb | Recent migration template | ActiveRecord::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.rb | Cache-write idiom to copy | TTL = 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.rb | Cache-read idiom to copy | REDIS_R.get(key); JSON.parse(result, symbolize_names: true); nil on miss |
app/core/domains/interactors/admin_view_user.rb | Interactor 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.rb | Repository template | < Repositories::AbstractRepository; def call; returns success Builders::X.new(...).build / failure 'msg' |
app/core/domains/repositories/uploaders/organization_avatar_uploader.rb | Asset/CDN pattern | < AbstractUploader; store_dir; extension/content-type whitelist; S3/OSS-backed URL |
app/core/domains/services/preference_v2.rb (L20-76) | Feature-flag gate | Services::Preference.new.enabled?(:flag, organization_id:) |
lib/hub_core/engine.rb (L118, 122-123) | Engine wiring | config.api_only = true; migrate paths database/core/db/migrate, database/log/db/migrate |
Existing Contracts to Reuse, Extend, or Replace
| Contract | Status | Justification | Owner |
|---|---|---|---|
Models::Organization (organizations) | reuse | Canonical tenant; branding FKs to organizations.id | hub_core |
REDIS_R/REDIS_W globals | reuse | Existing cache tier + namespace | hub_core |
AbstractUploader + S3/OSS CDN | reuse | Existing asset hosting | hub_core |
Services::Preference flags | reuse | Existing rollout gate | hub_core |
branding_configs / tenant_domains / branding_assets tables | new-with-justification | No branding/domain-mapping schema exists (greenfield; grep -ri branding app lib database → none); Host resolution needs an indexed host table | hub_core |
Interactors::Whitelabel::ResolveBranding / repo / redis services | new-with-justification | No branding logic exists; new use case | hub_core |
GET /branding HTTP endpoint | new-with-justification | No HTTP layer in hub_core; must be built in hub_service | hub_service (cross-repo) |
Patterns to Follow (and where to find them)
| Concern | Pattern in repo | Reference file | Deviation in this RFC? |
|---|---|---|---|
| HTTP handler shape | N/A in hub_core — no HTTP layer | config/routes.rb (empty), lib/hub_core/engine.rb:118 | endpoint built in hub_service (Decision 9) |
| Interactor / use case | AbstractIteractor + Dry::Monads, def result, contract DSL | app/core/domains/interactors/admin_view_user.rb:3,6-7,15 | none |
| Repository / DB access | AbstractRepository, def call, returns success/failure monad + builder | app/core/domains/repositories/users/find_user.rb:3,11,21-23 | none |
| Redis cache producer/consumer | Single-responsibility service, org-scoped key, setex/get, CustomLogFormat rescue | app/core/domains/services/redis/organizations/set_owner_business_info.rb:4,14-15 | none |
| Error response shape | Failure('message') monad; CustomLogFormat.new(...).error for logs | find_user.rb:22, set_owner_business_info.rb rescue | none — HTTP error shape owned by hub_service |
| Logging / tracing | CustomLogFormat structured JSON (lib/custom_log_format.rb) | set_owner_business_info.rb rescue block | none |
| Migration | ActiveRecord::Migration[6.1], id: :uuid, in database/core/db/migrate/ | database/core/db/migrate/20260624000001_create_direct_send_message_histories.rb:3,5,6 | none |
Reading Order for the Agent
AGENTS.md— architecture, layers, feature flags, Redis globals, conventions.app/core/domains/models/organization.rb— the tenant anchor.spec/dummy/db/schema.rb(L1282-1314) —organizationsauthoritative columns.database/core/db/migrate/20260624000001_create_direct_send_message_histories.rb— migration template.app/core/domains/services/redis/organizations/set_owner_business_info.rb+get_owner_business_info.rb— cache idiom.app/core/domains/interactors/admin_view_user.rb— interactor template.app/core/domains/repositories/users/find_user.rb— repository template.app/core/domains/repositories/uploaders/organization_avatar_uploader.rb— asset/CDN.app/core/domains/services/preference_v2.rb(L20-76) — feature flag.lib/hub_core/engine.rb(L118, 122-123) — engine wiring / migrate paths.
Source Verification (anti-hallucination — required)
| Anchor / pattern / contract | Verified by | Evidence |
|---|---|---|
Models::Organization | read | class Models::Organization < Models::AbstractModel at organization.rb:3; mount_uploader :avatar, ...OrganizationAvatarUploader L14; ~120 store_accessor :settings L20-190 |
organizations schema | read (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 migration | read (agent) | database/core/db/migrate/20200417051101_create_organizations.rb:5 create_table :organizations, id: :uuid |
| recent migration template | read (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 globals | read | config/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 idiom | read | set_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 idiom | read | get_owner_business_info.rb:11 result = REDIS_R.get(key); L12 JSON.parse(result, symbolize_names: true) |
| interactor base + example | read (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 + example | read (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 / CDN | read | organization_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 service | read (AGENTS.md) | Services::Preference at preference_v2.rb:20-76; enabled?(:flag, organization_id:) |
| no HTTP layer | read | config/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 registration | read (agent) | lib/hub_core/engine.rb:122 appends /database/core/db/migrate to paths['db/migrate'] |
| greenfield (no branding code) | grep | grep -ril "branding|whitelabel|white_label" app lib database → no matches |
| test/lint commands | read | bitbucket-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 here | hub_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_configshas 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]) indatabase/core/db/migrate/, Postgres, UUID PKs — matching20260624000001_create_direct_send_message_histories.rb.citextrequires thecitextextension (verify enabled; else usestring+ 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).
hostis 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_primaryis 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 taggednew-with-justificationand owned cross-repo.
Outbound endpoints (consumers call us)
| Endpoint | Method | AuthN/AuthZ | Request | Response | Status codes | Idempotency | Versioning | Reuse? |
|---|---|---|---|---|---|---|---|---|
/branding (hub_service) | GET | none (public) — edge allow-list | Resolution via Host header or ?company=<code> / auth claim | branding JSON (below) | 200 (always, incl. default fallback); 400 only on malformed params | pure 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) | call | none (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) | idempotent | n/a | new |
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 path | Transaction scope | Partial failure behavior | Idempotency key | Consistency | Duplicate-event handling | Stale-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 converge | Cache bust after commit; stale ≤ TTL if a bust is dropped |
| Cache set on read | single SETEX per key | on Redis error: log via CustomLogFormat, still return DB-resolved payload (cache best-effort) | key = org/host | eventual | overwrite (last-writer) | TTL-bounded |
Detail 2.B — Concurrency Collision Map
| Resource | Writers | Collision scenario | Resolution | Behavior on failure |
|---|---|---|---|---|
branding_configs row (per org) | admin write path | Two admins update same org concurrently | Unique index on organization_id + AR upsert; last-writer-wins within short window | Second write overwrites; both bust cache; no lock needed (rare, low-value) |
tenant_domains.host | admin write path | Two orgs claim the same host | Unique index on host → 2nd insert raises ActiveRecord::RecordNotUnique | Return Failure('host already claimed'); no cross-tenant hijack |
Branding::* cache keys | resolver (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 value | Worst 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 / service | Inbound trigger | Outbound effect | Failure handler | Anchor |
|---|---|---|---|---|---|
1. Route Host→tenant, allow-list /branding unauth | Platform / DevOps (edge) | Browser HTTPS on tenant domain | Forwards to hub_service without auth gate | Misroute → wrong/absent Host; hub_service defaults | doc Decision #3 follow-through |
2. Serve GET /branding (public) | hub_service (API squad) | Edge HTTP | Calls ResolveBranding; sets Cache-Control | Interactor Failure/exception → 200 default or 5xx-safe fallback (hub_service decides) | Decision 9, 10 |
| 3. Resolve + cache + fallback | hub_core (this RFC) | in-process call(host:/org:) | Success(Entities::Branding); Redis set | unknown tenant → default (not error); Redis error → DB value | §2.1, §2.2 |
| 4. Persist + invalidate (admin) | hub_service (authz) → hub_core | admin PUT/POST | UPSERT + cache bust | txn rollback; unique-violation Failure | §2.2 write path |
| 5. Consume payload, theme app | FE squads (hub-chat, crm-fe-v3) | app boot fetch | applyBranding writes --mp-colors-*, swaps favicon/font | payload fetch fail → last localStorage/default (FE) | doc §5, §4b |
6. Complete data-panda-theme=next rollout (hub-chat) | FE squad | — | brand tokens reach all components | legacy routes stay hardcoded blue.400 | doc §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
| Entity | State field / event | Default values | Updated by | Read via | Stale window |
|---|---|---|---|---|---|
| Branding (per tenant) | productName, colors map, assets, font, links | Qontak defaults (product Qontak, Qontak tokens/logo) | CreateOrUpdateBranding | ResolveBranding → GET /branding | ≤ 1 h (cache TTL); ~0 after invalidation |
| Tenant domain mapping | tenant_domains.host → organization_id, is_primary | none (unmapped host → default) | admin write path | resolver (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 /brandingper 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.hostunique index keeps Plan 2 O(1). Stampede protection: cheap repopulate; optionalset nx:guard (pattern atservices/uploader/broadcast_media.rb:29) if a single tenant ever herds. - Load test: k6/JMeter against hub_service
GET /brandingwith 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_missmetric names (app/core/domains/services/redis/abstract_models.rb:62) for branding cache → Datadog (service-metadata.yamlgolden-metric dashboard). - Resolver logs:
CustomLogFormatstructured JSON (class_name,method_name,args) on error, matchingset_owner_business_info.rb. - Alert: fallback-rate (default branding served for a branded org) crossing a threshold indicates a resolution/cache regression.
- SLO:
GET /brandingavailability ≥ 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:erroron infra failure (CustomLogFormat.error), else quiet. - PII scrubbed: none present — no user PII in this domain.
hostand brand fields are public.
Security Implications
- Threat model: (a) tenant-hijack via
hostcollision → prevented by unique index ontenant_domains.host(§2.B); (b) injection of a sensitive field into a world-readable payload → prevented by theEntities::Brandingallow-list (Decision 6) — reviews must reject PII additions; (c) malicious CDN/URL values (SSRF/XSS via injected URLs) → https-only + host allow-list validation oncdn_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_tokenskeys ∈ verified allow-list; values match/\A#[0-9A-Fa-f]{6}\z/;hostmatches a hostname regex, lowercased;*_url/cdn_urlmust behttps://with an allow-listed host;kind∈{logo, favicon, apple_touch_icon, font};product_namelength-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.ymlbrakeman 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
| Role | Endpoint(s) | Permitted methods | Tenant scope | Additional constraint | Audit trail |
|---|---|---|---|---|---|
| Anonymous visitor | GET /branding | GET | resolved single tenant (own domain) | read-only; no PII returned | edge/request log (no PII) |
| Backend renderer (email/PDF) | ResolveBranding (in-process) | call | scoped by passed organization_id | read-only | CustomLogFormat |
| Admin / support (branding author) | write path (CreateOrUpdateBranding) | create/update | own 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 call | Timeout | Retries | Circuit breaker | DLQ | Caller behavior on persistent failure |
|---|---|---|---|---|---|
| Redis GET/SETEX (cache) | Redis client default (short) | none (best-effort) | n/a | n/a | Log CustomLogFormat; fall through to DB and still return payload |
| Postgres read (config/domain/assets) | DATABASE_TIMEOUT (env, 5000 ms) | none | n/a | n/a | On replica failure read primary; on total DB failure return default branding (200) rather than 5xx |
| Postgres write (admin upsert) | DATABASE_TIMEOUT | none (admin retries) | n/a | n/a | Transaction rollback → Failure(message); cache not busted |
| CDN asset fetch | n/a (browser→CDN, not in resolver) | n/a | n/a | n/a | Resolver returns URLs only; asset availability is a browser/CDN concern |
Detail 3.A.1 — Branch & Skip Catalog
| Branch trigger | Where checked | Downstream effect | Audit trail | User-visible? |
|---|---|---|---|---|
| Unknown host / unmapped company | ResolveBranding (hub_core) | return default Qontak branding | log fell_back=true | yes (default brand shown) |
whitelabel_branding flag OFF for org | ResolveBranding (Services::Preference) | return default Qontak branding | log fell_back=true | yes |
| Branding config row absent for resolved org | ResolveBranding | return default Qontak branding | log fell_back=true | yes |
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) route | FE (design doc §4b) | brand tokens don't reach hardcoded blue.400 components | n/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).
| Endpoint | Error code | HTTP status | Message | When | User-facing? |
|---|---|---|---|---|---|
GET /branding | INVALID_PARAMS | 400 | "Invalid branding request" | malformed ?company= with no Host | no (rare) |
GET /branding | (none) | 200 | default branding payload | unknown/unbranded/flag-off tenant | no (transparent) |
| write path | HOST_TAKEN | 409 | "host already claimed" | tenant_domains.host unique violation | yes (admin) |
| write path | INVALID_TOKEN | 422 | "unknown color token / invalid hex" | token not in allow-list / bad hex | yes (admin) |
| write path | INVALID_URL | 422 | "URL must be https and allow-listed host" | bad cdn_url/*_url | yes (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
organizationsor any existing contract. - Compatibility window: n/a (additive).
- Consumer notification: FE squads + API squad coordinate on the new
GET /brandingcontract. - 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_brandingviaServices::Preference(Flipper + Redis), org-scoped. Default OFF → all orgs resolve to default Qontak branding (identical to today's behavior). - Rollout stages:
- internal: migrations deployed; flag ON for one internal test org with seeded branding +
a
tenant_domainsrow on a staging host; verify resolution + cache + fallback. - 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. - N tenants: enable per onboarded tenant (org-scoped flag) — branding is opt-in per tenant, so "100%" = every whitelabel customer, not every org.
- internal: migrations deployed; flag ON for one internal test org with seeded branding +
a
- Stop conditions:
GET /brandingerror 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 / flag | Type | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|
whitelabel_branding (Flipper flag) | feature flag | OFF | yes | Services::Preference (migration/admin task) | no |
BRANDING_CACHE_TTL_SECONDS (optional) | integer | 3600 | no | env (defaults to constant if unset) | no |
OSS_CDN_URL / AWS_BUCKET / AWS_REGION | string | existing | yes | env (existing) | key/secret yes (existing) |
REDIS_R_URL / REDIS_W_URL | string | existing | yes | env (existing) | no |
Edge allow-list rule for /branding (unauth) | ingress config | none | yes | Platform/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.
| Layer | Command (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 — models | bundle 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 cache | bundle exec rspec spec/core/domains/services/redis/branding/ | setex key+TTL; get parse; invalidate deletes keys |
| Unit — interactor/repo | bundle exec rspec spec/apps/whitelabel/ (co-located per AGENTS.md) | resolve by host / org; fallback; wrong-org isolation; write + invalidate |
| Full suite | bundle exec rspec (AGENTS.md) | no regressions |
| Lint | bundle exec rubocop --no-color (AGENTS.md) | style clean |
| Security | bundle exec brakeman --no-exit-on-warn --no-exit-on-error --color -q (AGENTS.md) | no new high warnings |
| Pre-PR gate | bin/overcommit_run (AGENTS.md) | bundle + rspec + brakeman + rubocop |
Contract / E2E GET /branding | n/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 underspec/core/domains/. Every new interactor/repo/service needs a spec covering happy path, validation failure, wrongorganization_id, and external failure (AGENTS.mdDefinition of Done).
| Order | Chunk | Files to modify/create | Commands to run | Acceptance criteria |
|---|---|---|---|---|
| 1 | Migrations: 3 branding tables | database/core/db/migrate/20260724000001_create_branding_configs.rb, ..._000002_create_tenant_domains.rb, ..._000003_create_branding_assets.rb | RAILS_ENV=test bundle exec rails app:db:migrate | tables exist; unique indexes on organization_id, host, (organization_id,kind); db:rollback reverts cleanly |
| 2 | Models | app/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 |
| 3 | Entity + builder + token/URL validators | app/core/domains/entities/branding.rb, app/core/domains/builders/branding.rb, app/apps/whitelabel/constants/* (allow-list) + specs | bundle exec rspec spec/core/domains/builders/branding_spec.rb | builder maps config+assets→Entities::Branding; allow-list rejects unknown token; hex regex enforced |
| 4 | Redis cache services | app/core/domains/services/redis/branding/{set_config,get_config,set_domain,get_domain,invalidate}.rb + specs | bundle 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 |
| 5 | Repository + Resolve interactor (Plan 1/2 + fallback + flag) | app/apps/whitelabel/repositories/find_branding.rb, app/apps/whitelabel/interactors/resolve_branding.rb + co-located specs | bundle 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 |
| 6 | Write interactor + invalidation | app/apps/whitelabel/interactors/create_or_update_branding.rb + repo + specs | bundle 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 |
| 7 | Register feature flag | migration/rake task calling Services::Preference.new.add(:whitelabel_branding, target: 'feature', ...) + spec | bundle exec rspec spec/apps/whitelabel/ | flag registered; resolver honors org-scoped enable/disable |
| 8 | Full gate | — | bin/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):
RAILS_ENV=test bundle exec rails app:db:create app:db:migrate(README; resets test DB if duplicate-migration error)bundle exec rubocop --no-colorbundle exec rspec spec/apps/whitelabel spec/core/domains/models/branding_config_spec.rb spec/core/domains/services/redis/branding(feature specs)bundle exec rspec(full suite)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 /brandingp99 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=truerate ≈ 0 for orgs that have branding.
- Datadog Qontak Chat golden-metric dashboard (
- Rollback recipe (in order):
Services::Preference.new.disable(:whitelabel_branding)(or disable per-org) → all tenants serve default Qontak branding immediately.- If the migration must be reverted:
RAILS_ENV=<env> bundle exec rails app:db:rollback STEP=3(tables are empty/greenfield — safe). - Confirm
GET /brandingerror rate returns to baseline andfell_backbehavior 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
- [BLOCKER — cross-repo ownership] The design doc says the service is "owned by qontak.com",
but
hub_corehas no HTTP layer. Confirm with the API squad thatGET /branding(public gate + edge allow-list) is built inhub_service(API::Core::V1vsAPI::Internal::V1?) calling thehub_coreResolveBrandinginteractor. A companion hub_service RFC is needed (endpoint routing, auth-skip,Cache-Control, rate-limit, E2E).hub_serviceis not checked out at../backend/hub_core, so its exact patterns are unverified here. - [BLOCKER — approvers] Assign reviewers (hub_core, hub_service, FE) and approvers including an
infosec approver — required because
GET /brandingis public/unauthenticated (Decision 10). - Resolution signal for Plan 1: which identifier does the FE/login send —
company_id(exists onorganizations, unique) orsso_id/unified_sso_id? This RFC assumescompany_idfor the?company=path; confirm and adjust the resolver contract. citextextension:tenant_domains.hostusescitextfor case-insensitive uniqueness. Confirm thecitextextension is enabled in thechatDB; if not, migration mustenable_extension 'citext'or fall back tostring+ lowercased-on-write + unique index.- Audit sink for admin writes: use
Models::Billing::AuditLog, PaperTrail, or a new branding audit table? (AGENTS.mdcitesModels::Billing::AuditLogfor billing events.) Decide before B5. - Default branding source: hardcode Qontak defaults in a constant, or seed a
branding_configsrow for a "default" org? This RFC assumes a code constant (Entities::Branding.default). - hub-chat next-theme prerequisite (design doc §4b): full component theming on hub-chat depends
on completing the
data-panda-theme=nextrollout — track as an FE dependency; branding for non-migrated hub-chat routes will be partial. Legacyhubis not themeable at all. - Neutral token alias (design doc §4): consider introducing
--mp-colors-brand-primaryso the payload isn't named after Qontak (--mp-colors-brand-qontak). FE/design-system decision; thecolor_tokensjsonb already stores whatever keys are allow-listed, so this is a validation-list change, not a schema change. - Target release
2026-Q3is 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
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-07-23 | RFC drafted (rfc-starter) from Confluence design doc, grounded in hub_core | Assign 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 /brandingHTTP endpoint, public/unauth edge allow-listing (hub_service), and the FEapplyBrandingconsumers 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),citextavailability, audit sink, default-branding source.
Optional: hand this RFC to
rfc-reviewerfor a second-pass score now that the §7 gate isyesfor the hub_core scope.