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.
1 · The problem, in three sentences
- Branding:
web/companion.htmlguesses a theme by substring-matching the flow id ("plumb"→ blue). New flows silently render as the space channel. - Sprawl: a flow's behavior is assembled from pack YAML, flow YAML, env vars, the record-policy cascade, the LLM-policy inheritance, and the voice hierarchy — six mechanisms, no single answer to "what is this flow's config and why."
- Sharing: flow families already exist (space_channel ×3; capsule_law's two legal flows share one 10DLC SMS campaign) but each flow re-declares shared facts, and they drift.
2 · The cascade — six levels, plain nouns
Most specific wins. Only one level is new.
GoalSegment (riff/types.py)flows/orgs/capsule_law.org.yamlAn 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}
- Flows point at their org (
org: capsule_law) — one direction only; the org file has no member list, so membership can't drift. A dangling reference is a lint error. - The five domains (
brand / telecom / policy / llm / voice) resolve independently — sharing SMS identity while overriding voice is normal. A flow overrides a domain; it never joins a second org. - Safety is monotonic:
policy.*may only tighten downstream. An org'srecord: offcan never be flipped on by a state. Loosening is a load error.
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:
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.
brand:→
compiler → ui_brand on the flow→
/api/companion/state serves tokens only→
one companion.html applies CSS variables
- The resolved brand is served at request time by
brand_for_flow()— kept out of the prompt-facingbusiness_profile. (A compiledui_brandfield is the PR4 end-state.) - The public bearer link gets colors, name, badge, tagline — no provenance, no internal ids.
- Bespoke looks (space channel's starfield) become allowlisted named themes shipped in-repo — never arbitrary CSS paths, so contrast checks and screenshots stay trustworthy.
- Keyword mapping stays as the mid-priority fallback until PR4, then dies. Missing brand on a production flow is a lint warn today, hardening to error at PR4; hash-derived palettes cover otherwise-unbranded flows.
- One
ui_brandobject feeds the companion page, the intake live mirror, and the Line Page — link, mirror, and operator view always match.
6 · The admin portal — grows in three deliberate steps
| step | what | why 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
| pr | content | gate |
|---|---|---|
| 1 | effective_config.py + org loader + linter rules + monotonic/provenance tests | consumes nothing yet |
| 2 | migrate record-policy, LLM-policy, voice onto the resolver + Flow carries EffectiveConfig + org files join the loader cache fingerprint | semantic ratchet: state-on-over-segment-off flows must be restructured; full-suite diff for the rest |
| 3 | branding end-to-end; sniffing deleted | lint error replaces silent styling |
| 4 | org files for real families | after the intake agent's pack WIP lands |
| 5 | report v0 → portal v1 + Playwright screenshots | resolver already authoritative |
8 · Decided & rejected (Codex review)
| decision | rationale |
|---|---|
| level named org | "business" collides with pack business: + business_profile; "group" with GoalSegment; "tenant" over-promises |
| flows point at org, one direction | no bidirectional membership drift |
| domains resolve independently | shared telecom + different brand is normal |
| policy is monotonic | recording/privacy must never loosen downstream |
| allowlisted themes, no css paths | keeps contrast + screenshot guarantees |
| env = deployment layer, forever | no routing/numbers/secrets in git |
| portal read-only until resolver is authoritative | otherwise the portal lies or forks logic |
| missing prod brand = lint error | a hash-palette fallback would masquerade as intentional |
9 · Backlog
| item | what | blocked 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 |