KDCube Application Configuration: Six Data Classes, One Live Contract
Configuration is not one YAML document. Six data classes keep ownership, secrecy, scope, and export rules explicit.
Configuration is not one YAML document. KDCube separates deployment properties, deployment secrets, user properties, user secrets, platform settings, and platform secrets because they have different owners and export rules. App code reads one effective contract through supported SDK helpers; provider-backed reads and writes remain async. It does not parse environment variables or reach behind the configured authorities.
An AI app configuration model has to answer more than "where is the key?" It must answer who owns the value, whether it is secret, whether it belongs to one user or the whole deployment, which runtime may receive it, when a change takes effect, and whether it can be exported.
KDCube makes those dimensions explicit. Code defaults describe the app's portable non-secret shape. bundles.yaml carries deployment overrides and surface policy. bundles.secrets.yaml or another configured provider carries deployment app secrets. User properties and user secrets remain in user-scoped stores. A small request-scoped context can cross supported runtime boundaries without becoming durable configuration.
This article explains that configuration engine. The package and first-surface story remains in Build an AI App with KDCube; the exact change/reload moments are treated separately in the application lifecycle article.
KDCube now says app in builder-facing prose. Literal platform contracts still say bundle in names such as bundles.yaml, bundle_id, bundle_prop(...), and kdcube bundle config apply.
00Implementation highlights
| Concept | The takeaway |
|---|---|
| Three scopes, six data classes | Platform, deployment app, and user app scope each have separate non-secret and secret state. |
| Effective properties are a merge | Code defaults are deep-merged with descriptor/admin overrides; app code reads the effective result. |
| Secrets do not join that merge | Secret values have no code defaults, remain in the configured provider, and are resolved asynchronously. |
| Redis is a cache, not descriptor authority | Live app-property writes persist to authority first, then update Redis and notify workers. |
| Request context is not configuration | bundle_call_context carries small JSON-safe invocation facts but is neither durable nor exportable. |
| Platform-reserved keys stay app properties | enabled, surfaces, model roles, UI, execution, and public-content blocks are ordinary app properties with platform interpretation. |
01The scope decision comes first
KDCube has three configuration scopes:
platform / deployment one tenant/project runtime app deployment one app for that tenant/project app user one resolved user inside that app
Each scope has non-secret and secret state, giving six data classes:
| Data class | Typical examples | Read contract | Durable owner |
|---|---|---|---|
| Platform properties | auth mode, service ports, storage backend selection | typed platform settings | promoted platform configuration |
| Platform secrets | deployment-wide provider key, signing secret | await get_secret("canonical.key") | configured secrets provider |
| App properties | feature gate, model role, endpoint, cron, surface policy | self.bundle_prop(...) | app descriptor authority, with Redis runtime cache |
| App secrets | app-specific provider key, webhook secret, MCP credential | await get_secret("b:...") | configured secrets provider |
| User app properties | theme, selected account, product preference | async user-property helpers | PostgreSQL user_bundle_props |
| User app secrets | one user's personal provider credential | await get_secret("u:...") | configured secrets provider under user scope |
The classification prevents common leaks:
- a Slack access token cannot become a non-secret app property;
- one user's selected account cannot become an app-wide default;
- a deployment signing key cannot be written by ordinary app code;
- a model-role choice for one invocation does not have to mutate durable app configuration.
App code may read all six through supported SDK contracts. It normally writes only its own deployment properties/secrets and its own user properties/secrets. Platform/global state remains operator-owned.
One app entry, three different contracts
The app item in bundles.yaml contains both registry facts and non-secret app properties, but they do not have the same runtime meaning:
bundles:
items:
- id: reporting@1-0
# Registry/source and lifecycle contract
repo: https://github.com/example/acme-apps.git
ref: 2026.07.18.1
subdir: apps/reporting
module: entrypoint
singleton: true
# Non-secret effective app-property overrides
config:
reports:
page_size: 50
enabled:
cron:
refresh-index: trueThe registry fields tell proc which code generation to resolve and how to load it. They do not participate in self.bundle_prop(...). The config mapping does participate in the effective-property merge described below. App secrets live under the matching app id in bundles.secrets.yaml or its configured provider; they never become another block in config.
That separation also explains the change boundaries. Editing reports.page_size is a property update. Selecting a new ref, subdir, module, or singleton setting changes the loaded app definition and requires generation invalidation. Rotating a secret changes the secret authority and the clients that resolve from it. One editor may present these controls together, but the runtime does not collapse their semantics.
02Code defaults plus deployment overrides produce effective properties
Stable, non-secret defaults intrinsic to the app belong in configuration_defaults() or the entrypoint's configuration default contract. Deployment-specific overrides belong under the app item in bundles.yaml.
app code
configuration_defaults()
|
| recursive dictionary merge
v
effective app properties
self.bundle_prop("path.to.value")
^
|
bundles.yaml / Bundle Admin authority
bundles.items[].config
Merge behavior is intentionally simple:
- dictionaries merge recursively;
- a descriptor/admin value wins at the same path;
- lists and scalar values replace the default at that path;
- an absent override leaves the code default effective.
For example, code can declare a safe default:
def configuration_defaults(self):
return {
"reports": {"page_size": 25, "export_enabled": False},
"enabled": {"cron": {"refresh-index": True}},
}The deployment can override only what differs:
bundles:
items:
- id: reporting@1-0
config:
reports:
export_enabled: trueApp code reads:
page_size = self.bundle_prop("reports.page_size", 25)
export_enabled = self.bundle_prop("reports.export_enabled", False)Code defaults are not copied into bundles.yaml merely because the app loaded. Bundle Admin can show persisted props and code defaults separately. An explicit reset/materialization action can write defaults when that is the operator's intent, but the normal descriptor remains a concise set of deployment choices.
03App properties are the operator-visible configuration channel
Every app knob an operator may need to change belongs in the property contract:
- timeouts and limits;
- feature switches;
- model-role and embedding selection;
- surface enablement and visibility;
- MCP server endpoints and per-agent inventory;
- UI source/build declarations and product presentation;
- cron schedules and job policy;
- public-content catalogs;
- app-specific storage/index behavior.
An environment variable read inside app code is not an app configuration surface. It is invisible to the app descriptor, Bundle Admin, config export, and the normal live-update path. Platform processes may use deployment environment internally to assemble platform settings, but app behavior must be declared as app properties.
When an existing standalone solution uses environment variables, keep that idiom inside the standalone package and overlay KDCube app properties at the adapter boundary. In the hosted app, the descriptor value is authoritative.
Module-level globals are not a replacement either. They are process-local, cannot represent per-user or per-invocation state, survive changes unpredictably across imports, and disappear on worker restart.
04Reserved properties are still part of the same app contract
Some property paths are interpreted by platform subsystems. They are not a fourth store and not hidden metadata.
| Property family | Platform meaning |
|---|---|
enabled.* | app and per-surface feature-gate overrides |
surfaces.as_provider.* | visibility, authentication ownership, and product intent for offered surfaces |
surfaces.as_consumer.* | outbound connections, per-agent tool inventories, UI/scene consumption |
role_models | model routing by semantic role |
embedding | embedding provider/model selection |
execution.runtime | app-level generated-code runtime and limits |
ui.widgets.*, ui.main_view.* | UI source/build and site configuration |
public_content.* | public-content enablement, canonical base, catalogs, and presentation |
economics.* | app-level economics defaults interpreted by guarded surfaces |
Surface enablement is an override layer, not a duplicated inventory:
config:
enabled:
bundle: true
api:
"operations.export.POST": false
mcp:
reports: true
widget:
reporting: true
cron:
refresh-index: falseDecorators and code defaults declare what exists and its normal enabled state. The descriptor should carry intentional deployment differences, not mirror every surface as true.
enabled answers whether a surface is active. surfaces.as_provider answers who may reach it and which authentication owner applies. Combining those into one flag would lose the distinction between existence and policy.
05Secrets have no defaults and never enter the property merge
Deployment app secrets live in bundles.secrets.yaml in local secrets-file mode or in the configured secrets provider in another deployment mode.
# bundles.secrets.yaml
bundles:
items:
- id: reporting@1-0
secrets:
integrations:
partner:
api_key: replace-through-secret-providerApp code resolves them asynchronously:
from kdcube_ai_app.apps.chat.sdk.config import get_secret
api_key = await get_secret("b:integrations.partner.api_key")The b: prefix means the current app. The u: prefix means the current resolved user inside the current app. A canonical unprefixed key addresses platform/global secret scope.
App code can apply a useful service-key override sequence explicitly:
await get_secret("b:services.<provider>.<key>")
-> platform/global secret services.<provider>.<key>
This lets one app use its own model, Git, payment, or search credential while other apps inherit the deployment default. The fallback is an async app choice, for example:
api_key = (
await get_secret("b:services.openai.api_key")
or await get_secret("services.openai.api_key")
)The supported configuration path does not place secret values in self.bundle_props or bundles.yaml. Trusted app code must also avoid copying resolved secrets into:
- model prompts or generated code;
- browser config;
- logs;
bundle_call_context.
Async secret helpers keep provider I/O off the shared event loop. A long-lived client that captured a secret at construction still needs an explicit reconstruction policy after rotation; secret storage does not mutate an already-created client object.
06User properties and user secrets are separate from app deployment state
User-scoped app properties are durable product state in PostgreSQL. User-scoped secrets remain in the configured secret provider. Neither is merged into deployment app properties or exported to app descriptors.
tenant + project + app + resolved user + typed key
The resolved user may be a platform account in chat or a stable app-owned external identity on a public integration. App code should not assume every user scope has a browser login.
Use user properties for decisions that must survive requests:
- selected account or workspace;
- notification preferences;
- UI/product choices;
- per-user feature settings;
- remembered configuration that is not secret.
Use user secrets for personal credential material:
- provider access/refresh tokens;
- app passwords;
- user-specific API keys.
The helper names are intentionally uniform, and app clients use them as async APIs: await get_user_prop(...), await set_user_prop(...), await delete_user_prop(...), and the corresponding user-secret helpers. Direct SQL or provider-internal access would bypass the app/user scope and future backend evolution.
07The portable context room is not a seventh store
Some values belong only to the current invocation but must follow nested work across supported runtime boundaries. KDCube carries those JSON-safe facts in bundle_call_context.
Examples:
- request mode such as preview/final;
- app-owned correlation metadata;
- a temporary model-role overlay;
- small inputs a background or fenced runtime must reconstruct.
current API/chat/job invocation -> bundle_call_context -> nested async task / tool / supported child runtime -> restored for that execution
This context is not durable, not exported, and not a secret channel. If the value must affect a later independent request, persist it first as an app property, user property, job payload, or app-owned domain record.
The context carries descriptors and ids, not live database pools, Redis clients, callbacks, or file bytes. Trusted services are reconstructed on the other side. In the split generated-code runtime, privileged descriptor and credential material stays with the trusted supervisor rather than the restricted executor.
08The live property path has one authority
For deployment app properties, the normal write journey is:
Bundle Admin or await set_bundle_prop(...) -> load and validate current app descriptor authority -> persist the change to that authority -> update Redis app-property cache -> publish bundles.props.update -> cached instances refresh effective properties -> on_props_changed only when effective values differ
Redis accelerates runtime reads and distributes change notification. It is not the durable authority. After cache loss, the descriptor authority rebuilds the runtime view.
Authority depends on deployment mode:
| Mode | App property authority | App secret authority |
|---|---|---|
| CLI local compose | staged writable bundles.yaml | bundles.secrets.yaml when secrets-file mode is active, otherwise configured provider |
| Direct local service | explicitly addressed descriptor file | configured provider/file |
| Cloud | configured app descriptor authority; recommended file-backed ECS shape uses writable mounted descriptor state | configured secrets provider such as AWS Secrets Manager |
Code should not branch on those backends. It uses bundle_prop, secret, and user-state helpers.
09Apply and export preserve the ownership split
KDCube keeps app descriptors portable without pretending every runtime value belongs in them.
seed descriptor set
bundles.yaml
bundles.secrets.yaml
|
| kdcube bundle config apply
v
live app descriptor + secret authorities
|
| Bundle Admin / supported helpers
v
new live app state
|
| kdcube config export
v
reviewable bundles.yaml + bundles.secrets.yaml
kdcube bundle config apply --descriptors-location ... reapplies the app descriptor pair. It does not modify assembly.yaml, gateway.yaml, or platform secrets.yaml.
kdcube config export --out-dir ... is the safety valve when live Bundle Admin or app writes may be newer than the seed files. User properties and user secrets are never exported into deployment descriptors.
Raw descriptor helpers such as get_plain("b:...") are for intentional descriptor inspection. They do not return the code-default merge and should not replace self.bundle_prop(...) in normal app behavior.
10One configuration, several consumers
An effective property can be data and also require application to a live runtime object.
Model-role configuration is a clear example:
code default role_models -> deployment override in bundles.yaml -> effective app properties -> platform applies model-router state -> optional bundle_call_context overlay for this invocation
The platform applies reserved model and embedding paths during property refresh. App-specific runtime objects use the async on_apply_props(props) hook. That hook runs after platform-owned application and can ask the base to rebuild model services when necessary.
The same principle applies to other long-lived objects. Reading a new endpoint property does not mutate a client already constructed with the old endpoint. The app owns a concise reconciliation step in on_props_changed or on_apply_props.
Configuration therefore has two stages:
- resolve one effective value set from the correct authorities;
- deliberately apply relevant values to runtime objects.
11Configuration review
For every app setting, answer:
- Is it platform, app deployment, or app user scope?
- Is it secret?
- What is the durable authority?
- Does app code read the effective value or intentionally inspect raw descriptor state?
- Does the value need to cross only this invocation through
bundle_call_context? - Which component must be reconstructed when the value changes?
- Is the value exportable, and to which descriptor?
- Is app code using the supported SDK helper, with async helpers for I/O, rather than environment, global state, provider internals, or blocking I/O?
When those decisions are explicit, Bundle Admin, CLI, app code, background jobs, tools, and UI all describe the same configuration system.
Read the contracts
Platform documentation:
- App runtime configuration and secrets
- App properties and secrets lifecycle
- Runtime configuration and secrets store
- App descriptor
- App secrets descriptor
- Reserved app properties
- Cross-runtime context
Related public articles:
- Build an AI App with KDCube
- One Home for What the User Decided
- The Portable Context Room
- Local LLM for Your Agent