Skip to content

Configuration Layers ​

The Manager resolves an application's frontend configuration through a cascade of settings rows. Defaults live on the project, a customer organization (tenant) can override them, and one tenant's use of one application can override them again.

The layers ​

┌─────────────────────────────────────┐
│        Project Settings             │  ← most general
│   (defaults for all tenants/apps)   │
└─────────────────────────────────────┘
              ↓
┌─────────────────────────────────────┐
│      Application Settings           │  ← module fields ONLY
│   (AvailableModules, EnabledModules)│     (theme/locale/key-values ignored here)
└─────────────────────────────────────┘
              ↓
┌─────────────────────────────────────┐
│         Tenant Settings             │
│   (defaults for one customer org)   │
└─────────────────────────────────────┘
              ↓
┌─────────────────────────────────────┐
│  Tenant × Application Settings      │  ← most specific (leaf)
│  (Tenant2Application: one customer  │
│      org's use of one application)  │
└─────────────────────────────────────┘

Every layer is a Settings row with a frontendSetting and a backendSetting. A Settings row hangs off exactly one owner: a project, an application, a tenant, or a Tenant2Application join row.

The leaf layer is the Tenant2Application row

The cascade leaf is the Tenant2Application join: the settings for one customer organization's use of one application. Config is fetched per (application, tenant) with GET /api/frontend-config/:application_id/:tenant_id, and the Manager returns 404 if the application is not enabled for the tenant. See Multi-tenancy.

The application layer is partial

The tenant-aware cascade takes only AvailableModules and EnabledModules from the application's own settings. Its theme, locale and key-value fields are skipped, because a freshly created application row carries schema defaults (DefaultTheme: "light", DefaultLocale: "en") that would otherwise clobber the project's values for every tenant. Put theme, locale and key-value defaults on the project or the tenant, not on the application.

How inheritance works ​

When the Manager builds the configuration for (application, tenant), it starts from an empty config and merges each layer in turn, least specific first. A later layer only changes a field when it sets a non-empty value for it, so:

  1. Tenant × Application wins if it sets the field.
  2. Otherwise Tenant wins if it sets the field.
  3. Otherwise Application wins, for the two module fields only.
  4. Otherwise Project wins.
  5. Otherwise the field is empty.

Example ​

The values below are custom key-value pairs (stored in KeyValuePairs; both keys and values are strings).

Project: Customer Portal

json
{ "theme": "light", "locale": "en", "features": "basic-dashboard" }

Tenant: ACME Corp

json
{ "theme": "dark", "support_email": "support@acme.com" }

Tenant × Application: ACME Corp × Mobile App (the leaf)

json
{ "locale": "es", "features": "chat" }

Effective configuration for Mobile App as ACME Corp sees it:

json
{
  "features": "chat",                  // Tenant × Application overrides Project
  "locale": "es",                      // Tenant × Application overrides Project
  "support_email": "support@acme.com", // Tenant
  "theme": "dark"                      // Tenant overrides Project
}

Merged key-value pairs come back sorted by key, so the response is stable across requests.

Configuration types ​

Field names below are the ones the GraphQL FrontendConfigInput and ModuleConfigInput use and the Settings Editor shows. The REST response from /api/frontend-config/... uses the same fields in snake_case (enabled_modules, default_theme, key_value_pairs, ...).

Frontend settings ​

Client-side configuration, delivered to the browser as window.__PRODUCTIFY__.config:

FieldMeaning
AvailableModules / EnabledModulesPilets (micro-frontends) the application may load, and the ones it does load. EnabledModules also decides which pilets the feed serves.
AvailableThemes / EnabledThemes / DefaultThemeTheme catalogue, enabled subset, and the default.
AvailableLocales / EnabledLocales / DefaultLocaleLocale catalogue, enabled subset, and the default.
KeyValuePairsCustom string parameters.
pilets.pinsPer-pilet version pins; only honoured on dedicated tenant hosts. See Per-tenant pilet pins.

Example:

json
{
  "EnabledModules": ["dashboard"],
  "EnabledThemes": ["light", "dark"],
  "DefaultTheme": "light",
  "EnabledLocales": ["en", "es"],
  "DefaultLocale": "en",
  "KeyValuePairs": [{ "Key": "support_email", "Value": "support@acme.com" }]
}

Two fields are attached at serve time and are never read from stored settings: pilet_signing_keys (the pilet trust anchor) and communications (the {enabled} subset of the comms configuration).

Backend settings ​

Server-side configuration:

FieldMeaning
AvailableModules / EnabledModulesBackend feature modules. Operator-readable only, see below.
KeyValuePairsCustom string parameters. Operator-readable only, see below.
communicationsThe notification/e-mail configuration block (operator-only; the browser sees just {enabled}).

Example:

json
{
  "EnabledModules": ["auth", "api", "analytics"],
  "KeyValuePairs": [
    { "Key": "api_version", "Value": "v2" },
    { "Key": "rate_limit", "Value": "1000" }
  ]
}

Backend settings are edited per layer (updateBackendModuleConfig in GraphQL, pfy application comms-config for the comms block). Only the communications block is resolved through the project → tenant → leaf cascade by the Manager; the other backend fields are read from whichever layer you query.

Backend settings are not readable by ordinary members

Unlike the frontend layer, which is delivered to every browser as window.__PRODUCTIFY__.config, nothing reads the backend KeyValuePairs at runtime. Its readership is the operators who edit it, and the read bar is the same as the write bar: project maintainer/owner, system administrator, or a tenant principal holding tenant.apps.configure for the tenant-owned layers.

The backend module lists are behind the same bar, for the same reason: nothing resolves them for a browser either. GetEnabledModules reads the frontend setting, and the frontend lists of the same name stay readable by everyone; they are what the SDK asks for.

A caller below that bar receives an empty list, which is deliberately indistinguishable from "no pairs are set", so the response carries keyValuePairsRedacted and moduleListsRedacted, alongside communicationsRedacted. Read the flag rather than inferring from emptiness, and never write back a list you were not allowed to read: an empty list sent as an update is a wipe.

moduleListsRedacted covers both module lists with one flag, because they are one editor, one mutation input and one refusal. It is separate from keyValuePairsRedacted because the claims differ: a layer with no key-value pairs but several modules is ordinary, so one flag for both would report "nothing withheld" over a withheld module list.

Override strategies ​

The merge behaviour depends on the field. A layer that leaves a field empty never changes it.

Replaced fields ​

EnabledModules, EnabledThemes and EnabledLocales are completely replaced when a more specific layer sets a non-empty list:

  • Project: EnabledModules: ["reporting", "analytics"]
  • Tenant × Application: EnabledModules: ["admin-panel"]
  • Result: ["admin-panel"]

This is what lets a tenant turn a module off: list the modules you want and the inherited list is gone. It also means a leaf that wants "everything the project enables plus one more" has to repeat the project's list.

DefaultTheme and DefaultLocale are overridden whenever a more specific layer sets a non-empty value.

Merged fields ​

AvailableModules, AvailableThemes and AvailableLocales are unioned across layers, duplicates removed, in first-seen order.

KeyValuePairs are merged by key; the more specific layer wins on a conflicting key. The result is sorted by key.

pilets.pins merge per pilet name; a leaf that pins one pilet keeps the pins inherited from the project and tenant for other pilets. Pins for pilets not in the effective EnabledModules are dropped before the config leaves the server.

When to configure at each layer ​

Project level ​

Use for defaults shared by every tenant and application in the project: the module catalogue, the default theme and locale, common key-value parameters.

Tenant level ​

Use for one customer organization's defaults across all of its applications: branding (theme, logo URL), support contact, default language.

Tenant × Application level ​

Use for what differs for one customer in one application: a locale override, a different module set, a pilet version pin.

What settings do not do ​

  • Settings are not per environment. A Settings row has no environment edge. dev, qas and run all resolve the same cascade. What differs per environment is the deployment spec (image, resources, env vars, domains), which lives on the Deployment row. See Anatomy of a Stack.
  • Settings are not access control. Enabling a module for a tenant does not grant its users anything; membership and roles do. See Access Model.

Viewing effective configuration ​

In the Settings Editor ​

The Settings Editor (project, tenant or application settings pages) shows a layer viewer:

  • A list of the layers that apply on this page: Project, then Tenant, then Application, depending on where you opened it.
  • A layer that defines its own values for fields also set above it carries an Override badge.
  • An inheritance strip shows the chain of layers leading to the one you are editing.

The leaf (Tenant × Application) row is edited through the per-tenant application config dialog (Application → Tenants), not from this viewer.

From the API ​

The authoritative answer is what the proxy fetches:

bash
curl -H "X-Proxy-Auth: $PFY_PROXY_SECRET" \
  "$MANAGER_URL/api/frontend-config/<application_id>/<tenant_id>"

The route is proxy-secret gated: the header must carry the Manager's PFY_PROXY_SECRET. A 404 means the application is not enabled for that tenant.

Best practices ​

Define a value once ​

Do not repeat the same value at every layer. Set support_email on the project and let tenants and applications inherit it. Set it on a tenant only when that customer's value differs.

Override only when necessary ​

Project:              theme = "light"
Tenant ACME:          (no theme — inherits "light")
ACME × Mobile App:    theme = "dark"     ← only this pair needs it

Remember that lists replace ​

If you set EnabledModules on a tenant, you are choosing that tenant's whole module list, not adding to the project's.

Test configuration changes ​

Settings are the same in every environment, so you cannot "try it in dev first" through settings alone. Test with a dedicated test tenant, or check the effective config through the API before changing a customer's leaf.

Troubleshooting ​

A setting is not taking effect ​

  1. Is a more specific layer setting the same field? The leaf wins over the tenant, which wins over the project.
  2. Is it a list field (Enabled*)? A non-empty list at a lower layer replaces the whole inherited list.
  3. Is it theme, locale or a key-value pair set on the application row? Those are skipped in the tenant-aware cascade; move them to the project or tenant.
  4. Is the application enabled for the tenant? If not, the config route returns 404 and the browser gets nothing.
  5. Is the browser caching an older window.__PRODUCTIFY__? Reload the page.

An unexpected value appears ​

Fetch the effective config through the API and compare it with each layer's stored settings, top to bottom, to find which layer set it.

See also ​