Config, secrets, and variables
PartialEverything an operator sets resolves the same way: a typed value, owned at a scope, resolved most-specific-wins down the cascade on every poll and every tick. Three kinds share that resolution but differ in what they are keyed to and what lifecycle they carry:
- config (
Design): a device setting you declare. Keyed by a canonical signal (aproperty_type), so it has an observed side and can be reconciled. - secret (built): an access secret, encrypted at rest. Its own
secret_typeshape registry, envelope crypto behind a pluggable KEK provider, resolved down the cascade and consumed by a$sec:token. - variable (built): a free interpolated value (a macro). Not bound to a signal, just resolved
down the cascade; the
$var:splice into functions and interfaces is a later slice.
| config | secret | variable (macro) | |
|---|---|---|---|
| what it is | a declared device setting | an access secret, encrypted at rest | a free interpolated value |
| keyed by | a canonical signal (property_type) | its own secret_type shape name | an org config key (cascade namespace) |
| has an observed side? | yes, a datapoint via a get function | its validity, not the secret value | no |
| lifecycle | drift → reconcile (a set function) | refresh + rotation + expiry (deferred) | none; resolved and interpolated |
| example | video.input = HDMI1 | an snmp-community, a basic-auth | poll_interval = 30s, a base URL, a label |
The common thread is the cascade and an exclusive-arc scope (exactly one of
platform | template | location | system | component): the same exclusive-arc ownership as
datapoints, plus a template-scoped binding the datapoint arc lacks (and unlike datapoints,
config is not node-owned). The three are not three subsystems; they are three uses of one
“set a value, resolve it down a scope” idea.
config: declared device state, keyed to a signal
Section titled “config: declared device state, keyed to a signal”A config item is the declared side of a canonical signal. video.input is one key with two
sides: the observed value the device reports (a state, provenance=observed) and the
declared value you set. They share the key but not the storage: the declared value lives
in the config table, resolved down the cascade, and is never a datapoint row. Same name, opposite
direction, the observed side flowing up from the device and the declared side flowing down from
the operator. This is not a “declared provenance” (there are no declared rows in the datapoint
tables); it is one signal with two homes, and their gap is drift.
Keying config to the signal registry instead of a private name is what removes the import problem: a
component template brings no keys, it references registered ones, exactly as it does for the
datapoints it reads. Two display templates that both touch video.input are two references to one
governed key, not a collision. Config reuses the property_type’s value domain, so a declared value
is validated against the same {values: […]} the observed side uses.
The template is the source of truth for configurability. A signal becomes settable on a device
class when that class’s component template binds a get function (an
ordinary collection function that emits the observed datapoint) and a set function (a
command-triggered function that writes it). The registry may carry a soft settable hint, but the
binding is authoritative: no set function, not enforceable here.
Each piece of a config item has one home, joined by the canonical key:
| Piece | What it holds | Lives in |
|---|---|---|
| signal definition | key, kind, value domain, unit | property_type (the registry) |
| get / set binding | how this device class reads and writes the signal | the component_template version |
| declared value | the intent (HDMI1), plus the per-item reconcile policy | the config table (cascaded) |
| observed value | what the device reports (HDMI2) | state rows (observed) |
| drift | declared ≠ observed | computed on read, not stored |
Drift and reconcile
Section titled “Drift and reconcile”When a config item has both a declared and an observed value, their gap is drift: the same
disagree(declared, observed) comparison used
everywhere, with the declared side sourced from config. A per-item reconcile policy turns drift
into action:
observe(default): record the drift, raise no alarm. Log that it differs and go get the info; drift stays visible throughdisagreeand the config view, silently.warn: raise an alarm for the drift, at warning severity. Surface it, change nothing.enforce: declared wins. Call the template’s set function to push the value back; that issues a command, writes anintendeddatapoint, and reconciles against the next observation (desired-state convergence, the controller half of spec-and-status). If the set fails, raise a real alarm (enforcement failure).
Adopting the observed value as the declared one (reality becomes intent) is not an ongoing mode; it is a separate one-shot import action an operator runs deliberately.
The power here is that remediation needs no rule. You do not author an event_rule or a flow to
fix a setting; you declare the value, set the policy to enforce, and the cascade plus drift plus
the set function close the loop. Reconcile runs per item, so one reconciled setting is better than
none; the capability of any item is simply which of its get/set functions the template has bound (get
only gives observe or warn on drift; a set too makes it enforceable). The data-mediated loop (set -> device ->
observe -> drift clears) is the one guarded at action dispatch
(alarms and actions), with a per-item backoff so a device that
refuses a write does not hammer.
Declaring at the system level
Section titled “Declaring at the system level”Because config rides the standard cascade, you rarely declare on the device. Declare
video.input = HDMI1 for the main-display role on the system template, and the cascade resolves it
onto whichever display fills that role; the display’s own template declared nothing. Drift and
reconcile then just happen, no per-device authoring.
secret: a typed, encrypted cascaded value
Section titled “secret: a typed, encrypted cascaded value”A secret is an access secret: a typed value, encrypted at rest, owned on the exclusive arc
(platform | location | system | component) and resolved most-specific-wins down the cascade like config
and variables, but sealed so its value never sits in the clear. Its first slice is built
(ADR-0017):
the typed cell, the crypto, the cascade resolver, and the operator surfaces. A sensitivity ladder is
not a flag any value carries; a secret is its own primitive because it is stored encrypted and read back
only through a masked, audited path. (Within the secret primitive, a binary admin_sensitive flag
does split admin-only platform credentials from operator-visible device secrets, described below; that is
a visibility tier, not an arbitrary sensitivity level bolted onto every value.)
Two senses of “platform”, kept apart. A platform credential is a kind of secret, a vendor or
cloud account key (a Zoom client secret) as opposed to a device secret, and it is marked by the
admin_sensitive flag at whatever scope it sits. The platform tier is a place on the cascade,
the install-wide rung. A device secret can sit at the platform tier, and a platform credential can sit
on one location; the words are unrelated.
Shape is a secret_type registry. A secret has a structured secret_type shape: a per-field
list of {name, type, secret, origin}, so one field is secret (an snmp-community string, a password)
while another is plaintext (a username), and origin marks whether the operator sets a field or the
lifecycle fills it. The type also carries a default_admin_sensitive boolean that seeds the create
form’s admin_sensitive default (see the two-axis visibility below): a device type (snmp-community,
basic-auth) defaults operational, a platform-integration type (oauth2-client) defaults
admin-sensitive. The ship-with types are snmp-community, basic-auth, and oauth2-client; an
official boolean marks shipped-canonical versus org-local, exactly as the datapoint and role
registries do.
Envelope-encrypted at rest. Crypto is envelope AES-256-GCM behind a pluggable KEK provider:
the key comes from the env (OMNIGLASS_SECRET_KEY), a file (OMNIGLASS_SECRET_KEY_FILE), or a warned
fallback under OMNIGLASS_DATA_DIR, with a KMS or Vault able to drop in behind the same seam and no
model change. Each secret field is sealed under a per-value DEK wrapped by the KEK, with (owner, name, field) bound as AAD, so a ciphertext cannot be lifted from one row into another. A secret
field is stored as its {ciphertext, nonce, wrapped_dek, key_id} envelope; a non-secret field is
stored plaintext.
Consumed at the site, by token. A secret is not composed from references; the cascade is the reuse
mechanism (define once high, inherit below). Interpolation lives at the consumption site, a
$sec:name.path token in an interface input or a function arg, never inside a secret’s own fields.
(The interpolation consumer that splices a live value into a request is the deferred collection-driver
slice.)
Masked everywhere except an audited decrypt. A secret’s value is masked (••••••) in every read:
the directory and the type list. Reading the plaintext is a
separate, privileged action: secret:reveal gates the decrypt (both an on-screen reveal and a
clipboard copy, recorded under distinct reveal and copy verbs), and every decrypt writes an
audit row.
Two axes decide who reaches a secret (ADR-0025).
Placement scope (where the secret attaches on the exclusive arc) gives locality: a Singapore-scoped
field tech creates and reveals device secrets under Singapore and cannot reach one attached at the
platform tier.
A per-secret admin_sensitive flag gives same-scope sensitivity: when set, every action on that
secret is lifted to the :admin tier, so a platform credential (a Zoom client secret) stays
admin/owner-only even at the same scope as an operational device secret an operator sees. secret is
also a sensitive resource kept off the bare *:read floor, so a viewer (only *:read) reads no
secrets at all, while an operator/deploy holds an explicit scoped secret:read,reveal,create,update
and works the operational secrets in its subtree. Concretely:
- The
/secretsdirectory is scope-filtered (no longer all-scope-only) and hides admin-sensitive rows from a caller without the admin tier, so a platform credential’s existence and field names never appear to an operator. - Reveal, update, and delete of an admin-sensitive secret without the admin tier is a non-disclosing 404 (identical to an out-of-read-scope row), never a 403, so existence does not leak.
- Creating a secret marked
admin_sensitiveneedssecret:create:admin, so an operator can mint only operational secrets (nor pick a type that defaults admin-sensitive). - Sealing and editing an operational secret (
secret:create,update) and revealing it (secret:reveal) are open to operators in scope; delete stays admin-and-owner.adminholdssecret:>(reaching the:admintier a two-tokensecret:*cannot);owner’s>covers everything.
Lifecycle is a later slice. The first slice is the typed encrypted cell and its cascade; the lifecycle a plain config never carries (refresh, rotation, expiry) is deferred, each behavior a template-declared use of functions, time, and flows rather than a new subsystem:
- refresh (an
oauth2access token) is lazy: refreshed on use when within a skew window of expiry, coordinated across replicas by a NATS KV lock (CAS on the secret key). Idle secrets never refresh; the refreshed token is a separate encrypted cache, not an operator secret. - rotation (a password on a schedule) is a flow: generate → set on the device (a set function) → update the store → verify → invalidate the old, driven by the time primitive.
- expiry and reminders are an expiry timestamp plus a watchdog that fires an event and an alarm before the secret lapses.
Secret health is its validity, not its value. A secret’s observable is whether it still
works: intrinsic expiry (an oauth2 token, a tls_cert notAfter) warns proactively, and
observed-use failure flips it unhealthy after N consecutive auth failures consumers report. Both
surface through the ordinary datapoint-to-alarm pipeline, so a secret gets a health story without
being a device signal.
Shared versus per-device is just scope. A fleet-wide SNMP community is a secret set high in the cascade; a unique-per-device secret is the same shape set at component scope. No shared-versus-unique split to model; it is the cascade, like everything else.
variable: free interpolated values (macros)
Section titled “variable: free interpolated values (macros)”A variable is the leftover, and the most familiar: a value you splice into behavior that is not
a device signal and carries no lifecycle, like a poll interval, a base URL, an environment label, or a
tuning constant. These are Zabbix-style macros, resolved platform → template → instance down the
same cascade and interpolated as $var:<name> into functions, interface definitions, and rule
scopes.
- Names are org-specific config keys, not canonical signals (the one place the “operator-defined, not curated” namespace genuinely applies). There is no registry authority and no pre-registration; sprawl is controlled by a creation role-gate (IAM) and by every variable being surfaced in the tree as it is added.
- Install-wide and template-local are the same primitive at different scopes. A
platformmacro (a company-wide NTP server) and a template-local one (a device class’s poll interval) differ only by where on the cascade they sit. - A variable has no observed side and no reconcile; nothing on a device mirrors a poll interval. That absence is exactly what separates it from config.
Scalar shapes (string, int, float, bool, json) cover the common case; a variable may be
flagged secret (a free secret like a webhook signing token) without being a full secret cell, since it
has no lifecycle.
property: one typed name, a classifier contract, a stored value
Section titled “property: one typed name, a classifier contract, a stored value”A property is one typed name used three ways: the catalog says the name exists and what it means,
a classifier contract says which of those names its instances carry (and what they default to), and a
value row says what one owner holds for it. Where a variable or a secret is a single cascaded cell, a
property is a named slot a whole class of things carries, whose value is set per instance. It revives the
fixed-inventory idea (Zabbix’s inventory_1..40 that never fit and were never custom) as a contract on the
SKU, so “every Samsung QM55 carries an asset_tag” is one declaration, not forty fixed slots.
The provenance column is why this is one primitive and not several. The same name and the same value store hold a value a device observed, a rule calculated, config intended, and an operator declared; only provenance separates them. A “field” was only ever the declared case, which is why it folded here rather than staying its own table.
What the design intends, and this slice does not yet do:
- The cross-owner cascade (
product -> location -> system -> component, deepest-wins). Today a value resolves on one instance alone: its own declared value, else its own classifier’s contract default. All four owner kinds resolve, but each in isolation; there is no walk across owners yet, even though the arc is drawn for it. - The non-declared producers.
observed(materializing a current value out of the datapoint stream),calculated(a rule’s output), andintended(the config member above writing a desired value) all have a seat in the provenance column and nothing writing to it. The config section’s “declared intent lives in config, not as a row” reads against this newer shape; reconciling the two is the config member’s own slice. - Macro interpolation of a value (the consumer of
$var:/$sec:/$datapoint:). A value holds a literal today; holding a macro string, resolved through the same interpolation engine at read, is deferred. - The
sourcesmodel: the per-property allowed origins (literal/variable/secret/datapoint/file), the inline pickers they drive, and the override rules. - File typing (a
filedata_typewith accepted MIME types and formats), which would give an attached file a semantic role by the property it fills (afloor_plan, afirmware). - A driver access/mode column on the contract (whether a driver can get, set, or only declare a property), which lands with the driver slice. The three classifier contracts themselves are built; a node still has no classifier to declare one, by design.
The Properties panel can set, re-set, and clear a value: the effective read returns the
property id (value_id) alongside the literal, so each override row offers a revert control
that deletes the value and falls back to the contract default (the owning entity’s own :update write,
since a value belongs to its owner).
tag: a normalized label vocabulary
Section titled “tag: a normalized label vocabulary”A tag is an operator key: value label attached to an entity to organize, filter, and scope by
dimensions Omniglass does not model natively (category: audio-dsp, environment: prod,
cost_center: 4021). A tag is not a signal and carries no lifecycle; it rides the cascade with a
union-on-key, override-on-value combinator, so keys accumulate down the tree while the
most-specific binding wins each value.
The key is a tenant-wide governed vocabulary. A tag key is a row in the tag registry, shared
across the whole tenant (one registry per database, which is the tenant boundary). Minting a new key is
permissioned: it takes a tag:create grant (identity and access),
an admin or curator action. Setting a value on an existing key is the ordinary entity write
(component:update and friends), open to operators. That split is the point: the vocabulary stays
normalized (no one inventing env beside environment beside Environment) while binding values
stays routine. The UI autocompletes keys from the registry as you type, so you reach for the
existing key instead of coining a near-duplicate.
Values bind down the cascade. A tag_binding sets a value for a key at any scope
(platform | template | location | system | component) and through groups,
exactly like config and variables. Keys union (an entity surfaces every tag bound at or above it);
values override most-specific-wins. A template binds its own tags onto
its component (category: audio-dsp). Because resolution is cascaded, you tag a location once and every
system and component beneath it inherits it, which is what makes tags a practical scoping dimension: a
high-weight group can key a rule-set off compliance: pci, an action can read
a maintenance_window.
What’s shared
Section titled “What’s shared”- The cascade. Config, secrets, and variables resolve most-specific-wins down
platform → … → component, with a template-scoped value as a shipped baseline; tags resolve down the same cascade with a union-on-key, override-on-value combinator. One resolver (cascade). - The exclusive-arc scope. Each value is owned at exactly one scope: the same exclusive-arc
ownership as datapoints, plus a
template-scoped binding the datapoint arc lacks (and config is notnode-owned). - Typing. A secret takes a structured
secret_typeshape registry (per-field secrecy and origin); a variable types inline against itsvalue_type(a scalar, validated in the app, no registry); config instead borrows theproperty_type’s domain, because its key is a signal. - Interpolation renders variables (
$var:) and secret fields ($sec:) into requests (a later consumer slice for both); config is read by key like a datapoint. Secrets are masked in every read and surface in the clear only through the audited reveal; a variable is plaintext.
The observed side of config is maintained by one event-driven worker (the one-worker-plus-stages
model): when a state lands whose (owner, key) a config item is keyed to, it refreshes
that item’s cached observed value, reverse-indexed so “is this datapoint a config’s observed side?” is
a sargable lookup, not a scan. It is the one controlled, one-directional crossing from the timeseries
back into current-value config.
How this changes provenance
Section titled “How this changes provenance”Modeling declared state as config (and access secrets as secrets) keeps declared out of the
datapoint provenances. Datapoints carry three (observed, calculated,
intended); declared intent lives in config,
keyed to the same signal but stored down the cascade rather than as a row. The state kind is
unchanged: an observed power.state = on is still a state, and a config item is keyed to
it. What moved is the declared value, out of the datapoint tables and into config resolved by the
cascade. There is no separate property or vault store; config, secrets, and variables are one
resolution model, and the spec-and-status loop gets a real home instead of overloading datapoint
provenance with operator intent.
Storage
Section titled “Storage”The shape registries, the value cells, and the operator-label tables; the physical layout (the owner
arc, the cascade key) lives on storage. The secret and variable cells
below are built; the config cell is Design.
The two shipped members each got their own table rather than a shared discriminated cell: secret
is its own secret_type + secret pair (it carries encryption and a masked read path), and
variable is a single table typed inline. Whether the remaining config cell joins variable in
one table with a discriminator or stays separate is still open; they share the cascade and the
exclusive-arc scope either way.
| Table | Key columns | Notes |
|---|---|---|
secret_type | id, official, schema (per-field {name, type, secret, origin}) | Built. The secret shape registry (snmp-community, basic-auth seeded); official marks shipped-canonical versus org-local, like the datapoint and role registries |
secret | (name, owner arc), secret_type, admin_sensitive, value (secret fields as {ciphertext, nonce, wrapped_dek, key_id} envelopes, non-secret fields plaintext) | Built. The encrypted cell and the $sec: cascade key; scope is the exclusive arc (platform/location/system/component). Read masked through a scope-filtered directory; decrypted only through the audited :reveal / :copy path. Visibility is placement scope plus the per-secret admin_sensitive flag (admin-only when set); secret is off the *:read floor (ADR-0025) |
variable | (name, owner arc), value_type (string/int/float/bool/json), value (jsonb) | Built. The plaintext variable cell and the $var: cascade key; scope is the exclusive arc. Typed inline (no variable_type registry: the value is validated against value_type in the app), no observed side. The config cell (declared/observed/reconcile) is a separate, deferred member |
property | name (PK), official, data_type (string/int/float/bool/json), nullable kind (metric/state/log), display_name, unit, validation (JSON Schema) | Built. The primitive-agnostic catalog of typed names, the single source for what a name means whoever produces it; seed-owned official rows are read-only. Value tables key by the name string, so the catalog governs the vocabulary without owning the values (ADR-0043) |
product_property / standard_property / location_type_property | (classifier, property), default_value (jsonb), required | Built. The classifier’s declared-property contract, one table per classifier and all the same shape: which catalog properties every instance of that product, standard, or location type exposes, with an optional default and a required flag, unique per pair. data_type and validation are not repeated here, they stay on property. Replaces the retired field_definition. A driver access/mode column is deferred; a node has no classifier by design |
property | (owner arc, property, instance, provenance), value (jsonb) | Built for declared on all four owner kinds. The value store, on the same exclusive arc as the datapoint sinks and event; the series key is unique nulls not distinct, since the arc leaves three owner columns NULL. The effective read is contract-default-or-override plus the off-contract set (is_set marks the override, value_id carries the row id so the UI can clear it), resolved by one owner-generic query and ABAC-scoped on every arc, read and write. Replaces the retired field_value. The observed/calculated/intended producers, macro-string values, and the cross-owner cascade are deferred |
tag | name, applies_to, propagates | Built. The tenant-wide governed key vocabulary; minting a key needs tag:create (identity and access). No _type, no namespace; values bind via tag_binding. See tags |
tag_binding | (tag, owner arc), value | Built. The key: value binding: union on key, override on value down the cascade, owned on the exclusive arc (platform / location / system / component); setting a value is the owner’s own update write. Binding via groups and a template-scoped binding are deferred |