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. Three kinds share that resolution but differ in what they are keyed to and what lifecycle they carry:
| | 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 | its own name, an org-local cascade namespace |
| has an observed side? | yes, a sample 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 samples,
plus a template-scoped binding the sample arc lacks (and unlike samples, config is not
node-owned).
config: declared device state, keyed to a signal
Section titled “config: declared device state, keyed to a signal”secret: a typed, encrypted cascaded value
Section titled “secret: a typed, encrypted cascaded value”A secret is a typed value, encrypted at rest, owned on the exclusive arc
(platform | location | component; secrets have no system band, ADR-0052) and resolved down the
cascade
(ADR-0017,
built). A secret is its own primitive (stored encrypted, read back only through a masked, audited
path), not a sensitivity flag on ordinary values; within it, the admin_sensitive flag below splits
admin-only platform credentials from operator-visible device secrets.
A secret carries a label beside its name for the same reason a variable does: the name is the
cascade key, unique per owner and on the entity name rule, and the label is what an operator reads in
the directory. It reaches every projection the name reaches, the directory and the per-component
cascade both; the reveal and the copy carry no identity at all, only the decrypted field map, so
there is nothing there for it to appear in.
Two senses of “platform”, kept apart. A platform credential is a kind of secret (a Zoom
client secret), marked by admin_sensitive at whatever scope it sits; the platform tier is a
place on the cascade, the install-wide rung. The words are unrelated.
Shape is a secret_type registry: a per-field list of {name, type, secret, origin}, so one
field is secret (a password) while another is plaintext (a username), origin marking whether the
operator or the lifecycle fills it. A default_admin_sensitive boolean seeds the create form’s
admin_sensitive default: a device type (snmp-community, basic-auth) defaults operational, a
platform-integration type (oauth2-client) admin-sensitive; those are the ship-with types, with
official marking shipped-canonical versus org-local as in the sample and role registries.
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, a KMS or Vault able
to drop in behind the same seam. Each secret field is sealed under a per-value DEK wrapped by the
KEK, (owner, name, field) bound as AAD so a ciphertext cannot be lifted from one row into
another, and stored as its {ciphertext, nonce, wrapped_dek, key_id} envelope (a non-secret field
plaintext).
Consumed at the site, by token. The cascade is the reuse mechanism (define once high, inherit
below); interpolation is a $sec:name.path token in an interface input or function arg, never
inside a secret’s own fields (the splicing consumer is the deferred collection-driver slice).
Masked everywhere except an audited decrypt. A secret’s value is masked (••••••) in every read.
Reading the plaintext is a separate, privileged action: secret:reveal gates the decrypt (on-screen
reveal and 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 gives locality: a Singapore-scoped field tech works device secrets under
Singapore, not one attached at the platform tier. The per-secret admin_sensitive flag gives
same-scope sensitivity: when set, every action on that secret lifts to the :admin tier, so a
platform credential stays admin/owner-only beside an operational device secret. secret is also a
sensitive resource off the bare *:read floor: a viewer reads no secrets, an
operator/deploy holds an explicit scoped secret:read,reveal,create,update. Concretely:
- The
/secretsdirectory is scope-filtered and hides admin-sensitive rows from a caller without the admin tier. - 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.
- Creating a secret marked
admin_sensitiveneedssecret:create:admin(nor may an operator pick a type that defaults admin-sensitive). - Sealing, editing, and revealing an operational secret are open to operators in scope;
delete stays admin-and-owner.
adminholdssecret:>(reaching the:admintier a two-tokensecret:*cannot);owner’s>covers everything.
The secret lifecycle and validity health, tracked in #153; rotation flows are ADR-0050, the timer #419
Lifecycle is a later slice, each behavior a template-declared use of functions, time, and flows:
- refresh (an
oauth2access token) is lazy: refreshed on use within a skew window of expiry, coordinated across replicas by a NATS KV lock (CAS on the secret key); 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 time.
- expiry and reminders are an expiry timestamp plus a watchdog firing an event and an alarm before the secret lapses.
Secret health is its validity, not its value: 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 through the ordinary sample-to-alarm pipeline.
Shared versus per-device is just scope: a fleet-wide SNMP community sits high in the cascade, a unique-per-device secret at component scope. Nothing else to model.
variable: free interpolated values (macros)
Section titled “variable: free interpolated values (macros)”The first slice (#183) is built: the typed plaintext
cell (a value_type of string / int / float / bool / json, stored as jsonb, validated against the type
in the app), owned on the exclusive arc and resolved down the cascade, with a Variables directory. It grants
variable:create,update to operators (delete stays admin and owner), mirroring the secret member. Deferred from
the design below: the template owner scope (slice 1 mirrors the secret arc,
platform | location | system | component; template scope and cascade groups are
#184); a variable_type registry (slice 1 types
inline with the value_type enum, matching the “operator-defined, not curated” model); and the $var:
consumer and the secret-flagged variable. Logged in the decision log.
A variable is the leftover, and the most familiar: a value spliced into behavior that is
not a device signal and carries no lifecycle (a poll interval, a base URL, an environment
label). Zabbix-style macros, resolved down the same cascade and
interpolated as $var:<name> into functions, interface definitions, and rule scopes.
- A name is org-specific, not a canonical signal, the one place the “operator-defined, not
curated” namespace applies: no registry, no pre-registration. The ordinary entity name rule still
holds, though (one segment of lowercase letters, digits, and hyphens, no dot), so
newtron-sshnames a variable andnewtron.sshis refused. Sprawl is controlled by a creation role-gate (IAM) and by every variable being surfaced in the tree. - A label rides beside the name, the friendly string an operator reads (“Poll Interval”) where
the name is what the cascade resolves by
(ADR-0119).
Optional, not an identifier, and never interpolated:
$var:reads the name and only the name. A variable with no label reads its name verbatim. - Install-wide and template-local are the same primitive at different scopes (a company-wide NTP server versus a device class’s poll interval).
- No observed side and no reconcile, exactly what separates it from config.
Scalar shapes (string, int, float, bool, json) cover the common case; a variable may be
flagged secret (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”Three pieces are built. The property_type catalog: the registry of typed value names
(name, data_type over string / bool / json, optional label, a JSON Schema
validation), official rows read-only; numeric names live in its metric_type sibling
(ADR-0043,
ADR-0079). The three classifier contracts
(product_property, standard_property, location_type_property), one shape: a catalog property
with an optional default_value and a required flag, unique per (classifier, property). And the declared rows in the
property series, on the same owner exclusive-arc as every sample, with an instance
discriminator; the write path appends provenance=declared rows. The read is
EffectiveProperties(ownerKind, ownerID), one parameterized query serving component, system, location,
and node: coalesce(the instance's value, the contract default) plus the off-contract values, so a
productless component, a one-off system, and a classifier-less node still resolve
(ADR-0048). The
vertical is whole: transactional audited storage; the API (each contract gated by its owner catalog’s
permission, values by the owning entity’s :read / :update, ABAC-scoped on every arc); the generated CLI
and typed client; the console (Declared properties editors on the product, standard, and location type
blades, a Properties panel on the component, system, and location details). The rest of the design is
deferred, listed so a built badge never hides drift
(ADR-0047).
A property is one typed name used three ways: the catalog says the name exists and what it
means, a classifier contract says which names its instances carry (and their defaults), and a
value row says what one owner holds. Where a variable or secret is a single cascaded cell, a
property is a named slot a whole class carries, set per instance (“every Boreal Edge 55 carries an
asset_tag” is one declaration, the fix for Zabbix’s fixed inventory_1..40).
The provenance column is why this is one primitive: the same name and store hold a value a device observed, a rule calculated, config intended, and an operator declared.
Target design: the cross-owner cascade (#291), the calculated producer (#430), interpolation and sources (#296), file typing (#242), and the driver column (#489)
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 classifier’s contract default), no walk across owners yet. - The
calculatedproducer: ingest upsertsobservedand the command pillarintendedinto thepropertycache; onlycalculated(a rule’s output) has a seat and no writer. 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:/$sample:): a value holds a literal today. - The
sourcesmodel: per-property allowed origins (literal/variable/secret/sample/file), the inline pickers, and override rules. - File typing: a
filedata_typewith accepted MIME types and formats, giving an attached file a semantic role by the property it fills (afloor_plan, afirmware). - A driver access/mode column on the contract (get, set, or declare-only), landing with the driver slice; a node still has no classifier to declare a contract, 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 deleting
the value and falling back to the contract default (the owning entity’s own :update write).
tag: a normalized label vocabulary
Section titled “tag: a normalized label vocabulary”A tag is an operator key: value label for dimensions Omniglass does not model natively
(category: audio-dsp, environment: prod): not a signal, no lifecycle, riding the cascade with a
union-on-key, override-on-value combinator over a tenant-wide governed vocabulary. The
tags page owns the model and the cascade the
precedence.
Value-domain normalization. Key governance and the enum constraint are settled (tag.allowed_values,
ADR-0024); open is
whether a key may normalize values on input through an Expr transform (lowercase, trim, fold
synonyms) so Prod, prod , and PROD resolve to one value.
What’s shared
Section titled “What’s shared”- The cascade. One resolver serves all four kinds; the structural chain, precedence key, and fall-through to a declaration default are stated once, on cascade; tags ride it with their own combinator (tags).
- Interpolation renders variables (
$var:) and secret fields ($sec:) into requests (a later consumer slice for both); config is read by key like a sample.
The config member’s observed-side worker, tracked in #153
The observed side of config is maintained by one event-driven worker (the one-worker-plus-stages
model): when a property sample lands whose (owner, key) a config item is keyed to, it refreshes that item’s
cached observed value, reverse-indexed so “is this sample 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”The config member, tracked in #153
Modeling declared state as config keeps declared out of the sample provenances: samples carry
three (observed, calculated, intended),
and declared intent lives in config, keyed to the same signal but stored down the cascade. The
lane is unchanged (an observed power-state = on is still a property sample); only the
declared value moved.
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 are built; the config cell is Design.
The two shipped members each got their own table rather than a shared discriminated cell (the
secret_type + secret pair carries encryption and a masked read path; variable types inline).
Whether the config cell joins variable in one table with a discriminator is open; they share the
cascade and the exclusive-arc scope either way.
secret_type (Built) is the secret shape registry (snmp-community, basic-auth seeded);
official marks shipped-canonical versus org-local.
| Column | Type | Constraints | Notes |
|---|---|---|---|
name | text | not null | |
official | boolean | not null, default false | Shipped-canonical versus org-local; official rows are read-only |
label | text | ||
schema | jsonb | not null, default '[]'::jsonb | The per-field shape: {name, type, secret, origin} per field |
created_at | timestamp with time zone | not null, default now() | |
default_admin_sensitive | boolean | not null, default true | |
id | uuid | PK, default uuidv7() |
CHECK constraints and unique indexes on secret_type
-
secret_type_name_key:CREATE UNIQUE INDEX secret_type_name_key ON public.secret_type USING btree (name)
secret (Built) is the encrypted cell and the $sec: cascade key; scope is the exclusive arc
without its system arm (ADR-0052); visibility and the masked read path are per
ADR-0025
above.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK, default uuidv7() | |
name | text | not null | |
owner_kind | text | not null | |
value | jsonb | not null, default '{}'::jsonb | Secret fields as {ciphertext, nonce, wrapped_dek, key_id} envelopes; non-secret fields plaintext |
created_at | timestamp with time zone | not null, default now() | |
updated_at | timestamp with time zone | not null, default now() | |
admin_sensitive | boolean | not null, default true | Flips the secret to the admin tier regardless of placement |
component_id | uuid | FK → component.id | |
system_id | uuid | FK → system.id | |
location_id | uuid | FK → location.id | |
secret_type | uuid | FK → secret_type.id, not null | |
label | text | The friendly string an operator reads; NULL falls back to the name, verbatim |
CHECK constraints and unique indexes on secret
-
secret_owner_arc:CHECK ((((owner_kind = 'platform'::text) AND (component_id IS NULL) AND (system_id IS NULL) AND (location_id IS NULL)) OR ((owner_kind = 'component'::text) AND (component_id IS NOT NULL) AND (system_id IS NULL) AND (location_id IS NULL)) OR ((owner_kind = 'system'::text) AND (system_id IS NOT NULL) AND (component_id IS NULL) AND (location_id IS NULL)) OR ((owner_kind = 'location'::text) AND (location_id IS NOT NULL) AND (component_id IS NULL) AND (system_id IS NULL)))) -
secret_owner_kind_check:CHECK ((owner_kind = ANY (ARRAY['platform'::text, 'component'::text, 'location'::text]))) -
secret_component_key_uuid:CREATE UNIQUE INDEX secret_component_key_uuid ON public.secret USING btree (name, component_id) WHERE (owner_kind = 'component'::text) -
secret_location_key_uuid:CREATE UNIQUE INDEX secret_location_key_uuid ON public.secret USING btree (name, location_id) WHERE (owner_kind = 'location'::text) -
secret_platform_name:CREATE UNIQUE INDEX secret_platform_name ON public.secret USING btree (name) WHERE (owner_kind = 'platform'::text) -
secret_system_key_uuid:CREATE UNIQUE INDEX secret_system_key_uuid ON public.secret USING btree (name, system_id) WHERE (owner_kind = 'system'::text)
variable (Built) is 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 is a separate, deferred member.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK, default uuidv7() | |
name | text | not null | |
value_type | text | not null | string, int, float, bool, or json; the app validates value against it |
owner_kind | text | not null | |
value | jsonb | not null | The cascade-resolved payload |
created_at | timestamp with time zone | not null, default now() | |
updated_at | timestamp with time zone | not null, default now() | |
component_id | uuid | FK → component.id | |
system_id | uuid | FK → system.id | |
location_id | uuid | FK → location.id | |
label | text | The friendly string an operator reads; NULL falls back to the name, verbatim |
CHECK constraints and unique indexes on variable
-
variable_owner_arc:CHECK ((((owner_kind = 'platform'::text) AND (component_id IS NULL) AND (system_id IS NULL) AND (location_id IS NULL)) OR ((owner_kind = 'component'::text) AND (component_id IS NOT NULL) AND (system_id IS NULL) AND (location_id IS NULL)) OR ((owner_kind = 'system'::text) AND (system_id IS NOT NULL) AND (component_id IS NULL) AND (location_id IS NULL)) OR ((owner_kind = 'location'::text) AND (location_id IS NOT NULL) AND (component_id IS NULL) AND (system_id IS NULL)))) -
variable_owner_kind_check:CHECK ((owner_kind = ANY (ARRAY['platform'::text, 'component'::text, 'system'::text, 'location'::text]))) -
variable_value_type_check:CHECK ((value_type = ANY (ARRAY['string'::text, 'int'::text, 'float'::text, 'bool'::text, 'json'::text]))) -
variable_component_key_uuid:CREATE UNIQUE INDEX variable_component_key_uuid ON public.variable USING btree (name, component_id) WHERE (owner_kind = 'component'::text) -
variable_location_key_uuid:CREATE UNIQUE INDEX variable_location_key_uuid ON public.variable USING btree (name, location_id) WHERE (owner_kind = 'location'::text) -
variable_platform_name:CREATE UNIQUE INDEX variable_platform_name ON public.variable USING btree (name) WHERE (owner_kind = 'platform'::text) -
variable_system_key_uuid:CREATE UNIQUE INDEX variable_system_key_uuid ON public.variable USING btree (name, system_id) WHERE (owner_kind = 'system'::text)
The other value-bearing tables this page leans on are rendered on their home pages, once each:
property_type, the classifier contracts (product_property and siblings), and the
property series on samples; tag and
tag_binding on tags.