RIFF design doc · v3 · 2026-07-06 · reviewed with Codex (15 findings addressed) · markdown twin: docs/config-cascade-and-branding.md

The Config Cascade & Per-Business Branding

Every flow gets its own branded follow-along link, and every configuration question gets a one-lookup answer.

TL;DR. Today every flow's companion page wears the space-channel look, and a flow's real configuration lives in six different places. We add one config cascade with a new explicit business level, resolved by one module that remembers where each value came from (provenance). Branding becomes just another config domain riding that cascade — so the plumbing line is blue, the law line is gold, and nobody inherits the space channel by accident.

1 · The problem, in three sentences

2 · The cascade — six levels, plain nouns

Most specific wins. Only one level is new.

callone live phone call — per-caller voice, consent
▲ overrides
stateone step — "collect the address"
▲ overrides
state groupa named section of one flow — the existing GoalSegment (riff/types.py)
▲ overrides
flowone phone experience — one YAML in flows/
▲ overrides
org NEWone company/business owning several flows — orgs/capsule_law.org.yaml
▲ overrides
globalrepo defaults + this deployment's env (keys, phone routing)
Org vs business — they are the same real-world thing.

An org is a business (a company). The two words only tell you where the YAML lives. The old assumption was one company = one flow, so each pack got a business: block — the company's info card for that one phone line (business.id doubles as the flow's identity and feeds the prompt-facing business_profile). That assumption broke — capsule_law has two flows, space_channel three — so the org is simply the missing "shared by all of this company's lines" file.

One-liner: org = the company's shared file · the pack's business: block = that company's info card for one specific line. "Group" was unusable (state groups / GoalSegment); "tenant" over-promises isolation we've deferred. Long-term, one word should mean one thing — see backlog F-370.

3 · Five domains, one file per org

# orgs/capsule_law.org.yaml
org_id: capsule_law
display_name: "Capsule Law"
brand:   {badge: "CL", tagline: "your intake, live",
          colors: {primary: "#c8a24a", highlight: "#e2c56f", bg: "#0b0e19", panel: "#131730"},
          theme: null}            # optional ALLOWLISTED theme name — never a css path
telecom: {sms_campaign: C51GVEC}  # identity only; numbers/routing stay in env
policy:  {record: on, companion_privacy: phone_verified}
llm:     {default: gemini-2.5-flash}
voice:   {voice_id: en_GB-alba-medium, style: warm}

4 · One resolver, with provenance

riff/effective_config.py · resolve(flow_id, state_id=None) + apply_call_overlay(cfg, overlay) for per-call overrides — runs at load time; every value carries its source. The existing record-policy and LLM-policy cascades migrate onto this resolver (behavior-preserving, full-suite diff as the bar) so there is exactly one implementation — and the answer below is always true, never a guess:

"Why is this call recording?"
policy.record = on org:capsule_law unless record = off state:collect_ssn tighten ok never record = on at a state under an off org load error

Env vars stay the deployment layer forever — phone routing, numbers, and keys never migrate into git.

5 · Branding rides the cascade PR3 SHIPPED

Hands-on guide: How-To — Brand Your Flow's Follow-Along Page (colors, badge, tagline, and journey: caller-friendly step labels). Note: the flow's MENU and live PROGRESS were always per-flow (they're data); branding adds the look and the journey wording.

org YAML brand: compiler → ui_brand on the flow /api/companion/state serves tokens only one companion.html applies CSS variables
APAustin Plumbing
primary #38bdf8 · highlight #facc15
CLCapsule Law
primary #c8a24a · highlight #e2c56f
SCSpace Channel
theme: space (allowlisted)

6 · The admin portal — grows in three deliberate steps

stepwhatwhy it's safe
v0 · report a script emits static HTML per flow/org: every resolved value + its source + a drift table + brand swatch zero new surface; validates the model before any app exists
v1 · read-only page Line-Page-style operator page: flow + state picker, call-overlay simulation, config table color-coded by source, org drift view, live brand preview (renders companion.html against a mocked state — same harness as the Playwright screenshot tests) LAN-only like existing operator pages; cannot write anything
v2 · writes deferred validated YAML patch → compiler + linter run server-side → branch-only git commit ships only with auth, audit log, and an explicit reload story (config_epoch exposes stale servers); separately designed and reviewed

7 · Rollout

prcontentgate
1effective_config.py + org loader + linter rules + monotonic/provenance testsconsumes nothing yet
2migrate record-policy, LLM-policy, voice onto the resolver + Flow carries EffectiveConfig + org files join the loader cache fingerprintsemantic ratchet: state-on-over-segment-off flows must be restructured; full-suite diff for the rest
3branding end-to-end; sniffing deletedlint error replaces silent styling
4org files for real familiesafter the intake agent's pack WIP lands
5report v0 → portal v1 + Playwright screenshotsresolver already authoritative

8 · Decided & rejected (Codex review)

decisionrationale
level named org"business" collides with pack business: + business_profile; "group" with GoalSegment; "tenant" over-promises
flows point at org, one directionno bidirectional membership drift
domains resolve independentlyshared telecom + different brand is normal
policy is monotonicrecording/privacy must never loosen downstream
allowlisted themes, no css pathskeeps contrast + screenshot guarantees
env = deployment layer, foreverno routing/numbers/secrets in git
portal read-only until resolver is authoritativeotherwise the portal lies or forks logic
missing prod brand = lint errora hash-palette fallback would masquerade as intentional

9 · Backlog

itemwhatblocked on
F-370
retire the "business" double-meaning
Migrate company facts (name, timezone, industry, default hours, brand) up into orgs/<id>.org.yaml; shrink + rename the pack business: block to line:/storefront: holding per-line overrides only, behind a deprecation window (business: emits a lint info). business_profile — the compiled, prompt-facing artifact — keeps its name. End state: one word = one meaning (org = company, line = per-flow card).
docs/project-memory/backlog/F-370-retire-business-word-from-pack-schema.md
PR1/PR2 (org level exists) + the intake agent's pack WIP landing