Flat keys, real inheritance: how Smpl Config works
Configuration management has two competing failure modes. Sprawl: the same value defined in forty places, nobody sure which one is authoritative. And coupling: a hierarchy so deep that changing a root value has consequences nobody can trace. Smpl Config is our answer to both, and its design rests on two decisions that look small and aren’t: flat dot-notation keys with no deep merge, and inheritance through an explicit parent field.
Why flat keys
The common model is nested objects — YAML where database.host is a host key inside a database block — with deep merge to override specific nested keys. The trouble is that “deep merge” isn’t one algorithm. Helm, Kustomize, and environment-overlay YAML each implement it slightly differently, and the edge cases pile up: which level wins? What happens when a child adds a key the parent lacks? How does a child delete a parent key?
Flat keys keep the namespacing — database.host, database.port — but the key string is the whole path. Nothing nests. Override semantics collapse to one sentence: each key is either defined at this config or inherited from the parent, and defining it overrides the parent’s value completely. One key, one value, one owner per level.
The trade-off is that you can’t override everything under database.* in one stroke; you list each key. In practice that has made overrides more intentional, not less convenient — an override you had to type is an override someone meant.
The inheritance model
Each config may name a parent. Resolution builds the obvious view: keys defined here win; anything else comes from the parent.
common-config
├── database.host: db.example.internal
├── database.port: 5432
├── logging.level: INFO
└── timeout.api: 5000
payments-service (parent: common-config)
└── timeout.api: 15000 # payments needs longer timeout; inherits rest
payments-service defines one key and inherits four. That’s the canonical shape: a common-config holds what every service shares, and each service’s config is a short list of exceptions.
Inheritance is deliberately shallow — a service → common chain, not parent-of-parent-of-parent. Deep chains are exactly how configuration becomes archaeology, and a two-level chain covers the overwhelming majority of real cases.
Per-environment overrides
Values exist per environment: a key’s full representation is a map like {"production": "db.prod.example.internal", "staging": "db.staging.example.internal"}. The console renders the whole thing as a grid — configs as rows, environments as columns — where an empty cell means “inherit from the parent, or fall back to the key’s default.”
Resolution picks the most specific applicable value for the requested environment: this config’s value for the environment, else the parent’s, else the default. Promotion from staging to production is editing the production cell to match staging — deliberately a per-key act. There is no “copy entire environment” button, because that button carries a staging experiment into production alongside the value you meant to promote.
Types and descriptions
Every key carries a type (STRING, NUMBER, BOOLEAN, JSON) and a description. Types are metadata — values are stored as strings and coerced at resolution.
The description field is the underrated one. Configuration values without context become cargo-cult artifacts: everyone knows the value is 5000, nobody remembers why, and nobody will touch it because nobody knows what breaks. So descriptions are first-class and sit next to every key in the console — an operator meeting timeout.api: 5000 for the first time reads “API request timeout in milliseconds. Payments increased to 15000 for Stripe calls.” and knows the intent without hunting down the PR that set it.
The resolution API
# Returns a dict of key → value, coerced to declared types
config = client.config.resolve("payments-service", environment="production")
db_host = config["database.host"]
# Returns a typed Pydantic model if a model class is provided
config = client.config.resolve("payments-service", model=PaymentsConfig)
db_host = config.database_host
resolve() returns the full resolved view — inherited keys included, overrides applied — then caches it and subscribes to change notifications over WebSocket, re-resolving when anything in the view changes. The subscribe() variant returns a live proxy instead: a dict-like object that always reads the current value rather than a snapshot.
live_config = client.config.subscribe("payments-service", environment="production")
# This always reads the current value, even after the config changes:
timeout = live_config["timeout.api"]
One API oddity worth a sentence: over the wire, values arrive wrapped — {"value": "5000"} — because JSON:API attributes can’t be bare primitives. The wrapper earns its keep by making “key absent,” “empty string,” and “null” three unambiguous cases, and the SDK unwraps it before your code ever sees it.
Still on the list
Validation happens at resolution time, not at set time — so a "500o" typo in a numeric field sails through the console and fails when an SDK reads it, which is the wrong place to find out. A native diff view for “what’s different between staging and production” doesn’t exist yet; today that’s a manual comparison in the grid. And secrets are explicitly out of scope — Smpl Config is for application configuration, and credentials belong in a secrets manager; first-class secret handling is on the long-term roadmap.