Metrics and properties
PartialThis page owns the value model: the two sample lanes and their catalogs, series and current values, the provenance axis, and how values reconcile, diverge, and read back. Physical layout (tables, partitioning, the lineage CHECK, tiering, retention) lives in storage; the spine is the architecture overview; see events, commands, calculations, and alarms and actions.
Metric and property: quantity and value
Section titled “Metric and property: quantity and value”The telemetry model is five lanes with five names and no overlap: a metric, a property, an event, a command, and a log line. This page owns the two sample lanes:
- A metric is a quantity: a numeric reading of a registered
metric_type, a float8 row inmetric. Quantities aggregate:AVG(volume), a p95 latency, a rate over time are all meaningful. The firehose. - A property is a value: what something is right now, a jsonb row in
propertytyped by a registeredproperty_type. A value can be a number used as a name (input 3, zone 4): averaging inputs is meaningless. Values do not aggregate, they have duration: “how long was the input 3?” is the question the lane answers, so it is written transition-only and the series is its own edge history.
A sample is one timestamped reading in either lane, (owner, type, instance, ts, value, provenance, source, lineage). A series is one stream of them, (type, owner, instance, provenance). The current value is the latest row of a series, derived by reading it, never stored beside it (below).
The two lanes are one shape in code and two tables in storage, split because they answer different questions and retain differently, not because they differ conceptually. The lane a name belongs to is decided by catalog membership: volume is a metric because metric_type holds it. There is no per-key kind column; the split catalog made it redundant (ADR-0079).
A log line is not a third sample lane: it is untyped raw arrival on its own lane (log_line for a component’s lines, node_log for a node’s self-logs), no registered name, no catalog gate, where a sample is a value of a named type at a time (ADR-0066). An event is not a sample either: our semantic assertion that something happened, not an observed value (the razor, events).
Ownership: the exclusive-arc
Section titled “Ownership: the exclusive-arc”The owner is the exclusive-arc: an owner_kind enum (component / system / location / node) plus the matching typed FK, a CHECK enforcing exactly one set. No platform or fleet-root arm (a fleet-wide series is a design question). The same arc owns event and command rows and is the design for alarm (component-local today). System-, location-, and node-level samples are first-class (health is a property owned by a component, system, or location today), fixing Zabbix’s inability to put a verdict on a group of hosts. See Ownership on the spine and the storage DDL.
The instance dimension: many values of one name on one owner
Section titled “The instance dimension: many values of one name on one owner”One owner can hold several values of the same name (three fan speeds, per-port counters). The catalog holds one row per measurement (fan-speed, not fan-speed-intake); the discriminator is an instance text NOT NULL DEFAULT '' column on both sample tables. Series identity is (owner, type, instance, provenance): each instance its own series, the singleton (instance = '') the default; aggregation groups by name and ignores instance, so per-instance trends stay distinct.
Built today, instance is stamped server-side: the ingest consumer sets each node-lane sample’s instance to the observing interface’s name, keeping two interfaces reporting one name distinct series; the API push lane sets it directly.
The has-a-value-now razor (sample vs event)
Section titled “The has-a-value-now razor (sample vs event)”A sample records a value; an event records an occurrence.
"input is 1"is a value, so it is a sample (a property)."call started"is an occurrence, “what is call-started now?” is meaningless, so it is an event (events).
An unnormalized raw occurrence (a syslog line, a raw webhook frame) is neither: it lands on log_line; a derivation rule may later promote it to an event, though most lines never become one.
The two catalogs
Section titled “The two catalogs”Each lane that carries typed data has its own registry: metric_type and property_type for the sample lanes, event_type for occurrences, command_type for invocations. The two sample catalogs split on data_type, the lane key: the lane is the value type, so no discriminator column exists and none is needed.
metric_typedescribes every numeric name:(name, data_type: int | float, unit, precision, fusion_policy, official). The numeric facts, unit and display precision, live here and only here.property_typedescribes every value name:(name, data_type: string | bool | json, validation, fusion_policy, official). The value domain (a JSON Schema invalidation) lives here; no unit, no precision.
The three ingest registries (metric_type, property_type, event_type) share one resolution namespace: a create is refused when the name exists in a sibling catalog, because a name in two lanes would make per-lane routing ambiguous.
Naming is owner-agnostic and single-token: a name is one kebab segment (at most 100 characters, no dots: icmp-rtt-avg, power-state, video-input), naming a measurement, never its owner (temperature, not room-temperature), with the canonical unit in metric_type.unit (fan-speed + unit: rpm, not fan-rpm). One canonical name is one comparable signal across vendors, the normalization cross-fleet dashboards and AI hinge on. The official set seeds from internal/seed/metric_types.yaml and internal/seed/property_types.yaml: today the eleven types the built collection vertical uses (4 metric: icmp-rtt-avg, tcp-open, tcp-connect-time, and icmp-reachable; 7 property: interface-reachable, health, video-input, serial-number, mac-address, firmware-version, model-number); the OpenTelemetry-semconv-aligned IT leaves and the OpenAV minimum-device-functionality guidelines set are still to come.
Validation on insert. Ingest checks per lane: (1) the name resolves in the lane’s own catalog; an unresolved name is reject-not-project, kept out of the typed series. (2) The value conforms: a metric is numeric by wire shape; a property’s value must parse as the type’s data_type and satisfy its validation schema when one is declared, and a violating push is refused before anything publishes. The cascade-resolved validation_policy modes (bypass / audit / enforce) and the collection.failed raw carry are design, tracked in #430.
Units: one canonical unit per metric
Section titled “Units: one canonical unit per metric”Only a metric carries a unit (and the display precision); even a dimensionless metric has one (ratio or count); a property has neither. Storage is canonical-at-rest: every metric_type declares one canonical unit, stored values are always in it, and every threshold, calc, and fusion compares like with like. Mixed units are never stored, the unit never rides the instance dimension, and a genuinely different measurement is a different metric_type: units convert only within one family.
Dimensionless is still a unit. The ratio family is canonical ratio (0..1) with percent an alternate (ratio * 100): cpu-utilization is stored as 0.9, shown as 90%. A bare count is a cardinal count, distinct from a ratio.
Conversion happens only at the edges and in expressions:
- Normalize-in at the edge. The edge parse converts native to canonical before the sample is emitted: a Fahrenheit display’s driver emits
celsius. The native unit is a collection-time fact, never a storage fact. - Convert-out on read. The UI and views convert to the operator’s per-user / per-locale display unit via the
unitregistry, off the same rows. - Expressions:
convert(value, "<unit>"). A stdlib function in expressions: the source unit is inferred from the bound sample; the target must be a registered unit in the same family (a compile error otherwise).convert(value, "fahrenheit") > 100authors a Fahrenheit threshold over Celsius storage. Available wherever expressions run: event rule and alarm criteria, calc leaves, list filters.
Display precision is part of the type. The optional precision (significant digits to render) is a presentation default: the stored data_type keeps full precision, the UI or a locale can override, and dropping noise at ingest is a separate collection-time rounding.
Key scope: template, org, official
Section titled “Key scope: template, org, official”This section is future design. Built: the official boolean on both catalogs (names unique per catalog, official: true the seeded canonical set, official: false operator-authored). The target is a three-layer scope (the built boolean folding into the enum’s top value), deciding where a name is unique and where its trust comes from:
| scope | identity (uniqueness) | trust | who defines it |
|---|---|---|---|
| template | (template_id, name) | local | the template author |
| org | name, unique within the deployment | local custom canonical | the org / operator |
| official | name, globally | shipped with the distro | the distro |
Template-scope conflicts are impossible (identity is (template_id, name): two templates can both declare an input, the Zabbix model). Trust comes from distribution, not a label: an official name is in the release, not spoofable; an org name is authoritative within its one database (one database is one tenant); a template name is local to its template.
Every sample is typed by a catalog row, just at some scope. The sample-to-catalog FK is non-null in both lanes: template-scoped names would be catalog rows (scope=template, with a template_id), so no nullable type FK, no dual identity.
The promotion ladder is template -> org -> official, each step a cheap re-scope or re-point, not a migration: lift a template’s input to an org-canonical video-input and re-point the template; official is earned by shipping in the distribution. Collected samples keep resolving. Normalization is optional but encouraged: aligning buys cross-fleet comparability, and most templates just reference a shipped official name. Sharing is template-level (an import is linked or copied), its names landing at template or org scope.
Governance is curation, not runtime enforcement: Omniglass is a Postgres database an operator runs; we vouch for what we ship, you vet what you import. Commands sit at template scope with the same ladder (templates).
Provenance: how we know a value
Section titled “Provenance: how we know a value”Lane is a property of the name, fixed by catalog membership (power-state is always a property); provenance is a property of the row: the same name can be observed, calculated, intended, or declared at different moments. Each provenance points at the immutable record that produced it (its lineage), the populated lineage column mutually exclusive per provenance, CHECK-enforced.
| Provenance | How we know it | Lineage points at |
|---|---|---|
| observed | measured from a component | on-row: source_rule (+ version), the edge function that parsed it |
| calculated | derived from other samples | on-row: source_rule (+ version), the calc_rule |
| intended | the declared effect of a command we issued, pending reconciliation | command_id, the command that opened it |
| declared | asserted by an operator | none: no rule, no event, no command |
All four land in the same series tables, side by side for the same name, making divergence detection free. The property lane admits all four; the metric lane admits the first three today (there is no declared-metric writer yet, and its schema refuses the row until one exists).
A separate source column records which sensor or path produced an observed value (codec-cec vs display-lan vs control-system): three sensors on one display’s power are three observed rows differing only in source, the basis of fusion.
Trace columns, orthogonal to lineage. correlation_id and source_event_id (the causation parent) live on event, correlation_id on log_line and node_log; the sample tables carry none today. The design adds them to samples, nullable and outside the lineage CHECK, so an action’s command can propagate its correlation_id onto the adaptive-poll’s observed sample, feeding the DAG invariant’s cycle guard (alarms and actions).
observed: from a component, via an edge parse
Section titled “observed: from a component, via an edge parse”“Measured from a component,” not “from a device”: every device is a component, not the reverse. The node parses the payload at the edge and publishes the observed sample to the JetStream raw ingress subject (admission confines its owner before the trusted stream); it never writes to Postgres. Lineage rides the row: source_rule + source_rule_version (the backtest hinge). The verbatim payload is not kept (no telemetry table): raw surfaces only on a collection.failed event, a dev raw-mode tap, or the bounded opt-in raw_sample policy (collection). No separate execution table: a derived sample is itself the evidence of its function’s run.
intended: the declared effect of a command
Section titled “intended: the declared effect of a command”Issuing a command records the invocation, a caused event, and the intended value it expects, in one transaction. The intended row’s lineage is the command itself (command_id): the value points at what caused it, not at the event derived from the cause. It reconciles against the observed value the device round trip produces, and the command’s recorded status carries the outcome (commands). Intended vs observed is the central razor: intent in progress versus measured reality.
1. command issued: "power on display-5" -> the command row + a caused event2. intended write: display-5 power = on, provenance=intended, command_id=<the command> a bet: intended, not measured3. adaptive poll: the command triggers a poll sooner than the normal interval4. observed arrives: observed = on -> settled (the bet paid off; the command records it) observed = off -> failed (the command did not land)No separate “mapping” primitive exists: which value a command intends lives on the command definition, and a command may target either lane (set an input is a property, set a volume is a metric). Only commands set intended values; an external event implying a value (“meeting started, so the room is occupied”) is observed reality, landing as observed through the edge parse.
| The source says | Means | Path | |---|---|---| | “eth0 is down” | a component reporting measured reality | edge parse, then an observed row, directly | | we sent “power on” | intent in progress, not yet confirmed | command, then an intended row naming it |
declared: an operator’s assertion, as a series row
Section titled “declared: an operator’s assertion, as a series row”mac, ip, serial, locked-input: anything an operator sets on an owner is a declared series row, provenance='declared', in the same property table as every other row (ADR-0079). Three consequences fall out of rows-not-store:
- An edit appends. Changing a declared value writes a new row; the old one stays. The edit history is the series, for free, with no shadow audit table.
- Unset is a tombstone. Clearing a declared value appends a declared row whose value is JSON null; every reader resolves that latest row as absence. The unset is itself part of the history, and an unset-of-unset is an explicit miss, not a growing pile of tombstones.
- The current declared value is the latest row, like every other provenance; the effective read (
EffectiveProperties) coalesces the owner’s classifier-contract default in when the series says nothing (core entities).
An identical re-set appends nothing: a same-value save is a no-op, which the PUT route’s idempotency and the dev seed’s second-run-adds-nothing invariant both require.
The cascade-resolved config items, variables, and secrets remain their own primitive (config, secrets, and variables): a declared property is a fact about one owner; a config binding is a policy resolved down a tree.
Precedence: spec versus status
Section titled “Precedence: spec versus status”Which side wins is a per-item reconcile policy (config):
- observed wins (
reconcile: observeorwarn): the declaration was a hint, reality is truth; a differing observed MAC surfaces as divergence (silent, or an alarm underwarn); adopting it as declared is a separate one-shot import. - declared wins (
reconcile: enforce): the declaration is the spec; converge via the set command (the Kubernetes spec-and-status pattern), alarm if the set fails.
Sample provenances never contest: intended is a pending bet that observed confirms or refutes (intended), superseded on arrival. Device-swap is handled by maintenance mode, which suppresses drift.
Current value is the latest series row
Section titled “Current value is the latest series row”“What is this property now?” is answered by reading the series: the latest row per (type, owner, instance, provenance), ordered by ts (id breaks same-instant ties). That tiebreak is insertion order, not an arbitrary identifier: the row id is a bigint identity column, so two values written in the same tick resolve to the one written second, every time. ts leads rather than the id because the observed lane accepts a caller-supplied timestamp (a node backfilling, a device reporting a reading it took a minute ago), and a late-arriving older sample must not become current merely by being written last; the price of that, stated so it is not rediscovered, is that a database clock stepping backwards between two writes to one series resolves to the earlier-written row. There is no maintained latest-value store: the one-catalog design kept one (a property cache upserted from the sink) and it retired with the fold (ADR-0079), because a derived copy of a fact the series already holds is a second thing to keep honest. The property lane is transition-only and sparse, so the read is an index probe, not a firehose scan; the metric lane’s current-value read rides the same shape.
Two rules keep the read honest under retention, shipped as the PruneSamples primitive before any retention feature exists: a prune never deletes a declared row (an operator’s assertion is the whole truth however old, not a sample), and never deletes the latest row of any series (a prune must not erase a current value).
Reconciliation: want, told, is
Section titled “Reconciliation: want, told, is”The read side pivots three values per property of an owner: want (the current declared value, with the contract default coalesced in), told (intended, nil until a command opens one), is (observed). All three are series reads. Drift is is present and disagreeing with want, computed on read so it cannot go stale (disagree). The command pillar adds the windowed form: settlement is told vs is gated on the command’s settle window, and the command records the outcome as its status.
Ground truth versus derived
Section titled “Ground truth versus derived”- Raw payload: not stored. The sample is authoritative; see observed.
- Live on NATS, durable in PG. The live sample is the message on the JetStream
samplesstream; the durable copy inmetric/propertyis the persistence write, and samples do not go through CDC (messaging). - Ground truth, logs (immutable, append-only, the actor’s own record):
log_line(a component’s words),node_log(a node’s self-narration),audit_log, and the design-stagesession_log,internal_log, andcollection_log. No rule-execution table: a derived row is the evidence of its rule’s run, carryingsource_rule+source_rule_versionitself. - Derived (produced by rules, reconstructable in principle):
metric,property, event, alarm, action.
The DAG invariant
Section titled “The DAG invariant”The pipeline must stay acyclic.
A rule may read observed and calculated values as truth. It may compare an intended or declared value against observed (drift). It may not treat an intended value as truth to infer a new fact.
A drift rule reads the pair (intended, observed): the intended value is tested, not trusted. The one forward edge, command-to-intended-value, is terminal, so event rules reading only observed/calculated keep the graph acyclic with no runtime guard, except the command -> device -> observed-sample round trip, invisible to the static graph. The propagated correlation_id closes that gap: the cycle-guard walk follows a real carried id across the round trip instead of inferring lineage.
disagree and divergence
Section titled “disagree and divergence”Drift is a condition operator, disagree(A, B), usable inside event rule conditions, comparing two provenances (or two sources) of one name:
disagree(intended, observed): the command did not land (reconciliation)disagree(declared, observed): the world drifted from intent (config drift, device swap)disagree(observed, observed)acrosssource: sensors conflict (a failing sensor)
Any two provenances of the same name that disagree = an anomaly. One detector.
The sample tables
Section titled “The sample tables”The physical layout, partitioning, and the lineage CHECK live on storage. metric is the firehose (BRIN on ts, numeric aggregation); property is sparse and transition-only (time-in-value and dwell).
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | bigint | PK | |
ts | timestamp with time zone | not null, default now() | |
owner_kind | text | not null | |
instance | text | not null, default ''::text | Discriminates many values of one name on one owner; empty string is the default series |
value | double precision | not null | The numeric payload; aggregation happens here |
provenance | text | not null, default 'observed'::text | How we know it: observed, calculated, intended |
source | text | not null, default ''::text | |
source_rule | text | ||
source_rule_version | bigint | ||
event_id | bigint | FK → event.id | |
component_id | uuid | FK → component.id | |
system_id | uuid | FK → system.id | |
location_id | uuid | FK → location.id | |
node_id | uuid | FK → node.principal_id | |
metric_type_id | uuid | FK → metric_type.id, not null | The canonical name this sample is a reading of |
command_id | bigint | FK → command.id | Lineage of an intended value: the command that opened it |
CHECK constraints and unique indexes on metric
-
metric_lineage_check:CHECK ((((provenance = 'observed'::text) AND (event_id IS NULL) AND (command_id IS NULL)) OR ((provenance = 'calculated'::text) AND (source_rule IS NOT NULL) AND (event_id IS NULL) AND (command_id IS NULL)) OR ((provenance = 'intended'::text) AND (command_id IS NOT NULL) AND (source_rule IS NULL)))) -
metric_owner_arc_check:CHECK ((((owner_kind = 'component'::text) AND (component_id IS NOT NULL) AND (system_id IS NULL) AND (location_id IS NULL) AND (node_id IS NULL)) OR ((owner_kind = 'system'::text) AND (system_id IS NOT NULL) AND (component_id IS NULL) AND (location_id IS NULL) AND (node_id IS NULL)) OR ((owner_kind = 'location'::text) AND (location_id IS NOT NULL) AND (component_id IS NULL) AND (system_id IS NULL) AND (node_id IS NULL)) OR ((owner_kind = 'node'::text) AND (node_id IS NOT NULL) AND (component_id IS NULL) AND (system_id IS NULL) AND (location_id IS NULL)))) -
metric_owner_kind_check:CHECK ((owner_kind = ANY (ARRAY['component'::text, 'system'::text, 'location'::text, 'node'::text]))) -
metric_provenance_check:CHECK ((provenance = ANY (ARRAY['observed'::text, 'calculated'::text, 'intended'::text])))
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | bigint | PK | |
ts | timestamp with time zone | not null, default now() | |
owner_kind | text | not null | |
instance | text | not null, default ''::text | Same series discrimination as metric |
provenance | text | not null, default 'observed'::text | How we know it: observed, calculated, intended, declared |
source | text | not null, default ''::text | |
source_rule | text | ||
source_rule_version | bigint | ||
event_id | bigint | FK → event.id | |
component_id | uuid | FK → component.id | |
system_id | uuid | FK → system.id | |
location_id | uuid | FK → location.id | |
node_id | uuid | FK → node.principal_id | |
property_type_id | uuid | FK → property_type.id, not null | The canonical name this sample is a value of |
value | jsonb | not null | The jsonb payload; transitions only, no steady-state rows; JSON null is the declared tombstone |
command_id | bigint | FK → command.id | Lineage of an intended value: the command that opened it |
CHECK constraints and unique indexes on property
-
property_lineage_check:CHECK ((((provenance = 'observed'::text) AND (event_id IS NULL) AND (command_id IS NULL)) OR ((provenance = 'calculated'::text) AND (source_rule IS NOT NULL) AND (event_id IS NULL) AND (command_id IS NULL)) OR ((provenance = 'intended'::text) AND (command_id IS NOT NULL) AND (source_rule IS NULL)) OR ((provenance = 'declared'::text) AND (source_rule IS NULL) AND (event_id IS NULL) AND (command_id IS NULL)))) -
property_owner_arc_check:CHECK ((((owner_kind = 'component'::text) AND (component_id IS NOT NULL) AND (system_id IS NULL) AND (location_id IS NULL) AND (node_id IS NULL)) OR ((owner_kind = 'system'::text) AND (system_id IS NOT NULL) AND (component_id IS NULL) AND (location_id IS NULL) AND (node_id IS NULL)) OR ((owner_kind = 'location'::text) AND (location_id IS NOT NULL) AND (component_id IS NULL) AND (system_id IS NULL) AND (node_id IS NULL)) OR ((owner_kind = 'node'::text) AND (node_id IS NOT NULL) AND (component_id IS NULL) AND (system_id IS NULL) AND (location_id IS NULL)))) -
property_owner_kind_check:CHECK ((owner_kind = ANY (ARRAY['component'::text, 'system'::text, 'location'::text, 'node'::text]))) -
property_provenance_check:CHECK ((provenance = ANY (ARRAY['observed'::text, 'calculated'::text, 'intended'::text, 'declared'::text])))
log_line and node_log are not sample tables: no registered name, no provenance, no lineage CHECK (ADR-0066); severity / facility are their retention and routing axes.
Common sample columns (both lanes): ts, the owner arc (owner_kind plus component_id / system_id / location_id / node_id), the type FK, instance, provenance, source, and the lineage columns source_rule, source_rule_version, event_id, command_id; only the value column differs (float8 / jsonb). A union view over the lanes is deliberately not built: the retired datapoint name stays free, and any union a caller wants is a query.
The catalogs typing these tables:
| Column | Type | Constraints | Notes |
|---|---|---|---|
name | text | not null | |
label | text | ||
data_type | text | not null | The lane key: int or float |
unit | text | The canonical unit label; the numeric fact the split moved here | |
precision | integer | Display significant digits; a presentation default | |
fusion_policy | jsonb | ||
description | text | not null, default ''::text | |
registered_at | timestamp with time zone | not null, default now() | |
official | boolean | not null, default false | Shipped-canonical versus org-local; official rows are read-only |
id | uuid | PK, default uuidv7() |
CHECK constraints and unique indexes on metric_type
-
metric_type_data_type_check:CHECK ((data_type = ANY (ARRAY['int'::text, 'float'::text]))) -
metric_type_handle_key:CREATE UNIQUE INDEX metric_type_handle_key ON public.metric_type USING btree (name)
| Column | Type | Constraints | Notes |
|---|---|---|---|
name | text | not null | |
label | text | ||
data_type | text | not null | The lane key: string, bool, or json |
fusion_policy | jsonb | ||
validation | jsonb | JSON Schema the value must satisfy | |
description | text | not null, default ''::text | |
registered_at | timestamp with time zone | not null, default now() | |
official | boolean | not null, default false | Shipped-canonical versus org-local; official rows are read-only |
id | uuid | PK, default uuidv7() |
CHECK constraints and unique indexes on property_type
-
property_type_data_type_check:CHECK ((data_type = ANY (ARRAY['string'::text, 'bool'::text, 'json'::text]))) -
property_type_handle_key:CREATE UNIQUE INDEX property_type_handle_key ON public.property_type USING btree (name)
The end-to-end pipeline diagram lives on messaging.
Related: events, commands, calculations, config and credentials, collection, alarms and actions, and the glossary.