# Omniglass documentation (full text) Omniglass is an open, AV-native observability and control plane for AV and IT estates: one Go binary over PostgreSQL. This file concatenates the whole documentation site as one machine-readable artifact for LLM tools (NotebookLM and the like). This is a proposed, forward-looking architecture; per-page build status (Design / Partial / Built / Diverged) lives at /architecture/status/. Source: https://docs.omniglass.hyperscaleav.com/ # Why Omniglass URL: /architecture/why/ What Omniglass is, what it is for, and why AV needs its own observability platform instead of an IT monitoring tool. Run AV at scale and you know the feeling. You find out a room is broken when someone walks out of it. The operation runs on tribal knowledge, escalations, and last-known-good guesses. Every high-profile meeting is a potential resume-generating event, and when one goes wrong you are in the postmortem with no data to back you up. It is the difference between a good night's sleep and a 3am call. Here is the part nobody says out loud: **that is not a reflection of you or your team.** AV is genuinely, structurally hard to see, and the industry never shipped a right way to do it. Omniglass exists to ship one. ## AV is not built to be observed Most AV gear is built to be **controlled, not monitored**. You can tell a device what to do. Asking it what it is *doing* is a different problem entirely, and the device was not designed to answer it. So you cannot reliably say what is up, what is down, and what is about to fail. At a handful of rooms you can brute-force that with grit. At a thousand rooms, it collapses. It is hard for reasons that are real, and none of them are your fault: - **It is agentless.** AV gear is firmware appliances. You cannot install an agent and be done; you have to ask the device from the outside and take whatever it is willing, or able, to give you. - **There is no standard, and the APIs are uneven.** Control interfaces are usually decent; *management* data is an afterthought, when it exists at all. Different port, protocol, and format for every vendor, every product, sometimes every firmware revision. Every integration is bespoke. - **The system is the hard part.** A room is not a device. It is a signal chain (a display, a video bar, the microphones, a DSP, a control processor, the UCC service in the cloud, the network) and "healthy" is a fact about the whole chain. Two of those mics might be redundant; the control processor speaks its own command dialect over a TCP port. Every unique combination of gear is its own health model, and there is no standard to lean on. - **It is fragmented by design.** Each manufacturer portal sees only its own devices. Stack them up and you have a dozen panes of glass and still no single view of the room. Put plainly: AV was never required to be observable, so it isn't. That is an industry problem, not an operator one. ## Why an IT monitoring tool does not finish the job The IT monitoring world (Zabbix, Prometheus, and the rest) is genuinely excellent at what it was built for: a fleet of servers, an agent on each one, clean and standardized metrics. The host-and- metric model is the right shape for a data center, and these tools are the best in the world at it. That model quietly assumes the three things AV does not have: **an agent to install, a standard API to read, and a host that is the thing you actually care about.** Point it at a room and the gaps show. There is no agent. There is no standard to read. And it has no idea what a "room" is, no language for an AV control protocol, no concept of a redundant mic. It can tell you a host is up. It cannot tell you the room is usable. You *can* bend these tools to AV. Skilled people do it every day, scraping web interfaces, automating CLI sessions, gluing middleware on the side to reach the gear the platform cannot. That work is real and it is impressive. But you are doing the platform's job for it, by hand, forever, and it still has no model of the room at the end. ## It is an architecture problem, not a tooling problem The fix is not a better dashboard. It is a method: figure out **why** you monitor, then **what** (model what "healthy" means), then **how** (go get the data, however the device will give it). That is the [AV Observability Framework](https://hyperscaleav.com/framework), and its keystone is the **health model**, the thing that answers one deceptively simple question: > Is this room usable right now? The health model always runs. The only question is whether it runs *as a system* against real signal, or in the operator's head at 3am against half of it. Omniglass is the tool that runs it as a system. ## What Omniglass is Omniglass is an **open, self-hosted observability and control plane for AV (and IT) estates**, built for the real world rather than the demo. It does three things an IT tool cannot, because they were designed in from the start, not bolted on. **It meets the devices where they are.** Agentless and protocol-diverse, it goes and gets the data however the device will give it (SNMP, HTTP, SSH, a control processor's raw command dialect) and normalizes every vendor's reading into one canonical signal, so a Sony display and a Samsung display answer the same question the same way. **It models your estate the way it actually nests.** Components, systems, rooms, buildings. The room is a first-class system, not a tag, so health, alarms, and config attach at the level you actually operate. **It runs the health model.** Signals roll up the tree into "is the room working," and the rollup is role-aware: a *required* display down takes the room down, a *redundant* mic only degrades it, an *informational* sensor does not touch it. That is what turns a wall of red dots into one honest answer, and it is what makes a real uptime SLA possible at all. ```d2 direction: up classes: { node: { style.border-radius: 8 }; warn: { style: { border-radius: 8; bold: true } } } c1: "Display: up" { class: node } c2: "Video bar: not in call" { class: node } c3: "Backup mic: down\n(redundant)" { class: node } system: "Boardroom A\ndegraded" { class: warn } floor: "Floor 3\n1 room degraded" { class: node } c1 -> system c2 -> system c3 -> system system -> floor ``` And then it acts: notify the right person, run remediate-verify-escalate (send the command, wait, re-check the real signal, escalate if it did not take), open and close the ticket as the alarm opens and clears. It is flexible enough to handle the mess, and clear enough that you can actually run it across a thousand rooms. Open source, self-hosted, vendor-agnostic, one server over a database you already know how to run. And it is free. ## The architecture, as one journey Every monitoring system is the same shape: **collect, evaluate, raise an event, hold it as an alarm, act, and see it the whole time.** Omniglass is that shape, built AV-native, and the architecture follows it end to end. ```d2 direction: right classes: { node: { style.border-radius: 8 }; key: { style: { border-radius: 8; bold: true } } } gear: "AV gear\nSNMP · HTTP · SSH · raw AV control" { class: node } datapoint: "datapoint\none canonical signal" { class: key } event: event { class: node } alarm: "alarm\nroom degraded" { class: node } act: "notify · remediate · ticket" { class: node } config: "config\ndesired: input = HDMI1" { class: node } gear -> datapoint: collect: functions, parse at the edge datapoint -> event: evaluate: event_rule event -> alarm: fire opens / clear resolves alarm -> act: act config -- datapoint: "drift?" { style.stroke-dash: 4 } ``` Read it as a journey, and each stop is a page: 1. **[Collection](/architecture/collection/)** goes and gets the data from gear that never wanted to give it, and parses it at the edge. 2. **[Datapoints](/architecture/datapoints/)** type every reading into one owned, canonical signal, the same measurement across every vendor. 3. **[Config](/architecture/variables/)** holds what a device *should* be, so drift becomes a signal you can see and a fix you can push. 4. **[Health](/architecture/health/)** rolls the signals up the system tree into the one answer that matters. 5. **[Alarms and actions](/architecture/alarms-actions/)** detect a condition, hold it until it resolves, and respond. That journey is the whole architecture. The [overview](/architecture/) is the map of it. ## The point We did not build Omniglass to add another monitoring tool to a crowded shelf. We built it so the people who keep rooms working can finally **know their systems**: see them as systems, not as a pile of hosts, and act before the 3am call. An IT tool answers "is the host up?" Omniglass answers "is the room working?" The industry never shipped a right way to see AV. So we did. --- # Architecture URL: /architecture/ The architecture told as one journey, following a single reading from the gear through its whole life to the answer and the action on it. Monitoring, stripped down, is one shape: **collect the data, evaluate it, see it, act on it.** The whole reason to do it is to **know your systems**, and the one question that matters most is deceptively simple: > Is this system working right now? Omniglass is built around that question. This page follows a **single reading through its whole life**, top to bottom, from the gear to the answer and the action on it. Each **bold word** is an official term; the linked ones open their deep dive, and every one is defined in the [glossary](/architecture/glossary/). :::note[A proposed architecture] This is a **proposed, forward-looking architecture**: where we intend to take Omniglass, written in present tense as the target design, not a promise that every detail ships unchanged. Expect it to adjust as we build. Each page carries a status badge, **Design** (specified, little or none built), **Partial** (some capabilities shipped), **Built** (all shipped and tested), or **Diverged** (built, but the implementation differs from this design, see the page's note); the badge is the page's floor. The per-capability breakdown and what is actually shipped live on [implementation status](/architecture/status/); undecided design points are flagged inline as `Open question` asides; how a call was made or reversed and where the build diverges lives in the [decision log](/architecture/decisions/); and the epics and the arc ahead are indexed on the [roadmap](/architecture/roadmap/). Every prose architecture page is also published as one machine-readable file at [/llms-full.txt](/llms-full.txt) (with a curated index at [/llms.txt](/llms.txt)) for LLM tools (the interactive `.mdx` pages are not included). ::: ## The estate Three nouns describe what you operate. - A **[component](/architecture/core-entities/)** is a deployed device, app, or service: a display, a codec, a DSP, a control processor, a cloud UCC service. - A **system** is a set of components that work together to do one job. A meeting room is a system. So is a classroom, a video wall, a broadcast chain. The word is deliberately universal: a system is the unit you actually care about, whatever shape it takes. - A **location** ties systems and components to a physical place (campus, building, floor, room). A component belongs to a system; a system sits in a location. ```d2 direction: down classes: { node: { style.border-radius: 8 }; key: { style: { border-radius: 8; bold: true } } } location: location { class: node } system: system { class: key } c1: component { class: node } c2: component { class: node } c3: component { class: node } location -> system system -> c1 system -> c2 system -> c3 ``` ## Something happens A display drops off the network. A codec changes input. A meeting starts, or a fan stalls. The gear changes state, and that change is what the rest of the architecture exists to catch and make sense of. ## Collect AV gear is **agentless**: you cannot install something inside it, so the reading has to come from the outside. Sometimes the component **pushes** it to Omniglass; usually Omniglass **polls** for it on an interval. Either way, a **[node](/architecture/nodes/)** running close to the gear reaches a component over an **interface** (whatever the device speaks: SNMP, HTTP, SSH, a control processor's own command language) and reads. How to reach a class of device, and what to read from it, is declared once in the component's **template**, the reusable device shape. The node runs that and, crucially, **parses the answer right there at the edge**, turning a vendor's raw response into a normalized reading on the spot. That normalized reading is a **datapoint**. ## The datapoint A **[datapoint](/architecture/datapoints/)** is one value of one **canonical signal** (`power.state`, `audio.level`), owned by exactly one entity through the **exclusive arc**: one owner, a component or a system or a location, never more than one. It carries a **provenance** (how we know it: **observed** from the device, **calculated** by Omniglass, or **intended** by a command we sent) and a **source** (which sensor or path told us). The meaning of each signal (its kind, unit, and validation) lives in a governed **registry**, and a template *references* a registered signal rather than inventing one. That is the whole trick: two displays from different manufacturers answer the same question the same way, because the **measurement** is named, not the device. One canonical name, one comparable signal across the whole fleet. ## What it should be Not every value is measured. Some are **declared**, set by an operator rather than read from a device: a setting that should hold (this input should be HDMI1), or a value that rides down the tree (this system polls every 30 seconds). A declared value is **[config](/architecture/variables/)** when it is bound to a signal, or a plain **variable** when it just rides down the tree, both resolved down a **[cascade](/architecture/cascade/)**: set once high, overridden exactly where it matters. Config has an observed side, so the gap between intent and reality is **drift**, a signal you can alarm on or a fix you can push back. ## Detect An **[event_rule](/architecture/alarms-actions/)** watches a datapoint and fires when its condition is met, recording an **event**: our assertion, in our own words, that something happened. Pair a fire with a clear and the two events open and resolve an **alarm**, the stateful incident, one row per occurrence, the thing an operator works and a ticket binds to. An alarm names the **capabilities it degrades**, which is what turns a detection into a verdict on the system. ## Model health A single alarm is rarely the point. The headline is **[health](/architecture/health/)**: a verdict on the **system**, carried as a calculated datapoint. The chain is one hop per question: an alarm degrades a **capability**, so the component that had it no longer **satisfies** the **role** it was filling; a role below its **quorum** is impaired and contributes its declared **impact** (outage, degraded, or none); the system takes the worst contribution, and a location the worst of its systems. That is the answer to "is the system working?", and a target on it over time is a real uptime **SLA**. The other half is "since when". Health is recorded as a **transition**, written by the change that caused it rather than by whoever opens a page, so the edges are exact weeks later. The rollup ships **opinionated by default**, a first-class model rather than a byproduct of the rules engine, with an escape hatch for the systems the defaults get wrong. The health model always runs; the only question is whether it runs *as a system* against real signal, or in the operator's head against half of it. ## Act An **action_rule** subscribes to events and alarms and runs an **[action](/architecture/alarms-actions/)**. An action can be one step (notify the right person) or many (remediate, wait, re-check the real datapoint, escalate if it did not take; or open and close a ticket as the alarm opens and clears). The loop closes where it started, at the gear. ## See it The operator never queries raw tables. Reads go through **views** (a named query returning a uniform `{columns, rows}`), rendered in the **[console](/architecture/ui/)**: the fleet-health grid, the alarm drill-down, the "why did this value win" cascade explainer. The whole journey is visible the entire time. ## The journey, end to end ```d2 direction: right # Shape colors are deliberately omitted: the inline SVG is themed from the site's # brand tokens in custom.css so it follows Starlight's light/dark toggle. Only # structure (rounding, dashes, the highlighted key node) lives here. classes: { node: { style.border-radius: 8 } key: { style: { border-radius: 8; bold: true } } } gear: gear { class: node } datapoint: "datapoint\ncanonical signal" { class: key } event: event { class: node } alarm: alarm { class: node } health: "health\nrolls up the system" { class: node } action: "action\nnotify · remediate · ticket" { class: node } config: "config\ndeclared" { class: node } views: "views → console" { class: node } gear -> datapoint: collect (node + edge parse) datapoint -> event: event_rule event -> alarm: fire / clear alarm -> health: degrades a capability alarm -> action: action_rule action -> gear: command { style.stroke-dash: 4 } config -- datapoint: drift { style.stroke-dash: 4 } datapoint -> views alarm -> views health -> views ``` ## Underneath The journey rides on a few foundations, named once: - the **[Storage Gateway](/architecture/storage/)** is the one door to the database; every read and write goes through it, which is where **scope** ([identity and access](/architecture/identity-access/)) is enforced: a permission on every route, a visibility filter on every query. - the **[workers](/architecture/workers/)** are one machinery draining a few worklists (the rule engine, the outbox, the clock, reconcile); no bespoke loops. - the **[audit](/architecture/audit/)** trail and the operational logs are immutable, append-only ground truth: the record of who changed what and what the platform did. - **[time](/architecture/time/)** is the one primitive that turns the passage of time into events, so the rest of the pipeline stays purely event-driven. - **[scaling and deployment](/architecture/scaling/)**: the single binary is a modular monolith with run modes, deployed as one container for a small estate or scaled out on Kubernetes with a distributed edge. One binary is the packaging, not a scale ceiling. Datapoints are parsed and emitted at the edge, so they are not re-derived from a raw store. Raw payloads are a debugging aid (a raw mode you turn on while developing, plus failure logging on collection); how much of that to persist, and for how long, is still being settled. ## The invariants A handful of patterns hold everywhere, and they are why the model stays coherent: - **Exclusive-arc ownership**: every datapoint, event, and alarm names exactly one owner (component, system, location, node, or global), so system- and location-level signals are first-class. - **Immutable template versions**: an instance pins a frozen template version (or tracks `latest`); editing mints a new version; re-pointing is explicit. - **On-row lineage**: a derived row carries its own evidence; there is no separate execution table. - **Scope and the `official` boolean**: the key registries (`property_type`, `event_type`) carry a `scope` (template / org / official) deciding where a name is unique; the other registries and rule rows carry an `official` boolean (the same axis minus the template layer). `official` is the curated ship-with set, the rest is operator-authored and local to a deployment. - **Views by default**: current-state reads are plain views, materialized only when a profile proves it necessary. - **Not event-sourced**: stateful entities (alarm, action) hold their state directly. - **Per-database isolation**: there is no tenant column; a tenant is a database. ## Look up any term Every official term is defined once in the **[glossary](/architecture/glossary/)**. The deep pages in the sidebar follow this same journey: collection, the device shape, the data model, config and credentials, the cascade, health, alarms and actions, then the foundations underneath. Omniglass is built greenfield, one vertical slice per PR; the physical schema lives in [storage](/architecture/storage/). --- # AI URL: /architecture/ai/ AI as a governed capability acting through the same API, permission, and scope seams as any caller, marked and audited, with human-in-the-loop gating. AI in Omniglass is a **capability that spans from assistive to operational**, governed exactly like any other actor: at the assistive end it enriches and explains, at the operational end it proposes and acts. Today an AI tool authenticates via **OAuth as a `human` or `service` principal** and acts with exactly that principal's grants, so it reaches the estate through the same seams every caller uses, never a private lane ([identity and access](/architecture/identity-access/)). ## The capability spectrum What AI does, from the assistive end toward the operational end: - **Enrichment.** Event and alarm enrichment: context, a likely cause, a suggested next step on an occurrence the operator is already looking at. Read-only, surfaced inline. - **Diagnosis and reporting.** Troubleshooting support, root-cause analysis across correlated signals, and report generation (health summaries, incident write-ups, period reviews). - **Natural-language surfaces.** NL business query ("which rooms had the most ghost meetings last month"), NL configuration (authoring dashboards, rules, and alarms from a description), and NL template development (drafting a component template from a device's behavior). - **Operational actions.** Acting on the platform on an operator's behalf: room and meeting rebooking, and general platform configuration, under that operator's grants. - **Closed-loop automation.** Diagnose-and-fix flows that close the loop on a known failure class. **Human-in-the-loop is the default**: a mutating action is gated until the class has earned looser handling. ## AI acts through the same seams as any principal AI is **not a side channel**. It reaches the estate through the same three seams every actor uses: - the **API** (no private back door, no direct database path), - **IAM permissions** (the `:` capability checked on every route), and - the **Storage Gateway scope** (the ABAC visible-set injected on every applicable query). The richest AI seam is the **generated [MCP server](/architecture/api/)**: an MCP tool call is a call to a real API operation, so an external model drives Omniglass through the same routes, permissions, scope, and [audit](/architecture/audit/) as the SPA or the CLI, carrying the **acting user or service principal's** credential, never a parallel surface. It is a generated client like the others (a curated tool catalog, the [views](/architecture/views/) exposed as search tools, not a raw one-method-per-tool dump). If a permission or a scope would stop a human from doing something, it stops the AI doing it too. There is no elevated AI lane. ## Provenance and audit Every AI-produced output, an enrichment, a calculated value, a configuration change, is **marked as AI-sourced and audited**. The marking is what keeps the capability assistive-not-authoritative: a reader can always tell what came from AI, weigh it accordingly, and trace it. The audit half is native: the write attributes to the **acting principal** (the human or service the AI authenticated as) in [`audit_log`](/architecture/audit/), and the AI-sourced marking rides alongside, so the trail names a responsible actor on every move. Nothing AI touches is anonymous or unattributable. ## Human-in-the-loop gating Mutating AI actions can require **operator sign-off**: the AI surfaces a proposed change, an operator approves it, then it executes, and the approval lands in the audit trail. Read and diagnostic actions run within the acting principal's scope without a gate. This is a **policy on AI-sourced mutations**, not a separate authorization model: the AI never exceeds the grants of the principal it acts as, and the gate is an extra confirmation on top of that boundary. --- # Alarms and actions URL: /architecture/alarms-actions/ How Omniglass detects a condition with a stateful alarm and responds with an action, which is a flow when it has more than one step. Alarms and actions are how Omniglass turns a detected condition into a held incident and then into a response, so an operator gets paged, a ticket opens, or a device gets fixed without anyone watching a dashboard. An **alarm** detects a condition and holds it; an **action** does something about it. A simple action is a single step (a `notify`); a multi-step action is a **flow** (it branches, waits, and runs steps in parallel). Both alarm and action are **stateful entities that hold their state directly** (not event-sourced). The credentials an action uses to reach a sink live in credentials; the Expr and Go-template machinery in [expressions](/architecture/expressions/). :::note[Partial] Built today: the **alarm as a held row**, raised on a **component** by a caller, carrying a `severity` (`info` / `warning` / `critical`), a message, a `raised_at`, and a nullable `cleared_at` (clearing keeps the row), plus the **capabilities it degrades**, which is how it reaches a system's [health](/architecture/health/). Still `Design`: everything that **produces and routes** one, so the `event_rule` lifecycle (fire / clear criteria, `for` sustain, the `(event_rule, owner)` key, ack and snooze), the **exclusive-arc owner** (an alarm is component-local today, so there is no system- or location-owned alarm), the severity-level registry, the CDC publish, and the whole `action_rule` and action / flow tier ([ADR-0050](/architecture/decisions/#adr-0050-health-is-a-recorded-transition-computed-from-the-alarm-capability-role-chain)). See [implementation status](/architecture/status/). ::: ## The alarm (a stateful entity) **Metaphor: a morning alarm, not a pop-up.** A pop-up *alert* appears and is gone (that is a `notify` action). An **alarm** goes off when its condition is met and **stays high until it is interacted with**. The `alarm` row **holds its current state directly** (`status`, `severity`, `opened_at`, `resolved_at`, `acked_by`); it is **not** event-sourced. It is **one incident, a new row per open**, keyed by `(event_rule, owner)` (the exclusive-arc owner, so a system- or location-owned datapoint yields a system/location-owned alarm), the ITSM correlation anchor ([datapoints](/architecture/datapoints/)). The open and resolve **events** carry the `alarm_id` and are the edge log; the alarm row is the live state. The alarm is **PG-first**: the firing `event_rule` consumer writes the stateful alarm row in the **same Postgres transaction** as the event, with the alarm transition **serialized per `(event_rule, owner)`** so an incident never double-opens. A leader-elected CDC publisher (logical decoding of the WAL) then publishes the committed open / resolve transition to JetStream, where `action_rule` consumers react. Born in the commit, fanned out by CDC: there is no dual-write, and Postgres is never a message bus. Transitions, by who drives them: - **opened / resolved** are **rule-driven** (the `event_rule`'s `fire_criteria` / `clear_criteria`), each emitting an `event` carrying the `alarm_id`; - **acked / snoozed** are **operator-driven**, recorded in `audit_log` (also carrying the `alarm_id`) and applied to the alarm row. The full timeline assembles by `alarm_id` across events + audit; the alarm row is never reconstructed from them. Alarms are **terminal upstream**: they never write datapoints, so they cannot feed back into the datapoint layer (see *Cycle safety*). ## The `event_rule` An `event_rule` carries a required `fire_criteria` and an optional `clear_criteria`. With a `clear_criteria` the fire event **opens** an alarm and the clear event **resolves** it; without one the rule is momentary (a one-shot event, no alarm). There is no separate `alarm_rule`. ```yaml event_rule: scope: 'component.template == "polaris-dsp-16"' # the shared selector (expressions) datapoint: dsp.temperature window: { reduce: avg, over: 10m } # machinery (optional) fire_criteria: "value > 65" # opens the alarm clear_criteria: "value < 60" # resolves it (defaults to !fire_criteria) for: 0 # fire-side sustain (optional) for_clear: 0 # clear-side sustain (optional) severity: average degrades: [microphone] # optional: the capabilities this alarm takes away ``` `scope` selects the entities (fan-out, one alarm per match); `datapoint` is the input; `window` / `for` / `for_clear` are the aggregation machinery; `fire_criteria` / `clear_criteria` are the Expr leaves; `severity` names a level by id (below). A rule is **suppressible by name through the cascade** ([cascade](/architecture/cascade/)): a high-weight group can remove a false-firing rule without editing it (the firmware-bug workaround). `for` and `for_clear` are **symmetric sustains** on the two edges. `for` is the fire-side sustain: the `fire_criteria` must hold for `for` before the alarm opens. `for_clear` mirrors it on the recovery edge: the `clear_criteria` must hold for `for_clear` before the alarm resolves, so a source flapping at the cadence boundary does not churn the alarm open and clear. Both default to `0` (immediate), and a rule can set them independently (a long `for_clear` over a short `for` holds an incident through a noisy recovery without delaying the page). An event_rule also declares the **capabilities its alarm degrades** (default none): while that alarm is open, the component can no longer be counted on for those, so any [system role](/architecture/core-entities/#system-roles-the-slots-a-system-needs-filled) requiring one of them loses it. Most rules degrade nothing (an advisory alarm); the few that do are the owner's failure conditions. This is what makes health **alarm-sourced** rather than a parallel computation. **The rule does not declare a health verdict**: how much a lost capability matters is the **impact** on the role that wanted it, since the same failure means different things in different slots ([health](/architecture/health/)). Because a rule is scoped to whichever arc owns its datapoint, the same machinery yields **component-level** and **system-level** alarms: a system-scoped rule reads member data and fires a system-owned alarm for a condition only the system cares about (a display on input 2 is fine for the display but wrong for the room), which is how system health sees what no single component can. ## Severity: a registry of named levels Severity is a **registry of named levels**, not a bare integer. Each level has an **`id`** (`info`, `warning`, `average`, `high`, `disaster`), a **`label`**, a **`color`**, and an integer **`order`** used only for comparison. Operators and rules reference a level **by id**; the order ranks them. Official defaults ship, **spaced** so a new level inserts by picking an order between two others, no renumbering: | id | label | color | order | |---|---|---|---| | info | info | gray | 10 | | warning | warning | yellow | 20 | | average | average | orange | 30 | | high | high | red | 40 | | disaster | disaster | dark red | 50 | Severity is **distinct from health**: severity is alert importance, health is entity operational state ([health](/architecture/health/)), different axes. Higher order is more severe. The level set is **operator-customizable**: an operator can relabel to P1/P2/P3, recolor, add a level between two others, or define three levels or seven, all config, no code. Rules and `action_rule` predicates compare **by level**, resolved through the order (`alarm.severity >= "high"` matches `high` and `disaster`); the UI renders the label and color from the level. :::caution[Open question] Whether a severity level is purely a label, color, and order, or also carries policy such as a default ack-timeout per level. ::: ## The action (a stateful entity) What an `action_rule` raises and runs. Like an alarm it is **stateful** and holds its own state directly (status, current step, delivery), not event-sourced: - **kinds**: `notify` (in-app), `webhook`, `email`, `run` (execute a command; the edge realization is in [templates](/architecture/templates/) / [nodes](/architecture/nodes/)). - a **simple** action carries delivery state (`queued / sent / failed / retried`, the at-least-once JetStream consumer); - a **multistep** action is a **flow**: it carries workflow state (current step, waiting, branches), exactly like an alarm's lifecycle. The `action` row carries identity, kind, config, and current status. ## The `action_rule` (decoupled subscription) Detection and response are kept **separate**, the discipline that avoids Zabbix's action/operation tangle: the `event_rule` does not contain its response. Instead an `action_rule` is a **NATS consumer** on the CDC-published event / alarm stream, selecting with an Expr predicate, so one action rule can serve many alarms. Subscriptions are **indexed by event key and label**, so dispatch evaluates only the predicates whose key or label already matches, not every rule on every event; an action rule may carry **multiple triggers** and fires if any matches (including a label or wildcard trigger, e.g. any event labeled `room=boardroom-a`). It is a subscription, not a fourth datapoint-pipeline rule family (the derivation rules, calc and event, produce data; the `action_rule` wires the resulting events and alarms to actions): ```yaml action_rule: on: alarm when: 'transition == "open" && alarm.severity >= "high" && component.type == "device"' action: pagerduty-notify ``` The **source is polymorphic** but guarded (see *Cycle safety*): an alarm transition (open / resolve, each an `event` carrying the `alarm_id`), a scheduled fire (an `event` with `origin=scheduled`, not a separate source), an operator (manual), and the declarative runbook step-list. Bodies are **Go templates** and sink auth is a **credential reference**, both per [expressions](/architecture/expressions/) and credentials. ## Storm and dependency suppression The alarm grain stays **`(event_rule, owner)`**: one upstream fault still fans out to one alarm per affected owner. Two primitives keep that fan-out from becoming a page storm without collapsing the grain. **Dependency suppression** mutes a child alarm whose owner's **parent entity on the [exclusive-arc](/architecture/datapoints/) structural tree** is itself down. When the parent is in an `outage` health state, the child alarms beneath it are held suppressed (open, but not dispatched), so one upstream failure does not emit N child pages. It is expressed over the exclusive-arc tree: the same arc that owns a datapoint and its alarm gives the parent walk for free, no separate dependency graph. **Action-level grouping** coalesces alarms sharing **owner / label / `correlation_id`** into one **action dispatch**: one ticket with N members, not N tickets. The alarms stay distinct rows at the `(event_rule, owner)` grain; grouping happens at the dispatch edge in the `action_rule`, so a storm becomes one notification carrying the member list. A **system-scoped `event_rule`** is the sanctioned upstream-cause dedup lever. Because a system-scoped rule reads member data and fires a **system-owned** alarm for the room-level cause (above), it names the actual fault once at the level that owns it. Worked example: a switch reboot downs 20 endpoints. A system-scoped rule owns the room-level cause as a single system-owned alarm; dependency suppression mutes the 20 child endpoint alarms whose parent (the room or switch) is down; action-level grouping coalesces whatever child alarms remain into one dispatch. The operator sees the cause, not 20 symptoms. ## Durability and egress Action state is **PG-first + CDC-out**: the action's step transition is written to the `action` row in a Postgres transaction, and the leader-elected CDC publisher fans the committed change onto JetStream. The **external send** is then a **JetStream consumer** (retry with backoff, dead-letter), not a Postgres `SELECT ... FOR UPDATE SKIP LOCKED` relay. External sends are **at-least-once**; sinks tolerate dupes or we add an **idempotency key** (alarm + action + transition) so the outcome is exactly-once. Pipeline order: **render the body, then apply auth over the rendered bytes** (HMAC signs the rendered body), then send. **Egress safety** is always on: block internal / metadata IPs, verify TLS, bound timeouts, control redirects. :::caution[Open question] The dead-letter surface and the operator retry of failed actions. ::: :::caution[Open question] The observed-use auth-failure feedback from actions into credential health (paired with the credential-health model in [config and credentials](/architecture/variables/)). ::: ## Cycle safety in the action layer The `collection -> datapoint -> alarm` core is acyclic by construction (see *Cycle safety*), and **only data authors events**: an `event_rule` over datapoints (plus the clock's `origin=scheduled`) is the *only* way an event enters the log. Flows and actions never manufacture events, so the response layer cannot inject into the event graph at all. That leaves a single possible loop, the **data-mediated control loop** (an action commands a device, the device's new state arrives as a datapoint, which opens an alarm, which fires the action again), closed with three rules: 1. **Alarms are terminal upstream** (they never write datapoints), so detection cannot feed itself directly. 2. **ack / snooze transitions do not match `action_rule`s** (only open / resolve do), which breaks the `action -> alarm(ack) -> action` loop. 3. **The control loop is lineage-guarded at dispatch.** Before an `action_rule` runs an action, the engine walks the triggering event's **causation lineage** (carried on **NATS message headers** across the bus); if the same `(action, owner)` already appears upstream it is suppressed, with a depth bound as a backstop. Flows are finite by construction (a step list, per-open-alarm, gated on open, cancelled on resolve / ack). The walk crosses the command-to-device round trip because the command **stamps its originating `correlation_id` onto the intended write and onto the adaptive-poll's observed datapoint** ([datapoints](/architecture/datapoints/)). The `event_rule` that fires off that observed datapoint inherits the id, so the lineage walk follows a real carried id across the device edge rather than an assumed lineage; the depth bound stays as the backstop. The carrier crosses the lane boundary, not one continuous header hop. The `event_rule` writes the triggering datapoint's `correlation_id` **and a `caused_by_event_id` parent edge** onto the `event` row it creates (the record lane, [events](/architecture/events/)), and the CDC publisher re-emits both into the record-lane message header. So "carried on NATS headers" is really header (data lane) -> PG column -> header (record lane): the walk is unbroken because each hop copies the pair forward. So no edge can close a loop: events come only from data, alarms are terminal toward datapoints, the response layer cannot author events, operator transitions never re-trigger, and the one real-world control loop is lineage-bounded. ### Correlation id: the trace of a causal chain The same causation lineage the cycle guard walks also powers a read-side **trace**: a **correlation id** that threads a whole causal chain end to end. A datapoint fires an **event**, which opens an **alarm**, which triggers a **flow / action**, which runs a **function / command**, which may change a value and clear the alarm. The `alarm_id` links one alarm's own open / clear events; the **correlation id** links the *entire* chain, the originating event through every downstream event and action it caused. It is built on the existing causation lineage (an id stamped at the head and propagated along each caused edge, riding **NATS message headers** across the bus), pure DX and observability sugar: it lets an operator see the chain at a glance and query "everything this one event set in motion." The caused edge that crosses the device is carried explicitly: when an action runs a command, the command **stamps its `correlation_id` onto the intended write and onto the adaptive-poll's observed datapoint** ([datapoints](/architecture/datapoints/)), so the chain stays threaded through the device round trip and the `event_rule` that fires off the returned datapoint inherits the id. It is **not** a datapoint kind and **not** a stored span subsystem, just an id carried on the chain and queryable. No new tables, no tracing backend. ## Flows: the multi-step action A **flow** is a bounded multi-step action: a **DAG of steps** (`notify`, `command`, `wait`, `branch`, `parallel`) over one alarm. It is **instantiated per open alarm**, gated on the alarm staying open, **cancelled on resolve or ack**, **cycle-guarded** by the same causation-lineage walk documented above, and **finite** by a depth / step cap. It depends on the durable per-incident timer ([time](/architecture/time/)) for its `wait` steps. The canonical case is an **escalation**: remediate, `wait`, re-check the **real datapoint** the alarm is built on, and escalate if it is unchanged ("run the fix, wait 10m, if the datapoint still trips page a human, wait 1h, page the next tier"). Two more cases fall out of the same engine: a **time-bound access grant** (grant, `wait`, revoke) and an **AI-troubleshooting flow** that fetches more data, has the model analyze it, and routes on the verdict. This is the platform's programmable layer, and it is deliberately a **bounded** workflow engine, not a Turing-complete one: a finite step list, lineage-guarded (above), with a depth / step cap as defense. A flow does **not** author events; it acts, and any effect it has on the world returns as ordinary data (which is the only edge that could re-open its alarm, and that edge is lineage-bounded). A drag-and-drop editor edits flows. **The single-step and multi-step shapes are one model.** An action is one or many steps: a single `notify` or `command` is the simple case, and a multi-step **flow** is the same step list grown past one over the same engine. Nothing in the design distinguishes them but the length of the list. ## Namespacing Event rules, actions, and severity levels carry the same **`official` boolean** and `UpsertOfficial` as the rest of the registries. `official: true` rows ship vetted; `official: false` rows are operator-authored and central to component templates (the concrete way to notify or to run a command against a given device class). ## Storage `alarm` and `action` are **stateful entities that hold their current state directly** (not event-sourced); the physical layout (the owner arc, partitioning) lives on [storage](/architecture/storage/). | Table | Key columns | Notes | |---|---|---| | `alarm` | **id**, event_rule, owner arc, **status, severity, opened_at, resolved_at, acked_by** | a stateful entity, **one incident, new row per open**; holds current state directly (not event-sourced); the ITSM anchor. History = events + audit by `id` | | `action` | id, **steps (ordered: notify/command/wait/branch)**, status, current_step | a stateful entity; delivery and step state; driven by events/alarms | A command is **not a table**: it is a `component_template_version.spec` declaration (the interface `commands` block); a command instance is an `action` row with `kind=command`. The `event_rule` / `action_rule` config rows live with the [rule families](/architecture/calculations/). ## Model-keyed command cascade An abstract action (`reboot`) resolves to different concrete commands by component type / model through the cascade. The edge dispatch and the `command` declaration live in [templates](/architecture/templates/) / [nodes](/architecture/nodes/); the cascade is the abstract-to-concrete resolution layer above them, so one `reboot` action targets a heterogeneous fleet and each device runs the command its model declares. --- # API URL: /architecture/api/ The API contract: AIP-style resources and :verb methods, cursor lists, a problem+json error envelope, idempotent writes, and long-running operations carried by the action row. The contract is **two typed surfaces, one source of truth**. The **public HTTP / OpenAPI contract** (this page) is the north face: every operator action, every integration, the SPA, the CLI, and the [MCP](#also-an-mcp-surface) server go through it, and it is the only caller of the [Storage Gateway](/architecture/storage/). The **internal and edge transport is a sibling NATS subject contract** (subjects, message schemas, request-reply, JetStream stream and consumer definitions), the service-to-service and node wire; it is typed and versioned the same way and lives in [messaging](/architecture/messaging/). This page is the **contract every HTTP route honors**. The doctrine behind it (the API is the source of truth, the clients are generated from it) and the generation pipeline live in [API first](/contributing/api-first/); this page is the conventions that doctrine points at. :::note[Partial] Built today: the Huma-over-chi API with the OpenAPI 3.1 document generated from the Go structs (`make gen`), the AIP-style resource and `:verb` routing, and the problem+json error envelope, proven on `/auth`, `/roles`, `/locations`, `/systems`, `/components`, `/nodes`, `/interfaces`, `/tasks`, and the per-component reachability read, plus the type registries, the `/products` and `/standards` catalogs, the classifier-contract and instance-value property routes, the role declaration, resolution, and staffing routes, and the component alarm plus system and location [health](#health-the-verdict-and-why) reads. The node `:enroll` and `:claim` custom methods are the first `:verb` routes in the wild. Still `Design`: the expression `filter` language, idempotency keys, long-running operations over the `action` row, the MCP surface, the SSE relay, and the NATS node contract. See [implementation status](/architecture/status/). ::: ## Shape: resources and `:verb` methods Everything lives under `/api/v1`. The path shape is derivable, not special-cased: - **Plural resource collections**, standard methods by primary key (AIP-style): `POST` creates (409 on PK collision), `GET` reads, `PATCH` partial-updates (AIP-134), `DELETE` removes. No upsert shortcuts. - **Custom methods carry a colon**, `:verb` not `/verb`, for anything that is not CRUD: `/alarms/{id}:ack`, `/components/{name}:apply`, `/views/{id}:run`. The verb is also the **permission**: `:ack` is gated by `alarm:ack`, so the route and the [authorization](/architecture/identity-access/) check share one vocabulary. The **self-scoped** `/auth/me` family is the exception: `/auth/me:changePassword`, `/auth/me/sessions/{id}:revoke`, and the bulk `/auth/me/sessions:revokeAll` (a `{ purpose }` body, keeping the current credential) are **authn-only** (they resolve the target from the session, never a path id, so they carry no capability and a credential id that is not the caller's own is a 404, not a cross-principal action). The **admin** counterparts on `/principals/{id}` do carry a capability and a scoped path id: `GET /principals/{id}/sessions` and `POST /principals/{id}/sessions/{sid}:revoke` (both gated by `principal:revoke-session`) let an administrator list and end **another** principal's sessions, the revoke bounded to that target and behind the owner takeover guard. `POST /principals/{id}/sessions:revokeAll` (a `{ purpose }` body, same gate and guard) bulk-ends all of one kind at once, returning the count. - **Singular kind sub-segments** for the typed families: `/rules/calc`, `/datapoints/metric`, `/location-types`. - **Collection-level custom methods** carry the colon on the collection, not a member: `POST /systems:checkName` (also `/components:checkName`, `/locations:checkName`) is an advisory precheck for a technical-name rename, returning `{ valid, available, reason }`. It is gated by `:update` like a rename, but its availability answer is deliberately **scope-blind**: the `name` uniqueness constraint is global, so a scope-filtered answer would report a name held outside the caller's scope as free and then 409 at save. This is a bounded, documented exception to the ABAC-scope-on-every-query rule (it discloses only that a technical name is taken somewhere, nothing more), not a license to skip scope elsewhere. - **A principal is addressable by uuid or username.** Every `/principals/{id}` route (read, update, grants, the lifecycle verbs, reset, sessions, impersonate) accepts either the principal's uuid or a human's current username, resolved server-side (a value that parses as a uuid is used directly; otherwise it is a username lookup, and an unknown one is a 404). The uuid is still the stable identity (a username is mutable and nothing keys on it), so a username is a convenience address resolved at call time. Service principals have no username and stay uuid-addressed. ## Lists: filter, order, page A list takes `filter`, `order_by`, `page_size` (capped by a server maximum), `page_token`, and `fields`: - **Cursor pagination, never offset.** A list returns a `next_page_token`; the client echoes it on the next call. The token is opaque and stable under concurrent inserts, where an offset would skip or repeat rows. - **`filter` is one [Omniglass expression](/architecture/expressions/)** over the resource's fields, the same language as rule scopes and dynamic groups, so an operator learns it once. - **`filter`, `order_by`, and `fields` name fields, not raw SQL.** Every field resolves through the gateway's generated-column allow-list (an unknown field is a 400), and values are bound parameters, so none of the three can inject SQL ([storage](/architecture/storage/)). - **Every list runs through the scoped gateway**, so results are already scope-filtered: a list never returns a row outside the caller's visible set, and the page count is over visible rows only. ## Partial responses: field masks The `fields` parameter selects a subset of the response (a read field mask, AIP-157); the default is the full resource. `PATCH` carries a **write mask implicitly**: only the fields present in the body change, so a partial update never clobbers an omitted field. :::caution[Open question] Field-mask depth: top-level fields only, or nested paths (`a.b.c`), and whether a list's `fields` and a get's `fields` share one grammar. ::: ## Errors: one problem+json envelope Every error is **RFC 9457 `application/problem+json`**: `type`, `title`, `status`, `detail`, `instance`, plus an Omniglass `code` (a stable machine string) and, for validation, a `violations` array of `{field, message}`. One shape, so the generated client and the CLI render every failure uniformly. The status mapping: | Status | Meaning | |---|---| | 400 | malformed request (bad JSON, an undeclared param) | | 401 | unauthenticated | | 403 | **action denied on this target**: the principal lacks the capability entirely, or can read the target but not perform this action on it (below) | | 404 | not found, **including out-of-read-scope** (below) | | 409 | conflict: PK collision, a stale conditional write, or an idempotency replay mismatch | | 422 | semantic validation (the `:apply` unmet-required-inputs case) | | 429 | throttled | **The 403/404 split is three-way, by where the target sits in the caller's [per-action scope](/architecture/identity-access/).** (a) The action is in **no** grant the principal holds: **403**, capability missing entirely. (b) The target is in the caller's **read-scope** but outside `visible_set(P, action)` for the requested action (the principal can `GET` it but cannot `:ack` it): **403**, which leaks nothing because the caller can already read the row. (c) The target is **outside the caller's read-scope** entirely: **404**, so the API never discloses that an entity exists outside the caller's visible set. Out-of-read-scope is the only 404 case; a readable-but-not-actionable target is a 403, never a 404. ## Idempotency and concurrency - **`Idempotency-Key`** is accepted on `POST` and on state-changing custom methods. The server records the key with its **effect** (the created or changed resource) for a retention window; a retry with the same key returns the original outcome, not a duplicate, so a flaky network never produces two components or a double `:ack`. **Only successful (2xx) outcomes are memoized.** An authorization result (401 / 403 / 404) is **never** stored against the key; it is re-evaluated against current grants on every call, so a denial recorded before an access change is not re-served, and a success is never replayed after a grant is revoked: a replay **re-enters the authorization and gateway path** before the memoized effect is returned. Re-evaluation guards the replay, not the original effect, which already committed. - **Optimistic concurrency**: a conditional update carries the resource version (an `ETag` / `If-Match`); a write against a stale version is a 409, never a silent last-writer-wins. :::caution[Open question] The idempotency-key retention window, and whether it is uniform or per-method. ::: ## Long-running operations: the action is the handle Some operations are not instantaneous: a `command` against a device, a reconcile `:enforce`, a credential rotation, a multi-step flow. These do **not** block the request and do **not** introduce a parallel `operations` resource. The custom method **returns an [`action`](/architecture/alarms-actions/) row** (its id and status), the same stateful entity the response layer already uses, and the caller polls `GET /actions/{id}` through `queued -> sent -> done` / `failed`. The action **is** the operation handle, so "fire and follow" is one model whether the trigger was a rule or an API call. A fast operation may inline its result when it finishes within the request, but the handle is always returned, so a slow device never holds the connection open. The action row is ABAC-owned by its target's exclusive-arc owner, so polling `GET /actions/{id}` is read-scoped to whoever can see the target, independent of the per-action scope that launched it. The HTTP method is the front door; the **dispatch is over NATS**. The command stays HTTP-exposed (returns the handle, poll `GET /actions/{id}`), but the work is carried on the internal NATS contract: the action fans out through [messaging](/architecture/messaging/) to the responsible consumer or node, and the result flows back the same way to advance the row. The caller sees one model, the transport is the bus. ## Writes are audited and scoped - Every write emits an [`audit_log`](/architecture/audit/) row in the **same transaction** as the change, a gateway responsibility, so it cannot be forgotten or bypassed. - Every route **declares its permission** (checked before the handler runs) and every query **carries the caller's scope** (injected by the gateway). Both are [identity and access](/architecture/identity-access/) invariants, and the API is the gateway's only caller, so there is no unscoped path. That declared permission is also **published in the generated spec**: each gated operation carries an `x-omniglass-permission` extension (for example `role:read:admin` on `GET /roles`), so `api/openapi.json` is a machine-readable map of the authz contract, and the set of all stamps is the **permission universe** the [Roles view](/architecture/identity-access/#the-permission-universe-published-per-route) reports. ## The collection surface: nodes, interfaces, tasks The [collection](/architecture/collection/) authoring routes are the first concrete resources that exercise every convention above at once: standard methods by primary key, the first `:verb` custom methods, the non-disclosing 404, a declared permission per route, and injected scope per query. They ship in the AIP shape the [Shape](#shape-resources-and-verb-methods) section describes. | Method | Path | Permission | |---|---|---| | GET | `/nodes` | `node:read` | | GET | `/nodes/{name}` | `node:read` | | POST | `/nodes` | `node:create` | | PATCH | `/nodes/{name}` | `node:update` | | DELETE | `/nodes/{name}` | `node:delete` | | POST | `/nodes/{name}:enroll` | `node:enroll` | | POST | `/nodes:claim` | none (public) | | GET | `/interfaces` | `interface:read` | | GET | `/interfaces/{id}` | `interface:read` | | POST | `/interfaces` | `interface:create` | | PATCH | `/interfaces/{id}` | `interface:update` | | DELETE | `/interfaces/{id}` | `interface:delete` | | GET | `/tasks` | `task:read` | | GET | `/tasks/{id}` | `task:read` | | GET | `/components/{name}/reachability` | `component:read` | | GET | `/components/{name}/events` | `component:read` | **The node custom methods are the day-one enrollment handshake.** `POST /nodes/{name}:enroll` mints (or re-mints) the node's enrollment token and returns it **once**; the server stores only its hash and never logs it, so a re-enroll invalidates the previous token. It is gated by `node:enroll`, the verb-is-the-permission rule. `POST /nodes:claim` is the **node-facing** side of the exchange: a node presents its token and receives its NATS credential (url, username, password). It is the surface's **one public route**, unauthenticated because the token itself is the authentication, so it carries no permission and an invalid token is a **401** (a claim must not disclose which nodes exist). A node is estate-wide, so `node:read` and `node:create` require an **all-scope** grant, not a tree-scoped one. **The interface is authored; the task is derived.** An interface is addressed by a surrogate `id` and is **named by its protocol**: its `name` derives from its `interface_type` and is unique **within its component** (so create takes a type, not a name, and a duplicate protocol on one component is a **409**). Creating an interface **derives its one poll task**, so the task surface is **read-only** (`GET /tasks`, `GET /tasks/{id}`): there are no task create, update, or delete routes and no `task:create` / `task:update` grants. A task references its interface by `interface_id`, its id is **content-addressed** over its interface, mode, and spec, and it carries **no node column**: its placement **projects from the interface**. An interface belongs to a component (or is server-hosted, which needs an all-scoped grant), and a task belongs to an interface, so both inherit the component's [scope](/architecture/identity-access/): an out-of-read-scope component's interface or task is a non-disclosing **404**, exactly the [403/404 split](#errors-one-problemjson-envelope) above. **The reachability read is a typed composed read, not yet a view.** `GET /components/{name}/reachability` composes, per interface, the latest verdict state (`interface.reachable`), the probe-layer signals that compose it (the raw `icmp`/`tcp` metrics), and the recent verdict transitions the availability strip reads. It is gated by `component:read` and scope-injected through the component, so an out-of-scope component is a non-disclosing 404 and the datapoint reads only ever run on a verified, in-scope component. It is a hand-written typed `GET`, an early and deliberate exception to [reads beyond one resource are views](#reads-beyond-one-resource-are-views), standing in until the `ViewResult` framework lands. **The event read is the log-kind mirror of the reachability read.** `GET /components/{name}/events` returns the component's recent **log occurrences** (the [`event` log sink](/architecture/core-entities/#the-event-sink-the-first-arc-owned-occurrence)), newest first, bounded to the last 24 hours and capped at 200 rows. Each row carries its `ts`, the property `key` (e.g. `syslog.line`), the `instance` discriminator, the `message`, optional structured `attributes`, its `provenance` (`observed` for direct collection), and the `source` interface type. It is gated by `component:read` and scope-injected through the same `GetComponent` gate as the reachability read, so an out-of-scope component is the same non-disclosing 404 and the event read only ever runs on a verified, in-scope component. Like reachability, it is a hand-written typed `GET` standing in until the `ViewResult` framework lands. :::note[Thin cuts today] These routes ship the operationally useful slice, not the full CRUD matrix. A **node** has create, list, get, `:enroll`, and `:claim`, but no update or delete; a node **purge cascades** its interfaces and their derived tasks. An **interface** `PATCH` changes only its node placement and its params (target); the type (and so the name it derives) and the owning component are fixed at creation, and a delete is refused while a task still references it (a **409**). A **task** is **derived and read-only**: it is created with its interface and has no write routes, and its placement follows the interface's rather than being set on the task. The four built interface types are `icmp`, `tcp`, `ssh`, and `http`; there is no `interface_type` list route yet. ::: ## Secrets: masked reads, an audited reveal A **secret** is a typed, encrypted-at-rest operator value ([config, credentials, and variables](/architecture/variables/)), and its routes are a worked instance of the conventions above: the AIP resource plus a `:verb` custom method, the verb-is-the-permission rule, the implicit `PATCH` write mask, same-transaction audit, and a scoped read. The registry and the directory read ride the **viewer read floor** (`secret:read`, which `*:read` satisfies); the three writes gate on `secret:create` / `secret:update` / `secret:delete`; the plaintext decrypt gates on **`secret:reveal`**, a permission the `*:read` floor does **not** carry, so a plain "read everything" grant sees only masks and **only admin (`secret:*`) and owner (`>`) reveal**. Every `:reveal` writes an [audit](/architecture/audit/) row (verb `reveal`) in the same call. - `GET /secret-types` lists the shape registry, each `{id, name, display_name, official, fields:[{name, type, secret, origin}]}` (`secret:read`). - `GET /secrets` is the **all-scope admin directory** (`{secrets: [secret]}`); like the principal directory it needs an all-scope grant, and a non-all scope is a 403 (`secret:read`). - `POST /secrets` creates one from `{name, secret_type, owner_kind: platform|location|system|component, owner?, fields}` (201, `secret:create`); a `platform` secret needs an all-scope grant **and** `platform:create` (the install-wide tier permission below). `PATCH` and `DELETE` on a secret that sits at the tier likewise take `platform:update` / `platform:delete`. - `PATCH /secrets/{id}` re-seals the given `fields`, merged over the stored value so an omitted field keeps its value (`secret:update`). - `DELETE /secrets/{id}` removes it (204, `secret:delete`). - `POST /secrets/{id}:reveal` returns the decrypted `{fields: {name: plaintext}}` (`secret:reveal`, audited). A secret's fields are masked in every read: the `secret` body (`{id, name, secret_type, secret_type_id, owner_kind, owner_id?, owner_name?, fields:[{name, value, secret}]}`) returns `••••••` for a secret field, and only `:reveal` returns plaintext. A **variable** is the plaintext sibling of a secret ([config, secrets, and variables](/architecture/variables/)): the same owner arc and cascade, but shown in the clear (no registry, no mask, no reveal). The directory read rides the viewer floor (`variable:read`); `POST` / `PATCH` gate on `variable:create` / `variable:update` (granted to operators); `DELETE` gates on `variable:delete` (admin, owner). The value is polymorphic JSON typed by `value_type`. - `GET /variables` is the **all-scope admin directory** (`{variables: [variable]}`); like the secret directory it needs an all-scope grant, and a non-all scope is a 403 (`variable:read`). - `POST /variables` creates one from `{name, value_type: string|int|float|bool|json, owner_kind: platform|location|system|component, owner?, value}` (201, `variable:create`); a `platform` variable needs an all-scope grant **and** `platform:create`, and the `value` is validated against `value_type`. `PATCH` and `DELETE` on a variable at the tier likewise take `platform:update` / `platform:delete`. - `PATCH /variables/{id}` replaces the `value` (validated against the fixed `value_type`; `variable:update`). - `DELETE /variables/{id}` removes it (204, `variable:delete`). A `variable` body is `{id, name, value_type, owner_kind, owner_id?, owner_name?, value}`, the `value` in the clear. A **tag** ([tags](/architecture/tags/)) is a `key: value` label, and its routes split along the governance line: **minting a key** is a tenant-wide governance action, but **setting a value** is the owning entity's own write. The key vocabulary and an entity's tags read on the viewer floor (`tag:read`, `component:read`). - `GET /tags` lists the governed key vocabulary (`{tags: [tag]}`, `tag:read`); a `tag` body is `{id, name, applies_to, propagates}`. - `POST /tags` mints a key from `{name, applies_to?, propagates?}` (201, `tag:create`, all-scope); the name is normalized to a lowercase identifier (a 422 otherwise), `applies_to` is an entity-kind allow-list (empty = universal), and `propagates` defaults true. - `PATCH /tags/{name}` replaces a key's `{applies_to?, propagates?}` (`tag:update`, all-scope); the name is fixed. - `DELETE /tags/{name}` removes a key, cascading its bindings (204, `tag:delete`, all-scope). - `POST /tags/{name}:setPlatform` sets the **platform-tier** value for a key from `{value}`; `POST /tags/{name}:clearPlatform` removes it (204). A platform binding has no owning entity, so it gates on `tag:update` plus `platform:update` (the install-wide tier permission below). - `GET /{components,systems,locations,nodes}/{name}:listTags` lists the bindings set **directly** on one entity (`{tags: [tagBinding]}`, the entity's `:read`). - `POST /{components,systems,locations,nodes}/{name}:setTag` binds a value from `{key, value}` on the entity; the key must exist and its `applies_to` must admit the kind (a 422 otherwise). Setting a value is the entity's own write, so it gates on the entity's **`:update`** (`component:update` and friends), not a tag permission. `POST /{...}/{name}:removeTag` from `{key}` removes the binding (204). Bindings are custom methods on the entity (like the principal lifecycle) rather than a nested collection, so the generated CLI stays collision-free. - `GET /components/{name}/effective-tags` is the **cascade** for one component: each a `resolvedTag` (`{key, value, owner_kind, owner_id?, owner_name?, band, depth, winner}`), keys unioning and values overriding most-specific-wins, with the winner and shadowed candidates. A non-propagating key resolves only from a binding on the component itself (`component:read`; the component must be in the caller's component read-scope). - The directory list routes (`GET /components`, `/systems`, `/locations`) each carry an **`effective_tags`** map (`{key: winning_value}`, winners only) on every row, resolved for the whole page in one batched query. It feeds the Tags column. A component resolves the full arc; a location resolves `platform` plus its location tree; a system resolves `platform`, its system tree, and the location it is placed at. Provenance lives in the per-entity effective-tags detail, not the row. A `tagBinding` body is `{key, value, owner_kind, owner_id?, owner_name?}`. The **component-classification catalogs** ([core entities](/architecture/core-entities/#catalog-reference-data-vendor-driver-capability)) are Catalog reference data, flat official-vs-custom registries the `product` layer references, on the same pattern as the `*_type` registries. Each is its own resource with the same CRUD shape: the list and read routes sit on the viewer floor (`vendor:read` / `driver:read` / `capability:read`, which `*:read` carries); the three writes gate on `:create` / `:update` / `:delete`, all at the admin tier, exactly like `type:*`. An **official** (seed-owned) row is read-only (`PATCH` and `DELETE` both 422). **Every registry body carries both handles** ([ADR-0062](/architecture/decisions/#adr-0062-a-registry-takes-a-uuid-primary-key-and-a-renameable-handle)): a uuid **`id`** (stable identity, the target every foreign key stores) and a unique, renameable **`name`** (the kebab handle an operator reads and types). A create takes `name`; the uuid is the database's to mint. A path or a reference (`vendor`, `driver`, a parent) resolves whichever form it is given, since a kebab handle can never look like a uuid. A **vendor** (Crestron, Biamp, ...) names an organization, generalizing the former manufacturer-only `component_make` with a **`kind`** of `manufacturer` / `integrator` / `developer` (default `manufacturer`, a 422 for any other value). - `GET /vendors` lists the registry, ordered alphabetically by display name (`{vendors: [vendor]}`, `vendor:read`). - `POST /vendors` mints a custom vendor from `{name, display_name, kind?, icon?, support_phone?, website?}` (201, `vendor:create`, admin). - `GET /vendors/{id}` reads one (`vendor:read`). - `PATCH /vendors/{id}` updates `{display_name?, kind?, icon?, support_phone?, website?}` (`vendor:update`, admin). - `DELETE /vendors/{id}` removes a custom vendor (204, `vendor:delete`, admin). A `vendor` body is `{id, name, display_name, kind, icon, support_phone, website, official}`. `website` is validated to an `http`/`https` scheme on write (a 422 for any other scheme, for example `javascript:`). A **driver** (Generic SNMP, Cisco xAPI, ...) names the implementation that gets, emits, or sets a product's signals, with an optional **`version`**. - `GET /drivers` lists the registry, ordered alphabetically by display name (`{drivers: [driver]}`, `driver:read`). - `POST /drivers` mints a custom driver from `{name, display_name, version?}` (201, `driver:create`, admin). - `GET /drivers/{id}` reads one (`driver:read`). - `PATCH /drivers/{id}` updates `{display_name?, version?}` (`driver:update`, admin). - `DELETE /drivers/{id}` removes a custom driver (204, `driver:delete`, admin). A `driver` body is `{id, name, display_name, version, official}`. A **capability** (Microphone, Display, ...) names what a component can do. It is the vocabulary two other surfaces consume: a **product** declares the set its instances provide, a **component** adds to or suppresses that set with [its own facts](#roles-a-system-declares-a-slot-a-component-fills-it), and a **system role** requires a set of them. - `GET /capabilities` lists the registry, ordered alphabetically by display name (`{capabilities: [capability]}`, `capability:read`). - `POST /capabilities` mints a custom capability from `{name, display_name}` (201, `capability:create`, admin). - `GET /capabilities/{id}` reads one (`capability:read`). - `PATCH /capabilities/{id}` updates `{display_name?}` (`capability:update`, admin). - `DELETE /capabilities/{id}` removes a custom capability (204, `capability:delete`, admin). A `capability` body is `{id, name, display_name, official}`. A **product** ([core entities](/architecture/core-entities/#catalog-reference-data-product)) is the concrete **SKU** that ties the leaf catalogs together: a **vendor** (who makes it), a **driver** (what talks to it), a **kind** (`device` / `app` / `service` / `vm`, default `device`, a 422 for any other value), an optional **parent** product (a variant), and the **capabilities** it provides. It is the layer the catalogs above were built for, and the target of `component.product_id`. Its writes gate on `product:create` / `product:update` / `product:delete` at the admin tier; the list and read routes sit on the viewer floor (`product:read`, which `*:read` carries). An **official** (seed-owned) row is read-only (`PATCH` and `DELETE` both 422). - `GET /products` lists the registry, ordered alphabetically by display name (`{products: [product]}`, `product:read`). Each row carries its vendor, driver, kind, and capabilities. - `POST /products` mints a custom product from `{id, display_name, kind?, vendor_id?, driver_id?, parent_product_id?, capabilities?}` (201, `product:create`, admin). - `GET /products/{id}` reads one, with its capabilities (`product:read`). - `PATCH /products/{id}` updates `{display_name?, kind?, vendor_id?, driver_id?, parent_product_id?, capabilities?}` (`product:update`, admin); `capabilities`, when given, **replaces** the whole set. - `DELETE /products/{id}` removes a custom product (204, `product:delete`, admin); an official row is refused (422), and a product still referenced by a component is refused (409). A `product` body is `{id, name, display_name, kind, vendor, vendor_id, driver, driver_id, parent_product_id, capabilities, official}`. The `vendor` and `driver` handles read the referenced registry's current name beside its uuid. An unknown vendor / driver / parent / capability reference is a 422. A **standard** ([core entities](/architecture/core-entities/#catalog-reference-data-standard)) is the **blueprint a system conforms to** (Huddle Room, Classroom, Auditorium), the system-side counterpart of a product. Because it carries its own declared-property contract it is a **Catalog entity, not a bare type registry**: it takes its own `standard:*` resource rather than the shared `type:*`, and its routes live at `/standards`, not `/types/system`. The list and read sit on the viewer floor (`standard:read`); the writes gate on `standard:create` / `:update` / `:delete` at the admin tier. - `GET /standards` lists the catalog, ordered alphabetically by display name (`{standards: [standard]}`, `standard:read`). - `POST /standards` mints a standard from `{name, display_name, parent_standard_id?}` (201, `standard:create`, admin). - `GET /standards/{id}` reads one (`standard:read`). - `PATCH /standards/{id}` updates `{display_name?, parent_standard_id?}` (`standard:update`, admin). - `DELETE /standards/{id}` removes one (204, `standard:delete`, admin); a standard still referenced by a system is refused (409). A `standard` body is `{id, name, display_name, parent_standard_id, official}`. An unknown parent is a 422. The **shipped** standards are `official: false`, so unlike a seeded product they are fully editable ([the seed model](/architecture/core-entities/#the-seed-model-forked-templates-versus-canonical-catalogs)). ### The install-wide tier permission The cascade's least-specific tier is **`platform`** ([cascade](/architecture/cascade/)), and a write that lands there needs **two** permissions: the resource's own (`secret:create`, `variable:update`, `tag:update`, `settings:update`) **and** `platform:`. Estate **scope** and install-wide **authority** are different questions, and an all-scope grant answers only the first: a senior operator can run every site without being able to move the value that applies to the whole install under them ([identity and access](/architecture/identity-access/#install-wide-authority-is-not-estate-scope)). `platform:*` is seeded to `admin` (and to `owner` through `>`); `operator` and `deploy` deliberately do not hold it. The tier gate is **published in the spec** like every primary gate: a route that can write at the tier carries an `x-omniglass-platform-permission` extension beside its `x-omniglass-permission` stamp, and both land in the route-derived permission universe the Roles view reports. Where the request body names the tier (`owner_kind: platform`, and every settings write) the handler checks it up front; where only the stored row knows its tier (an update or delete by id) the resolved capability rides into the Gateway alongside the ABAC scope, so the 404-versus-403 split stays non-disclosing. ## Properties: a classifier declares, an instance sets A **contract** is the set of properties a classifier's instances expose ([core entities](/architecture/core-entities/#declared-properties-the-classifier-contracts-and-the-value-store)). Each contract is a **sub-collection of its classifier**, addressed by property name, not a resource of its own, so the line is idempotent: `PUT` declares it or revises it in place. Type and validation are **not** in the body, they come from the [property catalog](/guides/admin/properties/). Three classifiers carry a contract, on identical route shapes: - `GET /products/{id}/properties`, `PUT` / `DELETE /products/{id}/properties/{property}`, gated `product:read` / `:update` / `:delete`. - `GET /standards/{id}/properties`, `PUT` / `DELETE /standards/{id}/properties/{property}`, gated `standard:read` / `:update` / `:delete`. - `GET /location-types/{id}/properties`, `PUT` / `DELETE /location-types/{id}/properties/{property}`, gated `type:read` / `:update` / `:delete` (the location type registry is still a `type` registry, so its contract keeps that permission story). Note the path: the registry CRUD stays at `/location-types`, while its contract hangs off the plural `/location-types` collection. The list returns `{properties: [contractProperty]}` ordered by property name, each `{property_type_name, property_type_id, default_value, required}`: the label and type are the catalog's to serve, so a surface that wants them reads `/property-types` alongside. `PUT` takes `{default_value?, required?}`. `DELETE` withdraws the line (204); instances **keep** any value they set for it, now off contract. An **official** (seed-owned) classifier is read-only (422), an unknown classifier is a 404, and a property the catalog does not know is a 422. An instance's **values** are the same shape on the other side of the contract, and unlike the classifier routes they are **ABAC-scoped through the instance**, so an out-of-read-scope instance is a non-disclosing **404** and every write is audited. Four owners are addressable, each gated by its own entity's permission: - `GET /components/{name}/properties`, `PUT` / `DELETE .../{property}` (`component:read` / `:update`). - `GET /systems/{name}/properties`, `PUT` / `DELETE .../{property}` (`system:read` / `:update`). - `GET /locations/{name}/properties`, `PUT` / `DELETE .../{property}` (`location:read` / `:update`). The `GET` is the **effective read** (`{properties: [effectiveProperty]}`): every property the instance's classifier declares (its `product`, its `standard`, its `location_type`), resolved to `coalesce(the instance's own value, the contract default)`, plus every property set directly on the instance that the contract does not declare. Each row carries the catalog's `display_name` and `data_type`, then `value`, `default_value`, `set_value`, `is_set` (the override marker), `from_contract`, `required`, and the `value_id` the surface clears. An instance with **no classifier** (a productless component, a one-off system) returns only its off-contract values. `PUT .../{property}` sets the instance's value from `{value}`. It is **idempotent**: the first set stores the value, a later set replaces it. The property need **not** be on the contract, but it must exist in the catalog (422 otherwise). `DELETE .../{property}` clears it (204), so the property falls back to the contract default, or leaves the effective read entirely when it was off contract. Clearing a value the instance never set is a 404. Setting a property is the **owning entity's own write** (`component:update`, `system:update`, `location:update`), the same rule tag bindings follow: the property catalog governs the vocabulary, the owning entity governs its values. ## Roles: a system declares a slot, a component fills it A **[system role](/architecture/core-entities/#system-roles-the-slots-a-system-needs-filled)** is a slot a system needs filled, and the surface is three arcs: **declaration** (what a standard says every conforming system needs, and what one system declares ad-hoc), **resolution** (the per-system read that merges both with who fills each role today), and **staffing** (assign and unassign). It is **not** the [IAM role](/architecture/identity-access/): `/roles` is the RBAC catalog, these routes are the estate model. A role is addressed **by name within its owner**, so like a property contract line every declaration is a `PUT` that declares or revises in place. The body is `{display_name?, quorum?, capabilities?, impact?}`, `capabilities` **replaces** the required set wholesale (omitting one drops it), and `impact` is `outage` / `degraded` / `none` (omitted means `degraded`), what an impaired role does to its system's [health](#health-the-verdict-and-why). An unknown impact is a 422. Gating follows the owner: - `GET /standards/{id}/roles` plus `PUT` / `DELETE /standards/{id}/roles/{role}`, gated `standard:read` / `:update` / `:delete`. The list returns `{roles: [systemRole]}`, each `{name, display_name, quorum, capabilities, impact}`. Withdrawing a role takes every assignment conforming systems made to it (a cascade), and a role the standard does not declare is a 404. - `PUT` / `DELETE /systems/{name}/roles/{role}`, gated `system:update`, for a role declared **directly on one system**. A role the system does not declare **itself** is a 404 here: an inherited role is withdrawn on the standard, not on the system that inherits it. - `GET /systems/{name}/roles` is the **resolved read** (`{system, roles: [effectiveRole]}`), gated `system:read`. Each row is the declaration (including its `impact`) plus `from_standard` (inherited, or declared on the system), `assigned_to` (the component names filling it here), `assigned`, and **`understaffed`** (how many more the role wants before quorum, zero when staffed). The counts are **served, not computed by the client**, so every surface reads staffing identically. A one-off system returns only its own roles. - `PUT /systems/{name}/roles/{role}/assignments/{component}` puts a component in the role (204, idempotent), and `DELETE` takes it out (204; a component that was not filling the role is a 404). Both gate on `system:update`. - `GET /components/{name}/capabilities` returns the **resolved set** (`{component, capabilities}`, gated `component:read`): what the component's product declares, plus what the component adds, minus what it suppresses. `PUT /components/{name}/capabilities/{capability}` records one own fact from `{present}` (true adds, false suppresses; 204, idempotent), and `DELETE` clears the fact so the component falls back to its product (204; clearing a fact it never declared is a 404). Both writes gate on `component:update`, since a component's capabilities are the component's own data. Every system and component route resolves its owner **within the caller's scope first**, so an out-of-scope system or component is a **non-disclosing 404** on the read and the write alike, and every write is audited in the same transaction. **The assignment refusal is a 422 that names the gap.** When the component's resolved capabilities do not cover every capability the role requires, the assignment is refused with the missing capabilities listed: ``` component "panel-1" cannot fill role "table-mic": missing microphone, speaker ``` The names are sorted, so the same gap always reads the same way and two refusals are comparable. This is a **semantic** refusal, the 422 case in the [status table](#errors-one-problemjson-envelope), not an authorization one: the caller is allowed to assign, the model says this component cannot fill this slot. A bare 422 would be useless here, because the operator's next move (declare the missing capability on the component, or pick a different component) is exactly what the message has to tell them. Around it: an unknown role is a **404**, an unknown standard or an unknown capability on a declaration is a **422**, and an unknown (or out-of-scope) system or component is the same non-disclosing **404** as anywhere else. ## Health: the verdict, and why **[Health](/architecture/health/)** is two shapes on this surface: the **alarm**, which is what is wrong with one component, and the **report**, which is what that means for a system or a location. The split is the model's: an alarm is component-local, and it reaches a room only through the **capabilities** it degrades. An alarm hangs off its component and rides that component's gating: - `GET /components/{name}/alarms` lists them newest first (`{component, alarms: [alarm]}`, `component:read`), the **active** set by default and the whole history with `include_cleared`. An `alarm` body is `{id, component, severity, message, capabilities, raised_at, cleared_at?, active}`. - `POST /components/{name}/alarms` raises one from `{severity, message?, capabilities?}` (201, `component:update`). `severity` is `info` / `warning` / `critical`; `capabilities` is what the condition **takes away**, and an alarm naming none is a note on the component that reaches no system. An unknown capability or a bad severity is a **422**. - `DELETE /components/{name}/alarms/{id}` clears it (204, `component:update`). The row is **kept**, so the record of what was wrong and when outlives the fix; clearing one that is already cleared, or that belongs to another component, is a **404**, because clearing twice is an explicit miss rather than a silent success. Both writes **recompute health in the same transaction**, so an alarm and the verdict it caused are never separately visible, and the recorded edge carries the time the estate changed rather than the time somebody asked. The reports are one shape over two owners: - `GET /systems/{name}/health` (`system:read`) returns `{owner_kind, owner, verdict, roles, systems, transitions}`. `verdict` is `healthy` / `degraded` / `outage`. `roles` is every role the system needs filled, each `{name, display_name, impact, required, quorum, satisfying, impaired, assigned_to, degraded, alarms}`: `satisfying` counts the assigned components that can currently fill it, `degraded` names the **required** capabilities an active alarm has taken away, and `alarms` is the alarms that took them. An impaired role with an **empty** `degraded` is **short-staffed**, not broken, which is a different job for the operator. - `GET /locations/{name}/health` (`location:read`) returns the same envelope with `systems` filled instead: every system placed **anywhere** beneath the location, with its verdict, as the drill-down. The system read explains the rest, so the location report stays a map. - `transitions` is the **recorded edges** over the last 30 days, oldest first, each `{ts, verdict}`. One entry per change, never a sample. Both resolve their owner **within the caller's scope first**, so an out-of-scope system or location is a **non-disclosing 404**, and **neither read writes anything**. The verdict served is computed from the very rows served beside it, so a report can never disagree with its own evidence, while the transitions stay the recorded history ([ADR-0050](/architecture/decisions/#adr-0050-health-is-a-recorded-transition-computed-from-the-alarm-capability-role-chain)). ## Files: content-addressed bytes behind a handle A **file** is a searchable handle over a content-addressed [blob](/architecture/files/): the metadata is tenant-wide (no placement arc), so unlike a secret these routes take **no scope**, only the `file:` permission plus the per-file `sensitive` tier. Reading rides the **viewer floor** (`file:read`, which `*:read` carries, since a file is not a sensitive *resource*); a **sensitive** file is instead fenced to the `:admin` tier (`file:read:admin`), hidden from a lister without it and a **non-disclosing 404** to a reader without it, exactly the [secret sensitivity rule](/architecture/decisions/#adr-0025-secret-is-a-sensitive-resource-a-per-secret-admin_sensitive-flag-flips-a-secret-to-the-admin-tier). The bytes ride **base64 in JSON** on both create and download (the [avatar precedent](/architecture/decisions/#adr-0018-the-avatar-read-endpoint-is-json-not-raw-image-bytes)), so the whole surface stays under the authz middleware and generates a uniform client. - `GET /files` is the directory (`{files: [file]}`), sensitive files omitted below the admin tier (`file:read`). - `POST /files` creates one from an upload `{name, content_type, content (base64), sensitive?}` (201, `file:create`): the server hashes the bytes, **deduplicates** the blob, and writes the handle. A `sensitive: true` file additionally needs the admin tier. - `GET /files/{id}` returns one handle's metadata (`file:read`); a sensitive file is a non-disclosing 404 without the admin tier. - `GET /files/{id}:download` returns `{name, content_type, content (base64)}`, the blob read back and its hash verified (`file:read`). - `DELETE /files/{id}` removes the handle (204, `file:delete`); the blob is freed in the same transaction when no other handle references it (dedup-aware, so storage is reclaimed), and a blob still shared by another handle is kept. A `file` body is `{id, name, content_type, size, sha256, sensitive, created_at}`; the `sha256` is the content address of the blob it points at, so two handles over identical bytes share one blob. ## Reads beyond one resource are views A single resource reads through its typed `GET`. Anything richer, a dashboard, an explorer, the cascade "why did this value win" view, goes through a **[view](/architecture/views/)**: a named query returning a uniform `ViewResult` (`{columns, rows}`), bound by declared params at `/views/{id}:run`, executed through the same scoped gateway. Views are part of the public API; an operator never gets raw SQL. A **live** read (a tile that streams) may upgrade from polling `:run` to a **server-relayed [SSE](/architecture/messaging/) stream** over the same scoped, permission-gated seam: the subscribe is **capability fast-rejected** at open (not authorized there), then the server holds the internal subscription and re-runs the gateway scope per message, filtering by `visible_set(P, read)` against each message's owner and pushing only visible deltas. The operator never connects to the bus, so the live path adds no second authorization model. ## Versioning and evolution The path carries the major version (`/api/v1`). Within a version, change is **additive only**: new fields, new optional params, new resources, never a removal or a meaning change; a breaking change is a new major version, not a silent edit. Because the [OpenAPI 3.1 document is generated](/contributing/api-first/) from the Go structs and the clients are generated from that, the contract cannot drift from the implementation: a drift check fails the PR if a route changed without regenerating. ## Also an MCP surface The same OpenAPI document that generates the typed SPA client and the CLI also generates an **MCP server**, one more [generated client](/contributing/api-first/) over the same gateway, so an AI [agent](/architecture/ai/) drives the platform through the exact seams a human does: every tool call is the same route permission, the same gateway scope, the same same-transaction [audit](/architecture/audit/). It is **not a side channel**. The binding is mechanical, but the **tool catalog is curated, not a raw one-method-per-tool dump**: task-oriented tools, the [views](/architecture/views/) exposed as search and query tools (the richest reads), pagination and the problem+json errors shaped for a model to consume. The MCP server runs under the **authenticated `human` or `service` principal's** credential ([identity and access](/architecture/identity-access/)), so its reach is exactly that principal's grants, scoped and audited like any caller ([AI](/architecture/ai/)). ## The node path is the NATS contract Nodes do **not** speak HTTP. The edge is a NATS client over the WAN: a node publishes telemetry to a JetStream stream, consumes its commands from a durable server-side JetStream command queue, and is enrolled by a NATS JWT/nkey, all on the sibling **NATS subject contract**, not this page's routes. The old node HTTP custom methods (the heartbeat, the telemetry post) are gone; their wire is now subjects and message schemas. The proto definitions survive **as the NATS message schema**, the typed shape on the bus. That contract, subjects, request-reply, stream and consumer definitions, JWT-scoped subject permissions, is documented in [messaging](/architecture/messaging/) and on the [node](/architecture/nodes/) page; the same AIP spirit, error envelope, and idempotency described here carry across to it (the idempotency key per message, the problem-shaped reply on request-reply). ## Self-describing The running server serves `GET /api/v1/openapi.json`, `/openapi.yaml`, and a human reference page, so the public contract is discoverable live against any deployment, not only in these docs. The internal NATS subject contract is self-describing the same way: its subjects, message schemas, and stream and consumer definitions are published from the running server, the sibling of OpenAPI for the bus. Related: [API first](/contributing/api-first/) (the doctrine and the generation pipeline), [messaging](/architecture/messaging/) (the sibling NATS subject contract and the bus), [identity and access](/architecture/identity-access/) (permission + scope), [audit](/architecture/audit/) (the write-time record), [UI](/architecture/ui/) (the views BFF and the renderer contract), and [expressions](/architecture/expressions/) (the `filter` language). --- # Audit URL: /architecture/audit/ The who-did-what record, written once in the same transaction as the change it describes. The audit log is how an operator answers "who changed this, and to what?" without trusting memory: every mutation is recorded once, at the source. :::note[Partial] Built today: the `audit_log` row written in the same transaction as every entity mutation, carrying the resolved actor, verb, resource, and `old -> new` diff. Still `Design`: secret-decrypt audit, the read-audit toggle, retention partitioning, and the backtest / reconcile consumers. See [implementation status](/architecture/status/). ::: ## The model `audit_log` is **ground truth** (not derived): one row per mutation, carrying `actor`, `verb`, `resource_kind`, `resource_id`, and the `old -> new` diff. - **Write-time mandatory.** Every API write emits one `audit_log` in the **same transaction** as the data write, a storage-layer responsibility, not per-handler discipline, so it cannot be forgotten or bypassed. - **The actor** is resolved by IAM ([identity and access](/architecture/identity-access/)): the human, service, or node. - **An AI-accepted suggestion is one row.** An AI tool acts via OAuth as a `human` or `service` principal, so the actor is **that principal**, attributed and audited like any caller; the AI-sourced marking rides alongside the row ([AI](/architecture/ai/)). - **Ground truth a backtest reads.** Operator-driven transitions and config changes are not recomputable from collected data, so the audit log is what a rule backtest reads for them: alarm ack and snooze ([alarms and actions](/architecture/alarms-actions/)), and every config change a reconcile consumes. ## Reads - **Secret decrypts are always audited and never filterable.** Every read of secret material emits an `audit_log` (a credential decrypt), and that subset cannot be filtered away. - **Other reads are not audited at the storage layer.** Optional read-audit is config-driven at the API layer (per-resource opt-in or a verbosity setting), off by default. :::caution[Open question] The read-audit granularity: per-resource opt-in versus a global verbosity setting. ::: ## Retention and integrity Audit carries the **longest retention** of any ground-truth log (compliance), range-partitioned by `ts` like the others. It is append-only by construction. :::caution[Open question] Tamper-evidence (a hash-chain or signed audit) for high-assurance deployments. ::: ## Who consumes it - **Backtest**: a rule backtest reads operator transitions and config changes from here, since they are not recomputable. - **Reconcile**: config changes arrive as `audit_log` rows, so reconcile reacts to them. - **The alarm projection**: ack and snooze come from audit. --- # Calculations URL: /architecture/calculations/ The rule families that run server-side over typed datapoints, and calc_rule in detail: cross-key and system-level derivation. Parsing a raw payload into datapoints is the **edge function** ([collection](/architecture/collection/)), not a server-side rule: a function extracts, keys, and normalizes on the node and emits resolved datapoints. The rules that run server-side over the typed datapoints are two derivation families plus a subscription, and this page is the home of the calc family. The rule families run as **JetStream consumers on the data lane**: confined datapoints arrive on the NATS **trusted** `datapoints` stream (an admission consumer owner-confines raw ingress first, [messaging](/architecture/messaging/)), and the calc and event families consume them directly from NATS (rules never wait on Postgres). A calc consumer reads datapoints and **publishes** its derived datapoints back onto the trusted stream directly, as a trusted server producer (no admission pass; the calc owner comes from the validated `calc_rule` scope); an event consumer reads datapoints and, on fire, writes the event and alarm transition to Postgres in one transaction (the record lane), which CDC then publishes. The two lanes share the one JetStream bus; see [datapoints](/architecture/datapoints/) for the data lane and [events](/architecture/events/) for the record lane. ## Rules: calc, event, action - **calc_rule**: datapoints to datapoint (calculated). The subject of this page (below). - **event_rule**: datapoint change to event. Lives on [events](/architecture/events/) and [alarms and actions](/architecture/alarms-actions/): it carries a required `fire_criteria` and an optional `clear_criteria`, with the fire/clear pair opening and resolving an alarm. - **action_rule**: a subscription wiring events and alarms to actions. Lives on [alarms and actions](/architecture/alarms-actions/). An alarm is not produced by a different rule; it is an event rule whose events are paired (open, close), so there is no `alarm_rule` and no `condition_rule`. Ownership for a templated function is stamped at the edge (the component is known); shared-interface ingress is owner-bound server-side. A **`discovery_rule`** (observed data creates entities) rounds out the family; see the spine's rules section. ## calc_rule: cross-key and system-level derivation A **calc_rule** runs as a calc consumer: it reads datapoints from the data lane (NATS) and publishes a datapoint back onto the trusted stream (provenance **calculated**, a trusted producer with no admission pass), where downstream calc and event consumers see it like any other datapoint. It owns inputs, a reduce (worst / majority / average / Expr), an output key, and a scope. It is for **cross-key** and **system-level** derivation: a 5-minute average, a system rollup, `room.in_use` derived from display power + codec call-state + occupancy. Same-key multi-source reconcile is the key's `fusion_policy`, not a calc (see [Fusion](/architecture/datapoints/#fusion)). The calculated value it writes is parallel to observed: both are machine-derived, distinguished by the **`provenance` column**, both carrying `source_rule` + `source_rule_version` on the row. See [calculated](/architecture/datapoints/#calculated-derived-by-a-calc-rule) for how the row records its lineage. Calc folds **every** instance of an input key into the reduce: a rule reading `fan.speed` from a component gets one candidate per fan, so `worst` / `average` / `count` / Expr aggregate across all of them. Calc **outputs** stay aggregate (`instance = ''`); per-instance outputs (one health per fan, a group-by) are a separate capability, output owners default to the singleton. See [the instance dimension](/architecture/datapoints/#the-instance-dimension-many-values-of-one-key-on-one-owner) for the full instance model. Calc is one half of [fusion](/architecture/datapoints/#fusion): cross-key / system-level fusion is the only fusion that authors a rule (a `calc_rule`), deriving a higher-order fact (a new key) rather than reconciling one key across sources. ## The DAG invariant Calc rules read observed and calculated values as truth; they never treat an intended value as truth to infer a new fact. That is what keeps the pipeline acyclic. The invariant is stated in full on [datapoints](/architecture/datapoints/#the-dag-invariant). ## Storage The three rule families share one config shape, versioned so a backtest can pin the rule version; the physical layout lives on [storage](/architecture/storage/). | Table | Key columns | Notes | |---|---|---| | `calc_rule` / `event_rule` / `action_rule` | **(id, version)**, scope, spec (jsonb: Expr + params) | config, named for function; versioned so a backtest can pin the rule version. `calc_rule` = cross-key/system-level derivation; `event_rule` = fire_criteria + optional clear_criteria ([events](/architecture/events/), [alarms and actions](/architecture/alarms-actions/)); `action_rule` = a subscription (an Expr predicate over events). Parsing is the edge function, not a rule; `discovery_rule` rounds out the family | Related: [datapoints](/architecture/datapoints/) (the data model calc reads and writes), [events](/architecture/events/) (the `event_rule`), [alarms and actions](/architecture/alarms-actions/) (the `action_rule` and the response layer), and [the glossary](/architecture/glossary/). --- # Cascade URL: /architecture/cascade/ How effective settings (config, variables, tags, rule-sets) are resolved for any entity and how the resolve view explains why a given value won. :::note[Partial] The **binding chain** is built for the three cells that ride it: a [tag](/architecture/tags/), a [variable](/architecture/variables/), and a secret each own a value on the exclusive arc `platform | location | system | component` and resolve most-specific-wins down the location, system, and component trees (union-on-key for tags), with the least-specific tier named **`platform`** and gated by its own `platform:` permission ([ADR-0057](/architecture/decisions/#adr-0057-the-cascades-least-specific-tier-is-platform-and-a-default-is-not-a-tier)). The same primitive resolves down the principal axis for [settings](/architecture/settings/). Still `Design`: the two **template** bands, [group](/architecture/groups/) placement by weight, additive **rule** accumulation with suppression, and the operator-facing **resolve view** (winner plus ordered shadowed bindings, including the fall-through-to-declaration provenance). ::: The cascade lets an operator set a value once, high up, and have it apply everywhere below while still being overridable on any one entity, and then explain exactly why a given value won. It resolves the effective settings (config, variables, tags, rule-sets) for any entity. ## What it resolves The effective **config and variables** ([config and credentials](/architecture/variables/)), **tags**, and **rule-sets** for any entity. A first-class **resolve view** explains every effective value: the winning source and what it shadowed. The order is deliberately hand-tuned (not derivable from a single rule), so the resolve view is the safety net, not an afterthought. ## User stories What the cascade is for, in operator terms: - **Set once, override where it matters.** I set the standard poll interval on the Room Kit template, but HQ needs its devices on a different credential, so I set it at the HQ location and it overrides the template for everything there, no template edit; a room three levels down just inherits the nearest setting. *(structural chain: deployment beats template bindings; deepest location wins)* - **Composition tightens the part.** My "Standard Huddle Room" template polls codecs every 30s, tighter than the 60s the codec's own template binds, and every codec placed in a huddle room picks that up. *(system_template beats component_template)* - **"Why did it get this?"** RM204 polls every 5 minutes and I don't know why; the resolve view shows it is the "Old-firmware Room Kits" group (weight 450) shadowing the template's 30s. *(the effective-config resolve view)* The cross-cutting cases (a fleet-wide fix that auto-clears, a broad policy as a floor, a hand-picked set) are the [group](/architecture/groups/) stories, where the group is placed by weight on this same scale. ## The structural chain Broad decision to specific deployment; most-specific (deepest) wins: ```text platform the install-wide binding component_template the leaf entity's template bindings system_template the leaf's owning-system template bindings location tree earth -> luna -> port-lovell (deepest wins) system tree parent system -> subsystem -> ... (deepest wins) component tree chassis -> card -> ... (deepest wins, = the leaf) ``` - Resolution runs **for the leaf** over its containment path; a non-leaf entity (a chassis, a floor) resolves over its own shorter subset. - **`platform` is the least-specific rung, not a floor under the chain.** It is what an admin set for the **whole install**, one decision like every other rung, and a write there needs the install-wide `platform:` permission on top of the resource's own ([identity and access](/architecture/identity-access/#install-wide-authority-is-not-estate-scope)). - **There is no root location.** The location tree is a forest with N unparented tops, and a top is an ordinary location: binding at `earth` misses `mars`, and a top added next quarter is silently uncovered. A tier above today's tops is a new `location_type` and a real node, never a magic one, which is why "everything" is `platform` and not a location at all. - **Philosophy: deployment beats the template.** HQ's location credential overrides what the Room Kit template binds; "Standard Huddle Room" overrides what the bare Room Kit binds. - **Templates are the leaf's base.** Ancestor nodes in the trees contribute their **instance** bindings, not their templates (a chassis hands a card its chassis-wide credential; the card keeps its own template). - The three structural segments are **variable-depth trees** (parent-reference nesting, arbitrary depth); the deepest node wins. Weight-free, pure depth. ## Bindings cascade; declarations do not There are two structures here, and only one of them is the cascade. **The binding side is the cascade.** Every rung above is something somebody **decided**: a template author's value, an operator's at a location, an admin's for the whole install. They order by deployment specificity and fold most-specific-wins. **The declaration side is what a thing *is*** absent any decision. A **default** lives there, as a column on a definition row, beside the unit, the kind, and the validation rule. It is not a rung: it shadows nothing and nothing shadows it. The cascade **falls through** to it when no rung bound anything, and the resolve view reports that as a declaration rather than as a winning source, because "an admin chose this for everything" and "nobody chose, so the type's declaration stands" are different facts an operator needs to tell apart. A default is a column on a definition row, so **a kind with no definition table has no default**. Absent means absent: | Kind | Where its default is declared | |---|---| | [setting](/architecture/settings/) | The tagged struct field on `Settings` (its `default:` tag). | | [property](/architecture/variables/#property-one-typed-name-a-classifier-contract-a-stored-value) | The **classifier contract's** `default_value` column (`product_property`, `standard_property`, `location_type_property`), the shipped instance of the pattern: `EffectiveProperties` reads `coalesce(the instance's set value, the contract default)` ([ADR-0047](/architecture/decisions/#adr-0047-the-fields-fold-product_property-and-property)). | | [variable](/architecture/variables/) | None. | | secret | None. | | [tag](/architecture/tags/) | None. | Note where a property's default is **not**: the `property` catalog entry carries the `data_type`, the unit, and the validation rule, but no value. A default is what a thing is under a given contract, and the contract is the classifier's, so the column lives on `product_property` and its two siblings. That is also the second ordering this side allows: a default may narrow along **type** specificity, one product or standard declaring a different default for the same catalog property. Narrowing on the declaration side never mixes with the binding cascade. A catalog-wide default beneath the contract ones does not exist today; if it lands, it lands as another column on the declaration side, not as a rung. ## Combinators (by what is resolved) - **config / variables -> scalar override**: the deepest/highest source wins; one value. - **tags -> union on name, override on value**: names accumulate; for a given name, the winning source's value wins. - **rules** (`calc_rule` / `event_rule`) -> **additive accumulation + explicit suppression**: a leaf is governed by the union of rules from every layer; a layer removes one by name with a suppression. ## Groups overlay the cascade, placed by weight The structural tree handles config by position and kind. **[Groups](/architecture/groups/)** handle config by attribute or by a hand-picked set, cutting across the tree. The cascade does not define groups (see [groups](/architecture/groups/) for the primitive); it consumes them by **weight** on the one specificity scale. **One specificity scale.** Structural layers auto-derive a specificity from position, weight-free, the operator never tunes it: `platform` lowest, then the templates, then the location / system / component trees by depth, then the entity's own **instance** at the ceiling. A **group's weight is its specificity on that same scale**, so a group sits wherever its weight lands relative to the structural bands: a high weight beats deployment (a must-apply override), a low weight loses to it (a baseline deployment overrides). The instance ceiling beats any group; equal specificity breaks by creation order. A typed group applies at its own level: a component-group to components directly; a system-group reaches a component **through the system layer** of its cascade. So however many groups an entity is in, the group band collapses to one weighted list on the shared scale, fully predictable, and the resolve view names the winner. **The comparison key is segmented, not a single number.** Precedence is a lexicographic key `(segment_rank, depth, group_weight, creation_order)`, compared field by field in that order. The `segment_rank` orders the structural bands (platform, templates, location / system / component trees, the instance ceiling); `depth` orders within a variable-depth tree (deepest wins); `group_weight` places a group relative to the structural bands; `creation_order` breaks an exact tie. Because the segment is the first field, a structural segment never overruns into another regardless of how deep a tree runs or how many group weights stack: a deeper node or a heavier group raises a later field, never the leading `segment_rank`. The single specificity numbers shown elsewhere on this page (e.g. `0`, `100`, the `300s`, the `400s`) are a **presentation-only** flattening of that key for the resolve view, not the comparison key itself. A **`_type`** (device/app, AV-System, room) is not a cascade layer: it is a classification attribute, resolved by a [group](/architecture/groups/) filter (`type == X`) placed by weight, never a tree position. The tree is structural; attributes are groups. ## The registry is outside the cascade `property_type` defines **identity** (kind, unit, validation, fusion_policy) for every datapoint key, which the cascade never overrides (policy, not ontology). A type's **default** lives on that declaration too, off the cascade: it is what the value is when no layer bound anything, not a rung the layers compete with. The resolve view reports it as a declaration rather than as a winning source. ## Structural multi-membership (a component in N systems) Distinct from group membership. A component belongs to zero or more systems through [`system_member`](/architecture/core-entities/#membership-what-a-role-attaches-to), and the resolution **takes the system to resolve against**: given one, it resolves against that system, and only if the component is a member of it, since naming a system it has no binding to must not lend it configuration. That is the "config differs per system" case, answered as a **per-system effective view** computed on demand rather than by merging chains. Asked with **no system in hand**, the chain is seeded from the component's **primary** membership. That is what `is_primary` is for, and it is the whole of what it is for: a default for context-free callers, never a rule that overrides a caller who named a system. A component in exactly one system, which is nearly all of them, never meets the distinction. The seed stays **single-valued** whichever way it is chosen, and that is a correctness requirement rather than a simplification: the rank below orders by band and then depth with **no tiebreaker after that**, so two seeds in the same band would resolve nondeterministically. Membership is many-valued; the chain it feeds is not. **Secrets carry no system band at all.** A credential authenticates a session with the device itself, a shared component has one password, and the room it happens to serve is the wrong owner for it. That is an ownership decision, not a tiebreak, and it removes the case where an ambiguous inheritance would have been actively dangerous. ## The resolve view For a target entity and a key, return: - the **effective value**; - the **winning source** (a tree node, or a group + its weight); - the **ordered shadowed bindings** it beat (source + value). When **no rung bound the key**, there is no winning source and nothing was shadowed: the view reports the **declaration** ("no binding; the type declares 60s") as its own provenance, never as a bottom entry in the shadowed list. Telling "an admin set this install-wide" from "nobody set it, so this is what the thing is" is the whole point of keeping the two structures apart, and this view is where an operator learns it. For rule-sets: the accumulated set, each rule tagged with its source and any **suppressions**. One view explains both override (variables / tags) and accumulation (rules). ## Worked example RM204 codec (Room Kit Pro, fw 11.2) at Room RM204 -> Floor 3 -> HQ Building -> HQ Campus, in the Huddle Room AV system. The estate has three unparented tops (HQ Campus, East Campus, Airport Office), so HQ Campus is a **top, not a root**: it has no parent and no siblings' subtrees. Member of two groups: **Old-firmware Room Kits** (weight 450) and **PCI-scope** (weight 250). Specificity bands are illustrative: `0` platform, `100/200` templates, `300s` location by depth, `400s` system by depth, `500` the instance. These single numbers are a presentation flattening of the segmented key `(segment_rank, depth, group_weight, creation_order)`, not the comparison itself. ```text RM204 - cascade precedence most-specific (highest) wins ============================================================================== spec source poll_interval credential retry_limit ---- -------------------------------- ------------- ---------- ----------- 500 component RM204 (explicit) - - - <- ceiling 450 group: Old-firmware Room Kits 5min * - - 440 system: Huddle Room AV system - - - 340 location: Room RM204 - - - 330 location: Floor 3 - vault-B * - 320 location: HQ Building - - - 310 location: HQ Campus (a top) - vault-A - 250 group: PCI-scope - vault-C - 200 system_template: Std Huddle Room - - - 100 component_template: Room Kit Pro 30s - - 0 platform (admin, install-wide) 60s - - ============================================================================== effective: poll_interval = 5min (group 450; shadowed template 30s, platform 60s) credential = vault-B (location Floor 3 @330; shadowed PCI-scope @250, HQ Campus @310) retry_limit = 3 (no binding at any rung: the declaration stands) ``` The three columns are the point: - **`poll_interval`**: the **Old-firmware group (450)** sits *above* deployment, so its `5min` workaround beats the template's `30s` and the admin's install-wide `60s`, what a fleet-wide bug fix needs. - **`credential`**: the **PCI-scope group (250)** sits *below* deployment, so the specific **Floor 3 (330)** setting beats it, the case a fixed band would get wrong. - **`retry_limit`**: **nothing bound it anywhere**, so the fold ends empty and the value falls through to the declaration. `3` is not a `platform` binding an admin made and it did not shadow anything; it is what the type says a retry limit is. Binding `credential` at **HQ Campus** covers HQ and nothing else: East Campus and Airport Office are separate tops, and a fourth site added next year starts uncovered. The install-wide `60s` at **`platform`** is the only rung that reaches all of them, which is why the tier exists rather than a synthetic root location. `make dev` seeds this estate, so the rule is inspectable rather than illustrative: three unparented tops with a device under two of them, one carrying the `staging` tag its subtree's binding sets and the other the `prod` the `platform` binding sets. `component RM204 (500)` would top everything if it set a value directly. Additive rules accumulate down this same ladder, and a group can **suppress** one by name (the Old-firmware group suppresses the false-firing `high_memory` alarm). ## Resolution, in one line Build the entity's ordered layer path, place matching groups on it by weight, fold variables (override) / tags (union + override) / rules (additive + suppress) down the combined specificity order, fall through to the type's declaration where the fold is empty, and emit effective values with provenance. --- # Data collection URL: /architecture/collection/ Data collection is built from functions: a versioned component template declares interfaces and a set of functions, each a trigger plus a DAG of steps that runs at the edge and parses on the spot. :::note[Partial] The first collection path is live end to end: an edge node runs real **reachability probes** against a component's interfaces (each an **API named by the protocol it speaks** and typed by its **transport**: `tcp`, `ssh`, and `http` interfaces reach by opening the tcp port, `icmp` pings), ships the result as a protobuf `Event` over a JetStream durable consumer, and the `tcp.open` / `tcp.connect_time` and `icmp.reachable` / `icmp.rtt_avg` datapoints land in `metric` owned by the target component. The owner is bound **server-side** from the task's interface (the node stamps no component identity), and the ingest consumer confines a node to its own tasks (an Event carrying another node's `task_id` is orphan-dropped) and rejects unregistered datapoint names. The icmp probe rides the same pipeline unchanged (the consumer does not branch on probe type); a target that does not answer is DATA (`icmp.reachable=0` with a reason), and an error is reserved for a node that cannot do ICMP at all, told apart by a once-cached loopback capability self-check. On top of the raw probe metrics the node now computes the per-interface **reachability verdict** `interface.reachable` (up/down, the AND of that interface's probe results) and emits it as a built-in **state** datapoint; the ingest consumer **routes by the registry kind** (metric to `metric`, state to `state`, and a **log** to the new **`event`** log sink, no longer dropped, see [ADR-0046](/architecture/decisions/#adr-0046-the-event-log-kind-sink)) under the same confinement, and the state series is **transition-only** (one row per flip, guarded both at the node and at ingest). Of the two collection primitives this pipeline reads, the **interface** is the authored one and the **task** is **derived**. An interface has an operator **CRUD API** (gateway + Huma routes at `/interfaces`, generated into the OpenAPI document, the cobra CLI, and the typed client); it is **named by its protocol** (the name derives from its `interface_type`, unique within its component, never a hand-typed label), so create takes a type, not a name. A **task** is **read-only plumbing**: creating an interface **derives its one poll task**, so there are no operator create/update/delete task routes, only `GET /tasks` and `GET /tasks/{id}`. A task carries **no node column**; its placement **projects from its interface** (the worklist and telemetry owner-confinement join the interface), and a **node purge cascades** its interfaces and their derived tasks. Both surfaces carry the two authorization layers, with **scope cascading through the owning component** (the interface and its derived task inherit the component's read/action scope, so an out-of-scope component's interface or task is a non-disclosing 404, reusing the component tier with no new scope kind). The full function/DAG authoring model below (multi-step, cross-interface, branching) is still design; what is built is the reach gate over four transports (`tcp`/`ssh`/`http` open the port, `icmp` pings). The protocol **drivers** that collect a normalized menu of datapoints and functions over a transport are the next slices. See [ADR-0033](/architecture/decisions/#adr-0037-telemetry-is-a-protobuf-event-over-jetstream-with-an-inline-owner-confining-consumer), [ADR-0034](/architecture/decisions/#adr-0038-the-reachability-verdict-is-a-built-in-state), [ADR-0035](/architecture/decisions/#adr-0039-an-interface-is-a-device-api-the-interface-type-is-its-transport-not-its-driver), and [ADR-0036](/architecture/decisions/#adr-0040-the-task-is-derived-read-only-plumbing-projected-from-its-interface). ::: Collection is built from **functions**. A versioned `ComponentTemplate` declares how to reach a class of device (its interfaces) and a set of **functions**, each a discrete unit of device logic that runs on the edge node, reaches the device over an interface, and parses the answer right there into datapoints. One authoring schema covers everything from a single SNMP read to a multi-step, cross-interface, branching procedure. ## Model overview Three strongly decoupled levels, plus a typed parameter surface and template metadata: ```text ComponentTemplate (apiVersion, kind, metadata.labels) inputs typed parameters; required = the :apply gate interfaces connections, declared once, may be persistent/stateful, own liveness functions each = one trigger + a DAG of steps steps typed operations (kind), gated by an interface, schema-validated ``` - **Authoring compiles to a runtime unit.** The hand-authored template is the contract. A compiler lowers it to the per-node execution unit the node runs: it resolves inputs and variables, validates the DAG, and bakes each datapoint's `kind` into the unit so the edge routes to the right table with no runtime registry lookup, the kind riding the published datapoint. The runtime unit is internal, never hand-authored. - **Edge-local execution.** A function runs per component on the node, in one tick, with zero server round trips: every interface sits on the one component, all reachable from the node, so a step can branch on a value a prior step just collected, straight from node memory. - **Two data planes, split by access pattern.** Timeseries [datapoints](/architecture/datapoints/) (observed and calculated) are append-heavy and history-bearing. Current-value config and credentials live in the separate [config and credentials](/architecture/variables/) store (sargable point-lookups); config is keyed to a datapoint as its observed side. - **Kubernetes-style versioning.** `apiVersion: collection.omniglass.dev/v1alpha1` plus a `kind` (`ComponentTemplate`, with `SystemTemplate` / `LocationTemplate` reserved in the same apiVersion). The parser gates on `apiVersion` and converts older versions forward. A **function** is the device-level unit. The platform-level workflow that *responds* to data, the thing that opens tickets, notifies, and orchestrates, is a [flow](/architecture/alarms-actions/); a flow can call a function, but the two live at different layers. ## Interfaces: the device's APIs, declared once An **interface is an API we intend to call** on a component (its `web`, `qrc`, `ttp`, `snmp` endpoint), **not** a network interface; a host or IP is a variable it consumes, not its identity. It is **named by the protocol it speaks**, unique within its component (so a device has one `web`, one `qrc`), never a hand-typed label. Reachability is the **first gate of a ladder** (reach, then auth, then responds, then collecting), and it needs only the interface's transport. A top-level `interfaces` array declares each one; the connection is **decoupled from the work**: a function's steps reference an interface by `id`, the interface owns the connection, not the step. Declaring it once removes per-step duplication, and the decoupling lets a **persistent session outlive any single function run**, so subscriptions and inbound streams attach to a connection established once. ```yaml interfaces: - id: snmp type: snmp # interface_type registry entry (param schema) host: ${input.ip} # references INPUTS, not $var: directly (see Inputs) version: "2c" auth: ${input.snmp} # snmp_community shape; community field is secret (masked, audited) liveness: { oid: 1.3.6.1.2.1.1.3.0 } # reachability gate, per interface - id: cli type: ssh host: ${input.ip} credentialRef: ${input.ssh} # ssh_credential shape, bound to a $var: at apply persistent: true # stateful session, outlives function runs ``` - **Type is the `interface_type`, which is the transport**: the wire the interface speaks over, not the protocol driver. The registry covers `snmp`, `http`, `ssh`, `telnet`, `tcp`, `icmp`, `webhook`, `mqtt`, `syslog`, and `websocket`, and carries each transport's connection-param schema and its default reachability probe. The protocol handler that turns a device's API into a normalized menu of datapoints and functions (the OIDs, the commands, the parse) is a separate **driver** layer, so the same protocol can run over several transports and a device's OIDs live in its driver, never on a template. See [ADR-0035](/architecture/decisions/#adr-0039-an-interface-is-a-device-api-the-interface-type-is-its-transport-not-its-driver). - **`liveness`** is the per-interface reachability gate; it decides whether the interface's functions run. See [nodes](/architecture/nodes/). - **`persistent: true`** keeps a session open across function runs (interface lifecycle contains a function run, which contains a step). Scheduled functions borrow it to send; listen functions wake on its inbound. - **Codec and framing.** Raw-TCP AV control planes wrap payloads non-trivially (line terminators, length prefixes, NUL framing, JSON-RPC or TTP envelopes). An interface carries encode/decode controls that lock raw to shape: the codec frames outbound payloads and parses inbound ones to the declared envelope, so a step sees structured content, not wire bytes. - **Node placement is not declared here.** It is server-assigned from the component's location. ## Functions: a trigger plus a step DAG A top-level `functions` array. Each **function** is one trigger and a DAG of steps, from trivial (one SNMP step reading 20 OIDs) to a multi-step branching procedure. A function is a discrete unit of device logic: it does one thing to or for a component. A function's **trigger** is one of three kinds, and the three unify what used to be separate primitives, a poller, a listener, and a command: | Kind | Fires when | This is | |---|---|---| | `schedule` | an interval elapses, or `onStart` once when the interface comes up | a poll (and `onStart` arms subscriptions) | | `listen` | inbound data arrives on a `source` (`webhook` / `trap` / `syslog`, or `subscribe` / `stderr` / `session-line` on a persistent interface) | a listener for pushed data | | `command` | invoked on demand, by an operator or by a [flow](/architecture/alarms-actions/) | an action you run against the device (`reboot`, `set-input`) | A `command` function takes typed `args` and is the imperative path: it is how the platform *acts* on a device, and how a reconcile pushes a declared config back (the **set** function, see [config](/architecture/variables/)). A function has exactly one trigger. `triggers` is modeled as a list to admit the multi-trigger case, a scheduled function that is also command-invocable for a targeted refetch. ### Two axes: task mode and interface transport A **task** is a node's unit of collection work. Two independent axes describe it, and keeping them separate is what keeps the model clean. - **Task mode** (a property of the task): **poll** (we ask for each datum) or **listen** (we wait for it to arrive). Stated from *our* perspective on purpose: "pull/push" inverts depending on whose frame you take, because the component pushes exactly when we pull. `poll` and `listen` are verbs *we* perform. - **Transport** (a property of the interface): **stateless** (a throwaway connection per shot) or **stateful** (a held-open connection, which becomes a `session` and emits `session_log` rows for connect/auth/drop/reconnect). These are orthogonal. All four cells are real: | | **poll** (we ask) | **listen** (we wait) | |---|---|---| | **stateless** | SNMP get, HTTP GET | webhook, SNMP trap, syslog | | **stateful** | SSH-exec or xAPI `xStatus` on a held session | MQTT subscribe, xAPI feedback | Waiting for a frame is a single mode (**listen**) regardless of transport; a held-open connection is a property of the interface, not a separate mode. So there are two task modes, and statefulness lives on the interface. **Native push.** First-class data pushed by smart senders (control-system programmers instrumenting directly) is self-describing (it carries its key), so its edge parse is a near-identity pass-through, marked `shape=native`. As with any function, a failed parse keeps the raw on a `collection.failed` event. ## Built interface types and their config The poll types and listeners in the `interface_type` registry and the operator config they read. The node translates each stored task + interface into a poller the collection engine runs; how the node *executes* these (tick scheduling, reachability gating, the task queue) is [nodes](/architecture/nodes/). ### Built poll protocols and their config | interface type | shape | host/target | per-task params | datapoints | |---|---|---|---|---| | `icmp` | inline probe | `task.params.target` | `count`, `timeout` | `icmp.reachable`, `icmp.rtt_avg` (fixed) | | `tcp` | inline probe | `task.params.target` (`host:port`) | `timeout` | `tcp.open`, `tcp.connect_time` (fixed) | | `snmp` | held connection | `interface.endpoint` (`host[:port]`, port defaults 161) | `task.params.oids` (comma-separated `name=oid`); `interface.params.version` (default `2c`), `interface.params.community` | one datapoint per OID, `name` = the datapoint key | | `http` | held connection | `interface.endpoint` (base URL) | `task.params.path` (joined onto the base URL), `method` (default `GET`), `timeout` (default `5s`), `body`, `extract` (comma-separated `name=json:`); `interface.params.header_*` (request headers, prefix stripped) | `http.reachable`, `http.status_code`, `http.response_time` (fixed) + one per `extract` entry | | `raw-tcp` | held connection | `interface.endpoint` (`host:port`) | `task.params.command` (sent verbatim + line ending), `timeout`, `extract` (comma-separated `name=re:`); `interface.params.line_ending` (default `\r\n`), `read_delim` (default `\n`), `connect_timeout`, `read_timeout` | `rawtcp.reachable`, `rawtcp.response_time` (fixed) + one per `extract` entry | | `telnet` | held connection | `interface.endpoint` (`host:port`) | as `raw-tcp`, plus `interface.params.username`/`password` (drive the default `login:` / `Password:` chain; `login_expect`/`password_expect` override the prompts) | `telnet.reachable`, `telnet.response_time` (fixed) + one per `extract` entry | | `ssh` | held connection | `interface.endpoint` (`host:port`) | as `raw-tcp` (the command runs as a one-shot `exec`), plus `interface.params.username` and `password` and/or `private_key` (inline PEM) | `ssh.reachable`, `ssh.response_time` (fixed) + one per `extract` entry | `icmp`/`tcp` are inline probes (the target rides the task); `snmp`, `http`, and the text transports (`raw-tcp`/`telnet`/`ssh`) are held connections, so the connection (host/port/version/community for snmp, base URL + headers for http, address + framing + auth for the text family) lives on the interface and the task names what to read. Every fixed built-in name (`icmp.reachable`/`icmp.rtt_avg`, `tcp.open`/ `tcp.connect_time`, `udp.open`, `snmp.reachable`, `http.reachable`/ `http.status_code`/`http.response_time`, and `.reachable`/`.response_time` for the text family) is a **registered canonical `property_type`** in the ship-with registry, so probe/liveness results persist as datapoints, not only as raw wire bytes. They are owner-agnostic measurements like any other: unregistered, reject-not-project would drop them at ingest. `registry.seed_validation_test`'s `liveness_builtins_present` locks the registry to exactly the names the node emits, so a rename on either side fails the build instead of silently going un-derived. For `snmp`, each OID is carried in its **native SNMP type**: numeric OIDs as numbers, string OIDs (OctetString / IPAddress / OID) as text, so a string-valued OID (an enum or label) lands as a `state` datapoint and a numeric one as `metric`. The owning table is decided at ingest from the key's `property_type` kind. Per-OID declared typing and richer collection specs live on the component template (the template declares the OID set, demoting `task.params.oids` to an override). SNMP runs v2c with a plaintext community or v3 with auth/priv; the community resolves from the interface params directly or through an `auth_secret` credential. Every extract spec (`oids`, the http/text `extract`) shares one name grammar: a name may carry a trailing **`key[instance]`** suffix to distinguish several values of the *same* canonical key on one owner (`fan.speed[intake]=`, `fan.speed[exhaust]=`). The bracket is stripped into the datapoint's reserved `instance` label, so the canonical registry still matches the bare key and the value lands in the `instance` column ([the instance dimension](/architecture/datapoints/#the-instance-dimension-many-values-of-one-key-on-one-owner)). A name without a bracket is a singleton (`instance = ''`). For `http`, `http.reachable` is `1` whenever the request completes a round trip (`0` on a transport failure: DNS, refused, timeout, TLS), and `http.status_code` carries the HTTP status separately, so reachability and a `>= 500` status are distinct alarm signals (a non-2xx response is still reachable). `extract` pulls values from a JSON body by dot-path (`name=json:data.0.temp`): a number or bool leaf becomes a `metric`, a string leaf a `state`; a missing path, a container/`null` leaf, or an unreachable endpoint yields no datapoint. Auth rides as `header_*` interface params (e.g. `header_authorization: Bearer ...`), resolved from a plaintext param or an `auth_secret` credential. Carry auth in `header_*`, never in the URL or body: the request `body` param is **not** stamped as a datapoint label, and the `target` label is the request URL with its query string (and any userinfo) stripped, so a token placed in the path query does not leak into attributes (but is still a bad idea). `method`/`body` support POST/PUT. For the **text family** (`raw-tcp`/`telnet`/`ssh`), the poll is one ephemeral round trip: connect, optionally authenticate, send `task.params.command` followed by the line ending, read the reply (to the `read_delim` for raw-tcp/telnet, to EOF for ssh's `exec`, bounded by `read_timeout`), extract, close. `.reachable` is `1` once the transport opened and the command round-tripped (`0` on a transport failure: refused, timeout, or rejected credentials, which are connection health, not errors), and `.response_time` is absent when unreachable. `extract` pulls values by **regex named capture** (`name=re:`, parallel to http's `json:`): each named group routes to the datapoint of the same name, or to the lone datapoint when the pattern has exactly one group; a captured value that parses as a number becomes a `metric`, otherwise a `state`; a non-matching pattern (or an unreachable endpoint) yields no datapoint, while a pattern that fails to compile is a configuration error. Auth resolves from interface params (telnet/ssh `password`, ssh inline `private_key`) or an `auth_secret` credential, the same posture as snmp's community and http's `header_*`, and ssh pins the host key. Credentials live on the interface and are never labelled; the `target` label is the command. The transport is swappable behind one boundary, so a `raw-udp` request/response poll (datagram in, reply out) slots in as a fourth kind without new machinery; UDP **listen** (unsolicited inbound: syslog, snmp-trap) is a different shape and belongs to the listener runtime. A held session (the stateful transport) carries the same text family over a persistent connection, with multi-line prompt-expect beyond the first delimiter, command echo handling, and Q-SYS-style frame/checksum framing; ssh runs its commands as a one-shot `exec`. ### Built listeners and their config A **listener** is inbound: rather than us polling, **we wait for pushed data** (`mode: listen`). That data can arrive several ways, a webhook POST, an MQTT/subscribe stream, an SNMP trap or syslog line, or a line on a held stateful session; a webhook is one transport, not the definition. A `webhook` listener is **server-hosted**: `placement: central` makes the server the endpoint for inbound external webhooks, so a webhook listen-task is **server-executed and unassigned** (`node_name IS NULL`); the server's `POST /webhooks/{path}` route is its runtime, not a node tick. | field | where | meaning | |---|---|---| | `path` | `interface.params.path` | the opaque, unguessable token in the inbound URL (`/webhooks/{path}`); a bearer locator, not the interface name | | `secret` | `interface.params.secret` | shared secret the sender presents in the `X-Omniglass-Token` header (or `?token=`), constant-time compared | | `component` | `interface.component` | when set, datapoints pre-bind to that component (trivial owner); when empty, shared-interface ingress is owner-bound server-side by labels | | `extract` | `task.params.extract` | comma-separated `name=json:dot.path`; number/bool -> metric, string -> state (same extractor as the http poller) | | `raw_log` | `task.params.raw_log` | optional key to store the whole raw frame under (as JSON when the body parses, else text), the holding-pen an event_rule can later promote | One or more `mode: listen` tasks bind to a webhook interface; each inbound POST runs every enabled one, parsing its points under that task's id and **publishing** them to the JetStream [datapoints](/architecture/datapoints/) stream, the same data lane the node publishes to (so owner attribution resolves server-side, and the rule engine and calc rollups react from the stream). The ingest is **owner-confined by the admission consumer** against the interface's **declared owner**, keyed off the trusted server-set `interface` label (not a payload claim), so a leaked path secret can publish only under that interface's owner, never an arbitrary one ([identity and access](/architecture/identity-access/)). **Response contract** (webhook senders retry on non-2xx): **202** = durably accepted; **401** bad/absent secret, **404** unknown path, **413** body over the 1 MiB cap (4xx = sender fault, don't retry); **5xx** = our fault, please retry. A `GET`/`HEAD` to the path answers the endpoint-verification ping some providers send, echoing a `?challenge=` value. The body cap, JSON-only parsing, and "non-JSON body makes declared extractions absent (not an error)" mirror the http poller. **Auth and spoofing**: the shared secret resolves from a plaintext `interface.params` value or an `auth_secret` credential (same posture as snmp's community), and the sender may instead present an HMAC signature verified behind the auth seam. The route stamps a trusted, server-set `interface` label on every datapoint and copies body fields into attributes **only** via the declared `extract` set, so a body field cannot impersonate another interface; shared-interface ingress should scope on `event.labels.interface`, and per-component interfaces (server-assigned owner) are preferred for high-trust sources. A listener also runs node-hosted for LAN-local sources, with idempotency/dedup and form-encoded bodies. ## Steps: the DAG A step is a typed **operation**: a `kind` (the operation it performs) that runs on a referenced `interface` and, for a read, produces datapoints through a typed extractor. ```yaml steps: - id: poll interface: snmp kind: snmp.get # gated by the interface type; schema-validated when: "$dp.power.state == 'on'" # optional guard = explicit branch datapoints: oid: - { key: cpu.utilization, oid: 1.3.6.1.4.1.55540.2.1.0, value: "raw / 100.0" } ``` - **Dependencies are data references, not array order.** A step reads `$steps..*` (ephemeral scratch: a session id, a token, a list element, never emitted) or `$dp.` (a real measurement, emitted and readable for branching). The set of references *is* the DAG; array order is cosmetic, so a function editor can round-trip the graph. - **`when`** is the explicit branch: an expression guard over the in-scope context. A false guard skips the step and its dependents. - **`forEach`** is the step-level fan-out: a step iterates a located collection, the element bound as `$steps..item`, and downstream steps run per element (a list-then-detail chain). Distinct from an extractor's `each`, which fans one response's array into many datapoints inside a single step. - **`kind` is interface-gated and registry-driven.** Valid kinds depend on the target `interface_type` (`snmp.get`, `snmp.walk`, `http.request`, `ssh.send`, `ssh.subscribe`, the interface-agnostic `extract` and `blend`). Each kind's param schema lives in the registry, one entry per adapter. In-scope reference namespaces within a function run: `$var:` (config and secret values, resolved through the [cascade](/architecture/cascade/)), `$dp.` (datapoints), `$steps..*` (ephemeral scratch), `$event` (the inbound payload of a `listen` function), and the extractor-local inputs a step prepares for its `value` leaf (`raw`, `groups`, `node`, `item`). ### Extractors: locate, then optionally transform Each extractor is a typed section that locates a raw value with its protocol-specific field, then optionally transforms it with a single [Expr](/architecture/expressions/) expression in `value` (default identity). **One interpolation convention.** Wherever a config, label, or template field could hold either a computed value or a fixed one, an **interpolated** value (an expression evaluated against the in-scope context) is wrapped `${...}`, and a **literal** is a bare string. So `${node.index}` reads the current element's index, while `"main-display"` is the literal text. The `value` leaf is always an Expr expression by definition, so it needs no wrapper. ```yaml datapoints: oid: - { key: device.uptime, oid: 1.3.6.1.2.1.1.3.0, value: "raw / 100.0" } # centiseconds to seconds regex: - { key: fan.speed, match: 'fan \(rpm\)\s*:\s*(\d+)', value: "int(groups[1])" } jsonpath: - { key: channel.gain, each: $.channels[*], value: "node.gain", labels: { channel: ${node.index}, name: ${node.name}, role: "main-display" } } ``` The extractor names a `key`. What that key *means* (kind, value type, unit, validation, fusion) lives on the [`property_type`](/architecture/datapoints/#the-property_type-registry) registry at some [scope](/architecture/datapoints/#key-scope-template-org-official): a template declares its own keys at **template** scope (no registry friction), or references an **org** / **official** key. Compile-time validation resolves every key to a reachable scope (template keys self-resolve; referenced org/official keys must exist); an unresolved key is reject-not-project at ingest, so a template never silently collects a measurement no scope knows. ## Inputs: the template's typed parameters A template takes typed `inputs`: shape-typed parameters it references internally, never a hardcoded `$var:`. That is the decoupling, a template needs an `ssh_credential`, not specifically `$var:crestron.ssh`. At `:apply` each input is **bound** to a value, either a literal or a [variable](/architecture/variables/) reference (`$var:`), with an optional default the template ships. Required inputs are the apply gate; the UI renders the form. ```yaml inputs: - group: connectivity fields: - { key: ip, type: ipv4, required: true, label: "IPv4 address" } - group: auth fields: - { key: snmp, type: snmp_community, required: true, label: "SNMP community" } - { key: ssh, type: ssh_credential, default: $var:crestron.ssh, label: "SSH login" } ``` The template body references `${input.snmp}` / `${input.ssh}`; the bindings resolve at apply and are overridable per component. So `$var:` lives at the **binding layer** (apply, and input defaults), not scattered through the template body, and the template stays reusable with any value of the right shape. Each input `type` is a `variable_type`, so per-field secrecy comes from the shape. ## Execution: parse at the edge A function runs the parse at the **edge**, not server-side: - **Function steps parse, extract, and normalize on the node** and **publish** resolved datapoints to the JetStream [datapoints](/architecture/datapoints/) stream (the data lane), not to the typed tables directly. The node is a NATS client publishing observed datapoints; a [persistence consumer](/architecture/datapoints/) batch-writes them to the typed tables as an async sink, idempotent on `(series, ts)`, while the [rule engine](/architecture/alarms-actions/) consumes the same stream live. The compiler still bakes each datapoint's `kind` into the runtime unit, so the routing to `metric` versus `state` is decided at the edge with no runtime registry lookup, and rides on the published message. - **Raw payloads are not stored**, the datapoint is the source: a dev raw-mode taps the wire bytes live while developing, and a parse or validation failure emits a `collection.failed` event carrying the raw. There is no telemetry table. - **Owner attribution:** a single-owner function lands its datapoints on its own component, identity stamped at the edge (the component is known, the function runs for it). A function that reports for many devices (a management platform) publishes datapoints for multiple owners, resolved server-side from the emitted identity labels (below). - **Placement-scoped writes.** A node publishes only the owners in its **placement visible_set** (the owners of the tasks assigned to it). That visible_set expresses as **NATS subject permissions** on the node's account, the `node` gateway mode in [identity and access](/architecture/identity-access/). At ingest, an emitted owner label **outside** that visible_set is **never an authoritative write**: it is treated as an **orphan / discovery candidate** and feeds the `discovery_rule` stream (below), so a compromised node cannot manufacture writes for owners it was never placed on. The perspectives / `disagree` model is the backstop for the other case, a legitimately-placed but compromised node reporting bad values for owners it **does** cover; bounding the visible_set and corroborating across perspectives are complementary, not the same defense. - Because parsing is the edge step, there is **no separately authored transform rule**. Routing is the template's fan-out, and cross-entity rollups are [calc](/architecture/calculations/) datapoints on system and location templates. The server-side work that remains is shared-interface owner-binding and untemplated raw ingress. ### Raw sampling: an opt-in re-parsable window The default is that raw payloads are not retained. An opt-in **`raw_sample`** policy keeps a bounded window of raw frames so a corrected extractor can re-derive its datapoints over that window, without reintroducing a telemetry table. `raw_sample` is **cascade-resolved**, settable on an interface, a task, or a template, and resolves to one of three values: - **`off`** (default): no raw retained. - **`all`**: every frame the matched task collects is buffered. - **`1-in-N`**: one frame in every N is buffered (sampled), bounding volume on a high-cadence source. The kept frames carry the **immutable function version** that parsed them, so the buffer is **re-parsable against that exact version**: a corrected extractor re-runs over the retained window and re-derives the datapoints, retroactively correcting them. The residual is stated honestly. **Outside** the kept window a wrong-but-conforming parse (one that produced a valid datapoint from a misread frame) is **forward-fixable only**: the fix applies to new collection, the already-parsed history is not retroactively corrected because the raw is gone. The buffer preserves the no-telemetry-table economics: off by default, bounded, sampled, and short-lived. It is a short-TTL holding pen, range-partitioned and cold-tierable like the [metric](/architecture/datapoints/) partitions and the [storage](/architecture/storage/) layout describe, not a parallel history of record. ## Shared-API collection: one component, many owners Some sources describe **many entities at once**: a SaaS / UCC platform (Zoom, Teams), a controller fronting many devices, a building gateway. Modeling each described entity as its own component is the legacy-platform reflex. Here the API is **one component** (one interface, one credential) and its data **fans out** to the entities it describes. - The API component's function pulls the batch (all rooms, all devices) in one call and **labels each emitted datapoint with the external identity** it belongs to (a Zoom Room ID). - The function does **not** stamp the owner, it is the conduit, not the owner. Ownership is **resolved server-side**: the identity is matched against a declared **identity config** (`zoom.room_id` on the target) and the datapoint is bound to that entity. This is the same shared-ingress owner-binding the model uses for webhooks and traps; a pull-side batch is the same shape. - **The owner can be a system, not only a component.** SaaS state that is telemetry *of* a room (no physical device) maps to **system-owned datapoints** directly. Reserve a virtual component for the genuine *member* case (its own node in the topology, a `health_role`, a lifecycle). Rule of thumb: **member -> component, telemetry -> system.** - **Unmatched identities are orphans**, a discovery candidate. The `discovery_rule` is the onboarding win: point it at the API and it auto-creates the entities and sets their identity, so you never hand-map. **Best practice.** Map SaaS / cloud telemetry to **system-owned datapoints**, and **wire it into system health** with a system-scoped `event_rule`. Treat the vendor's own status as an **input to that judgment, not the verdict**: a UCC platform reporting "offline" is one source's opinion, so corroborate it (against the codec, occupancy) before downing the room. See [health](/architecture/health/). ### Identity binding: the value-to-owner index A multiplexed source emits a row tagged with an external identity (a Zoom Room ID, a controller's slot number); binding that row to an Omniglass owner is a lookup against a **value-to-owner index**. The index is an **identity arc** on identity config: a `(property_type, value) -> owner` mapping, where `property_type` is the **match key** (the canonical identity key, e.g. `zoom.room_id`) and `value` is the external identity the source emitted. The index resolves **in the cascade scope** the identity config is set at, so an identity declared at a system or location scope binds the rows of every member below it. Two sides can supply the match value, and **precedence** is explicit: - A **declared identity config value** (an identity the operator set on the target) **wins**. - It falls back to the **observed identity datapoint** that shares the same key (a value the device itself reported under that `property_type`). So ownership resolution reads the **resolved identity** for the key (declared over observed), matches the emitted `(property_type, value)` against the index, and binds the row to the owner the index names. The [datapoints](/architecture/datapoints/) ownership-resolution machinery reads this same index. ### discovery_rule: orphans become candidates A `discovery_rule` turns the **orphan / unmatched stream** into proposed entities. Its **input** is every emitted identity that the value-to-owner index does **not** resolve: an unmatched `(property_type, value)` from a shared-API batch, plus the **out-of-placement labels** a node emits for owners outside its placement visible_set (above). Pointing a `discovery_rule` at a source is the onboarding win: it auto-creates the entities and sets their identity, so you never hand-map. - **What it creates.** Candidate components or owners, each seeded with the identity that surfaced it (the `(property_type, value)` becomes the new entity's identity arc), so the next batch from the same source resolves through the index instead of orphaning. - **Idempotent on re-discovery.** Re-seeing an identity the rule already materialized does **not** create a duplicate: the rule keys on the `(property_type, value)` it already bound, so a steady stream of the same orphan resolves to one candidate. - **Scope and standing.** A `discovery_rule` carries a cascade **scope** and an `official` / private standing like the other rule families (`event_rule`, calc), so a ship-with `official` rule and an operator's private rule compose without colliding. ## Storage The connection registry, the declared connections, and the node's units of work; the physical layout lives on [storage](/architecture/storage/). | Table | Key columns | Notes | |---|---|---| | `interface_type` | name, **built**, direction (in/out), param_schema (jsonb) | the protocol-and-style registry (`ssh`, `http`, `snmp`, `mqtt`, `webhook`, ...); generates the template config schema | | `interface` | **id** (surrogate), **name derived from interface_type** (unique per component, never hand-typed), **component** (nullable: set = pre-bound, null = shared/match-key), params (jsonb), **node** (placement, `ON DELETE CASCADE` so a node purge drops its interfaces) | the connection, the **authored** primitive ([nodes](/architecture/nodes/)) | | `task` | **id = content hash**, **interface** (`ON DELETE CASCADE`), **mode (poll/listen)**, spec (jsonb), enabled | a **derived** unit of collection work: created with its interface, read-only (no operator CRUD). **No node column**, placement **projects from the interface**. Parsing to datapoints is the **edge function**, not the task's job | --- # Core entities URL: /architecture/core-entities/ The estate model: component, system, location, and node as the structural entities, the variable-depth trees, and the exclusive-arc owner. Core entities are the things an operator actually manages, the component, system, location, and node, and giving each its own identity is what lets every datapoint, event, alarm, and config name exactly one of them as its owner. This page covers the structural entities, how they nest, and how everything else names one of them as owner. The shapes these entities pin are [templates](/architecture/templates/); the data they own is [datapoints](/architecture/datapoints/); the physical tables are [storage](/architecture/storage/). :::note[Partial] Built today: `component`, `system`, and `location` as name-addressable, variable-depth (`parent_id`) trees with full scoped CRUD; a delete is refused while a structural child remains. Each of the three carries an **optional classifier** that declares what its instances expose: a component points at its **`product`**, a system conforms to a **`standard`**, a location is typed by its **`location_type`** (the `component_type` registry retired, [ADR-0047](/architecture/decisions/#adr-0047-the-fields-fold-product_property-and-property); the `system_type` registry was promoted to `standard`, [ADR-0048](/architecture/decisions/#adr-0048-the-standard-blueprint-and-the-template-fork-seed-model)). The **exclusive-arc** owner columns are now real, carrying the datapoint sinks, the **`event`** log sink (see [The event sink](#the-event-sink-the-first-arc-owned-occurrence) below), and **`property`** (see [Declared properties](#declared-properties-the-classifier-contracts-and-the-value-store) below); a component, system, and location each own a recorded **[health](/architecture/health/)** series on that same arc. A system also declares the **roles** it needs filled, staffed by components whose **resolved capabilities** cover what the role requires, each role carrying the **impact** an impaired one has on the system (see [System roles](#system-roles-the-slots-a-system-needs-filled) below). Still `Design`: template pinning, `system_member` composition, operational mode, decommission / purge, and the **`alarm` arc** (an alarm is component-local today, so a system- or location-owned alarm is not yet a thing). See [implementation status](/architecture/status/). ::: ## The estate: four structural entities Three nouns describe what you operate, plus the edge process that collects for them. - A **component** is a deployed device, app, or service: a display, a codec, a DSP, a control processor, a cloud UCC service. It owns datapoints, pins a `component_template_version`, and points at the **`product`** it is, which is where its shape comes from (its vendor, its driver, the capabilities it provides, and the properties it declares). The pointer is optional: a productless component is legal, it simply carries no contract. - A **system** is a set of components that work together to do one job. A meeting room is a system. So is a classroom, a video wall, a broadcast chain. The word is deliberately universal: a system is the unit you actually care about, whatever shape it takes. It pins a `system_template_version`, is located at a location, and **conforms to a `standard`**, the blueprint it is built against. The pointer is optional, mirroring `component.product_id`: a one-off system conforms to no standard and simply carries no contract. - A **location** ties systems and components to a physical place (campus, building, floor, room). It is classified by `location_type` and, unlike component and system, has **no template**: for a location the type is the only shape-definer. A starter `location_type` set ships seeded (and is operator-owned, see [the seed model](#the-seed-model-forked-templates-versus-canonical-catalogs)), readable at `GET /location-types` (alphabetically by display name), which is what the type picker on the location form lists so a location is classified by a known type rather than a free-typed string. Each type also carries an `icon` (a glyph key like `building` or `landmark`) that the console renders as the leading glyph on every location of that type, so a campus reads differently from a building at a glance in the tree; an unknown key falls back to `map-pin`. Each type also carries `allowed_parent_types` (a set of `location_type` ids and/or the reserved `root` sentinel), the placement constraint: empty means unconstrained, a non-empty set is enforced on create and move, and the four shipped types carry it seeded (`campus={root}`, `building={root,campus}`, `floor={building,campus}`, `room={floor,building,campus}`). - A **node** is the edge process (`omniglass --mode node`) that pulls work, reaches components over interfaces, and ships results ([nodes](/architecture/nodes/)). It is structural because it is a first-class **owner**: a node owns its own self-health telemetry and can carry a node-owned alarm. A component belongs to one or more systems (see [membership](#membership-what-a-role-attaches-to)); a system sits in a location. ```d2 direction: down classes: { node: { style.border-radius: 8 }; key: { style: { border-radius: 8; bold: true } } } location: location { class: node } system: system { class: key } c1: component { class: node } c2: component { class: node } c3: component { class: node } location -> system system -> c1 system -> c2 system -> c3 ``` Above the four sits the singleton **`global`** estate owner: the top owner above every location where estate-wide health and KPIs roll up. One per deployment, no FK. It is an **owner**, nothing else: it is not a location (the location tree is a forest of N unparented tops with no root) and it is not the [cascade](/architecture/cascade/)'s least-specific binding tier, which is **`platform`** ([ADR-0057](/architecture/decisions/#adr-0057-the-cascades-least-specific-tier-is-platform-and-a-default-is-not-a-tier)). | Entity | What it is | Key columns | |---|---|---| | `component` | a deployed instance (`dsp-boardroom-3`) | name (unique), **parent_id** (self-ref tree), display_name; pins a `component_template_version`; carries **`product_id`** (optional, `on delete restrict`), the source of its shape | | `system` | a composition of components / subsystems (the service tree) | name (unique), **parent_id** (self-ref tree), display_name; pins a `system_template_version`; carries `location_id`; carries **`standard_id`** (optional), the blueprint it conforms to | | `location` | a place tree | name (unique), type, **parent_id** (self-ref tree), display_name; no template (the `location_type` is the only shape-definer) | | `node` | the edge process | name (the identity); carries labels, last_heartbeat_at, and its bound credential ([identity and access](/architecture/identity-access/)) | ### Catalog reference data: vendor, driver, capability The **component-classification catalogs** are flat, seed-and-custom registries, not structural entities: each names a reusable fact the `product` layer pins, on the same official/custom pattern as the `*_type` registries ([Types guide](/guides/admin/types/)). Three leaf catalogs ship here; the **`product`** catalog (below) sits above them as the concrete SKU a `component` points at: - A **`vendor`** (Crestron, Biamp, QSC, ...) names an organization in the estate model, carrying a **`kind`** of `manufacturer`, `integrator`, or `developer` (default `manufacturer`). It is the generalization of the former manufacturer-only `component_make`. - A **`driver`** (Generic SNMP, Cisco xAPI, ...) names the implementation that gets, emits, or sets a product's signals, carrying an optional **`version`**. - A **`capability`** (Microphone, Display, ...) names what a component can do. It is claimed in two places, a **product** (the default for its instances) and a **component** (its own facts, which add to or suppress the product's), and it is what a [system role](#system-roles-the-slots-a-system-needs-filled) requires. See the [Vendors guide](/guides/admin/vendors/) for the operator surface. ### Catalog reference data: `product` A **`product`** (Cisco Room Bar, Samsung QM55, ...) is the concrete **SKU** that ties the three leaf catalogs together: a stable `id` and `display_name`, a **`kind`** (`device` / `app` / `service` / `vm`, default `device`, a fixed enum checked in the DB and at the API edge), an optional **`vendor_id`** (who makes it) and **`driver_id`** (what talks to it), an optional **`parent_product_id`** (a self-reference: a variant points at its base product), and the `official` boolean. The capabilities a product provides are a many-to-many set carried in the **`product_capability`** join (`product_id`, `capability_id`); a video bar provides microphone, speaker, camera, and codec. The vendor, driver, and parent FKs are **`on delete set null`** (deleting a vendor nulls the pointer, it does not block). A **`component`** points at the product it **is** through **`component.product_id`** (**`on delete restrict`**): the product is the source of a component's shape (its vendor, driver, and capabilities), replacing the old `component_type`-as-shape notion. The restrict FK is the referential guard the leaf catalogs deferred, so a product still referenced by a component cannot be deleted (409). See the [Products guide](/guides/admin/products/) for the operator surface. ### Catalog reference data: `standard` A **`standard`** (Huddle Room, Classroom, Auditorium, ...) is the **blueprint a system conforms to**: the system-side counterpart of `product`. It carries a stable `id` and `display_name`, an optional **`parent_standard_id`** (a self-reference: a variant points at its base standard, exactly as `parent_product_id` does), and the `official` boolean. It is the promotion of the former `system_type` label registry, which had nothing to declare ([ADR-0048](/architecture/decisions/#adr-0048-the-standard-blueprint-and-the-template-fork-seed-model)). A **`system`** conforms to a standard through **`system.standard_id`**, and the pointer is **optional**, mirroring `component.product_id`: a **one-off system** that matches no blueprint is first-class and simply carries no contract. Conformance is **not** a copy: the standard's contract defaults resolve **live** for every system that conforms, until that system overrides one. See the [Standards guide](/guides/admin/standards/) for the operator surface. ### Declared properties: the classifier contracts and the value store A classifier does not only classify, it **declares what its instances expose**. Three tables carry that declaration, one per classifier, all the same shape: **`product_property`** (a component's), **`standard_property`** (a system's), and **`location_type_property`** (a location's). Each is one row per declared property (`_id`, `property_type_name` referencing the [`property_type` catalog](/architecture/variables/), an optional `default_value` in jsonb, and a `required` flag), unique per `(classifier, property)`. Type and validation are deliberately **not** repeated here: they live on the property, which stays the single source for what a name means. The value lives in **`property`**, which carries the **same owner exclusive-arc** as the datapoint sinks and `event` (`owner_kind` plus `component_id` / `system_id` / `location_id` / `node_id`, one-set CHECK), plus the property name, an `instance` discriminator, a **`provenance`**, and the jsonb `value`. Provenance is what makes the fold work: the same table holds a value an operator **declared**, a value a device **observed**, a value a rule **calculated**, and a value config **intended**, and only the provenance says which. Today the write path fills `provenance=declared`; the other three provenances are seats for the producers that come later. The read is **`EffectiveProperties(ownerKind, ownerID)`**, one query with two arms: - the **contract arm**: every property the instance's classifier declares, resolved to `coalesce(the instance's declared value, the contract default)`, marked `from_contract`; - the **off-contract arm**: the values set directly on the instance for properties its contract does not declare. **One resolver serves all four owner kinds.** The three classifier/instance pairs differ only in *where* the instance names its classifier and *which* table that classifier declares into, so those five identifiers (instance table, classifier column, contract table, contract key, arc column) are data in an `ownerContract` table and the SQL is written **once**: | Owner kind | Classifier column | Contract table | |---|---|---| | `component` | `component.product_id` | `product_property` | | `system` | `system.standard_id` | `standard_property` | | `location` | `location.location_type` | `location_type_property` | | `node` | (none) | (none) | An instance whose classifier is unset (a **productless** component, a **one-off** system) resolves to its off-contract arm alone, and a **node** has no classifier at all, so it always does. Writing the resolution three times would let the three drift; writing it once is the primitive-first move ([ADR-0048](/architecture/decisions/#adr-0048-the-standard-blueprint-and-the-template-fork-seed-model)). Every arc is **scope-checked on the write**, so setting a value on a system or location outside your scope is a non-disclosing 404, not a silent success. This pair replaces the retired `field_definition` / `field_value` feature: a "field" was always a property with **declared** provenance, and the catalog it hung off is now the classifier's contract ([ADR-0047](/architecture/decisions/#adr-0047-the-fields-fold-product_property-and-property)). See the [Properties guide](/guides/admin/properties/) for the operator surface. ### System roles: the slots a system needs filled A contract says what a system **carries**. A **system role** says what it **needs filled**: a table microphone, a main display, a confidence monitor. A role is a **slot**, not a component, so the system can state its shape before anything is assigned to it, and an **unfilled slot is visible** rather than absent. :::note[Not the IAM role] A **system role** is a slot in a system. An **[IAM role](/architecture/identity-access/)** (viewer / operator / admin / owner) is a capability set granted to a principal. They share only the word; nothing crosses between them. The storage methods are deliberately `ListSystemRoles` / `SetSystemRole` / `DeleteSystemRole` so they cannot be mistaken for RBAC's `ListRoles` / `UpsertRole`. ::: Five tables carry it: | Table | Key columns | Notes | |---|---|---| | `system_member` | (`system_id`, `component_id`, **`is_primary`**) | **membership**: the binding a role attaches to, many-valued, cascading from both ends | | `system_role` | `owner_kind` + `standard_id` / `system_id` (the arc), `name`, `display_name`, **`quorum`**, **`impact`** | the slot itself; the arc is the one `property` uses, with a one-set CHECK and a `unique nulls not distinct` key over the arc plus name | | `system_role_capability` | (`role_id`, `capability_id`) | what the role requires, **conjunctive**: a component must provide **every** listed capability | | `component_capability` | (`component_id`, `capability_id`, **`present`**) | the component's **own** capability facts, layered over its product's | | `system_role_assignment` | (`system_id`, `role_id`, `component_id`) | who fills the role here; the component FK is **`on delete restrict`** | ### Membership: what a role attaches to **A component that does a job in a system is a member of it, and the role is what that membership does.** Belonging and job are one attachment seen at two levels, not two facts that can drift apart. Membership is **many-valued on purpose**. A shared device belongs to every system it serves: a rack DSP feeding three rooms is a member of all three, and each of them depends on it. That a component is in this system *and two others* is exactly the kind of thing an operator needs to know before touching it. **Staffing a role creates the membership.** A component filling a job in a system that the system does not count as a member is a contradiction, so assignment binds it rather than asking an operator to say it twice. The reverse is deliberately **not** symmetric: giving up a role does not end membership, because the device is still in the room. A member carrying no role at all is ordinary and useful, and it is how the power conditioner, the rack shelf, and the spare on the shelf are accounted for. **`is_primary`** marks which membership answers a question asked *without a system in hand*. It is a **default for context-free callers, not a resolution rule**: anything that names a system resolves against that system. A component's first membership takes the default with nobody asking, so a component in exactly one system, which is nearly all of them, never meets the concept. A partial unique index makes a second primary impossible rather than merely unlikely. Membership **cascades from both ends**, since a binding is meaningless once either side is gone. It deliberately does not restrict the component the way `system_role_assignment` does: that restrict is load-bearing, because deleting a component that fills a job would silently break a system's health, but a membership carrying no role is an inventory fact, and refusing the delete for it would add a step to every component removal while protecting nothing the role table does not already protect. **A role is declared on a standard or on a system**, the same exclusive arc the property value store uses. Declared on a **standard**, every conforming system **inherits it live**, exactly as a contract default does: add a role to Meeting Room and every meeting room is short one component until it is staffed. Declared on a **system**, it is ad-hoc, which is both how a **one-off system** gets roles at all and how a conforming system adds what its standard does not cover. A role also carries a **`quorum`**, how many components should fill it (a room wanting two ceiling mics has one role with quorum 2, not two roles), at least one, because a role no component need fill is not a role. **A capability is a fact about a component, not only about its product.** `component_capability` layers over the product's set: `present=true` adds a capability the product does not claim (a unit with a mic pod nobody modeled), `present=false` suppresses one it does (a bar whose camera is dead or removed). The resolver **`EffectiveCapabilities(component)`** is then: > the **product's** capabilities, UNION the component's **`present=true`** rows, MINUS its **`present=false`** > rows. A **productless** component resolves to just its own declarations. This is the single definition of "what this component can do" for the whole platform, and it is the same **contract-plus-override** shape the declared properties above already use, applied to capabilities instead of values. The read side is **`EffectiveRoles(system)`**: the roles its standard declares (marked `from_standard`) UNION those declared on it directly, each with its required capabilities, its quorum, and the components filling it **here**. It serves **assigned** and **understaffed** (quorum minus assignments, floored at zero) rather than leaving each surface to do the arithmetic, so an under-staffed room reads the same way in the console, the CLI, and the API. **Assignment is refused when the component cannot fill the role**, with the missing capabilities **named**: ``` component "panel-1" cannot fill role "table-mic": missing microphone, speaker ``` This is a refusal on **modeled** grounds, the same class as the location placement constraint, and naming the gap is the point. A bare refusal leaves an operator guessing; a named one tells them to either fix the component's capabilities or pick a different component. It is also why capabilities had to become a **resolved** set at all: `product` is optional, so a strict guard over a product-only fact would have locked every productless component out of every role ([ADR-0049](/architecture/decisions/#adr-0049-the-system-role-capability-gated-staffing-and-the-resolved-capability-set)). **A role also declares its `impact`**: `outage`, `degraded`, or `none`, what an **impaired** role (fewer satisfying components than its quorum) means for its system. It lives on the role rather than on the component or the alarm because the same broken box matters differently depending on the slot it was filling: a dead confidence monitor is not a dead main display. Impact is the one input the **[health](/architecture/health/)** rollup takes from the declaration side, and **quorum is the redundancy knob** underneath it: a role wanting one component with two assigned tolerates a failure, a role wanting two with two assigned does not. Staffing stays readable **without** any of that: a role wanting two components with one assigned is under-staffed **today**, on data the operator entered, with nothing collecting. Health adds the second question (of the components that **are** assigned, how many can currently do the job) and routes the answer up the tree ([ADR-0050](/architecture/decisions/#adr-0050-health-is-a-recorded-transition-computed-from-the-alarm-capability-role-chain)). ### The seed model: forked templates versus canonical catalogs Not everything the binary ships with is the same **kind** of thing, and conflating the two kinds is what makes seeded defaults painful. Omniglass splits them: - **Example content** (a `standard`, a `location_type`) is created by **forking an in-code template**. The fork is **one-time, with no inheritance**: nothing in an estate points back at the template, so the template can be improved in any release without touching a single tenant. What lands is an ordinary **operator-owned** row (`official: false`), freely editable and deletable, and the boot seed installs it **only if absent** (`ON CONFLICT DO NOTHING`). Re-seeding never reasserts over an edit; if it did, an operator's rename of "Huddle Room" would silently revert on the next restart. The **roles** a shipped standard declares ride the same lane, so retuning a seeded role's quorum survives the next boot. - **Canonical vocabulary** (the [`property` catalog](/architecture/variables/), and later `command` and `event_type`) is the shared namespace a driver maps onto, so it must stay identical install to install. It seeds as `official: true` through an **authoritative upsert** (`ON CONFLICT DO UPDATE`), read-only in the API, so a release can correct it. The classification catalogs (`vendor`, `driver`, `capability`, `product`) sit on that same authoritative path today. The distinction is worth internalizing: **forking applies to template -> row, never to classifier -> instance**. A system does not fork its standard; it **conforms** to it and inherits **live**, so revising a standard's default moves every conforming system that has not overridden it. ## The variable-depth trees `component`, `system`, and `location` are each a **variable-depth tree**: a `parent_id` self-reference that nests to arbitrary depth (campus -> building -> floor -> room; parent system -> subsystem; chassis -> card). The trees are the structural backbone of the [cascade](/architecture/cascade/): resolution runs over an entity's containment path and the **deepest node wins**, weight-free, pure depth. ```d2 direction: right classes: { node: { style.border-radius: 8 }; key: { style: { border-radius: 8; bold: true } } } component: component { class: node } product: product { class: node } location: location { class: node } system: system { class: node } standard: standard { class: node } component -> product: is a (N:1) component -> component: parent (tree) system -> standard: conforms to (N:1) system -> location: located at (N:1) system -> system: parent (tree) location -> location: parent (tree) ``` A non-leaf node in a tree (a chassis, a floor, a parent system) contributes its **instance** bindings down the cascade, not its template: a chassis hands a card its chassis-wide credential while the card keeps its own template ([cascade](/architecture/cascade/)). ### Sub-components and sub-systems The `parent_id` self-reference is **same-kind nesting**: a **system may have a parent system** (sub-system nesting) and a **component a parent component** (sub-component nesting), both over the same variable-depth `parent_id` trees. A chassis with line cards is a parent component over child components; a building-wide AV system composed of room subsystems is a parent system over child systems. This nesting feeds two mechanisms. It feeds the **cascade**, where the **deeper node wins** down the component and system trees (a sub-system's bindings override its parent's, a sub-component's override the chassis'). And it feeds the **health rollup**: a **sub-system's health rolls into its parent system**, and a **sub-component's into its parent component**, the same role-aware composition that runs up the rest of the tree. The **practical starting depth is 3 levels** (parent / child / grandchild) for both trees, a guidance default, **not a hard cap**: the `parent_id` trees support arbitrary depth, and we revisit the guidance if a use case needs more. The depth-resolution and rollup semantics themselves live in [cascade](/architecture/cascade/) and [health](/architecture/health/). ## Ownership: the exclusive-arc Everything observed, asserted, or set in Omniglass attaches to exactly one structural entity, through the **exclusive-arc**. Every datapoint table, plus `event`, `property`, `alarm`, and `variable`, carries: - an **`owner_kind`** enum, plus - the **matching typed FK** (`component_id` / `system_id` / `location_id` / `node_id`, or none for the singleton `global`), plus - a **CHECK** that exactly the column matching `owner_kind` is set (or all null for `global`). This makes **system-, location-, node-, and global-level datapoints first-class** (e.g. `health` is a `state` owned by a system, estate-wide availability is owned by `global`, and a node's self-health is owned by the node), the fix for a monitoring tool that can only put state on a single host. It is also what carries a recorded [health](/architecture/health/) verdict: a component, a system, and a location each own their own transition series under the same `health` key, and the **owner** is what gives a reading its level. The same arc owns the `event` rows a datapoint produces, and is the design for `alarm` (component-local today), so a system-owned datapoint will yield a system-owned alarm. The full pattern and the storage DDL are on [storage](/architecture/storage/). ### The event sink: the first arc-owned occurrence The arc's first built sink beyond the datapoint tables is **`event`**, the **log-kind sink** of the collection pipeline. Where a `metric` or `state` records a **sampled present value** (a reading that has a value *now*, `last()` is meaningful), an `event` records a **past occurrence**: a device log line, something that *happened* and whose "what is it now?" is meaningless (the [has-a-value-now razor](/architecture/datapoints/#the-has-a-value-now-razor-datapoint-vs-event)). A collected datapoint whose property is **log**-kind (the seeded starter is `syslog.line`) is no longer dropped at ingest: it routes to `event` as an occurrence, carrying a **`message`** (its text, from `string_value`) and optional structured **`attributes`** (jsonb, from `json_value`). An `event` row carries the **identical owner exclusive-arc** as a datapoint (`owner_kind` plus the matching `component_id` / `system_id` / `location_id` / `node_id`, under the same CHECK that exactly one is set) and the **same provenance** vocabulary (`observed` / `calculated` / `intended` / `declared`, default `observed`), so a log occurrence is owned, addressed, and confined exactly like the value datapoints beside it: the **same** owner-confinement and reject-not-project gates apply at ingest. This is why the ownership pattern above already named `event` alongside the datapoint tables; it is the same arc, now with a built sink on it. Closing the loop, the reserved **`event_id`** columns on `metric` and `state` are now **real foreign keys** to `event(id)` (`on delete set null`): an **intended**-provenance datapoint (a value a command set) references the `event` that produced it, so a datapoint's lineage can point at the occurrence behind it. See [datapoints](/architecture/datapoints/) for the value sinks and [events](/architecture/events/) for the normalized-occurrence model layered above the raw log sink. ## Structural multi-membership (a component in N systems) A shared device legitimately belongs to more than one system, and [`system_member`](#membership-what-a-role-attaches-to) says so directly: it is many-valued, and a rack DSP serving three rooms holds three memberships. What stays single-valued is **which system chain feeds the cascade**, because the resolver ranks candidates by band and then depth with no tiebreaker after that, so a many-valued seed would resolve nondeterministically. That is the job `is_primary` does, and only for callers with no system in hand. The genuine "config differs per system" case is answered by **per-system effective views** on demand, not by merging chains into the resolution ([cascade](/architecture/cascade/)). :::note[Superseded] This page previously said a truly shared device **skips the system layer**, which was the best available answer while the only binding was a single pointer on the component. It no longer holds: a shared device is a member of every system it serves. ::: The binding itself is the **`system_member`** table, which is **built**: `(system_id, component_id, is_primary)`, described under [membership](#membership-what-a-role-attaches-to). The design below layered the role onto that same row and pinned it to a frozen template BOM; what shipped keeps the row to the binding alone and lets `system_role_assignment` carry the role, so a member can exist without one. A `system_template_member` declares, per role, a **requirement** (the canonical datapoints and commands a member must provide) plus its `health_role`; any component whose template meets the requirement can fill the role, validated on assignment. Detailed on [templates](/architecture/templates/). :::note[What is built is the role model above] `system_member` is **built** as the plain binding above; the **frozen template BOM** on it is still `Design`. The **built** slot is [`system_role`](#system-roles-the-slots-a-system-needs-filled), declared on a standard or a system rather than frozen into a `system_template_version`, requiring a **capability** set rather than canonical datapoints and commands, and assigned through `system_role_assignment`. Its **`impact`** also replaces the `health_role` tag above: `required` / `redundant` / `informational` are expressed by quorum plus impact, with no fourth vocabulary ([ADR-0050](/architecture/decisions/#adr-0050-health-is-a-recorded-transition-computed-from-the-alarm-capability-role-chain)). The two models are reconciled when template pinning is built ([ADR-0049](/architecture/decisions/#adr-0049-the-system-role-capability-gated-staffing-and-the-resolved-capability-set)). ::: ## Operational mode: active, maintenance, disabled Every entity has an **operational mode**, a cascade-resolved state that says how much the platform backs off, set on the entity or inherited from a parent ([cascade](/architecture/cascade/)): - **active** (default): collect, evaluate, act, enforce drift, count toward SLA. Normal. - **maintenance**: **keep collecting**, but **suppress the consequences** of what is seen. Planned work: watch, but do not act. - **disabled**: **stop collecting and interacting** with the device entirely (the Zabbix host-disable). The entity stays in the model, dormant, until re-enabled. Maintenance and disabled are the **same suppression**, differing on one knob, **collection**: maintenance suppresses consequences but keeps watching (so an operator can verify the work); disabled also goes dark (no polling, no commands). Both suppress the same four consequences: - **action dispatch is held**: an alarm may still open (you see it), but no `action_rule` pages or opens a ticket ([alarms and actions](/architecture/alarms-actions/)). - **drift is observed, not enforced**: the set function never fires, so a tech mid-swap is not fought ([config](/architecture/variables/)). The device-swap case (a brief declared-identity authority, [datapoints](/architecture/datapoints/)) is just maintenance suppressing drift. - **health rolls up no impact**: a member in maintenance or disabled does not sink its parent's [health](/architecture/health/); it surfaces as "outage (maintenance)", the truth plus the mode, not a fourth verdict value. - **SLA does not count it**: the window is excluded from availability and the SLO. Maintenance is **time-bound**: a window (start / end, [time](/architecture/time/)) that **auto-exits**, or open-ended until cleared, with a recurring window expressed as a schedule. Disabled is held until re-enabled. Entering or exiting either is an **audited** operator action ([audit](/architecture/audit/)), so "no page at 2am, it was in the patch window" is always explainable. Because the mode is cascade-resolved, maintenance on a system covers its components. ## Decommission and delete "Delete" is **decommission** by default, a **soft delete**: the entity is tombstoned, drops out of placement, worklists, and default views, but its **history is retained** (datapoints, events, alarms, audit), attributed to the tombstone and aging out by [retention](/architecture/storage/). An observability and control plane must not let "remove this projector" erase the incident record, and a decommissioned entity can be **re-commissioned** if the device returns. **Purge** is the privileged, audited hard erase for a genuine mistake (a test component); for a high-volume entity it runs as a background job over the partitions, while the cheap path is decommission plus letting retention age the firehose out. Decommissioning runs the **in-flight cleanup**, reusing mechanisms that already exist: collection stops (the worklist drops it, sessions close), **open alarms auto-resolve** (reason "decommissioned"), **pending commands and running flows cancel** (the durable command queue, and flows are already gated on their alarm staying open), and config / tag / credential / group bindings drop. The entity leaves its parent's health rollup. The cascade is **not** "delete everything below", because containers do not own their members: - a **component** (leaf) decommissions as above; - a **system** delete **unbinds its members** (the `system_member` rows) but **does not delete the components**; they become standalone, re-homeable; - a **location** delete is **refused by the API while occupied** (it returns what is placed there); the console offers re-homing before the delete (move the systems and components, then delete the empty location); - a **node** delete **re-places its tasks** (to the server or another node, or surfaces the components as uncollected) and revokes the node credential ([identity and access](/architecture/identity-access/)); `node.*` history is retained. --- # Datapoints URL: /architecture/datapoints/ The core data model: datapoints and their three kinds, provenance, the registries, key scope, divergence, fusion, and how a value reads back. :::caution[Direction: this page is mid-migration to ADR-0063] The `property_type` registry and the `property` value store are named per [ADR-0063](/architecture/decisions/#adr-0063-the-telemetry-model-is-typed-registries-over-bare-noun-data-tables) (built). Still directional, not yet built: `property` becoming a provenance-keyed latest-value cache (an `observed`, a `calculated`, an `intended` row per series), and `log` occurrences graduating from a `property_type` kind into the `event` family. ::: :::note[Partial] The observed **metric** path is built for reachability: a node's tcp probe produces `tcp.open` / `tcp.connect_time` and its icmp (ping) probe produces `icmp.reachable` / `icmp.rtt_avg`, and the ingest consumer writes them to `metric` with `provenance=observed`, the owner bound server-side from the task's interface (`owner_kind=component`) and reject-not-project enforced against the `property_type` registry. The observed **state** path now has its first producer too: the node computes the per-interface reachability verdict `interface.reachable` (up/down, the AND of the interface's probe results), and the ingest consumer **routes by the `property_type` kind** (metric to `metric`, state to `state`), so the verdict lands in `state` under the same owner-confinement, **transition-only** (one row per flip, guarded at the node and again at ingest). A **log**-kind observation now persists too, but as an **`event`** occurrence (the log-kind sink), not a `log_datapoint` row: it is **no longer dropped** at ingest ([ADR-0046](/architecture/decisions/#adr-0046-the-event-log-kind-sink)), routed to `event` under the same owner-confinement and reject-not-project as the metric and state sinks. The `log_datapoint` table described below, calculated provenance, fusion, and the live NATS data-lane are still design. See [ADR-0033](/architecture/decisions/#adr-0037-telemetry-is-a-protobuf-event-over-jetstream-with-an-inline-owner-confining-consumer) and [ADR-0034](/architecture/decisions/#adr-0038-the-reachability-verdict-is-a-built-in-state). ::: This is the heart of the authoritative data model: what a datapoint is, the two axes that define it, how we know a value (provenance), and how values reconcile, diverge, and read back. The physical layout (tables, partitioning, the lineage CHECK, tiering) lives in storage; the spine is [the architecture overview](/architecture/). Events, calc rules, and the response layer get their own pages: [events](/architecture/events/), [calculations](/architecture/calculations/), and [alarms and actions](/architecture/alarms-actions/). Datapoints are the **data lane**: observed and calculated datapoints are NATS-native, published to a JetStream `datapoints` stream and consumed live by the rule engine, with a persistence consumer batch-writing them to the PG tables as an async sink. Datapoints are the firehose and never wait on Postgres. Events, alarms, and actions are the **record/state lane**: born in a PG transaction and fanned out by change-data-capture (CDC). The two lanes share one bus (JetStream); this page is home for the data lane, and points at [events](/architecture/events/) and [alarms and actions](/architecture/alarms-actions/) for the record lane. ## Datapoints: one family, three kinds A **datapoint** is an observation: a value of one key, on one owning entity (component, system, or location), at one time. The row shape is the same for all three kinds: `(owner, key, instance, ts, value, provenance, source, lineage)`. They are three physical tables only because they index and retain differently, not because they are different concepts. - **metric** (`metric`): numeric (float), carries a **unit**. Continuous, aggregatable. Has a current value. - **state** (`state`): categorical, text, or a structured object. Discrete, dwell-measurable. Has a current value (`last()` is meaningful). - **log** (`log_datapoint`): a component's own words, the value is the log line (text or jsonb), keyed by log type (`log.system`, `log.os`, `log.app.`). A stream, not a current value, but still an observation with a value at a time, so it is a datapoint, not a separate primitive. In practice only components emit logs. Treating log as a datapoint removes the usual special case: an alarm on a log line is just an event rule whose condition matches a `log_datapoint` value, no different in shape from a metric threshold. **An event is not a datapoint.** A datapoint is an observation (a value we recorded); an **event** is *our semantic assertion that something happened*, in our vocabulary. Datapoints are what rules read; events are what event rules produce. See [events](/architecture/events/). ### Ownership: the exclusive-arc A datapoint attaches to a **structural entity**, not only a component. The owner is the **exclusive-arc**: an `owner_kind` enum plus the matching typed FK (`component_id` / `system_id` / `location_id` / `node_id`, or none for the singleton **`global`** estate root) with a CHECK that exactly the column matching `owner_kind` is set. The same arc owns `event` rows, and is the design for `alarm` (component-local today). This makes **system-, location-, node-, and global-level datapoints first-class** (`health` is a `state` owned by a component, a system, or a location today, and estate-wide availability will be owned by `global`), the fix for Zabbix's inability to put state on a group of hosts. See Ownership on the spine for the full pattern and the storage DDL. ### The instance dimension: many values of one key on one owner One owner can hold several distinct values of the *same* canonical key: three fan speeds on a switch, per-port counters, per-channel audio levels. The canonical registry deliberately holds **one** `property_type` per measurement (`fan.speed`, not `fan.speed.intake`), so the discriminator lives outside the key, as an `instance text NOT NULL DEFAULT ''` column on all three datapoint tables. Series identity is therefore **`(owner, property_type, instance, provenance)`**: each instance is its own series, while a singleton (`instance = ''`) is the default. Aggregation stays clean (group by `key`, ignore `instance`); per-instance trends stay distinct. The instance rides the pipeline as a reserved **`instance` label** on the collected datapoint: the collection extract spec authors it as a `key[instance]` suffix (`fan.speed[intake]=`, `fan.speed[exhaust]=`), the parser strips the bracket into the label so `registryAllows` / `kindFor` still match the bare canonical key, and the derive step reads `instance` into the column. Calc folds **every** instance of an input key into the reduce: a rule reading `fan.speed` from a component gets one candidate per fan, so `worst` / `average` / `count` / Expr aggregate across all of them (a singleton key yields one candidate). An input filter can select one instance (`instance == "intake"`). Recompute needs no instance granularity: a calc consumer reacting to a `fan.speed` datapoint on the stream recomputes over the current state of every instance for `(owner, key)`, so two fan changes in close succession converge on one correct recompute. Calc **outputs** stay aggregate (`instance = ''`); per-instance outputs (one health per fan, a group-by) are a separate capability, not a silent gap, output owners default to the singleton. ### The has-a-value-now razor (datapoint vs event) A datapoint records a value; an event records an occurrence. - `"input is 1"` is a value, so it is a **datapoint** (state). - `"call started"` is an occurrence, "what is call-started now?" is meaningless, so it is an **event**. See [events](/architecture/events/). A raw occurrence we have not normalized (a syslog line, a raw webhook frame) lands as a **`log_datapoint`** (observed, value = the line). An event rule can then **promote** it into a normalized event. So the log table is also the holding pen for un-normalized occurrences until a rule recognizes them. ## Kind and provenance: the two axes Every datapoint sits on two independent axes: - **Kind** answers *what kind of thing is this?* It is fixed per **key**, decided once when the key is defined (`power.state` is always a state), so kind is a property of the **key**, the three kinds above. - **Provenance** answers *how do we know this particular value?* It varies per **row**: the same `power.state` can be observed or intended at different moments. (A *declared* desired value is not a provenance; it lives in [config](/architecture/variables/), keyed to the signal.) Provenance is a property of the **row**, detailed below. Kind is set by the key, provenance by the row, and the two never depend on each other. ## The property_type registry A datapoint and an event are different shapes (a datapoint has a value; an event is an occurrence), so each gets a registry named for what it holds. The event half is [`event_type`](/architecture/events/); the datapoint half is `property_type`. We do **not** force them into one universal registry, that would be the false unification the rest of this model avoids. **`property_type`** describes every datapoint key: `(name, scope, template_id?, kind, value_type, unit, precision, fusion_policy, validation)`, with the **`scope`** (`template` / `org` / `official`) deciding where the name is unique (see [Key scope](#key-scope-template-org-official)). One registry across all three datapoint kinds (metric/state/log). The kind is decided by the key, not at runtime: the compiler bakes each key's kind into the edge unit, so a value routes to the right table with no runtime lookup, the same way at every scope. `fusion_policy` is the key's read-time **default** for reducing multiple perspectives, a hint rather than a mandate (see [Fusion](#fusion)). A key names a **measurement, never its owner** (`temperature`, not `room.temperature`), with snake_case segments in a dot hierarchy and the **canonical unit** in the `unit` field (`fan.speed` + `unit: rpm`, not `fan_rpm`); the ship-with official set lives in `internal/registry/defaults.yaml`. Adding or naming one: the `canonical-datapoint` skill. The naming convention is consistent: a `_type` registry defines what a thing *is*, named for the thing (`property_type`, `event_type`, like `location_type`, `interface_type`). `property_type` spans the three datapoint kinds, and events get their own registry because an event is a different shape. **Datapoint key naming is owner-agnostic.** A key names a *measurement*, never its owner: `temperature` is a Celsius reading whether a codec's thermals or a room's ambient sensor produced it, and the owner (component / system / location / node / global) plus a template's labels and the function that collected it give it context. So there is no `system.` / `device.` / `room.` prefix; keys group by measurement domain (`cpu.utilization`, `power.state`, `video.input`, `audio.level`, `network.icmp.rtt`). This is the normalization the product hinges on: one canonical path means one comparable signal across every vendor, which is what makes cross-fleet dashboards and AI useful. The official set is seeded from `internal/registry/defaults.yaml` following OpenTelemetry semantic conventions for the IT leaves (`cpu.utilization`, `memory.usage`; semconv's own `system.` prefix is dropped to avoid colliding with the `system` entity type) and the [OpenAV minimum-device-functionality guidelines](https://github.com/OpenAVCloud/specifications/blob/main/min-device-functionality/OAVC-AV-Device-Minimum-Functionality-Guidelines.md) for AV signals. A template declares its datapoints at **template** scope, or references an **org** or **official** key: the distro mints **official** keys, the deployment mints **org** keys, and a template mints its own **template**-scoped keys (the Zabbix model, where two templates can both declare an `input` with no collision). ### One identity, three shapes `property_type` is **one registry, not three**. A key's **kind** is intrinsic and fixed (one key is one kind, forever), so identity, [scope](#key-scope-template-org-official), and the promotion ladder live on one row, and `(scope, name)` is unique across all kinds: a name is a metric or a state, never both. What differs by kind is the **shape** the row carries: - **metric**: `value_type` (float), a **unit** and optional **precision**, and a numeric range (`validation: {min,max}`). The full numeric shape. - **state**: a **value domain**, the allowed set (`validation: {values:[...]}`); no unit, no precision. - **log**: almost nothing. There is **no `log_type`** worth the name: a log's "type" is its **key namespace** (`log.system`, `log.os`, `log.app.`, the hardware / service families), plus a level. You never give it a unit, a domain, or fusion. These shapes ride **inline** on the one row today: kind-conditional columns (unit / precision, on metrics) and the `validation` jsonb that reads as a range for a metric and a domain for a state. The kind is decided once and compiled into the edge unit, and the registry is cached in-process, so reading a type's shape is a map lookup, never a per-datapoint join. If the metric and state shapes grow, they may later move to **1:1 per-kind sidecar tables** (`metric_type`, `state_type`) keyed by the `property_type` id, exactly as the IAM [`principal`](/architecture/identity-access/) splits into per-kind `human` / `service` / `node` tables (`log` keeps no sidecar). That is a cold-path normalization, one cheap PK join when reading the shape, and the registry is cached either way, not a hot-path change: the firehose never joins the type registry. **Validation on insert, under a policy.** Every datapoint is typed by a `property_type` row (the FK is **non-null**: a template-scoped key is a real `property_type` row at `scope=template`, not an inline-only shape), so insert checks two things, plus an optional third. **(1) The key resolves** to a `property_type` at a reachable scope: a template-scoped key self-resolves; a referenced org or official key must exist, checked at template compile time. An unresolved key is **reject-not-project**: kept out of the typed series, a `datapoint.validation_failed` event raised, the raw carried on a `collection.failed` event so nothing is lost (backfillable once the registry or template is corrected). **(2) The value conforms** to the type's kind and domain: the type's `validation` (`{min,max}` for a metric, `{values:[...]}` for a state). Optionally **(3) the owner kind** must be one the type allows. All three are governed by the `validation_policy` config mode: **bypass** (skip), **audit** (the default: write the value but emit the event), or **enforce** (hold the value back from the typed series, emit the event). The point is visibility: an out-of-range or unmapped value means a template author declared a type the device disagrees with, so the violation surfaces as an owner-attributed event operators and admins see. The mode resolves per-entity down the cascade (platform, location, system, component), so a noisy device class can run in `audit` while the rest of the fleet enforces. ## Units: one canonical unit per key **Unit is a metric concept.** Only a **metric** carries a unit (and the display `precision` below): a number needs a unit to mean anything, and even a dimensionless metric has one (`ratio` or `count`). A **state** (categorical) and a **log** (text) have **neither**. For metrics, **storage is canonical-at-rest**: every metric `property_type` declares **one canonical unit** in its `unit` field (a registered unit, see below), and stored values are **always** in that unit. The firehose is single-unit, so every threshold, calc, and fusion compares like with like. We never store mixed units, and we never put the unit in the `instance` dimension: `instance` discriminates co-existing values of one key on one owner (three fan speeds), not the unit those values are expressed in. A genuinely different measurement is a different `property_type`, not a unit variant: units only convert **within one family**. **The `unit` registry.** Units live in a `unit` registry grouped by **family** (dimension): temperature, data-size, bitrate, ratio, and so on. Each family declares one **canonical unit** plus zero or more **alternate units**, and each alternate carries a **`to_canonical`** and a **`from_canonical`** transform: **affine** (a factor plus offset) for the common case, or an **Expr** for the rare nonlinear one (dB). The registry is **official / org scoped** like the other registries. Example: the temperature family is canonical `celsius`; `fahrenheit` carries `to_canonical: (v - 32) * 5/9` and `from_canonical: v * 9/5 + 32`. **Dimensionless is still a unit.** A **ratio** is not "a number with no unit", it is the `ratio` family: canonical `ratio` (`0..1`) with `percent` as an alternate (`ratio * 100`), so `cpu.utilization` is **stored** as `0.9` and authored or shown as `90%` through the same convert path, never stored as a percentage. A bare **count** (people, error tallies) is a cardinal `count`, distinct from a ratio. So the `unit` field is exactly what separates a ratio from a quantity carrying a physical unit (`celsius`, `rpm`, `bps`): both are `metric` kind, and the **unit (its family)** is the discriminator, dimensionless or dimensioned. (`kind` answers *metric / state / log*; `unit` answers *which dimension, if any*.) Conversion happens only at the two edges and in expressions; the rows in between stay canonical. **Normalize-in at the edge.** When a device reports a non-canonical unit, the component template's **alignment value-transform** (the existing "align to a canonical key, plus an optional value transform") converts native to canonical **before** the datapoint is emitted. A Fahrenheit display's template emits `celsius`. The device's native unit is a [collection](/architecture/collection/)-time fact carried by the [template](/architecture/templates/), never a storage fact. **Convert-out on read.** Showing a non-canonical unit to an operator is a **presentation** concern: the [UI](/architecture/ui/) and [views](/architecture/views/) convert canonical to the operator's display unit (a per-user / per-locale preference), looked up from the `unit` registry, exactly as a severity level's label and color resolve client-side. Storage is untouched: one operator reads Celsius, another reads Fahrenheit, off the same rows. Because the `property_type` declares the canonical unit, this conversion is automatic. **Display precision is part of the type.** Alongside the unit (and, like the unit, only on a metric), a `property_type` carries an optional **`precision`** (significant digits to render), a presentation default the same way the canonical unit is: a temperature shows `21.5`, a utilization `90%`, a link `1.2 Gbps`. It governs **rendering only**. The stored `value_type` (float8) keeps full precision, `precision` never truncates a stored value, and the [UI](/architecture/ui/) or a locale can override the default. (Dropping noise at *ingest* is a separate collection-time rounding, not the type's display precision.) **Expressions: `convert(value, "")`.** A stdlib function in Omniglass [expressions](/architecture/expressions/). The **source unit is inferred** from the bound datapoint's canonical unit; the **target** is a registered unit that must be in the **same family** (a compile error otherwise); the conversion comes from the `unit` registry. So `convert(value, "fahrenheit") > 100` lets an operator author a threshold in Fahrenheit while storage stays Celsius. It is data-driven and general, chosen over a per-unit method like `value.toFahrenheit()` that would need a method per unit, and is available wherever expressions run: event rule and alarm criteria, calc leaves, and list filters. ## Key scope: template, org, official A datapoint key carries a **`scope`**, the axis that decides where its name is unique and where its trust comes from. Three layers: | 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 | `official` is just `scope == official` (the prior pass's `official` boolean folds into this enum as its top value). **Conflicts are impossible** at template scope because a template-scoped key is identified by `(template_id, name)`: two templates can both declare an `input` datapoint with no collision (the Zabbix model). Trust still comes from **distribution**, not a label: an official key is trusted because it is **in the release**, the same `video.input` across every vendor, not spoofable. An org key is a deployment's own custom canonical, authoritative within that one database (per-database isolation makes it unambiguous, one database is one tenant). A template key is local to the template that minted it. **Every datapoint is typed by a registry row, just at some scope.** The datapoint -> `property_type` FK is **non-null**: template-scoped keys ARE `property_type` rows (`scope=template`, with a `template_id`), not inline-only shapes, so there is no nullable type FK and no dual identity. Kind, unit, and validation live on the type row at **every** scope, so the edge compiler bakes the kind and routes to the right table (metric / state / log) the same way for all three layers. Series identity is `(owner, property_type, instance, provenance)`. **The promotion ladder is template -> org -> official.** Each step is 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's datapoint at it; later it gets blessed **official** by being shipped in the distribution (the one real way trust is earned, not a flag an operator sets). Datapoints already collected keep resolving. This is the "shift to normalized over time" path. **Normalization is therefore optional but encouraged.** A template ships using template-scoped keys with zero registry friction; aligning a key to org or official is what buys cross-fleet comparability, dashboards, and AI. The shipped official set covers the common AV/IT signals, so most templates align by just **referencing** one. Sharing happens at the **template level** (a repo or marketplace of templates): an imported template is **linked** (tracks upstream) or **copied** (forked, diverged), and the keys it introduces land at **template** or **org** scope, not as a federated signal trust tier. **Governance is curation, not runtime enforcement.** Omniglass is a Postgres database an operator runs, so nothing stops a self-hoster inserting an org-scoped row or editing an official one in their own database. We vouch only for what we **ship**; you vet what you import, and you own the risk. Commands sit at **template** scope the same way (functions live on the template); a canonical command type follows the same promotion ladder (see [templates](/architecture/templates/)). ## Provenance: how we know a value Provenance is the second axis, stamped per datapoint row. The same key, with the same value, can be known three ways. Each provenance points at the immutable ground-truth record that produced it (its **lineage**), and the lineage column populated is mutually exclusive per provenance, enforced by a CHECK constraint. | 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 datapoints | on-row: `source_rule` (+ version), the calc_rule | | **intended** | the declared effect of a command we issued, pending reconciliation | `event_id` (the command event) | A value of any provenance is still a metric/state/log (the kind is fixed by the key); provenance only records *how it got there*. All three land in the same datapoint tables, side by side for the same key, which is what makes divergence detection free. Declared intent is the fourth value an operator can assert, but it lives in [config](/architecture/variables/), not in the datapoint tables, and can be compared against an observed datapoint for drift. A separate **`source`** column records *which sensor or path* produced an observed value (`codec.cec` vs `display.lan` vs `control.system`). Source is distinct from provenance: provenance is *how we know* (observed), source is *which sensor told us*. Three sensors reporting one display's power are three observed rows on one key, differing only in source. This is what makes multi-source corroboration and [fusion](#fusion) possible. **Trace columns, orthogonal to lineage.** Each datapoint table also carries a nullable **`correlation_id`** and an optional **`caused_by_event_id`**. These are **trace** columns, not lineage: they record *what causal thread this row belongs to*, not *what immutable record produced this value*, so they sit outside the mutually-exclusive lineage CHECK and never count toward it (a row may carry both its on-row `source_rule` lineage and a `correlation_id` with no conflict). An action's command **propagates the originating `correlation_id`** onto the adaptive-poll's observed datapoint, so the `event_rule` that fires off that observed value **inherits** the same id, and the cycle-guard walk crosses the command -> device -> observed-datapoint round trip on a real carried id rather than an assumed lineage. See [alarms and actions](/architecture/alarms-actions/) for the cycle-safety mechanic. ### observed: from a component, via an edge parse "Measured from a component," not "from a device", every device is a component, but not every component is a device. The node parses the payload at the edge and **publishes the observed datapoint to the JetStream raw ingress subject** (admission confines its owner before it reaches the trusted stream); it does not write to Postgres. The observed datapoint carries its own lineage on the row: `source_rule` + `source_rule_version` (which function and template version made it, the backtest hinge). The verbatim payload it parsed is **not** kept (no telemetry table); raw surfaces only on a `collection.failed` event or a dev raw-mode tap, or is retained for a bounded window under the opt-in `raw_sample` policy ([collection](/architecture/collection/)), which is still not a telemetry table. There is no separate execution table, a derived datapoint is itself the evidence of the function's run, exactly as an event/alarm/action row self-describes. ### calculated: derived by a calc rule A calculated value (a 5-minute average, a system rollup, a fused consensus) is parallel to observed: both are machine-derived. The difference is the input: an edge function parses a device payload, a calc rule reads **other datapoints**. A calc consumer reads datapoints **off the trusted JetStream `datapoints` stream** and **publishes its derived datapoint back onto it directly** (a trusted server producer, no admission pass), so calculated values re-enter the data lane exactly like observed ones (and are themselves available to downstream calc and to the rule engine). Both carry `source_rule` + `source_rule_version` on the row, so they are distinguished by the **`provenance` column** (an edge function versus a calc_rule), not by a pointer. The exact inputs a calc read are reconstructable from the rule version (that is what backtest does); if an immutable input snapshot is ever needed it is a nullable `inputs jsonb` column, not a table. The rule itself lives on [calculations](/architecture/calculations/). ### intended: the declared effect of a command When the action layer issues a command, it records the command as an **event** and writes the **intended** state it expects, in one step. The command and its event are born in the record/state lane (PG-first, CDC-out); the intended datapoint **re-enters the data lane** on the `datapoints` stream **under the command's target owner** (the target was scope-checked at dispatch; the action layer is trusted server-internal, so it publishes to the trusted stream directly, no admission pass), so the command's expected effect rides the same stream as observed and calculated values and reconciles against the observed value that the device round trip produces. The intended datapoint's lineage is that command event. The name is deliberate: **intended vs observed** is the central razor, intent-in-progress versus measured reality. ```text 1. command issued: "power on display-5" -> recorded as an event 2. intended write: display-5 power = on, provenance=intended, lineage= a bet: intended, not measured 3. adaptive poll: the command triggers a poll sooner than the normal interval 4. observed arrives: observed = on -> reconciled (the bet paid off) observed = off -> divergence (the command did not land) ``` There is no separate "mapping" primitive. Which state a command intends lives on the command definition. **Only commands set intended state** (intended's lineage is always a command event). An external event that implies a state ("meeting started, so the room is occupied") is not intended state: it is observed reality, so it lands as an **observed** state through the ordinary edge-parse path, not the command lane. Not every log-to-state path goes through a command. The split is measured fact vs pending intent: | The source says | Means | Path | |---|---|---| | "eth0 **is** down" | a component reporting measured reality | edge parse, then **observed** state, directly | | we sent "**power on**" | intent in progress, not yet confirmed | command, event, then **intended** state | ### declared values are config mac, ip, serial, locked-input, anything an operator *sets* is declared intent, and declared intent is **not** a datapoint provenance. It lives in [config](/architecture/variables/): keyed to the same canonical signal as its observed side, resolved through the scope cascade, never in the datapoint tables. There is no separate property store: config is the declared side of a signal plus the cascade. Ownership resolution reads the resolved identity (a declared identity config value, or the observed identity datapoint that shares its key) to bind observed data to components, through the [identity-binding index](/architecture/collection/) (a `(property_type, value) -> owner` arc) that collection maintains. ### Precedence: spec versus status lives in config When declared intent and observed reality disagree, which one wins is a **per-config-item `reconcile` policy** ([config](/architecture/variables/#drift-and-reconcile)), not a per-key datapoint attribute: - **observed wins** is `reconcile: observe` (or `warn`): the declaration was a hint or stale guess, reality is truth. A device reporting a different MAC than the declared one is a divergence to surface (silently under `observe`, as an alarm under `warn`); adopting the observed value as declared is a separate one-shot import action. - **declared wins** is `reconcile: enforce`: the declaration is the spec, reality should conform. Observed input HDMI2 against a declared HDMI1 means the world is wrong, converge via the set function (self-healing, the Kubernetes spec-and-status pattern), and alarm if the set fails. Among datapoint provenances there is no precedence contest: intended is a pending bet that observed confirms or refutes (reconciliation, see [intended](#intended-the-declared-effect-of-a-command)), and observed supersedes it on arrival. The spec-versus-status decision is config's reconcile policy, not a per-key datapoint attribute. Device-swap (where a declared MAC is briefly authoritative before the device reports it) is handled by a component's [maintenance mode](/architecture/core-entities/#operational-mode-active-maintenance-disabled), which suppresses drift. ## Ground truth versus derived Distinguished by a property of the table, not a naming suffix. - **Raw payload: not stored.** Datapoints are emitted at the edge, so the verbatim wire payload is **not persisted** (no `telemetry` table). Raw surfaces only on a **`collection.failed`** event when a parse or validation rejects (diagnosis, and the one backfill-after-fix case) and via a **dev raw-mode** tap; the datapoint is authoritative, its lineage is `source_rule` + version. The opt-in `raw_sample` policy ([collection](/architecture/collection/)) can retain raw for a bounded, sampled, short-lived window, off by default, still not a telemetry table. - **Live on NATS, durable in PG.** The live datapoint is the message on the JetStream `datapoints` stream; the durable copy in the `metric` / `state` / `log_datapoint` tables is written by a **persistence consumer** that batch-writes off the stream as an **async sink**, idempotent on series identity. The sink never gates the rule engine: rules read datapoints from NATS, and a slow or paused persistence consumer holds up only the durable record, not the live signal. Datapoints are the firehose, so they reach Postgres through the sink and **do not go through CDC**, unlike the record/state lane (events, alarms, actions), which is born in a PG transaction and fanned out by CDC. - **Ground truth, logs** (immutable, append-only, the actor's own record): **`log_datapoint`** (a component's words, a datapoint kind), **`audit_log`** (an operator), **`session_log`** (connection lifecycle, node-reported), **`internal_log`** (platform self-narration), and the **`collection_log`** / **`node_log`** companions. Each named for what it is. There is no separate rule-execution table: a derived row *is* the evidence of its rule's run, carrying `source_rule` + `source_rule_version` on the row itself. - **Derived** (produced by rules, reconstructable in principle from ground truth): **`metric`**, **`state`**, **event**, **alarm**, **action**. A datapoint's lineage is `source_rule` + version (the function that made it). The companions extend it: `collection_log` is the cheap per-run execution record (every run, including failures), `node_log` the node's operational narration. A failed parse rides a `collection.failed` event carrying the raw; there is no telemetry table in the chain. See the architecture overview on the spine. ## The DAG invariant The pipeline must stay acyclic. > A rule may **read** observed and calculated values as truth. It may **compare** an intended value, or config's declared value, against observed (drift). It may **not** treat an intended value *as truth* to infer a new fact. This is what makes drift safe: a drift rule reads the *pair* (intended, observed) and emits when they disagree; the intended value is tested, not trusted. The one forward edge command-to-intended-state is terminal (nothing reads back from an intended value to produce more state). Event rules reading only observed/calculated keeps the graph acyclic with no runtime cycle guard required. The command -> device -> observed-datapoint round trip is the one path where the acyclic structure cannot be read off the static graph (a command can provoke an observed value that fires the rule that issued the command). The propagated `correlation_id` closes that gap: because the command stamps its id onto the observed datapoint, the run that fires off that observed value carries the same thread, and the cycle-guard walk follows a **real carried id** across the round trip rather than inferring lineage. The DAG invariant is therefore enforced, not merely assumed, on the only edge that needs runtime help. ## disagree and divergence Drift is a condition operator, **`disagree(A, B)`**, usable inside event rule conditions, comparing two provenances (or two sources) of one key: - `disagree(intended, observed)`: the command did not land (reconciliation) - `disagree(declared, observed)`: the world drifted from intent (config drift, device swap); the declared side is read from [config](/architecture/variables/) - `disagree(observed, observed)` across `source`: sensors conflict (a failing sensor) > Any two provenances of the same key that disagree = an anomaly. One detector. Command reconciliation, configuration drift, sensor conflict, and hardware-swap detection are not separate features; they are one comparison applied to a key that can hold more than one provenance. ## Fusion When multiple sources report one signal, they land as **perspectives**: source-tagged observed rows differing only by `source`, **all preserved** (seeing multiple perspectives on one value is itself instructive). A reduce-on-read **policy** produces the effective value. Fusion splits by whether the inputs describe the same key: - **same-key, many sources** keeps every perspective and reduces on read. The key's **`fusion_policy`** on `property_type` is a **default/hint, not a mandate**: the right reduce often is not knowable a priori at the `property_type` level (for `display-5.power` from codec CEC, display LAN, and the control system, you cannot know how to fuse the value before considering the actual sources). So a policy may **default from the type**, but can be source-weighted, per-instance, or **left open**: keep all perspectives and decide at read time, by an operator, or by AI. When a policy reduces (`mode`: priority / weighted / majority / worst / average / latest, plus tie-break and optional per-source weights), the reduced value is what `current_value` and event_rule evaluation read; the source-tagged perspectives stay, so "which source is wrong" remains queryable. `event_rule` evaluation reduces over the **latest-per-source perspective set** for the owner and key, held from the live `datapoints` stream (a bounded, in-memory set), never a firehose scan of the durable tables. This improves *confidence in a reading*. A `source` registry carries default trust weights, so the simplest case needs no config. Materialize a fused series only if a profile earns it. - **cross-key / system-level** is a **`calc_rule`** (the only fusion that authors a rule): `room.in_use` derived from display power + codec call-state + occupancy. This *derives a higher-order fact*, a new key, not a same-key consensus. See [calculations](/architecture/calculations/). Conflict detection (`disagree(observed, observed)` across sources) is the complementary operation: even when an effective value is usable, a perspective disagreeing beyond tolerance is itself a signal. ## Reads: current value is a view Current value (latest per owner / key / **instance** / **provenance**, reduced across the source perspectives per the effective `fusion_policy`) is a **view** over the persisted tables, correct and zero-maintenance. It is keyed per-provenance because "current observed power" and "current intended power" are different values for the same key, and the divergence model depends on seeing both. A materialized `current_value` table is a measured optimization, earned when a read profile proves the view too slow: the driver is **operator and fleet-dashboard reads**, not the rule engine, since the rule engine evaluates against datapoints live off the JetStream `datapoints` stream and never reads the view. The same view-by-default discipline as storage applies. Ownership resolution reads resolved identity config (the declared value, else the observed [identity datapoint](/architecture/collection/)) by targeted indexed lookup, not a full scan, so it does not by itself justify the materialized table. ## The datapoint tables The three kinds are three physical tables only because they index and retain differently; the [physical layout, partitioning, and the lineage CHECK](/architecture/storage/) live on storage. | Table | Key columns | Notes | |---|---|---| | `metric` | id, ts, **owner_kind, component_id/system_id/location_id/node_id**, key, **instance**, **value float8**, provenance, source, **source_rule, source_rule_version, event_id**, **correlation_id?, caused_by_event_id?** | the firehose; BRIN on ts; numeric aggregation. `instance` (`''` default) discriminates many values of one canonical key on one owner. `correlation_id` / `caused_by_event_id` are nullable trace cols, outside the lineage CHECK | | `state` | id, ts, owner arc, key, instance, **value text/jsonb**, provenance, source, + same lineage and trace cols | sparse, transition-only; time-in-state and dwell. [Config](/architecture/variables/) is keyed to one as its observed side | | `log_datapoint` | id, ts, owner arc, key, instance, **value text/jsonb (the line)**, level, provenance, source, + same lineage and trace cols | GIN / tsvector full-text; also the holding pen for un-normalized occurrences | Common datapoint columns (all three kind-tables): `ts`, the **owner arc** (`owner_kind` plus `component_id` / `system_id` / `location_id` / `node_id`), `key, provenance, source`, the on-row lineage `source_rule, source_rule_version, event_id`, and the nullable trace columns `correlation_id, caused_by_event_id` (outside the lineage CHECK); only the value column differs (float8 / text-jsonb / line). A `datapoint` view UNIONs the common columns for "all datapoints for owner X". The key registry that types these tables is `property_type` (one registry across all three kinds), detailed at [the property_type registry](#the-property_type-registry): | Table | Key columns | Notes | |---|---|---| | `property_type` | name, **scope** (template/org/official), **template_id?**, kind (metric/state/log), value_type, unit, **precision**, **fusion_policy**, validation (jsonb) | the one key registry across all datapoint kinds; `scope` decides where the name is unique (`(template_id, name)` at template scope, `name` at org/official); referenced by templates, which also mint their own template-scoped rows. `unit` is the **canonical** unit, a row in the `unit` registry below; `precision` is a display hint (significant digits), not a storage truncation. Both apply to **metrics**; a **state** or **log** has neither | | `unit` | name, **family** (temperature/data-size/bitrate/...), **canonical** (bool), **to_canonical**, **from_canonical** (affine factor+offset, or Expr), **scope** (official/org) | the unit registry: one canonical unit per family plus alternates each carrying its conversion transforms; the `property_type.unit` canonical unit references it, and `convert(value, "")` resolves same-family targets through it | ## The pipeline, end to end ```d2 direction: down classes: { node: { style.border-radius: 8 } key: { style: { border-radius: 8; bold: true } } group: { style.border-radius: 8 } } edge: "Edge (node)" { class: group task: "task\npoll · listen\nstateless / stateful" { class: node } fn: "function\nextract → key → normalize" { class: node } task -> fn } raw: "raw ingress\nnode · webhook (untrusted)" { class: node } admit: "admission consumer\nowner-confine per class\n(system mode)" { class: node } ds: "JetStream\ntrusted datapoints stream" { class: node; shape: queue } failed: "collection.failed\n(carries raw)" { class: node } calc: "calc_rule consumer\ncross-key · system-level" { class: node } erule: "event_rule consumer\nfire_criteria (+ optional clear_criteria)" { class: node } persist: "persistence consumer\nbatch sink (async)" { class: node } tables: "metric · state · log\ndatapoint tables" { class: node; shape: cylinder } sched: "schedule + timer\n(leader-elected clock)" { class: node } pg: "event · alarm\n(PG)" { class: node; shape: cylinder } alarm: "alarm\none incident · new row per open\n(event_rule, owner)" { class: node } cdc: "JetStream\nrecord/state lane" { class: node; shape: queue } actions: "action_rule consumer\nnotify · command\nremediate-verify-escalate" { class: node } itsm: "ITSM (action target)" { class: node } operator: operator { class: node } config: "config\ndeclared (spec)" { class: node } audit: audit_log { class: key } divergence: divergence { class: node; shape: hexagon } edge.fn -> raw: "observed · lineage on row\n(source_rule)" edge.fn -> failed: "parse / validation fail" { style.stroke-dash: 4 } raw -> admit admit -> ds: "confined" ds -> calc calc -> ds: "calculated · trusted producer\n(direct, no admission)" ds -> erule ds -> persist persist -> tables: "durable copy" sched -> erule: "origin=scheduled" erule -> pg: "PG-first: event + alarm in one tx" pg -> alarm: "alarm transition" pg -> cdc: "CDC (logical decoding)\nleader-elected publisher" { style.stroke-width: 3 } cdc -> actions actions -> ds: "command's effect · provenance=intended\n(trusted, direct)" { style.stroke-dash: 4 } actions -> itsm: "ITSM: open->ticket · update->comment · resolve->close" { style.stroke-dash: 4 } actions -> edge.task: "command + adaptive poll" { style.stroke-dash: 4 } operator -> config: "declares (PG-first)" config -- tables: "links · drift" { style.stroke-dash: 4 } operator -> audit: "audit" { style.stroke-dash: 4 } cdc -- divergence: "disagree(A,B): drift / conflict" { style.stroke-dash: 4 } ``` Two lanes, one bus. The **data lane** is the JetStream **trusted** `datapoints` stream. Untrusted publishers (the edge node, an external webhook) land on a **raw ingress** subject; an **admission consumer** owner-confines each datapoint against the publisher's placement (or the webhook interface's declared owner) and re-publishes only confined points to the trusted stream, so a forged owner is dropped before the live `event_rule` can act on it ([identity and access](/architecture/identity-access/)). **Trusted server producers** (calc output, a command's intended write) publish to the trusted stream directly, no admission pass. The `event_rule` consumer evaluates against the trusted stream live, and a **persistence consumer** batch-writes the three datapoint tables (`metric`, `state`, `log_datapoint`) as an async sink (datapoints never go through CDC). The **record/state lane** is PG-first: an `event_rule` fire writes the event and alarm transition to PG in one transaction, and a leader-elected **CDC publisher** (logical decoding of the WAL) fans those committed changes onto JetStream, where `action_rule` consumers react. A command's intended datapoint re-enters the data lane (the device round trip). The teal node is `audit_log`, the ground-truth record of operator writes (including config changes); observed and calculated carry `source_rule` on the row, intended points at the command `event` (via `event_id`). The raw payload is not stored: a parse or validation failure rides a `collection.failed` event. [config](/architecture/variables/) holds declared intent (PG-first), keyed to a state datapoint as its observed side. Related: [events](/architecture/events/) (the event family and `event_type`), [calculations](/architecture/calculations/) (calc rules and the rule families), [config and credentials](/architecture/variables/) (declared config, drift, reconcile), [collection](/architecture/collection/) (how telemetry arrives), [alarms and actions](/architecture/alarms-actions/) (alarm lifecycle, actions), and [the glossary](/architecture/glossary/) (every term defined once). --- # Decision log URL: /architecture/decisions/ The dated history of architectural calls: reversals, settled open questions, and where the build currently diverges from the present-tense design. The architecture pages are written in the present tense as the **target design**, and each carries a status badge that says how much of it is built ([implementation status](/architecture/status/)). Neither axis carries **history**: why a call was made, when it was reversed, or why the shipped code differs from the page that describes it. That is what this log is for. A page tells you what the design **is** and how much is built. This log tells you how it **got there**: the decisions that bind the design, the ones that were reversed in the open, and the points where the implementation has deliberately (or accidentally) drifted from the prose. It is the project's architecture decision record (ADR), kept lightweight and append-only. ## How it works - **One entry per decision** that reverses a prior call, settles an [open question](/architecture/status/), or records a point where the build diverges from a page's present-tense design. - Each entry carries a **date**, a **status** (`Proposed`, `Accepted`, or `Superseded`), the **decision** in one line, the **context** that forced it, and the **page(s)** it touches. - A **divergence** entry is the partner of a page's inline note: the page says what is true *now*, this log says *why* and *when* it diverged, and which issue tracks closing the gap. - Entries are **never edited away**. A reversed decision is marked `Superseded` and points at the entry that replaced it, so the trail of reasoning survives. Nothing in this log is deleted when a page changes. - New reversals and divergences are added **per slice**, as part of the [ship gate](/contributing/slice-workflow/): if a slice changes a settled call or ships something that differs from its architecture page, the entry lands in the same PR. This log was seeded on 2026-06-30 from the first architecture-drift review, which backfilled the entries below from the project's history. From here it grows one slice at a time. ## Index | ID | Date | Status | Decision | |---|---|---|---| | [ADR-0001](#adr-0001-ai-acts-as-a-user-the-agent-principal-is-deferred) | 2026-06-27 | Accepted | AI acts as a `human` / `service` principal; a first-class `agent` principal is deferred | | [ADR-0002](#adr-0002-roles-carry-requirements-not-an-allow-list) | 2026-06-27 | Accepted | Authorization is role + scope grants, not a per-principal allow-list | | [ADR-0003](#adr-0003-health-reads-ok-not-up) | 2026-06-27 | Superseded by [ADR-0050](#adr-0050-health-is-a-recorded-transition-computed-from-the-alarm-capability-role-chain) | The healthy state is named `ok`, not `up` | | [ADR-0004](#adr-0004-credentials-ship-bearer-only) | 2026-06-27 | Resolved | Bearer shipped first; `password` credentials (argon2id) landed in identity slices 1-2. OIDC / NATS still deferred | | [ADR-0005](#adr-0005-the-first-owner-is-omniglass-bootstrap) | 2026-06-27 | Resolved | `omniglass bootstrap [--password]`; the password-on-create path shipped, the `iam` namespace is deferred | | [ADR-0006](#adr-0006-the-owner-invariant-is-enforced-by-bootstrap-for-now) | 2026-06-27 | Resolved | The single-owner invariant is now a DEFERRABLE constraint trigger, landed with grant revocation | | [ADR-0007](#adr-0007-principals-are-gated-at-all-scope-not-scope-tree) | 2026-07-01 | Accepted | A principal is not a scope-tree entity; the `principal` capability confers access only at all-scope | | [ADR-0008](#adr-0008-disable-is-hard-revocation-no-token-version-column) | 2026-07-06 | Accepted | Disable revokes live sessions via the per-request `active` re-read; no token-version column (nothing consumes it) | | [ADR-0009](#adr-0009-root-exclusion-lives-on-the-grant-not-a-new-scope-kind) | 2026-07-06 | Superseded by [ADR-0011](#adr-0011-grant-scope-is-an-operator-not-a-boolean-modifier) | The deploy "act on the subtree but not the root" capability is an `exclude_root` grant modifier, not a new scope kind | | [ADR-0010](#adr-0010-impersonation-is-a-session-not-a-credential-guarded-by-capability-cover) | 2026-07-06 | Accepted | Impersonation ships view-as + act-as as an `impersonation_session` (not a credential), guarded by capability-cover, with a real-actor audit column | | [ADR-0011](#adr-0011-grant-scope-is-an-operator-not-a-boolean-modifier) | 2026-07-06 | Accepted | Generalize the `exclude_root` boolean into a `scope_op` operator (`subtree` / `subtree_excl_root` / `self`), a flat enum, not a predicate-expression tree | | [ADR-0012](#adr-0012-owner-accounts-are-un-impersonatable-impersonation-stays-capability-gated-not-scope-intersected) | 2026-07-07 | Accepted | Owner accounts are un-impersonatable by anyone; impersonate stays swept by `principal:*`; drop act-as scope intersection (#101) | | [ADR-0013](#adr-0013-a-grant-cannot-confer-capabilities-the-granter-lacks) | 2026-07-07 | Accepted | Grant creation is refused when the granted role's capabilities exceed the granter's all-scope capabilities (admin cannot self-promote to owner) | | [ADR-0014](#adr-0014-the-audit-trail-is-a-sensitive-read-not-reached-by-a-partial-global-wildcard) | 2026-07-07 | Superseded by [ADR-0015](#adr-0015-permissions-are-topic-patterns-single-token-and-tail-wildcards) | The audit trail is admin/owner-only: `audit` is a sensitive resource that `*:read` does not confer, only an explicit `audit:read` or `*:*` | | [ADR-0015](#adr-0015-permissions-are-topic-patterns-single-token-and-tail-wildcards) | 2026-07-07 | Accepted | Permissions match like NATS subjects (`*` one token, `>` tail); admin-sensitivity is a deeper `:admin` token no partial wildcard reaches; owner is `>` | | [ADR-0016](#adr-0016-a-principal-can-be-purged-and-the-audit-trail-is-denormalized-to-survive-it) | 2026-07-09 | Accepted | A principal can be hard-deleted (purge, gated on archival); the audit trail survives via a denormalized actor label and `ON DELETE SET NULL`, retiring the "never hard-deleted" rule (soft-delete verb: archive) | | [ADR-0017](#adr-0017-credential-is-renamed-secret-the-cascade-is-the-reuse-mechanism) | 2026-07-09 | Accepted | The access-secret member of the config / credential / variable trio is renamed credential to secret: an encrypted-at-rest typed value resolved most-specific-wins down the cascade | | [ADR-0018](#adr-0018-the-avatar-read-endpoint-is-json-not-raw-image-bytes) | 2026-07-10 | Accepted | A profile picture is read through a JSON `image_base64` endpoint the console renders as a data URL, not a raw `image/jpeg` handler, so every route stays under the Huma authz middleware | | [ADR-0019](#adr-0019-every-credential-is-time-bounded-token-purpose-not-expiry-shape) | 2026-07-11 | Accepted | Every credential is time-bounded (reverses tokens-never-expire): session 12h, token / bootstrap 90d default with a `--ttl` capped at 365d; a `credential.purpose` column, not the expiry shape, tells session from token | | [ADR-0020](#adr-0020-variable-slice-1-types-inline-and-mirrors-the-secret-arc) | 2026-07-11 | Accepted | The variable member ships plaintext, typed inline against a `value_type` enum (no `variable_type` registry), on the secret owner arc; template scope, groups, the `$var:` consumer deferred | | [ADR-0021](#adr-0021-tag-slice-1-a-governed-key-registry-with-entity-update-gated-bindings) | 2026-07-12 | Accepted | The tag primitive ships its first slice (governed key registry, per-entity bindings, cascade); minting a key is admin `tag:create`, setting a value is the entity's own `update` | | [ADR-0022](#adr-0022-effective-tags-resolve-onto-systems-and-locations-a-placed-system-inherits-its-location) | 2026-07-13 | Accepted | Directory rows carry batch-resolved effective tags; effective resolution extends to systems and locations, and a placed system inherits its location's tags | | [ADR-0023](#adr-0023-the-iam-directory-reads-principal-role-principal_group-are-admin-tier) | 2026-07-13 | Accepted | The IAM directory reads (principal, role, principal_group) move to the admin tier (`:read:admin`), so viewer's `*:read` floor no longer reaches Users, Roles, and Groups | | [ADR-0024](#adr-0024-a-tag-key-may-constrain-its-values-to-an-enum) | 2026-07-13 | Accepted | A tag key may declare an `allowed_values` enum (empty = free text), enforced on the binding write; a free key autocompletes its distinct in-use values | | [ADR-0025](#adr-0025-secret-is-a-sensitive-resource-a-per-secret-admin_sensitive-flag-flips-a-secret-to-the-admin-tier) | 2026-07-13 | Accepted | `secret` leaves the bare `*` wildcard's reach (direct match and read floor); a per-secret `admin_sensitive` flag flips a secret to the `:admin` tier, so operators read operational device secrets in scope while platform credentials stay admin/owner-only at the same scope | | [ADR-0026](#adr-0026-console-nav-ia-estate-values-get-their-own-top-level-group-the-settings-group-becomes-admin) | 2026-07-13 | Accepted | Console nav IA: Variables, Secrets, and Config get their own top-level Values group; Inventory holds the estate entities including Nodes; Interfaces and Tasks become facet panels; the Settings group is renamed Admin | | [ADR-0027](#adr-0027-create-is-a-route-inventory-create-and-edit-unify-on-the-detail-accordion) | 2026-07-14 | Accepted | Inventory create/edit unify on the detail accordion: `New` routes to `//create` (a draft) and Save hands off to `//` in edit; view is read-only, edit is the sole writer; the create/edit Drawer is retired | | [ADR-0028](#adr-0028-rank-retired-from-the-type-registries-sort-is-alphabetical) | 2026-07-14 | Accepted | `rank` is dropped from `location_type`, `system_type`, and `component_type`; the three list operations sort by `display_name, id` instead | | [ADR-0029](#adr-0029-files-slice-1-a-content-addressed-blob-store-and-a-tenant-wide-file-handle) | 2026-07-14 | Accepted | Files slice 1: a content-addressed `blob` store primitive (pgblobs) and a tenant-wide `file` handle; no placement arc (a file is 1:many, its locality is a future attachment), a binary `sensitive` flag reusing the secret `:admin` tier (defaults off), a delete frees its unreferenced blob synchronously (async mark-sweep GC deferred), base64-in-JSON on the wire | | [ADR-0031](#adr-0031-component_make-registry-slice-1-an-official-boolean-a-deferred-referential-guard-and-website-scheme-validation) | 2026-07-14 | Accepted | `component_make` slice 1: an `official` boolean (not an `origin` enum) for consistency with the type registries; the in-use referential delete guard deferred to the `component_model` slice (nothing references a make yet); `website` scheme-validated to `http`/`https`, client and server, against stored XSS | | [ADR-0032](#adr-0032-the-required-permission-is-published-per-route-and-the-permission-universe-is-route-derived) | 2026-07-17 | Accepted | Every gated route stamps its required permission into the OpenAPI (`x-omniglass-permission`), so the permission universe is derived from the routes rather than a hand-kept catalog | | [ADR-0033](#adr-0033-settings-persist-only-the-override-level-base-layers-are-recomputed-in-memory) | 2026-07-17 | Accepted | The settings engine persists only the override level; the `code` and `file` base layers are recomputed in memory each boot, so restore is a delete (diverges from scaling.md's "materialized in Postgres") | | [ADR-0034](#adr-0034-the-settings-gateway-is-unscoped-only-the-permission-gates-it) | 2026-07-17 | Accepted | Settings Gateway methods are unscoped: ABAC storage-scope is not applicable to platform / principal config; only the `settings:` permission gates them | | [ADR-0035](#adr-0035-settings-resolve-as-a-cascade-over-principals-with-a-broader-wins-lock) | 2026-07-17 | Accepted | Settings resolve down the principal hierarchy reusing the cascade primitive, with per-key provenance and a top-down broader-wins lock | | [ADR-0036](#adr-0036-a-node-is-a-kindnode-principal-with-an-interim-bearer-credential-and-static-per-connection-nats-subject-permissions) | 2026-07-07 | Accepted | A node is a `principal` of `kind=node` with a 1:1 detail table and a bearer `credential` row (interim shared secret), and per-node NATS isolation is static per-connection subject permissions via an in-process auth callback; nkey/JWT deferred | | [ADR-0037](#adr-0037-telemetry-is-a-protobuf-event-over-jetstream-with-an-inline-owner-confining-consumer) | 2026-07-07 | Accepted | Telemetry is a protobuf `Event` over a JetStream durable consumer; the consumer binds the owner from the task's interface and confines a node to its own tasks inline (no separate raw-telemetry table or Postgres queue); raw persistence + replay and label-based multi-owner routing deferred | | [ADR-0038](#adr-0038-the-reachability-verdict-is-a-built-in-state) | 2026-07-07 | Accepted | The per-interface reachability verdict `interface.reachable` is a built-in **state** (not a metric); availability is `time_in_state` over it; readiness is interface-type-defaulted and interface-overridable, node-executed, not a `calc_rule` | | [ADR-0039](#adr-0039-an-interface-is-a-device-api-the-interface-type-is-its-transport-not-its-driver) | 2026-07-08 | Accepted | An interface is a device **API** named by its protocol (not a NIC); `interface_type` = its **transport** (the reach gate), a **driver** = the collect layer (protocol handler + transports + normalized menu, what a device CAN do), a template **curates** (SHOULD), the instance holds what **IS** there; OIDs/commands live in the driver, not the template | | [ADR-0040](#adr-0040-the-task-is-derived-read-only-plumbing-projected-from-its-interface) | 2026-07-14 | Accepted | The `task` is **derived** read-only plumbing: creating an `interface` derives its one poll task, so task create/update/delete routes and the `task:create` / `:update` grants are dropped; `task.node_name` is removed and **projected** from `interface.node_name` (the worklist and telemetry owner-confinement join the interface), and a node purge cascades its interfaces and their tasks. Reverses the checkpoint-5d task-CRUD build; refines ADR-0039 | | [ADR-0041](#adr-0041-settings-are-a-reflected-typed-struct-with-generated-client-and-server-validation) | 2026-07-19 | Accepted | A setting is declared **once**, as a tagged field on a canonical `Settings` Go struct; reflection produces the `code` defaults layer and the namespace registry, Huma reflects the struct into the OpenAPI schema so the typed client `values` is a `Settings` struct, and both the server PATCH and the generated client validate against that **same reflected schema**. Closes the slice-0 write-validation thin cut; retires the hand-kept `defaults.yaml` and `Namespaces()` slice | | [ADR-0042](#adr-0042-field-cascade-and-the-type-default-floor) | 2026-07-19 | Accepted | A field's resolved value is deepest-set-wins down `product -> location -> system -> component`, falling to the field's **type default** when nothing is set at any scope; the type default is the **floor** of the cascade, not a competitor to it. This slice is component-only (resolved = the component's set value, else the type default); the multi-scope cascade is tracked by #291 | | [ADR-0043](#adr-0043-the-property-catalog) | 2026-07-19 | Accepted | The `datapoint_type` catalog is generalized into a primitive-agnostic **`property`** catalog (the typed set of signals a datapoint observes and a field declares): the unused scope ladder becomes an `official` boolean, `value_type` becomes `data_type` (text to string, add bool), `kind` is nullable (a declared-only property has none), and `validation` is a **JSON Schema** validated by Huma's own validator (zero new dependencies). Value/source tables key by **name** (no FK), so the rename is behavior-preserving; the type-schema (`field_definition.key`) is the only binding and lands in PR-B | | [ADR-0044](#adr-0044-the-component-classification-catalogs) | 2026-07-20 | Accepted | The `component_make` catalog is generalized into **`vendor`** (a `kind` of manufacturer / integrator / developer), and two new leaf catalogs join it, **`driver`** (id, display_name, version) and **`capability`** (id, display_name), as the component-classification reference data: each a gated CRUD Catalog console page with read-only official seeded rows. `product` + `product_capability` + `component.product` are the next slice. This is PR2 of the estate-model shift toward property / event / command + vendor / product / driver / capability / standard / role / health | | [ADR-0045](#adr-0045-the-product-catalog) | 2026-07-20 | Accepted | **`product`** lands as a first-class catalog entity, the concrete SKU that binds a **`vendor`**, a **`driver`**, a **`kind`** (`device` / `app` / `service` / `vm`), and a capability set via the **`product_capability`** join; **`parent_product_id`** models variants, and **`component.product_id`** (`on delete restrict`) points a component at the SKU it is, making the product the source of a component's shape and retiring the `component_type`-as-shape notion. PR3 of the estate-model shift; consumes the vendor / driver / capability catalogs from ADR-0044 | | [ADR-0046](#adr-0046-the-event-log-kind-sink) | 2026-07-20 | Accepted | A **log**-kind observation is no longer dropped at ingest: it lands in a new **`event`** table, the log-kind sink (a past occurrence) beside `metric_datapoint` / `state_datapoint` (a sampled present value). `event` carries the same datapoint owner exclusive-arc and provenance, plus `message` + `attributes`, and the reserved `event_id` FK stubs on the two datapoint tables are closed (`on delete set null`). Scope excludes the `datapoint`->`sample` rename (a later cleanup) and `property_value` / the current-value store (the fold-fields slice). P1 follow-up of the estate-model roadmap | | [ADR-0047](#adr-0047-the-fields-fold-product_property-and-property_value) | 2026-07-21 | Accepted | The standalone **fields** feature retires and folds into the estate model: a field was only ever a **property with `declared` provenance**, never a primitive of its own. **`product_property`** is the product's declared-property **contract** (`product_id`, `property_name`, `default_value`, `required`), replacing `field_definition`; **`property_value`** is the value store, carrying the **same owner exclusive-arc** as `metric_datapoint` / `event` plus `instance` and `provenance`, replacing `field_value`. `EffectiveProperties` unions the contract arm (`coalesce(set value, contract default)`) with the off-contract arm, so a productless component still resolves. `field_definition`, `field_value`, `component.component_type`, and the whole `component_type` registry retire. PR5 of the estate-model shift | | [ADR-0048](#adr-0048-the-standard-blueprint-and-the-template-fork-seed-model) | 2026-07-21 | Accepted | `system_type` is promoted to **`standard`**, the blueprint a system conforms to and the system-side counterpart of `product`: it gains `parent_standard_id` (variants), a declared-property contract, and its own `standard:*` Catalog resource, and `system.standard_id` becomes **optional**. `standard_property` and `location_type_property` join `product_property`, and one **owner-generic** `EffectiveProperties(ownerKind, ownerID)` resolves component, system, location, and node off a single parameterized template. A standard and a location type are created by **forking an in-code template** (one-time, no inheritance), so a shipped row is **operator-owned** (`official: false`, seeded **if absent**), while a system **conforms** to its standard with **live** inheritance; only the canonical catalogs keep the authoritative upsert. PR6 of the estate-model shift | | [ADR-0049](#adr-0049-the-system-role-capability-gated-staffing-and-the-resolved-capability-set) | 2026-07-21 | Accepted | A **`system_role`** is a slot a system needs filled (a table microphone, a main display), declared on a **standard** (inherited live by every conforming system) or on one **system** (ad-hoc) over the same exclusive arc `property_value` uses, requiring a **conjunctive** `role_capability` set and carrying a **`quorum`**. A component's capabilities become a **resolved set** (`EffectiveCapabilities` = its product's, plus its own `component_capability` `present=true` rows, minus its `present=false` ones), because `product` is optional and a strict guard over a product-only fact would lock a productless component out of every role. `AssignRole` **refuses (422) and names the missing capabilities**, joining the location placement constraint as a refusal on modeled grounds that names the parties. **Quorum** ships here (staffing is visible without health); **impact** and the SLI rollup land in PR8. Supersedes the `system_template_member` role-requirement design. PR7 of the estate-model shift | | [ADR-0050](#adr-0050-health-is-a-recorded-transition-computed-from-the-alarm-capability-role-chain) | 2026-07-21 | Accepted | Health is **recorded as a transition** and **recomputed at the write**, never on read. An **`alarm`** is component-local and names the **capabilities** it degrades; a component satisfies a role only when it provides every required capability and none of them is degraded; a role below its **quorum** is impaired and contributes its **`impact`** (`outage` / `degraded` / `none`); a system takes the worst of its roles, a location the worst of its systems. The verdict domain is **`healthy` / `degraded` / `outage`** and the judgement is a **pure package** (`internal/health`), unit-tested with no database. The recorded carrier is **`state_datapoint`**, already transition-only, so the history is edges and only edges; **compute-on-read** (no history) and **write-through-on-read** (the edge timestamped when somebody looked) are both rejected. A **read never writes**, and it computes the verdict it serves from the same rows it shows, so a report cannot contradict its own evidence. PR8 of the estate-model shift, closing epic [#266](https://github.com/hyperscaleav/omniglass/issues/266) | | [ADR-0054](#adr-0054-the-shell-owns-a-panels-action-rail-the-body-registers-and-never-draws) | 2026-07-21 | Accepted | A panel's action bar is **declared, not laid out**: a blade body binds through `lib/blades`, a Drawer form body through `lib/formactions`, and `BladeStack` / `Drawer` draw the result through the one `PanelFooter` rail. The opt-in `DrawerFooter` helper is deleted. A convention a body must remember can be forgotten, and was, by two forms for months while it was copied into six new pages around them | | [ADR-0057](#adr-0057-the-cascades-least-specific-tier-is-platform-and-a-default-is-not-a-tier) | 2026-07-21 | Accepted | The cascade's least-specific **binding** tier is renamed `global` to **`platform`** on both axes (same rung, no precedence change); a **`default`** is off the axis entirely, a column on a type declaration rather than a tier; there is **no root location**; a write at the tier needs its own **`platform:`** permission. **Breaking:** a secret sealed at the old tier can no longer be decrypted (the AEAD binds the owner kind) | ## Entries ### ADR-0001: AI acts as a user; the `agent` principal is deferred - **Date:** 2026-06-27 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/), [AI](/architecture/ai/) - **Decision:** An AI tool authenticates over OAuth as an ordinary `human` or `service` principal and acts with exactly that principal's grants. A dedicated first-class `agent` principal kind is **not** in the initial architecture. - **Context:** A separate `agent` identity would need its own authN, its own grant semantics, and its own audit treatment before any AI surface exists to use it. Treating AI as a scoped, audited user reuses the whole identity machinery and keeps the audit trail honest (the acting principal is the human or service). - **Note:** The schema's `principal.kind` CHECK already **reserves** the `agent` value so a later slice adds the kind without editing the applied auth migration; no `agent` identity is issued today. If and when a first-class agent identity is built, that is a new entry that supersedes this one. ### ADR-0002: Roles carry requirements, not an allow-list - **Date:** 2026-06-27 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** Authorization is built from additive `(role x scope)` grants, where a role is a capability set of `:` permissions. An earlier sketch attached a per-principal **allow-list** of permitted actions directly. - **Context:** A per-principal allow-list does not compose: the same operator role at two scopes, or a role inherited and extended, would be re-listed by hand per principal. Roles plus scope make the common case (the same role at different places) a single reused definition, and keep permissions additive and positive (no negative entries). It is also what makes the per-grant binding (an action and its scope bind in the *same* grant) expressible. ### ADR-0003: Health reads `ok`, not `up` - **Date:** 2026-06-27 | **Status:** Accepted | **Pages:** [health](/architecture/health/) - **Decision:** The healthy state of a component or system is named **`ok`**. An earlier draft used `up`. - **Context:** `up` reads as reachability (the device answers), which is only one input to health. Health is a rollup verdict ("is this system working?") that can be unhealthy while every device is reachable, or healthy while a redundant member is down. `ok` names the verdict rather than the ping, so the word does not promise something narrower than the model delivers. - **Superseded by** [ADR-0050](#adr-0050-health-is-a-recorded-transition-computed-from-the-alarm-capability-role-chain) on the **word only**. The reasoning holds and the built domain still names the verdict rather than the ping; it spells the members **`healthy` / `degraded` / `outage`**, because `outage` says what a broken system means to the people in the room where `down` says what a device is doing. ### ADR-0004: Credentials ship bearer-only - **Date:** 2026-06-27 | **Status:** Resolved (identity slices 1-2) | **Pages:** [identity and access](/architecture/identity-access/) - **Resolved:** Password credentials shipped in identity slice 1 ([#35](https://github.com/hyperscaleav/omniglass/pull/35)) and slice 2 ([#70](https://github.com/hyperscaleav/omniglass/pull/70)): `credential.kind` now allows `bearer` or `password` (argon2id, PHC-encoded, one password per principal), and a human signs in with a username and password behind an httpOnly session cookie. The `oidc` / `nats` methods and the full `(method, identifier)` lookup key remain deferred (future slices). - **Decision (divergence):** The shipped `credential` table carries `kind = 'bearer'` only, stored as the token's sha256 with a non-secret `ogp_` locator prefix. The design's fuller model (the `password`, `oidc`, and `nats` methods, and the `(method, identifier)` lookup key) is **deferred**, not yet built. - **Context:** The auth foundation slice needed exactly one working authN method to prove the capability and scope seams end to end. Bearer tokens are the thinnest honest cut: a service credential the bootstrap and the CLI can both carry. Password login is the first slice of the [identity tier epic (#27)](https://github.com/hyperscaleav/omniglass/issues/27) ([slice #28](https://github.com/hyperscaleav/omniglass/issues/28)), which adds `password` to the `credential.kind` CHECK in a new migration (never editing the applied one). OIDC and the NATS node credential follow with their own surfaces. - **Closes the gap:** epic [#27](https://github.com/hyperscaleav/omniglass/issues/27). ### ADR-0005: The first owner is `omniglass bootstrap` - **Date:** 2026-06-27 | **Status:** Resolved (identity slice 1) | **Pages:** [identity and access](/architecture/identity-access/) - **Resolved:** `omniglass bootstrap --password ` shipped in identity slice 1 ([#35](https://github.com/hyperscaleav/omniglass/pull/35)): bootstrap now installs a password credential on create (plus `--email` / `--display-name`), so the owner can sign in to the console without a separate step. The `og iam` admin command namespace is still deferred (it lands with the admin user surface, slice 3). - **Decision (divergence):** The first owner is created by `omniglass bootstrap `, which mints an `owner@all` grant plus a **bearer** credential in one transaction. The design page describes the eventual `og iam create-owner --username ... --email ...` password path under an `iam` command namespace; that namespace and the password credential are **deferred**. - **Context:** Bootstrap has to work before any login surface exists, so it pairs with the bearer-only credential decision (ADR-0004): one trusted, idempotent command that produces a token the operator pastes into the console or CLI. The `iam` command family (and the password-on-create path) lands with the identity-tier admin surfaces. - **Closes the gap:** epic [#27](https://github.com/hyperscaleav/omniglass/issues/27). ### ADR-0006: The owner invariant is enforced by bootstrap for now - **Date:** 2026-06-27 | **Status:** Resolved (identity slice 3c) | **Pages:** [identity and access](/architecture/identity-access/) - **Resolved:** The `DEFERRABLE INITIALLY DEFERRED` constraint trigger (`principal_grant_owner_guard`) shipped with grant revocation ([issue #82](https://github.com/hyperscaleav/omniglass/issues/82)): it refuses to leave zero `owner @ all` grants at `COMMIT`, so revoking the last owner is a clean 409 while a swap (grant a new owner + revoke the old in one transaction) still passes. The gateway maps its custom SQLSTATE `OG001` to `ErrLastOwner`. - **Decision (divergence):** "At least one active `owner@all` grant exists at all times" is upheld today by the bootstrap path (it always creates one) and the absence of any grant-revocation surface. The design's **deferrable Postgres constraint trigger** that enforces it at `COMMIT` (so the swap-owners-in-one-txn pattern works) is **not yet built**. - **Context:** With no API to revoke a grant or delete a principal, the last-owner removal the trigger guards against is not yet reachable, so the trigger is not load-bearing until grant CRUD ships. It is required before the admin user-management slice exposes grant revocation ([epic #27](https://github.com/hyperscaleav/omniglass/issues/27), slice 3). - **Closes the gap:** epic [#27](https://github.com/hyperscaleav/omniglass/issues/27). ### ADR-0007: Principals are gated at all-scope, not scope-tree - **Date:** 2026-07-01 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** A `principal` is not a scope-tree entity: it is not "under" a location, system, or component, so the `principal:` capability confers access **only at all-scope**. A grant scoped to a location or system carries no principal access, and the Storage Gateway refuses a non-all scope on the principal directory with a 403 (`ErrPrincipalForbidden`) rather than silently returning an empty list. This falls out of the scope resolver: `applicableKinds("principal")` is empty, so only an `all` grant resolves to a non-empty set. - **Context:** The admin principal directory (slice 3a, [issue #77](https://github.com/hyperscaleav/omniglass/issues/77)) is the first surface to gate on `principal:*`. Modelling users as scope-tree entities would be wrong (there is no "users under HQ"), and returning an empty list to a mis-scoped admin would hide a misconfiguration, so making all-scope explicit keeps the capability honest and surfaces the error. The same rule governs the later principal-mutation and grant surfaces. - **Closes the gap:** n/a (a design decision, not a divergence). ### ADR-0008: Disable is hard revocation; no token-version column - **Date:** 2026-07-06 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** Disabling a principal revokes its live sessions immediately, achieved by the authn path re-reading `principal.active` on **every** request, not by a session-version / epoch column. `AuthenticateBearer` and `AuthenticatePassword` both filter `and pr.active` in the credential lookup on every call, with no caching anywhere in the authn path, so the very next request on an already-issued bearer or session cookie after a disable gets zero rows and a 401. `SetPrincipalActive` flips the flag in one statement: disable **is** revocation, atomically. No `token_version` column is added. - **Context:** Issue [#94](https://github.com/hyperscaleav/omniglass/issues/94) asked for "hard session revocation on disable", assuming disable was soft (a propagation delay). It is not: the per-request active check already is the hard-revocation mechanism, proven end to end by `TestDisableRevokesLiveSessionAPI` (a live token is 401 on its next request the moment it is disabled) and `TestDisablePrincipal`. A `token_version` column would matter only as an invalidation signal for an authn-result cache, which does not exist; adding it now would be a dead column with no reader, against the primitive-first and meaningful-migration disciplines. Revisit if any cache/memoization is introduced in the authn path (an epoch bump would then be its invalidation signal). - **Closes the gap:** issue [#94](https://github.com/hyperscaleav/omniglass/issues/94), closed as already satisfied. ### ADR-0009: Root exclusion lives on the grant, not a new scope kind - **Date:** 2026-07-06 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** The "act on the subtree but not the root" capability (the deploy / integrator case, issue [#87](https://github.com/hyperscaleav/omniglass/issues/87)) is a boolean `exclude_root` modifier on `principal_grant`, not a new `scope_kind` (e.g. `location_descendants`) and not a role-level flag. It narrows only the **modify** actions (update, delete) to the root's descendants; read and create-placement keep the root. An inclusive grant on the same root wins over an excluding one. - **Context:** A new scope_kind would fork the kind handling three ways (location / system / component) and grow the scope vocabulary; a role-level flag could not vary per grant (the same deploy role granted root-inclusive in one place and root-excluded in another). The grant modifier composes with the additive-grant model and confines the change to one predicate (`inScopeTree`) shared by all three tree entities. Keeping read and create-placement inclusive means a `PATCH` on the root is the existing readable-but-out-of-write-scope 403, so `exclude_root` reuses the three-way status split rather than adding a fourth case. Shipped with a new `deploy` official role (create + update on the three tree tiers, read via the viewer floor). The grant-builder toggle to set it from the console is a fast-follow ([#99](https://github.com/hyperscaleav/omniglass/issues/99)). - **Closes the gap:** issue [#87](https://github.com/hyperscaleav/omniglass/issues/87). ### ADR-0010: Impersonation is a session, not a credential; guarded by capability cover - **Date:** 2026-07-06 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** Admin/owner impersonation ships with **both** modes (view-as read-only, act-as full) in one slice. An impersonation token is an `impersonation_session` row (its own table: target, real actor, mode, expiry, revoke), **not** a `credential` (which authenticates a principal as itself). Authorization to impersonate is the escalation guard `actor.Covers(target)` (the caller's capabilities must cover the target's) plus the `principal:impersonate` capability at all-scope. Capability cover applies to both modes; **scope** is where the modes diverge: **view-as** is cross-scope (read-only grants no write authority, and seeing another scope is the troubleshooting case), but **act-as** additionally requires the caller's **all-scope grants alone** to cover the target: a capability held only through a narrower grant does not count. Without that, act-as would let a split-grant admin (all-scope user management, but infra scoped to campus X) impersonate a campus-Y admin and gain write in Y, since an impersonated request resolves its ABAC scope from the target: a scope escalation. Because the rule is capability-cover against the caller's all-scope grants (not a hardcoded list of scoped resources), it closes non-tree escalation too: a user-admin who holds grant authority only through a scoped grant (empty effective scope, cannot create a grant directly) cannot launder all-scope grant authority by acting-as a grant admin. Accountability is a nullable `audit_log.real_actor_principal_id` written on the row directly, not reconstructed from a time-window join (clock skew and concurrent sessions make that unreliable for an accountability record), and the self-service mutations (`/auth/me` profile and password) audit too so an act-as edit is never untracked. - **Context:** view-as is enforced by refusing every non-read action when the request carries a view-as claim; act-as threads the real actor through the audit writer via a request-scoped context value (`storage.WithRealActor`), so no mutating gateway signature changes. `authn` tries the impersonation session on a bearer-hash miss, so the same `Authorization: Bearer` path serves both. Disabling either party kills the session via the per-request `active` re-read ([ADR-0008](#adr-0008-disable-is-hard-revocation-no-token-version-column)). The console ships an Impersonate action (view-as / act-as) and an acting-as banner. Deferred: re-checking the escalation guard on every request (bounded instead by a short TTL plus revoke), and act-as within a scoped admin's own scope by intersecting the target's scope with the caller's ([#101](https://github.com/hyperscaleav/omniglass/issues/101)), rather than the current all-scope-only act-as rule. - **Closes the gap:** issue [#85](https://github.com/hyperscaleav/omniglass/issues/85). ### ADR-0011: Grant scope is an operator, not a boolean modifier - **Date:** 2026-07-06 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** Generalize the `exclude_root` boolean ([ADR-0009](#adr-0009-root-exclusion-lives-on-the-grant-not-a-new-scope-kind)) into a `scope_op` operator on `principal_grant` (issue [#102](https://github.com/hyperscaleav/omniglass/issues/102)): `subtree` (root + descendants, the default, == old `exclude_root=false`), `subtree_excl_root` (descendants only for update/delete, root kept for read/create, == old `exclude_root=true`), and `self` (the root row only for read/update/delete, no descendants and no create-placement, a leaf-lock, net-new). The operator is a **flat enum column**, not a full predicate-expression tree or a per-grant tuple list. It is part of a grant's identity: the dedup index includes `scope_op`, so the same role at the same root with a different operator is a distinct grant. - **Context:** Grant scope wants one composable axis, not a growing pile of booleans; the grant builder is already a filter-bar-style operator UI, so the operator vocabulary is the natural fit. The flat enum was chosen over a predicate-expression scope and a per-grant tuple list (negation, multi-root `in`): those buy expressiveness the boolean's two states never needed, at the cost of a much larger blast radius on the two authorization invariants (permission-on-every-route, scope-on-every-query). `self` is the cheap third value (a scalar `= any()` arm, no new recursive CTE) that turns a boolean rename into a real operator, and grant on exactly one node is a frequently-wanted capability the boolean could never express. The pure `scope.Resolve` gains a `SelfIDs` set; the three gateway walks (`inScopeTree`, `InScopeIDs`, `scopedListSQL`) gain a self arm. The migration also recreates the dedup index to include `scope_op`, fixing a latent collision, and threads `scope_op` through `RevokeGrant`'s audit SELECT (previously dropped). The operator model does **not** subsume the act-as scope intersection ([#101](https://github.com/hyperscaleav/omniglass/issues/101)): that blocker is plumbing (carry the real actor's grants and intersect two Sets per row), unchanged by how a Set is expressed. A future tuple model (negation, multi-root) stays a documented path if a real carve-out requirement lands. The console grant builder gains an operator stage (role -> kind -> entity -> operator), so [#99](https://github.com/hyperscaleav/omniglass/issues/99) (setting the modifier from the console) ships here too. - **Supersedes:** [ADR-0009](#adr-0009-root-exclusion-lives-on-the-grant-not-a-new-scope-kind) (the boolean is retired for the operator). - **Closes the gap:** issue [#102](https://github.com/hyperscaleav/omniglass/issues/102). ### ADR-0012: Owner accounts are un-impersonatable; impersonation stays capability-gated, not scope-intersected - **Date:** 2026-07-07 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** Harden the impersonation authorization model on tiers, not scope. (1) A principal holding `owner @ all` cannot be impersonated by **anyone**, including another owner, in either mode (issue [#106](https://github.com/hyperscaleav/omniglass/issues/106)): a target-side check in the `:impersonate` handler, before the mode branch. (2) The `principal:impersonate` capability stays **swept by the `principal:*` wildcard** (admin) and `*:*` (owner); it is not carved out as a sensitive action, because holding `principal:*` already lets a caller create and use its own principals, so impersonate confers no new reach there. (3) **Drop** act-as scope intersection ([#101](https://github.com/hyperscaleav/omniglass/issues/101)): act-as stays all-scope-only. - **Context:** The escalation guard (`Covers`) already blocks a lesser admin from impersonating an owner, but `owner.Covers(owner)` is true, so owner-impersonates-owner was possible. An owner is the highest-trust account and impersonating one is a full-takeover vector, so the explicit owner-protection rule removes it entirely and reads more clearly than relying on cover arithmetic. Owner detection reuses the same `role='owner' and scope_kind='all'` lane as the [owner invariant](#the-owner-invariant), so it is not new role-name branching. Scope intersection (a scoped admin acting-as within its own subtree by intersecting two scope Sets per row) was dropped as complexity for a narrow case; the tier model plus all-scope-only act-as is simpler and safe. The impersonated-vs-direct distinction an operator needs in the audit trail is already recorded by `audit_log.real_actor_principal_id` ([ADR-0010](#adr-0010-impersonation-is-a-session-not-a-credential-guarded-by-capability-cover)); surfacing it is a later auth-event audit slice. - **Refines:** [ADR-0010](#adr-0010-impersonation-is-a-session-not-a-credential-guarded-by-capability-cover). - **Closes the gap:** issue [#106](https://github.com/hyperscaleav/omniglass/issues/106); closes [#101](https://github.com/hyperscaleav/omniglass/issues/101) as dropped. ### ADR-0013: A grant cannot confer capabilities the granter lacks - **Date:** 2026-07-07 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** Grant creation is refused (403) when the granted role's capabilities are not covered by the granter's **all-scope** capabilities (`rbac.Set.Covers`, the same primitive as the impersonation escalation guard). So no caller can promote anyone, including itself, to a tier above its own: an **admin cannot grant `owner`** (`*:*`), because admin is an enumerated role that does not cover the global wildcard. Issue [#109](https://github.com/hyperscaleav/omniglass/issues/109). - **Context:** `CreateGrant` previously checked only that the granter held all-scope `principal_grant:create` (`action.All`), not that the granter covered the granted role, so an admin could grant itself `owner@all` and log in as a superuser, leaving the admin/owner distinction unenforced. The check lives in the `create-grant` handler (capability is a route/handler concern; ABAC scope stays the gateway's), mirroring the impersonation guard. Only the caller's **all-scope** grants count, so a capability held through a narrower grant cannot be conferred estate-wide (the same reason act-as requires all-scope cover). The consequence is a deliberate stance: **admin is bounded on purpose**, the top management role, never the superuser, and does not auto-gain future resources; `owner` (`*:*`) is the break-glass superuser and the [owner-invariant](#the-owner-invariant) anchor. The same cover rule must extend to role editing when it lands (you cannot edit a role above your own tier); tracked with that slice. - **Refines:** [ADR-0010](#adr-0010-impersonation-is-a-session-not-a-credential-guarded-by-capability-cover) (reuses its capability-cover primitive on the grant path). - **Closes the gap:** issue [#109](https://github.com/hyperscaleav/omniglass/issues/109). ### ADR-0014: The audit trail is a sensitive read, not reached by a partial global wildcard - **Date:** 2026-07-07 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** Reading the audit trail requires the `audit:read` capability, and `audit` is a **sensitive resource**: a partial global wildcard (`*:`, e.g. the `viewer` role's `*:read`) does **not** confer it. Only an explicit grant on the resource (`audit:read`, held by `admin`) or the full `*:*` superuser wildcard (held by `owner`) reaches it. So the audit trail is admin/owner-only; a read-only user does not see logins, impersonations, and access changes (issue [#116](https://github.com/hyperscaleav/omniglass/issues/116)). - **Context:** The `:read` floor and the `*:read` viewer role mean "read everything," which is right for the estate but wrong for the security audit trail: exposing who impersonated whom and every access change to any read-only operator leaks security posture. Rather than gate the route with a non-read action (a hack), `rbac` gains a small **sensitive-resource** set: in `Set.Allows`, a `*` resource entry that is not `allActions` skips a sensitive resource, so `*:read` no longer matches it while `*:*` still does and an explicit `audit:read` still does. This is the narrow, honest version of the "sensitive permission" idea (distinct from the impersonate call in [ADR-0012](#adr-0012-owner-accounts-are-un-impersonatable-impersonation-stays-capability-gated-not-scope-intersected), where the `principal:*` **resource** wildcard legitimately confers `principal:impersonate`; here it is the **global** `*:read` wildcard over a sensitive **read**). The set is extensible if other sensitive reads appear (it holds only `audit` today). - **Closes the gap:** issue [#116](https://github.com/hyperscaleav/omniglass/issues/116). - **Superseded by** [ADR-0015](#adr-0015-permissions-are-topic-patterns-single-token-and-tail-wildcards): the carve-out is replaced by consistent topic-pattern matching, where `:admin` is a deeper token no partial wildcard reaches. ### ADR-0015: Permissions are topic patterns (single-token and tail wildcards) - **Date:** 2026-07-07 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** Permissions match like **NATS subjects** (which the node path already uses, so the stack shares one wildcard convention): a colon-delimited token path where a literal matches itself, **`*` matches exactly one token**, and **`>` matches one or more tokens and must be last**. A normal permission is `resource:action`; an admin-sensitive one is `resource:action:admin`. Because `*` is a single token, a two-token pattern (`*:read`, `*:*`, `principal:*`) structurally cannot match a three-token `:admin` permission: admin-sensitivity is a **deeper token**, not a special case. The whole-estate superuser is `>` (issue [#118](https://github.com/hyperscaleav/omniglass/issues/118)). - **Context:** The prior ad-hoc wildcard let a two-token `*:*` match a three-token `x:y:z`, an inconsistency: the second `*` was silently absorbing a tail. Making matching a real topic match removes every special case, the [ADR-0014](#adr-0014-the-audit-trail-is-a-sensitive-read-not-reached-by-a-partial-global-wildcard) `sensitiveResources` set is **deleted**. `viewer`'s `*:read` misses `audit:read:admin` because two tokens cannot match three; `owner` reaches it via `>`; `admin` carries `audit:read:admin` explicitly. It also fixes, for free, a boundary wart from the [grant guard](#adr-0013-a-grant-cannot-confer-capabilities-the-granter-lacks): `principal:*` is now `principal:`, so it does **not** sweep an admin-tier `principal::admin`, those stay owner-only unless granted explicitly. `Set.Allows` matches by token; `Set.Covers` (the impersonation and grant-escalation guard) becomes pattern subsumption plus the `:read` floor, staying conservative (a reach covered only by the union of several patterns returns false, deny). The only seed change is `owner`'s `*:*` becoming `>`; every other permission keeps its meaning because `*` already meant a single token. A closed grammar also makes "what does this pattern set grant" exactly enumerable against a permission **catalog** (the set of all `resource:action[:admin]` the routes declare), the basis for a future custom-role preview. - **Supersedes:** [ADR-0014](#adr-0014-the-audit-trail-is-a-sensitive-read-not-reached-by-a-partial-global-wildcard). - **Closes the gap:** issue [#118](https://github.com/hyperscaleav/omniglass/issues/118). ### ADR-0016: A principal can be purged, and the audit trail is denormalized to survive it - **Date:** 2026-07-09 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** A principal gains a full **lifecycle**: **disable** (reversible, the `active` flag), **archive** (a soft delete, `archived_at`, hidden from the directory and unable to authenticate, reversible), and **purge** (an irreversible hard delete of the row). Purge is **gated on prior archival** (archive-before-delete) and on the admin-sensitive `principal:purge:admin`, so `admin` (which carries it explicitly) and `owner` (`>`) can purge but a two-token `principal:*` cannot reach it ([ADR-0015](#adr-0015-permissions-are-topic-patterns-single-token-and-tail-wildcards)). To keep the audit trail through a hard delete, the actor's human-readable label is **denormalized** into every `audit_log` row at write time, and the audit foreign keys become `ON DELETE SET NULL`: a purge nulls the id link but the text survives, so "who did X" outlives the principal. The read side coalesces the live join to the snapshot. - **Context:** [ADR-0006](#adr-0006-a-single-owner-invariant-enforced-at-the-database)'s single-owner invariant meant accounts were **disabled, never hard-deleted**, since audit rows referenced them (`RESTRICT`). But operators need to remove accounts created by mistake, a common task, without erasing history or orphaning the trail. Denormalizing the actor label decouples the audit record from the principal row, so the row can be purged while the history stays legible; the archive gate prevents an accidental one-click hard delete, and the last-active-owner guard (extended to archive) means a purgeable account is never the last owner. This retires the "never hard-deleted" statement in the identity-access page. - **Naming:** the soft-delete verb was renamed **deactivate to archive** (and reactivate to **restore**) when the console UI landed ([#146](https://github.com/hyperscaleav/omniglass/issues/146)): "disable" and "deactivate" read as synonyms, blurring two distinct operations. The ladder is now a *suspend* (**disable**, reversible, still listed) then an *offboard* (**archive**, soft delete, hidden, recoverable) then a *destroy* (**purge**), so the labels read pause to remove to destroy, matching the industry suspend-vs-delete pair. The column, endpoints (`:archive` / `:restore`), capability (`principal:archive`), and list param (`include_archived`) all follow the verb. - **Closes:** issue [#143](https://github.com/hyperscaleav/omniglass/issues/143) (backend), [#146](https://github.com/hyperscaleav/omniglass/issues/146) (console + rename). ### ADR-0019: Every credential is time-bounded; token `purpose`, not expiry shape - **Date:** 2026-07-11 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** All credentials are time-bounded (reverses the earlier tokens-never-expire choice). A web-login session keeps a 12h absolute lifetime; CLI/API tokens and the bootstrap token get a 90-day default expiry with a `--ttl` override capped at 365 days; nothing is issued without an expiry. Sessions and API tokens are distinguished by a `credential.purpose` column, not by whether `expires_at` is set. Expiry is enforced lazily at authentication; there is no background sweep, and session/token lists show only live credentials. Deferred: a sliding idle timeout, a housekeeping sweep of long-expired rows, and nearing-expiry notifications. - **Context:** The credential-expiry slice ([#157](https://github.com/hyperscaleav/omniglass/issues/157)) bounded only the web-login session and left the CLI/API token unbounded (`expires_at` null), overloading "has an expiry" to mean "is a session". That left an eternal secret in the field, against the every-secret- rotates principle, and coupled the session-vs-token distinction to a nullable column that both kinds now populate. A dedicated `purpose` column names the concept directly, so the list and the console read the discriminator rather than inferring it, and the default 90-day / 365-day-cap window keeps a minted token usable for real automation without becoming permanent. `AuthenticateBearer` already refused a passed expiry, so enforcement needed no change: giving tokens a future expiry is enough, and the list reuses the same `expires_at is null or expires_at > now()` filter so a dead row is never shown. - **Reverses:** the tokens-never-expire behavior introduced with [#157](https://github.com/hyperscaleav/omniglass/issues/157). - **Closes:** issue [#172](https://github.com/hyperscaleav/omniglass/issues/172) (self-service sessions and the every-credential-expires model). ### ADR-0018: The avatar read endpoint is JSON, not raw image bytes - **Date:** 2026-07-10 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** A human principal's profile picture is read through a **JSON** endpoint (`GET /principals/{id}/avatar` gated `principal:read`, `GET /auth/me/avatar` on the self lane) that returns `{ image_base64 }`, which the console decodes into a `data:` URL for the ``. The write lanes take base64 JSON in (`POST /principals/{id}:setAvatar` and the `/auth/me` self lane), and the server-normalized 256x256 JPEG is stored base64 on the `human` row; the principal read models carry only a `has_avatar` bool, so no image payload rides a list or the `loadPrincipal` hot path. - **Context:** The slice design spec proposed a **raw `image/jpeg`** read endpoint (with `ETag` / `Cache-Control` / `304`) so a browser `` could load it directly. But a raw-bytes handler would be a chi-native route sitting **outside** the Huma authz middleware, breaking the two-layer invariant that a `:` capability is checked on **every** route, and a bare `` cannot send a bearer header, so a token-only (non-cookie) session could not authenticate the image. Keeping the read as a Huma JSON route puts it under the same `authn` + `require("principal","read")` (admin) or authn-only (self) path as every other route, and the typed client (session cookie or bearer, both work) fetches the JSON and builds the data URL. The one normalized size is small (roughly 30 to 50 KB base64), so per-request payload is not a concern, and HTTP caching over `avatar_updated_at` is a later refinement if it is ever needed. This supersedes the spec's raw-bytes read decision; the write transport (base64 JSON) is unchanged. ### ADR-0017: `credential` is renamed `secret`; the cascade is the reuse mechanism - **Date:** 2026-07-09 | **Status:** Accepted | **Pages:** [config, credentials, and variables](/architecture/variables/) - **Decision:** The access-secret member of the [config / credential / variable](/architecture/variables/) trio is renamed **credential to secret**, and its first slice is built: a typed, encrypted-at-rest value owned on the exclusive arc (`global | location | system | component`) and resolved most-specific-wins down the [cascade](/architecture/cascade/). A secret is an **encapsulated typed cell** (a `secret_type` shape with per-field secrecy and origin), not a bag of references: the reuse a tool like Windmill gets from variable references, **the cascade already provides here** (define once at a broad scope, inherit it below), so composition solves a non-problem. Interpolation references live at the **consumption site** (`$sec:name.path` in an interface input or a function arg), never inside a secret's own fields. Crypto is **envelope AES-256-GCM** behind a pluggable KEK provider (env / file / fallback), the value sealed under a per-value DEK wrapped by the KEK, with `(owner, name, field)` bound as AAD; the provider seam lets a KMS or Vault drop in without a model change. "credential" is retained for the **authentication** credential (a principal's bearer or password), a distinct resource; only the collection-side access secret is renamed. - **Context:** The written [variables](/architecture/variables/) page named this member `credential` and left it `Design`. Building it surfaced two calls. First, **naming**: "credential" collided with the identity credential and undersold the general case (an `snmp_community`, an API key, an `oauth2` blob are all just sensitive cascaded values); "secret" is the Cloudflare-style vars-and-secrets pair and reads correctly. Second, **shape**: Windmill's resource-references-variables split was considered and rejected, because our cascade is the sharing mechanism and an atomic one-form typed cell (doctrine 4) suits an operator better than composing references. Reveal (plaintext decrypt) ships as an audited, `secret:reveal`-gated endpoint that the `*:read` floor does not reach, so only admin and owner may decrypt; the interpolation consumer (splicing a value into a live request) is deferred to the collection-driver slice that first needs it. This reverses the `credential` naming and any "references inside the value" reading on the page; the `variable` and `config` members stay `Design`. - **Closes:** issue [#155](https://github.com/hyperscaleav/omniglass/issues/155) (secret slice 1). ### ADR-0020: `variable` slice 1 types inline and mirrors the secret arc - **Date:** 2026-07-11 | **Status:** Accepted | **Pages:** [config, secrets, and variables](/architecture/variables/) - **Decision:** The **variable** member of the trio ships its first slice: a typed, cascade-resolved **plaintext** value owned on the exclusive arc and resolved most-specific-wins down the [cascade](/architecture/cascade/), with a Variables directory and a per-component effective-variables panel, mirroring the [secret](#adr-0017-credential-is-renamed-secret-the-cascade-is-the-reuse-mechanism) member minus crypto, masking, and the reveal. `variable:create,update` is granted to **operators** (delete stays admin and owner), the same split secret got. Three parts of the written design are deferred to keep the slice one vertical cut. First, **typing is inline**: a `value_type` enum (`string | int | float | bool | json`) on the row plus a jsonb `value` validated against it in a pure `internal/variable` package, **not** a `variable_type` shape registry. A scalar needs no governed vocabulary, and the page itself calls variables the "operator-defined, not curated" member, so a registry would contradict the model. Second, the **`template` owner scope** (the design's `global -> template -> instance`) is out: slice 1 mirrors the secret arc (`global | location | system | component`), and template scope plus cascade groups land together in [#184](https://github.com/hyperscaleav/omniglass/issues/184), because they touch the shared resolver once for both members. Third, the **`$var:` consumer** and the **secret-flagged** variable are deferred (the consumer has no live interpolation site yet, as with `$sec:`). - **Context:** The written [variables](/architecture/variables/) page sketched a `variable_type` registry and a shared config/variable cell carrying `observed_value` and `reconcile`. Building the member showed those belong to **config** (the declared-vs-observed member), not the free macro: a variable has no observed side. So `variable` shipped as its own single table, typed inline, and the page's Storage section is corrected to match. This diverges from the page's `variable_type`-registry and shared-cell sketch; the `config` member stays `Design`. - **Closes:** issue [#183](https://github.com/hyperscaleav/omniglass/issues/183) (variable slice 1). ### ADR-0021: `tag` slice 1, a governed key registry with entity-update-gated bindings - **Date:** 2026-07-12 | **Status:** Accepted | **Pages:** [tags](/architecture/tags/), [config, secrets, and variables](/architecture/variables/) - **Decision:** The **tag** primitive ships its first slice on its own [tags](/architecture/tags/) page: the governed **`tag`** key vocabulary, the per-entity **`tag_binding`** value cell owned on the exclusive arc (`global | location | system | component`), and a resolver that unions keys and overrides values most-specific-wins down the [cascade](/architecture/cascade/). Two permissions, not one: **minting a key** is a tenant-wide governance action gated by an all-scope **`tag:create`** (broadened to `tag:*` for admin, covering update and delete of keys), while **setting a value** is the owner's ordinary write (`component:update` and friends), so an operator who may edit an entity may tag it with no new grant; a global binding, having no owning entity, is gated by `tag:update`. A key carries **`applies_to`** (an entity-kind allow-list, empty = universal, checked on bind) and **`propagates`** (a flag that toggles cascade inheritance versus a flat per-entity set, the shape a [file](/architecture/files/) will reuse). Key names are validated as lowercase identifiers in a pure `internal/tag` package, keeping the vocabulary normalized. Four parts of the written design are deferred to keep the slice one vertical cut. First, the **operator console surface** (a Tags directory and a per-entity tag editor) is out; the slice ships over the API and the generated CLI, matching the files-first ordering the estate chose. Second, binding through **[groups](/architecture/groups/)** and a **`template`**-scoped default are out, landing with the shared-resolver work in [#184](https://github.com/hyperscaleav/omniglass/issues/184) that the variable member also waits on. Third, **value-domain governance** (a key constraining or normalizing its values) stays the page's open question; slice 1 ships free-text values. Fourth, binding a tag onto a **[file](/architecture/files/)** waits on the files primitive. - **Context:** The tag design lived inside the [config, secrets, and variables](/architecture/variables/) page as the fourth cascade user. Building it earned tags a page of its own, because its **governance model is distinct**: unlike a variable (one free value, one `variable:*` permission), a tag splits a curated key vocabulary (admin-minted) from routine value binding (operator-open via the entity's own write), and it resolves with a **union-on-key** combinator rather than a single value. The exclusive-arc scope and the cascade walk are shared with the variable and secret resolvers; the combinator and the two-permission split are what make it its own primitive. This diverges from the variables page's single-table sketch (the binding is its own `tag_binding` cell) and its "bindable via groups" note (deferred); the variables page's tag section now frames the shared cascade and points at the tags page. - **Closes:** issue [#188](https://github.com/hyperscaleav/omniglass/issues/188) (tag slice 1). The deferrals are filed: the console surface [#189](https://github.com/hyperscaleav/omniglass/issues/189), value-domain governance [#190](https://github.com/hyperscaleav/omniglass/issues/190), and binding onto a file [#191](https://github.com/hyperscaleav/omniglass/issues/191); groups and template scope ride [#184](https://github.com/hyperscaleav/omniglass/issues/184). ### ADR-0022: effective tags resolve onto systems and locations; a placed system inherits its location - **Date:** 2026-07-13 | **Status:** Accepted | **Pages:** [tags](/architecture/tags/) - **Decision:** The directory **Tags column** shows a row's **effective** (resolved-cascade) tags, not its direct bindings, so the list routes (`GET /components`, `/systems`, `/locations`) carry an **`effective_tags`** map (key to winning value, winners only) per row, resolved for the whole page in **one batched query per kind** (`Gateway.EffectiveTags(kind, ownerIDs)`, three per-kind recursive-CTE resolvers that thread a target id through the ancestor chains and rank per `(target, key)`). This required **defining effective tags for systems and locations**, which previously only components resolved: a **location** resolves `global` plus its own location tree; a **system** resolves `global`, its own system tree, **and the location it is placed at** (its `location_id` tree). A placed system therefore inherits its location's tags (a system in a PCI building surfaces `compliance: pci`), consistent with how a component picks up its own `location_id`. A component is unchanged (the full four-band arc). The resolver is **scopeless by contract**: the list query has already filtered the ids to the caller's read scope, so the batch adds no per-id check, matching the existing `rowActions` batch. Winners only in the column; provenance (which scope a value came from) stays in the per-entity effective-tags detail view. - **Context:** The tag-apply UI needs each directory row to show what tags actually apply to it. The cheaper option was to embed a row's **direct** bindings (a flat, non-recursive `where owner_id = any($1)` lookup); the architect chose **effective** so the column reflects inherited values, not just locally-set ones. That choice moved real work to the backend (a batched recursive cascade versus a flat index scan) and forced the systems-and-locations effective definition, whose one genuine call was whether a **system inherits its location**: yes, because a system carries a `location_id` exactly as a component does, so treating it as placement-that-inherits is the consistent reading. The added cost is a small bounded per-row recursion over the shallow estate trees, one round-trip, and is capped by the directory page size. This is the first (backend) slice of the tag-apply UI; the Tags column, the type-to-add editor, and tag search consume it in later slices. - **Closes:** issue [#201](https://github.com/hyperscaleav/omniglass/issues/201) (batch effective-tags resolver); part of [#189](https://github.com/hyperscaleav/omniglass/issues/189). ### ADR-0023: the IAM directory reads (principal, role, principal_group) are admin-tier - **Date:** 2026-07-13 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/) - **Decision:** The **read** (list and get) of `principal`, `role`, and `principal_group` moves from a two-token `:read` to the admin-sensitive **`:read:admin`**, so the `viewer` read floor (`*:read`) no longer reaches the Users, Roles, and Groups directories. `admin` carries an explicit `principal:read:admin`, `role:read:admin`, and `principal_group:read:admin` alongside its `:*` wildcards, the same shape as the existing `principal:purge:admin`; `owner`'s `>` is unaffected. Create, update, and the lifecycle verbs stay two-token: they were never reachable by a non-admin, so only the directory read needed promoting. The console gates the three Settings tabs on the same three-token permission and the route guard reads it from the shared nav map, so the sidebar and the server never diverge. - **Context:** `deploy` (an integrator or field tech) inherits `viewer`, whose `*:read` is a single-token resource wildcard. Because `*` matches exactly one token, `*:read` matched `principal:read`/`role:read`/`principal_group:read`, and the read floor shares that reach, so a field tech could enumerate every user, role, and group over the API (a real 200, not just a visible menu). Promoting the directory reads reuses [ADR-0015](/architecture/decisions/#adr-0015-permissions-are-topic-patterns-single-token-and-tail-wildcards)'s deeper-token rule rather than adding a matcher special case: admin-sensitivity is a third token `*` cannot reach. Secrets are a separate concern (an operator legitimately reads device secrets in scope), handled by a forthcoming slice that combines placement scope with a per-secret admin-sensitive flag; this ADR is the IAM directories only. - **Closes:** issue [#197](https://github.com/hyperscaleav/omniglass/issues/197). ### ADR-0024: a tag key may constrain its values to an enum - **Date:** 2026-07-13 | **Status:** Accepted | **Pages:** [tags](/architecture/tags/) - **Decision:** A tag key gains an **`allowed_values`** set (a new `text[]` column, empty by default). Empty leaves the key **free-text**, unchanged; a non-empty set is the **enum** a bound value must belong to, so `environment` can be declared as one of `prod`, `staging`, `dev`. The **binding write enforces it**: `SetTagBinding` rejects a value outside a key's non-empty allowed set with a dedicated 422 (`ErrTagValueNotAllowed`), so the constraint is a real server gate, not a UI hint. The Tags directory create and edit forms carry a value-domain control (a checkbox that turns the key into an enum plus a value-list editor), and the TagAdder value stage renders a **strict dropdown** for an enum key. A **free** key instead offers **value autocomplete from the distinct values already bound** for it, through a new `GET /tags/{name}:values` read (a `select distinct value`), so an operator reaches for an existing value without the key having to declare a set up front. Only the enum (a string set) ships; a typed `value_type` (int, bool, date) and input normalization (lowercase, trim, fold) stay the page's open question. - **Context:** The [tags](/architecture/tags/) page left value-domain governance an open question, with the enum, a typed value_type, and normalization all on the table. Operators asked first for the plain case, a key like `environment` that should only ever be one of a short list, so that shipped: a string enum on the key, enforced on write, with a strict picker. The distinct-in-use autocomplete is the free-key counterpart, cheap (one `select distinct`) and immediately useful, so the two ship together. This resolves the enum half of the page's open question; the value_type and normalization halves remain deferred. - **Closes:** issue [#190](https://github.com/hyperscaleav/omniglass/issues/190) (tag value-domain governance, enum). ### ADR-0025: `secret` is a sensitive resource; a per-secret `admin_sensitive` flag flips a secret to the `:admin` tier - **Date:** 2026-07-13 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/), [variables](/architecture/variables/) - **Decision:** Two orthogonal axes now decide who reaches a secret. **Placement scope** (the `global`/`location`/ `system`/`component` entity a secret attaches to on the exclusive arc) gives locality, unchanged. A new per-secret **`admin_sensitive` flag** gives same-scope sensitivity: when set, every action on that secret is lifted to the **`:admin` tier**, so a scoped two-token grant (`secret:reveal`) cannot reach it and only `admin` (`secret:>`) or `owner` (`>`) may see, reveal, update, delete, or create it. The flag defaults from the secret's `secret_type` (`secret_type.default_admin_sensitive`: an SNMP community defaults operational, an OAuth2 client secret defaults admin-sensitive) and the row's own value is authoritative; the column default is `true` (a secret is admin-only until marked operational). Enforcement is a capability flag computed at the API (`canAdmin` = the caller holds `secret::admin`) and passed to the Storage Gateway alongside scope: the gateway hides admin-sensitive rows from a lister/resolver without it, and returns a **non-disclosing 404** (not a 403) to a revealer/updater/deleter without it, so a platform credential's existence and field names are not disclosed through the read, reveal, list, or cascade paths. (One residual: because a secret name is unique per owner, an operator with create scope at the same owner can distinguish a create-collision 409 from a 201, a narrow existence-and-name oracle, no field values. It predates this slice, since operators already held `secret:create` without `secret:read`; closing it needs a namespace or create-path change and is a tracked follow-up, not a value-disclosure path.) Separately, `secret` joins a **sensitive-resource set** that a bare single-token `*` does not reach, in both places `*` grants read (the direct topic match and the read floor); `>` (owner), a literal `secret:read`, and a `secret:*` still name it. So `viewer` (only `*:read`) reads no secrets at all (not the directory, not the per-component effective-secrets cascade), `operator`/`deploy` gain a scoped `secret:read,reveal,create,update` and see and reveal the operational secrets in their subtree, and `admin`'s `secret:*` becomes `secret:>` so it reaches the admin tier. The `/secrets` directory, previously all-scope-only, is now scope-filtered. The client `can()` mirrors both the sensitive-set and the `:read` floor so the console hides exactly what the server denies. - **Context:** A field tech setting up a site must create and read back that site's **device** secrets (an SNMP community, a device login), but the **platform integration** credentials (a Zoom or Microsoft client secret the collection engine consumes) must never be revealed below admin. A device secret and a platform credential can sit at the **same** scope (both global), so placement alone cannot separate them, and a low/medium/high sensitivity ladder was rejected as arbitrary and hard-fixed to three tiers. A per-secret binary flag reusing [ADR-0015](/architecture/decisions/#adr-0015-permissions-are-topic-patterns-single-token-and-tail-wildcards)'s third-token `:admin` rule expresses the real distinction without a new matcher concept. Taking `secret` off the bare `*` wildcard (rather than promoting `secret:read` wholesale to `:admin`, which would deny operators their device secrets) is the one lever that keeps the two-token `secret:read` operators legitimately hold while stopping `viewer`'s `*:read` from reaching it. Negative grants (deny-after-allow) were rejected as a footgun the `:admin` tier and the sensitive-set already cover. This is Slice B of the same visibility rework as [ADR-0023](/architecture/decisions/#adr-0023-the-iam-directory-reads-principal-role-principal_group-are-admin-tier); the IAM directories use the `:admin` tier (no legitimate sub-admin reader) and are not in the sensitive-set, `variable` stays viewer-visible by decision and is not in the set. The move of Secrets, Variables, and Config out of Settings into Catalog is a separate branch, not this slice. - **Closes:** issue [#210](https://github.com/hyperscaleav/omniglass/issues/210). ### ADR-0026: Console nav IA: estate values get their own top-level group; the Settings group becomes Admin - **Date:** 2026-07-13 | **Status:** Accepted | **Pages:** [ui](/architecture/ui/) - **Decision:** The operator console left nav is reorganized around five genera: Catalog (the reusable, estate-agnostic model), Inventory (the estate instances: locations, systems, components, and nodes), Values (the operator-set values resolved down the scope cascade: variables, secrets, config), the observed surfaces (Explore, Alarms, Dashboards, Learn), and platform Admin. Secrets, Variables, and Config are values operators set on estate entities, so they move from the Settings menu into a **Values** group of their own, standing beside Inventory rather than nested inside it as a band. Config's meaning is fixed as the **CI store**: operator-set desired component and system configuration, optionally observed back from the device to detect drift and reconcile, distinct from platform Settings and from Variables. Inventory gains **Nodes** (the collection daemons, a monitored, scope-controlled entity, ungated "soon" until `node:read` lands) alongside Locations, Systems, and Components; Interfaces and Tasks are dropped from the nav entirely, since an interface is a facet of a component and a task a facet of a node, not a directory of their own. The Settings group is renamed **Admin** (Users, Roles, Groups, Audit) and gains an ungated "soon" Settings leaf that reserves the platform-settings-table page. - **Context:** Settings had become a junk drawer mixing platform governance, platform config, and estate-attached values. Those three values attach to a single estate entity on the scope cascade (the same genus as a tag assignment) but are not estate entities themselves, so they earned a home of their own, not Settings, not Catalog, and not a band folded inside Inventory. This **supersedes** the "into Catalog" line of ADR-0025 above: the earlier same-day plan named Catalog, and the decision is a dedicated Values group. Interfaces and Nodes were first sketched as Inventory children alongside the estate entities; Nodes stayed (a node is monitored and scope-controlled exactly like a location, system, or component, so it belongs with them, not under Admin), but Interfaces and the Tasks a node runs were cut from the nav once it was clear each is a facet of one owning entity's detail page (a component's device endpoints, a node's collection assignments), not a set an operator browses on its own. The relaxed whole-group-drop (an ungated Settings "soon" stub keeps the Admin group visible to a viewer, showing only that greyed placeholder while every data-bearing child stays admin-gated and hidden) is deliberate until the platform-settings backend ships and the leaf is gated on `setting:read:admin`. Design: `docs/superpowers/specs/2026-07-13-operator-console-nav-ia-design.md`. - **Closes:** issue [#222](https://github.com/hyperscaleav/omniglass/issues/222). - **Update (2026-07-14):** **Files** joins the **Values** group. The files slice ([ADR-0029](#adr-0029-files-slice-1-a-content-addressed-blob-store-and-a-tenant-wide-file-handle)) first shipped the Files directory under Inventory, but a file is not part of the monitored estate (no health, not polled): it is operator-uploaded **content**. So the Values group broadens from "operator-set values resolved down the cascade" to **operator-set values and content**, with the (deliberately non-cascading, flat) file as its content member alongside the cascaded variables, secrets, and config ([#249](https://github.com/hyperscaleav/omniglass/issues/249)). ### ADR-0027: create is a route; inventory create and edit unify on the detail accordion - **Date:** 2026-07-14 | **Status:** Accepted | **Pages:** [design system](/contributing/design-system/), [core entities](/architecture/core-entities/) - **Decision:** The inventory entities (component, system, location) drop the create/edit **Drawer**. Creating one is now a **route**: `New` navigates to `//create`, a **draft accordion** where Identity and Placement are writable and the binding sections (Tags, and later Secrets/Variables) are shown locked until the entity exists; **Save** commits the row and hands off to `//` in **edit mode** (a one-shot pending-edit flag consumed when the detail resolves, the Users `openPrincipalInEdit` pattern). The detail is one accordion, **read-only in view and the sole writer in edit**: no in-body field or binding mutation control renders while not editing (the footer's Edit / Delete chrome and the read-only effective-secrets/variables panels are exempt). This is the Users inline-blade-edit model generalised to inventory, and it holds on **both** the docked blade and the addressable full page. No new routes: the static `/create` outranks `/:name` in the router, so `create` is a reserved segment. The shared `TreeList` primitive gains a per-surface **edit slot on `ListCtx`** (the full page makes its own slot, since the shared `renderDetail` must not call `useBladeEdit` outside a blade provider), plus `renderCreate` / `onNew` / `onEdit` hooks and an optional `FormBody`, so a page opts into the model without breaking the others. - **Context:** Creating an inventory entity returned you to the list, so setting a tag meant find, reopen, edit; and `TagAdder` rendered a write control in view. A drawer that opened in edit after create would need a fragile cross-surface hand-off (the code-grounded review of the drawer design surfaced a full-page `useBladeEdit` crash, a `FormBody` footer collision, and a pending-edit gap). Framing create as its own URL dissolved the "create is blade-only" false dilemma: a draft with an address is deep-linkable full-page and dockable as a blade, and Save is a route hand-off, not a surface hop. Own-field edits commit on Save (Cancel reverts them); tag bindings keep their immediate per-binding write, so Cancel does not roll a tag back, and the tag control sits apart from the Save/Cancel form. Slice 2 (a shared cross-page form shell) and slice 3 (moving Users onto it) are deferred. - **Closes:** issue [#231](https://github.com/hyperscaleav/omniglass/issues/231). ### ADR-0028: `rank` retired from the type registries; sort is alphabetical - **Date:** 2026-07-14 | **Status:** Accepted | **Pages:** [core-entities](/architecture/core-entities/), [Types guide](/guides/admin/types/) - **Decision:** `rank` is dropped from `location_type`, `system_type`, and `component_type`: the column (a new idempotent migration), the three API bodies and create/update inputs, the boot-seed YAMLs, the generated client and CLI, and the Types catalog page (no Rank column, no Rank field on create or edit). `ListLocationTypes`, `ListSystemTypes`, and `ListComponentTypes` now order by `display_name, id` instead. - **Context:** `rank` was sort-only from the start (the location_type seed comment already said so: "rank does NOT constrain nesting"), never an enforcement mechanism. The upcoming `allowed_parent_types` placement constraint on `location_type` needed a clean field to introduce without a stale, unused ordering column sitting beside it, so retiring `rank` is the mechanical precursor to that slice rather than part of it: this PR only removes the field and switches the sort, `allowed_parent_types` is a separate slice. Alphabetical is the obvious default with no enforcement semantics to preserve; an operator who wants a specific browse order can still rely on the id or display name they chose. - **Closes:** part of issue [#239](https://github.com/hyperscaleav/omniglass/issues/239) (the `allowed_parent_types` half continues in a follow-up PR against the same issue). Design: `docs/superpowers/specs/2026-07-14-type-placement-constraints-design.md`. ### ADR-0029: files slice 1, a content-addressed blob store and a tenant-wide file handle - **Date:** 2026-07-14 | **Status:** Accepted | **Pages:** [files and blobs](/architecture/files/), [storage](/architecture/storage/), [identity and access](/architecture/identity-access/) - **Decision:** The files subsystem ships its first slice: a content-addressed **`blob`** store as a Storage Gateway primitive (a `blob.Store` seam, default **pgblobs** backend holding bytes inline, keyed by the sha256 of the bytes, dedup via `on conflict do nothing`, integrity-verified on read), and a **`file`** handle, searchable metadata (name, content_type, size, sha256, sensitive) that points at a blob by hash, with CRUD over the API, the generated CLI, and the typed client, plus the Files directory (under Values; see the [ADR-0026 update](#adr-0026-console-nav-ia-estate-values-get-their-own-top-level-group-the-settings-group-becomes-admin)). Four calls shape it. **(1) No placement arc on a file.** A file is tenant-wide, not on the exclusive arc a secret sits on, because a file relates **1:many** (to entities and types) rather than 1:1; that locality is a future many-to-many **attachment**, not an owner column, so the gateway injects no ABAC tree scope on a file query. (This reverses an in-design proposal to give `file` a secret-style owner scope.) **(2) Sensitivity reuses the secret mechanism, binary, defaulting off.** A per-file `sensitive` flag reuses [ADR-0025](#adr-0025-secret-is-a-sensitive-resource-a-per-secret-admin_sensitive-flag-flips-a-secret-to-the-admin-tier)'s `:admin`-tier rule (hidden from a lister without the tier, a non-disclosing 404 to a reader without it, admin-only to create), but defaults **false** (a file is shared unless marked, where a secret defaults sensitive because it is a credential), and `file` is **not** added to the sensitive-resource set, so the viewer floor (`*:read`) reads ordinary files. **(3) A delete frees its blob synchronously; async GC deferred.** `DeleteFile` drops the handle and, in the same transaction, frees the blob **when no other handle references it** (a dedup-aware refcount: a deleted file reclaims its bytes rather than leaking storage, but a blob shared by another handle is kept). The general async mark-sweep GC (for blobs referenced by other things, an aged large log body, a `collection.failed` raw, an attach event, none of which exist yet) stays a later slice; today a `file` is the only referencer, so the synchronous check is complete. **(4) One backend, base64-in-JSON on the wire.** Only the pgblobs backend ships (S3 and disk behind the same seam later); upload and download carry the bytes **base64 in JSON**, reusing the avatar precedent ([ADR-0018](#adr-0018-the-avatar-read-endpoint-is-json-not-raw-image-bytes)) so the whole surface stays under the Huma authz middleware and generates a uniform client and CLI. content_type lives on the **file**, not the blob: content-addressing is about the bytes, so identical bytes are one blob regardless of declared type. - **Context:** [files.md](/architecture/files/) specified the two-layer model (handle plus content-addressed blob) and an index-probe GC; its open questions (inline-versus-blob threshold, chunking, the grace floor) are untouched here. The **1:many** insight is what separated a file's *locality* (attachment, deferred) from its *access* (permission plus sensitivity), and is why the file does not copy the secret owner arc. A full **classification + clearance** lattice (an ordered ladder on the resource, a clearance on the principal, an external-principal class) was considered for the sensitive axis and split into [its own epic (#243)](https://github.com/hyperscaleav/omniglass/issues/243) rather than inflating this slice; the binary flag is a 2-rung subset it will subsume. Multipart streaming for very large blobs is deferred with the S3/chunking slice. - **Divergences logged:** files.md moved `content_type` from the blob to the file; the in-design file owner/scope arc was dropped (a file is off the placement arc). Both are reflected in the page. - **Lands:** [epic #242](https://github.com/hyperscaleav/omniglass/issues/242), [#244](https://github.com/hyperscaleav/omniglass/issues/244). ### ADR-0030: `allowed_parent_types` constrains where a location may be placed - **Date:** 2026-07-14 | **Status:** Accepted | **Pages:** [core-entities](/architecture/core-entities/), [Types guide](/guides/admin/types/), [Work with an entity](/guides/operator/entities/) - **Decision:** `location_type` gains `allowed_parent_types` (`text[]`, default `{}`): a set whose members are `location_type` ids and/or the reserved `root` sentinel (a placement at the top, no parent). An empty set is unconstrained (the default, and every existing custom type until an operator opts in); a non-empty set is enforced: a placement is valid iff the parent is null and the set contains `root`, or the parent location's type is in the set. `root` cannot collide with a real type id: `CreateLocationType` refuses it. Enforcement is forward-only, on `CreateLocation` and the location move path (`UpdateLocation`'s new `ParentName` patch field, added this slice so the "grandfathered until moved" guarantee is real and testable, not merely a claim); an existing placement a type's set no longer allows is untouched until something tries to move it. The four seeded types get their sets: `campus={root}`, `building={root,campus}`, `floor={building,campus}`, `room={floor,building,campus}`. Re-parent ships operator-usable this slice: the location edit form's Placement section makes Parent editable, a picker built on #240's inventory edit model (the same `Show when={editing()}` field/fact split every other editable field on the accordion uses), narrowed to the set and excluding the location's own subtree; moving back to root is not offered (the move primitive does not support it this slice). - **Context:** `rank` ([ADR-0028](/architecture/decisions/#adr-0028-rank-retired-from-the-type-registries-sort-is-alphabetical)) was sort-only and never expressed the estate's real hierarchy rule (a floor does not belong above a room). A `child.level > parent.level` rule was rejected: it does not generalize past locations (systems and components have no total order), while a type-level allowed-parent set expresses both the general "may skip a level" case and the specific "may never be root" or "may never nest under this particular type" cases with one field. A separate `root_placeable` boolean was rejected in favor of folding root into the set as a sentinel, keeping one field and one validation path. Enforcing retroactively was rejected: seeding a type's set must never invalidate an existing estate. Locations had no move or re-parent capability at all before this slice (create-time placement only); the storage/API primitive was originally scoped without a UI trigger (the console's placement fields render read-only in every edit context today, on all three inventory pages), but the decision changed once #240's create-as-route edit model landed as the concrete field pattern to hang a reparent picker off: one PR ships the enforcement point and a real way to use it, rather than a primitive an operator cannot reach. The picker's candidate list is narrowed client-side (a UX nicety); the server-side `validatePlacement` call is the actual gate, so a stale or bypassed client filter still gets an inline 422, not a silently-accepted violation. One divergence from the design surfaced while building the move primitive: `UpdateLocation` checks placement before the cycle guard, not after, so a move that is simultaneously a placement violation and a structural cycle (moving a location under its own descendant, where the descendant's type also does not allow this child) reports the `PlacementError` (422, naming both types) rather than the generic `ErrLocationCycle`; the design left the check order unstated, and the more specific, actionable error was chosen to win. Systems and components lose `rank` too but get no `allowed_parent_types` this slice, and keep their existing read-only-in-edit Parent field: a leaf or must-nest constraint there is closer to a boolean than an ordered set, deferred until a concrete need names the shape, and extending the same editable-Parent pattern to two more pages is a follow-up, not bundled here. - **Closes:** issue [#239](https://github.com/hyperscaleav/omniglass/issues/239). Design: `docs/superpowers/specs/2026-07-14-type-placement-constraints-design.md`. ### ADR-0036: A node is a kind=node principal with an interim bearer credential and static per-connection NATS subject permissions - **Date:** 2026-07-07 | **Status:** Accepted | **Pages:** [nodes](/architecture/nodes/), [identity and access](/architecture/identity-access/) - **Decision:** A node is a first-class `principal` of `kind='node'` with a 1:1 `node` detail table (keyed by `principal_id`, alongside `human` and `service`), exactly as [identity and access](/architecture/identity-access/) describes. Its `name` is `not null unique` on the detail table and stays the estate address the collection FKs (`interface.node_name`, `task.node_name`, `metric_datapoint.node_id`) reference. The node runtime ships with two deliberate calls that diverge from the present-tense design, both reversible in a later hardening slice. (1) The node's credential is a **bearer `credential` row** on its principal, minted, stored (only as `sha256`), and verified through the **same helpers a service bearer token uses** (`AuthenticateBearer`), and the enrollment token **doubles as the node's NATS password** (a shared secret), rather than being a single-use bootstrap exchanged for a distinct long-lived credential. The decentralized **nkey/JWT operator-account** model that identity and access describes for nodes (a `nats` credential kind, a signed nonce, a JWT carrying the node's subject permissions) is deferred; the `credential` kind CHECK is **not** widened for it here. (2) Per-node NATS isolation is **static per-connection subject permissions**: the embedded `nats-server` runs an in-process `CustomClientAuthentication` callback that resolves each connecting node by name, verifies its bearer credential, and registers a user whose publish/subscribe grants are scoped to that node's own `og.v1.*.` subjects, so a node cannot publish or pull as another. - **Context:** Checkpoint 2 of the reachability slice needed a real, negatively-tested per-node isolation mechanic against an embedded server, without carrying the full JWT/nkey machinery a single slice should not. The auth-callback path adds per-node users **dynamically at enrollment time with no config reload**, which is the simplest mechanism that keeps the isolation invariant real: the negative test proves node A cannot use node B's subjects (and a confused-deputy reply cannot forge another node's liveness), and a wrong credential is rejected at connect. The subject encodes the node name in its last token and the callback grants only that node's subjects, so the subject **is** the transport isolation boundary (the payload-owner admission fence is a later checkpoint). Modeling the node as a `kind=node` principal (rather than the standalone table an earlier checkpoint built) puts it on the shared identity spine from the start: it has a real `principal_id` so it can be an audit actor, its credential rides the audited human/service machinery, and only the credential *scheme* (interim bearer vs nkey/JWT) remains to tighten. JetStream is enabled on the server now (it boots and shuts down cleanly), but the control-plane messages (worklist, heartbeat) are JSON over core NATS; the protobuf telemetry `Event` over JetStream is the next checkpoint. - **Closes the gap:** the nkey/JWT node identity (the `nats` credential kind and the signed-nonce admission) and the single-use enrollment token are tracked with the node-identity hardening slice. ### ADR-0037: Telemetry is a protobuf Event over JetStream with an inline owner-confining consumer - **Date:** 2026-07-07 | **Status:** Accepted | **Pages:** [collection](/architecture/collection/), [datapoints](/architecture/datapoints/) - **Decision:** A node ships each collected batch as a protobuf `Event` (proto3, `proto/og/v1/event.proto`, `Event` + `Datapoint` messages only, no gRPC service) published to `og.v1.telemetry.`. This is omniglass's first protobuf; the wire is generated with `protoc` + `protoc-gen-go` via a `gen-proto` stage on `make gen`, and the generated `event.pb.go` is committed. The server hosts a JetStream stream (`OG_TELEMETRY` over `og.v1.telemetry.*`) and a **single durable consumer** (`og-telemetry-worker`, AckExplicit) whose handler, per Event, **derives and writes inline**: it decodes the batch, resolves the owner as the task's interface component, **confines** the node to its own tasks, applies reject-not-project against the `datapoint_type` registry, writes the surviving typed rows through the checkpoint-1 `InsertMetricDatapoints` path (`owner_kind=component`, `provenance=observed`), and acks. A permanent condition (an undecodable payload, or an orphan the confinement fence drops) is terminated/acked so it is not redelivered; only a transient failure (a DB error) is left unacked so JetStream redelivers. **The node stamps no component identity**: its only assertion is the publishing subject (its own name) plus the `task_id`; the server binds and confines. - **Context:** The prior (v2) design split telemetry into a hot path that persisted a raw event to a `telemetry` table and an async Postgres queue worker that derived from it. Checkpoint 3 deliberately **collapses that split**: the JetStream durable consumer **is** the at-least-once worklist, so there is no raw-telemetry table and no Postgres queue in this checkpoint; the handler derives, confines, writes, and acks in one place. This keeps the reachability slice small while keeping the two invariants **real and negatively tested**: a node cannot land a datapoint for a component it holds no task for (an Event carrying another node's `task_id` is orphan-dropped, no row written), and an unregistered datapoint name is dropped, not projected. Owner binding is the **interface-prebind path only** (task -> interface -> component); there is no separately-authored `transform_rule` (omniglass has none), so label-based multi-owner routing, discovery rules, and node-self binding are a later checkpoint. - **Closes the gap:** raw-`Event` persistence (backfill/replay) and the raw -> admission -> trusted two-lane topology, plus label-based multi-owner resolution, are tracked with a later collection checkpoint. ### ADR-0038: The reachability verdict is a built-in state - **Date:** 2026-07-07 | **Status:** Accepted | **Pages:** [datapoints](/architecture/datapoints/), [collection](/architecture/collection/) - **Decision:** The per-interface reachability verdict `interface.reachable` (value domain `up` / `down`) is a first-class **state** datapoint, not a metric, seeded as an official `datapoint_type` at `kind=state`, `value_type=text`, `validation: {values:[up,down]}`. It is gated **per interface**: the verdict is the AND of that interface's applicable probe results (for the inline tcp/icmp interfaces this is degenerate, one probe drives the verdict; it generalizes to an interface with several probes). The **node** computes it after running the interface's probe(s) and emits it as an `observed` state datapoint instanced by the interface; the ingest consumer **routes by the registry kind** (a metric name to `metric_datapoint`, a state name to `state_datapoint`) after the same owner-confinement and reject-not-project, so a foreign or unregistered state is dropped identically to a metric. The series is **transition-only**: the node remembers the last verdict per interface and emits only on a flip or first observation, and the ingest side re-guards by skipping a write whose value equals the latest stored value (the net for a node restart). Availability is `time_in_state` over this state (health's primitive one tier down), a later slice; the raw probe metrics (`tcp.open`, `icmp.reachable`, the rtts) keep emitting unchanged. Readiness config (an ssh command + regex, an snmp OID) is an **interface-type default, interface-overridable** concern executed **on the node**, not a server-side `calc_rule`; 5a builds no readiness-config column, its verdict is the inline probe result. - **Context:** Reachability history is only honest if the verdict is a **dwell-measurable** signal: availability is time-in-state, which needs a categorical state with transitions, not a numeric sample per tick. Modelling the verdict as a metric would conflate the raw per-probe reading (`tcp.open`, a firehose sample) with the interface-level judgement (an availability substrate), and it would make `time_in_state` a re-derivation over a numeric series rather than a read over the state's own transitions. Making it a state, and computing it at the node as the AND of the interface's probes, keeps the verdict where the probe results are, keeps the raw metrics untouched, and lets the read side reconstruct the availability strip directly from `state_datapoint`. - **Divergence:** checkpoint 1 seeded the `datapoint_type` canon **metric-only** (the reachability probe metrics), and cp3's ingest consumer assumed every surviving datapoint was a metric (`InsertMetricDatapoints` for all). This entry records the divergence: 5a adds the first **state** to the seed and makes the ingest consumer route by kind (the cp3-deferred "route by kind, not assume metric" note now come due). The `state_datapoint` table mirrors `metric_datapoint` (same owner exclusive-arc, same lineage CHECK) with a categorical `value text` plus an optional `value_json`. - **Closes the gap:** the availability SLI (`time_in_state` over `interface.reachable`) and the operator surfaces that render the transitions are a later slice (5b); readiness config as an interface-type default is a later interface-type concern. ### ADR-0039: An interface is a device API; the interface type is its transport, not its driver - **Date:** 2026-07-08 | **Status:** Accepted | **Pages:** [collection](/architecture/collection/), [nodes](/architecture/nodes/) - **Decision:** An `interface` is an **API endpoint we intend to call** on a component, identified by the **protocol it speaks** (`web`, `qrc`, `ttp`, `snmp`), not a network interface; a host or IP is a variable it consumes, not its identity. It is named by that protocol and is unique within its component (`unique(component, name)`), never a hand-typed label. Two axes are **decoupled**: the **transport** (how bytes move) and the **driver** (the protocol handler that produces the normalized functions and datapoints). `interface_type` is the **transport** (`ssh`, `tcp`, `http`, `snmp`, `udp`, `telnet`, `icmp`): a node-side wire capability that also carries the default **reachability** probe (tcp/ssh/http open the port, icmp pings). Reachability is the **first gate of a ladder** (reach to auth to responds to collecting) and needs only the transport. A **driver** is the **collect** layer: a protocol handler plus the transport(s) it can run over plus the normalized catalog (functions and datapoints, how to fetch them as commands/OIDs/paths, parse, a version). The same handler can run over several transports (a CLI over `ssh` or `telnet`), so the driver declares its transports and the instance picks one; a genuinely different grammar over a different transport (an ssh CLI vs a tcp JSON-RPC) is a **different driver** producing the same catalog. Device-specific fetch detail lives in the driver, never the template: `snmp` is the transport, a `biamp-snmp` (or `generic-snmp`) **driver** holds the OID map. The entities then split on **CAN / SHOULD / IS**: the **driver** owns what a device family CAN do and how (transports, catalog, normalization, discovery rules, version); a **template** (per model) owns what an operator SHOULD watch and how it looks (curate the driver's menu to a default subset, thresholds and event rules, an icon); the `interface` instance owns what IS actually there (transport, host, credentials, a driver when it collects, the discovered subset, per-device overrides). Discovery is a driver rule whose **result lands on the instance**; filtering-for-choice is a template default plus an instance override; capability is the driver. The reusable driver is **data on one generic engine** (a declarative `canonical datapoint <- fetch <- parse`), official or org-custom via the `(namespace, id)` shadow registry, with a pluggable-Go escape hatch only for a wire the engine cannot express; a "device pack" bundles a driver plus a template, and a template **declares its driver deps** (version-pinned) so a missing or shadowed driver surfaces, never silently misbinds. The house `` / `_type` pattern holds: `interface_type` is the transport (a reachability interface's type genuinely is its transport), and `driver` earns its own registry (SNMP and multi-transport protocols prove it folds into neither transport nor template). - **Context:** The 5a build named interfaces by a hand-typed string (`boardroom-tcp`) with `type` = the probe (tcp/icmp), which conflated identity with transport and implied operators name and wire-configure devices by hand. The reframe: operators are not programmers, so the value is a **driver that normalizes a device family into a pick-from menu**, which makes the template a light curation, policy, and presentation layer and means the operator never authors a protocol. You cannot cleanly split "how you talk" from "what you say" (the command is both), so the seam is elsewhere: the **transport** is the reusable connection, the **driver** is the reusable normalized menu over it, and the **template** is a selection plus policy. Keeping the driver as data (not Go per family) is what makes it community-shippable; growing the canonical menu device-by-device (not a universal ontology up front) is what keeps it honest; separating menu-of-types from discovered-instances is what fits programmable devices (a DSP's blocks are per-install); versioning the driver is what lets a template's picks resolve as the menu matures. - **Scope now (tier-0):** this slice (#114) ships only the first gate. `interface_type` is the transport primitive (`icmp`, `tcp`, `ssh`, `http` seeded `built`), each carrying a tcp-connect or ping reachability probe; an `interface` is named by its protocol and typed by its transport; the dev seed models a lab **polaris DSP** with a `web` (http) and a `qrc` (tcp) interface, the "two APIs on one device" story. The driver catalog, normalization, discovery, templates, versioning, and the shadow-resolved device pack are later slices of the [collection epic](https://github.com/hyperscaleav/omniglass/issues/113) (slices 2 to 4 realize this model). - **Refines:** [ADR-0038](#adr-0038-the-reachability-verdict-is-a-built-in-state) (the reachability verdict is the first rung of the gate ladder this ADR names). - **Status note (2026-07-08):** the `interface = API` / `interface_type = transport` half is **built and stable** (this slice). The **driver / collect layer** (the separate `driver` entity, the normalized menu, and the driver-centric split itself) is **under active design**: it departs from the original template-centric architecture (where protocol handling lived in the template), which is a serious enough change to redesign deliberately rather than on momentum. Recorded here as the current-best direction, **not a locked gate**; driver-centric vs template-centric is re-examined, and this ADR revised or superseded, in a later ADR before the collect layer is built. ### ADR-0040: The task is derived read-only plumbing, projected from its interface - **Date:** 2026-07-14 | **Status:** Accepted | **Pages:** [collection](/architecture/collection/), [api](/architecture/api/) - **Decision:** The `interface` is the **only authored** collection primitive; the `task` is **derived**. Creating an interface **derives its one poll task**, so the task surface is read-only (`GET /tasks`, `GET /tasks/{id}` only): the `POST` / `PATCH` / `DELETE /tasks` routes and the `task:create` / `task:update` grants are removed. A task carries **no node column**; `task.node_name` is dropped and its placement is **projected** from `interface.node_name`, so the worklist and the telemetry owner-confinement join the interface rather than reading a task-local node. A **node purge cascades** its interfaces and their derived tasks (`interface.node_name` and `task.interface_id` are `ON DELETE CASCADE`). - **Context:** The checkpoint-5d build gave both primitives a full CRUD surface and a node placement of their own. That let an operator author a task divorced from its interface, and left a task's node and its interface's node as two independently-set fields that could disagree. The reframe makes the interface the one thing an operator authors (an API on a component, [ADR-0039](#adr-0039-an-interface-is-a-device-api-the-interface-type-is-its-transport-not-its-driver)): a reachability check is an interface, its poll task is the plumbing that runs it, and placement is a property of where the interface is reached from, stated once. This is the honest shape for the reach tier; the richer driver-authored collection surface (multiple functions over one interface) is a later slice and does not reintroduce operator task CRUD. - **Refines:** [ADR-0039](#adr-0039-an-interface-is-a-device-api-the-interface-type-is-its-transport-not-its-driver) (the interface is the authored API; this ADR settles that its task is derived, not co-authored). ### ADR-0031: `component_make` registry slice 1, an `official` boolean, a deferred referential guard, and website scheme validation - **Date:** 2026-07-14 | **Status:** Accepted | **Pages:** [core entities](/architecture/core-entities/), [Vendors guide](/guides/admin/vendors/) - **Decision:** Three calls on the first slice of the `component_make` manufacturer registry (id, display_name, icon, support_phone, website), lands ahead of the rest of the make/model catalog. **(1) `official boolean`, not an `origin` enum.** The design sketch (below) proposed `origin official | seed | custom` on make and model, matching the model layer's eventual needs. Slice 1 ships a plain `official` boolean instead, because `component_type` and the other registries already distinguish seed-owned from operator rows with a boolean, and a two-value distinction gains nothing from a three-value enum until a real `seed` (installed, mutable) tier exists to fill it; `origin` can still land on `component_model` if that tier turns out to be real. **(2) The in-use / referential delete guard is deferred.** `component_type`, `location_type`, and `system_type` all refuse a delete while a location, system, or component still references the row (409). `component_make` ships **no equivalent guard**: nothing references a `component_make` yet (`component_model`, the referencing entity, does not exist), so a custom make deletes unconditionally (an official row is still refused, 422, the seed-owned rule). The guard is added when `component_model` lands and gives the registry something to be in-use by, rather than building an unused check now. **(3) Website URL scheme validation, client and server.** The create/edit form renders `website` as a live anchor; an operator-entered value with no scheme check is a stored-XSS vector (`javascript:`/`data:` executing on click). A `validWebsiteScheme` guard on the API (`http`/`https` only, empty allowed, else 422) and a matching `safeUrl` guard on the console (render a live link only when safe, else plain text, never a dead or unsafe anchor) close it in both places: server-side so a non-browser caller (CLI/curl) cannot persist a dangerous scheme, client-side so a value written before the server-side check existed (or by any path that bypassed it) still renders safely. - **Context:** `docs/superpowers/specs/2026-07-14-component-make-model-catalog-design.md` sketches the full make/model catalog (`component_make`, a `component_type` genus tree, `component_model`, and `component.model_id`) as four independent vertical slices; this is slice 1, make alone, with no dependency on the tree or the model layer. A review pass on the first cut of the console page (Task 4) found the missing website-scheme check as a stored-XSS gap before this shipped, closed in the same slice rather than carried as a follow-up. - **Divergences logged:** the design sketch's `origin official | seed | custom` enum is not what shipped; `official boolean` did, per (1) above. The design's delete-refused-while-referenced rule is not enforced yet; per (2), it is deferred to the `component_model` slice that gives it something to check. - **Lands:** [epic #254](https://github.com/hyperscaleav/omniglass/issues/254), issue [#255](https://github.com/hyperscaleav/omniglass/issues/255). Design: `docs/superpowers/specs/2026-07-14-component-make-model-catalog-design.md`. Plan: `docs/superpowers/plans/2026-07-14-component-make-registry.md`. ### ADR-0032: the required permission is published per route, and the permission universe is route-derived - **Date:** 2026-07-17 | **Status:** Accepted | **Pages:** [identity and access](/architecture/identity-access/), [API](/architecture/api/), [Access guide](/guides/admin/access/) - **Decision:** Every capability-gated route registers through one helper, `gated(op, tokens...)`, which sets the `authn` + `require` middleware (unchanged enforcement), stamps the operation with an `x-omniglass-permission` OpenAPI extension, and records the permission in an in-process registry. The required permission for each request is therefore **published in the generated `api/openapi.json`**, and the **permission universe** (the deduped set of every stamp) is **derived from the routes**, not a hand-kept catalog. `GET /roles` reports the universe plus, per role, the **held** subset (resolved by the same `rbac.Set.Allows` matcher as the effective set), and the console role blade renders it as a net `Held / Missing / All` view. Two build-time guards keep it honest: a **published-gate guard** (every gated route is stamped, allow-listed routes are not, so "gated" and "published" are the same set) and a **seed-drift guard** (every seed-role grant resolves into the universe or sits in an explicit `aheadOfRoutes` allow-list). - **Context:** the authz contract already existed (`require(...)` enforced a permission on every route) but lived only in Go middleware, invisible to the spec, the clients, and any reader; and a role blade could show only what a role granted, never the capabilities it lacked. Three options were weighed for the universe source: a hand-kept catalog YAML (drifts), a runtime-only set (invisible in diffs), and the route-derived stamp (self-maintaining, reviewable in the committed spec, exactly the enforced surface). The stamp won because it makes the universe fall out of the API-first pipeline with no second source to drift. Held is resolved server-side so the single rbac matcher is not duplicated in the SPA. Grants that resolve to nothing (for example `alarm:*`, `interface:*` before those subsystems have HTTP surfaces) are legitimate but ahead of their routes; they show as held-nothing and are allow-listed until the route lands. - **Lands:** issue [#272](https://github.com/hyperscaleav/omniglass/issues/272) under epic [#27](https://github.com/hyperscaleav/omniglass/issues/27). Design: `docs/superpowers/specs/2026-07-17-net-permissions-role-blade-design.md`. ### ADR-0033: settings persist only the override level; base layers are recomputed in memory - **Date:** 2026-07-17 | **Status:** Accepted | **Pages:** [settings](/architecture/settings/), [scaling and deployment](/architecture/scaling/) - **Decision:** The settings engine stores **only the override level** in Postgres (`setting_override`). The two base layers, the embedded `code` defaults and the operator `file`, are **recomputed into memory on every boot** and never written to the table, so the effective document is the in-memory base layers merged with the live DB override. Restore is therefore a `DELETE`: dropping a namespace's override row (or truncating the scope) re-exposes the base defaults, with no separate reset column and no re-seed of the file into the store. - **Context:** The [scaling](/architecture/scaling/) page sketched a single settings store "materialized in Postgres ... seeded declaratively from a settings file reconciled on every boot" (an `ON CONFLICT DO UPDATE` of the file into the table). Building the engine showed that materializing the file into the DB is the wrong shape: it duplicates the GitOps source into a second authoritative copy that can drift, and it conflates ship-with defaults (a compile-time asset) with operator changes (the only thing worth persisting). Keeping the base layers in memory makes the file always-fresh (a ConfigMap change lands on restart), keeps the store lean (it holds only what an operator actually changed), and makes restore fall out of the model as a delete rather than a re-seed. This diverges from the scaling page's "materialized in Postgres" wording; the settings page carries the corrected model and the scaling page moves to `Partial`. - **Closes:** issue [#271](https://github.com/hyperscaleav/omniglass/issues/271) (settings engine slice-0), under epic [#270](https://github.com/hyperscaleav/omniglass/issues/270). Design: `docs/superpowers/specs/2026-07-17-settings-engine-design.md`. - **Amended by [ADR-0057](#adr-0057-the-cascades-least-specific-tier-is-platform-and-a-default-is-not-a-tier):** the model is unchanged, the level names are not: `code` is now `default` (off the axis, the setting's own declaration) and `global` is now `platform` (the install-wide rung). ### ADR-0034: the settings Gateway is unscoped; only the permission gates it - **Date:** 2026-07-17 | **Status:** Accepted | **Pages:** [settings](/architecture/settings/), [storage](/architecture/storage/), [identity and access](/architecture/identity-access/) - **Decision:** The Storage Gateway methods for settings (`GetSettingOverrides`, `UpsertSettingOverride`, `DeleteSettingOverride`, `DeleteAllSettingOverrides`) are **unscoped**: no ABAC storage-scope predicate is injected. Only the `settings:` permission at the route gates them (`settings:read` admin read with provenance, `settings:update` write / restore / lock, both admin-tier; the client-safe `/settings/me` is authn-only). This is a deliberate carve-out from the "scope on every applicable query" invariant, recorded so it reads as intentional. - **Context:** The two authorization layers ([identity and access](/architecture/identity-access/)) are a `:` permission on every route and an ABAC **scope** injected on every **applicable** query. Platform and cascade settings describe the **platform and its principals**, not the estate, so there is no location / system / component subtree to scope them by, exactly as with the registry-type reads (`GET /types/...`), which are also unscoped. Forcing a scope predicate here would be meaningless (there is nothing to filter on) and would misrepresent settings as estate data. The carve-out is narrow: it applies only because the data is platform config. When the group and user override rungs land, override reads and writes **will** be constrained by the acting principal (a user edits only their own `user` row), but that is a per-principal ownership check, a different mechanism than estate ABAC, not a return of tree scope. - **Closes:** issue [#271](https://github.com/hyperscaleav/omniglass/issues/271) (settings engine slice-0). - **Amended by [ADR-0057](#adr-0057-the-cascades-least-specific-tier-is-platform-and-a-default-is-not-a-tier):** the model is unchanged, the level names are not: `code` is now `default` (off the axis, the setting's own declaration) and `global` is now `platform` (the install-wide rung). ### ADR-0035: settings resolve as a cascade over principals with a broader-wins lock - **Date:** 2026-07-17 | **Status:** Accepted | **Pages:** [settings](/architecture/settings/), [cascade](/architecture/cascade/) - **Decision:** A setting's effective value resolves down the **principal** hierarchy (global to group to user), reusing the same [cascade](/architecture/cascade/) primitive the estate uses down location to system to component: ordered layers deep-merged in JSON map-space (most-specific-wins by key presence), with per-key **provenance** (the winning level) reported alongside the value. Layered on top is a **top-down lock**: an admin locks a key at a level, pinning that level's value and forbidding any more-specific level from overriding it, and when two levels lock the same key the **broader level wins** (a `global` lock supersedes a `group` lock, so top-down admin authority is absolute). Slice-0 ships the global rung; group and user are a fast-follow. - **Context:** Omniglass already had one cascade resolver (the estate's secrets / variables / tags / config, [config and credentials](/architecture/variables/)). Rather than write a second resolver for settings, the engine points the same primitive at the identity axis (doctrine 5, primitive-first): a value defined once at a broad scope inherits below, which is exactly the reuse a variable-reference model (Windmill-style) would buy, provided here by inheritance for free. The **lock** is the piece the estate cascade did not need: settings are governance (an admin enforcing an org default a user cannot escape), so the engine adds a per-key lock with a broader-wins conflict rule, the inverse of the most-specific-wins value rule, applied to the enforcement axis. Provenance reuses the estate's effective-values vocabulary (the winning level per key), extended from three estate bands to five principal levels plus a lock chip. The pure `settings` package is the primary unit-test target; the DB override is supplied through a narrow function seam so the package never imports storage. - **Closes:** issue [#271](https://github.com/hyperscaleav/omniglass/issues/271) (settings engine slice-0), under epic [#270](https://github.com/hyperscaleav/omniglass/issues/270). - **Amended by [ADR-0057](#adr-0057-the-cascades-least-specific-tier-is-platform-and-a-default-is-not-a-tier):** the model is unchanged, the level names are not: `code` is now `default` (off the axis, the setting's own declaration) and `global` is now `platform` (the install-wide rung). ### ADR-0036: retire the standalone effective-secrets and effective-variables per-component panels; fields become the component value surface - **Date:** 2026-07-16 | **Status:** Accepted | **Pages:** [config, secrets, and variables](/architecture/variables/), [identity and access](/architecture/identity-access/), [API](/architecture/api/) - **Decision:** The standalone per-component **Effective secrets** and **Effective variables** panels are removed, along with their `GET /components/{name}/effective-secrets` and `GET /components/{name}/effective-variables` routes (and the generated `omniglass effective-secret list` / `effective-variable list` commands and the matching typed-client methods). A component's value surface is the **field** primitive: a component's values are its **fields**, each resolving override-versus-type-default and shown in the **Effective fields** panel. A secret or a variable reaches a component by being **sourced into a field** (the deferred field `sources` model) or **bound to a collection interface input**, not through a per-component cascade-browse panel. **Kept** unchanged: the storage cascade **resolvers** (`ResolveSecrets` / `ResolveVariables`) as the internal primitive the future `$sec:` / `$var:` interpolation consumer will call, and the **Secrets** and **Variables** directories (browse, create, edit, reveal) with all their routes and CLI. - **Context:** The per-component effective-* panels predated the field primitive and listed **every** cascade-resolving cell that reached a component, which at any real depth is mostly inherited noise (a global SNMP community, a location poll interval) rather than anything set on that component. The [field](/architecture/variables/#property-one-typed-name-a-product-contract-a-stored-value) primitive ([#266](https://github.com/hyperscaleav/omniglass/issues/266)) is the schema-over-cells consumer the design always intended: a component carries a typed set of fields, each resolving to a set literal or its type default, and the intended `sources` model lets a field draw its value from a variable, a secret, a datapoint, or a file. Once fields are the value surface, a second per-component cascade browser over the raw cells is redundant and misleading (it reads as though the cells attach to the component when they only resolve onto it). Retiring the panels narrows the component detail to its fields and keeps the cells' own management on the Secrets and Variables directories, where the cascade is authored. The resolvers stay because the interpolation consumer (`$sec:` / `$var:`) still needs them; only the browse-panel surface retires. - **Closes:** issue [#281](https://github.com/hyperscaleav/omniglass/issues/281) (retire the per-component effective-secrets / effective-variables panels), under the field epic [#266](https://github.com/hyperscaleav/omniglass/issues/266). ### ADR-0041: settings are a reflected typed struct with generated client and server validation - **Date:** 2026-07-19 | **Status:** Accepted | **Pages:** [settings](/architecture/settings/) - **Decision:** A setting is declared **once**, as a tagged field on a canonical `Settings` Go struct (`internal/settings/schema.go`), and that single declaration is the whole source of truth. Reflection over the struct produces the `code` defaults layer (`Defaults()`, from each leaf's `default:` tag) and the namespace registry (`Namespaces()`, from the `json` and `settings:` tags), replacing the hand-kept embedded `defaults.yaml` and the hand-kept `Namespaces()` slice (both retired). Huma reflects the struct into the OpenAPI schema, so `make gen` yields the typed SPA client `values` (a `Settings` struct, not a free-form object). Writes validate against that **same reflected schema** on both sides: the server backstops `PATCH /settings/{namespace}` (unknown namespace to 404, unknown key / wrong type / `enum` or `pattern` violation to a 422 naming the `namespace.key`, `null` allowed as a delete), and a `make gen` step slices the field constraints out of `api/openapi.json` into a committed client artifact (`web/src/api/settings.schema.gen.ts`) that drives inline form validation (enum-as-select, Save blocked while invalid). The cascade merges partial generic maps as before; typing lives only at the edges (the effective read unmarshals into `Settings`, and Go code reads a setting through the `EffectiveTyped` accessor). - **Context:** Slice-0 shipped the engine with **untyped** values: a setting lived in two hand-kept places (the `Namespaces()` slice and the embedded `defaults.yaml`), the API exposed `values` as a free-form object, the generated client typed it as `Record`, and the PATCH write accepted any namespace, key, or value and stored it as-is (the documented write-validation thin cut). That is the one surface that dodged doctrine 1 (API-first, typed, generated). Making `Settings` a reflected struct pulls the default, the schema, the typed client, both validators, and the typed accessor from a single declaration, so adding a setting is one tagged field and there is no second place to drift. The cascade keeps merging partial maps because a Go struct cannot express "unset" versus a zero value; typing is applied only at the edges. This **closes the slice-0 write-validation thin cut** and retires the `defaults.yaml` asset and the `Namespaces()` list. - **Deferred:** the declarative operator-file machinery (a generated JSONSchema for the operator `settings.json`, validation of the **file** layer at boot, and letting the file layer take precedence over the database, the GitOps-wins / read-only lever) is a future slice on the same epic, as are operator-open namespaces (a typed map with a `Default()` method) and the group and user cascade rungs; none is built here. - **Closes:** issue [#288](https://github.com/hyperscaleav/omniglass/issues/288) (settings engine slice-1), under epic [#270](https://github.com/hyperscaleav/omniglass/issues/270). ### ADR-0042: Field cascade and the type-default floor - **Date:** 2026-07-19 | **Status:** Accepted | **Pages:** [config, secrets, and variables](/architecture/variables/) - **Decision:** A field's **resolved value** is **deepest-set-wins** down the field arc `product -> location -> system -> component`: a value set at any scope beats every broader scope. When **nothing is set at any scope**, the value falls to the field's **type default**. The type default is therefore the **floor** of the cascade, not a competitor to it: a value set at any higher scope always beats the default, and no cascade rule is bent to make that true. Raised during design as "does a value set higher in the cascade beat the type default?", the answer is **yes**, and it costs the model nothing, because the default is simply the bottom rung. - **Context:** The override-rendering slice needed the resolution rule pinned before the renderer could say what "inherited" means. Modelling the type default as a **competing scope** would force an ordering question at every read (does a `location` value or the type default win?); modelling it as the **floor** removes the question: any set value at any scope wins, and the default is what remains when the arc is empty. This slice is **component-only**: resolved = this component's set value, else the type default, so the deeper arc (`product`, `location`, `system`) is drawn in the model but not yet walked. The multi-scope cascade itself lands later. - **Closes the gap:** the multi-scope cascade is tracked by [#291](https://github.com/hyperscaleav/omniglass/issues/291); this ADR settles only the resolution rule and the type-default floor. - **Amended by [ADR-0057](#adr-0057-the-cascades-least-specific-tier-is-platform-and-a-default-is-not-a-tier):** the resolution outcome is unchanged (any value set at any scope beats the default, and the default is what remains when the arc is empty), but the default is **off the axis**, a column on the definition row, rather than the cascade's bottom rung. The vocabulary moved; the rule did not. ### ADR-0043: The property catalog - **Date:** 2026-07-19 | **Status:** Accepted | **Pages:** [config, secrets, and variables](/architecture/variables/) - **Decision:** The `datapoint_type` catalog is generalized into a primitive-agnostic **`property`** catalog: one typed catalog whose entry (a **property**) is a canonical, typed name that a datapoint **observes** and a field **declares**, identified by a **`key`** (its canonical name). The physical table is `property`; the concept, the API resource (`/properties`), the Go `Property` type, and the console all read `property`, while a property's identifier (its `key`, and the `field_definition.key` reference) stays `key`. Four shape changes fold `datapoint_type` in: the unused `(scope, name)` ladder (`org`/`template` never had an operator write path, `template_id` was a dangling column) collapses to a `name` primary key plus an **`official`** boolean (seed-owned rows are read-only); `value_type` becomes **`data_type`** over the unified set `{string, int, float, bool, json}` (`text` backfills to `string`, `bool` is added); **`kind`** (metric/state/log) becomes nullable, since a declared-only attribute property has no observed kind; and **`validation`** is a **JSON Schema** fragment (`pattern`/`enum`/`minimum`/ `maximum` and, for a json-typed property, a nested object schema), enforced by Huma's own validator with the stored schema loaded through `yaml.v3`, so there is **no new dependency**. Value and source tables continue to key by the **name string** (no foreign key, reject-not-project at ingest exactly as before), so the rename is behavior-preserving: the collection registry, the reachability BFF, and the metric/state sinks keep working unchanged. - **Context:** Datapoints already had a typed canonical-key catalog (`datapoint_type`: name, value_type, display_name, unit, validation, kind), while fields, variables, secrets, and tags had no catalog at all, an operator typed a key and it was registered nowhere. Rather than build a parallel table, this slice makes the one catalog primitive-agnostic so `serial_number` is the same concept whether a device reports it (observed, a datapoint) or an operator types it (declared, a field). The `official` boolean chassis is chosen over finishing `datapoint_type`'s never-built scope-shadow precedence: it is the proven, finished model the `*_type` registries already use. - **Values reference the key, not a `_key` layer:** the only binding is the type-schema (`field_definition` gaining a `key` reference so a field draws from the catalog), which is **PR-B**, not this slice. Provenance rides the value (an observed metric versus a declared field value share the key), so reconciliation of declared-versus-observed sources needs no middle table; it is deferred. - **Deferred:** the `field_definition.key` reference (PR-B); the type-schema editor (how a `component_type` selects properties); reconciliation (the declared-versus-observed drift signal); a console editor for the validation JSON Schema (set via the API for now); variables/secrets/commands/tags adopting a `key` reference; and an operator shadow of an official property. - **Closes:** issue [#297](https://github.com/hyperscaleav/omniglass/issues/297) (the field catalog, expanded to the property catalog), under epic [#266](https://github.com/hyperscaleav/omniglass/issues/266). ### ADR-0044: The component classification catalogs - **Date:** 2026-07-20 | **Status:** Accepted | **Pages:** [core entities](/architecture/core-entities/) - **Decision:** The [`component_make`](#adr-0031-component_make-registry-slice-1-an-official-boolean-a-deferred-referential-guard-and-website-scheme-validation) catalog is generalized into a **`vendor`** catalog carrying a **`kind`** (`manufacturer` / `integrator` / `developer`), so the one organization that makes, integrates, or writes for a component is a single reference entity rather than a make-only registry. Two new **leaf** catalogs join it as the rest of the component-classification reference data: a **`driver`** (`id`, `display_name`, `version`, the software that speaks to a component) and a **`capability`** (`id`, `display_name`, a thing a component can do). Each of the three is a **gated CRUD Catalog console page** (`/vendors`, `/drivers`, `/capabilities`), reusing the `official`-boolean chassis the type and property registries already use: seed-owned official rows are read-only (an official row refuses update and delete, 422), a custom row is full CRUD gated by the resource's `:create` / `:update` / `:delete` permission and audited in the same transaction. The official rows are seeded at boot. This is a pure classification slice: **`product`** (the specific model an organization sells), the **`product_capability`** link, and the **`component.product`** pointer that binds a component to its product are the **next slice**, not this one. - **Context:** The estate model is shifting from the make/model catalog sketch toward a fuller classification vocabulary: **property / event / command** on the signal side (property landed in [ADR-0043](#adr-0043-the-property-catalog)) and **vendor / product / driver / capability / standard / role / health** on the component side. This is **PR2** of that shift. `component_make` was manufacturer-only, but the same organization concept covers an integrator who installs the estate and a developer who writes a component's software, so generalizing make into a `kind`-tagged vendor is the honest widening rather than three parallel organization registries. Driver and capability are leaf catalogs (no tree, no cross-references yet), so they ship as the plain seeded-plus-CRUD pattern the registries already prove; they gain their bindings (a driver to an interface / product, a capability to a product) when the product slice gives them something to reference. - **Deferred:** `product`, `product_capability`, and `component.product` (the next slice); a referential delete guard on vendor / driver / capability (nothing references them yet, exactly as `component_make` shipped with no guard until `component_model` was to land); and an operator shadow of an official row. - **Refines:** [ADR-0031](#adr-0031-component_make-registry-slice-1-an-official-boolean-a-deferred-referential-guard-and-website-scheme-validation) (the `component_make` registry is renamed and generalized to `vendor` with a `kind`; its `official`-boolean, deferred-delete-guard, and website-scheme-validation calls carry over unchanged). ### ADR-0045: The product catalog - **Date:** 2026-07-20 | **Status:** Accepted | **Pages:** [core entities](/architecture/core-entities/), [Products guide](/guides/admin/products/) - **Decision:** **`product`** is a first-class catalog entity, the concrete **SKU** (a Cisco Room Bar, a Samsung QM55) that ties the [ADR-0044](#adr-0044-the-component-classification-catalogs) leaf catalogs together. A product carries a stable `id` and `display_name`, a **`kind`** from a fixed enum (`device` / `app` / `service` / `vm`, default `device`, enforced by a DB CHECK and at the API edge), an optional **`vendor_id`** (who makes it) and **`driver_id`** (what talks to it), an optional **`parent_product_id`** (a self-reference: a variant points at its base product), and the `official` boolean the type and classification registries already use. The **capabilities** a product provides are a many-to-many set in the **`product_capability`** join (a video bar provides microphone, speaker, camera, codec); setting capabilities on an update replaces the whole set. It is a gated CRUD Catalog console page (`/products`) on the same chassis as the leaf catalogs: seed-owned official rows read-only (update and delete 422), custom rows full CRUD gated by `product:create` / `:update` / `:delete` and audited in the same transaction, official rows seeded at boot. Crucially, **`component.product_id`** (`on delete restrict`) now points a component at the product it **is**: the product is the source of a component's shape (its vendor, driver, and capability set), **replacing the `component_type`-as-shape notion**. The restrict FK is the referential guard the leaf catalogs deferred: a product still referenced by a component cannot be deleted (409). The vendor, driver, and parent FKs are `on delete set null` instead (deleting a vendor nulls a product's pointer, it does not block). - **Context:** The estate model is shifting toward property / event / command on the signal side and vendor / product / driver / capability / standard / role / health on the component side. [ADR-0044](#adr-0044-the-component-classification-catalogs) landed vendor, driver, and capability as leaf catalogs with nothing to reference them; **product** is **PR3**, the layer they were built for and the first consumer of all three. A component's shape used to be a job for a `component_type` genus; binding a component to a product instead makes the shape data-driven from the SKU (the same product supplies the same vendor, driver, and capabilities to every component that is one), which is why `component.product` is a `restrict` FK, not `set null`: a product in use is load-bearing for its components. - **Deferred:** a product's own template or field-schema binding; and the remaining component-side catalogs (standard, role, health). - **Supersedes:** the `component_type`-as-shape notion (a component's shape now comes from its `product`, not its genus type). **Consumes** [ADR-0044](#adr-0044-the-component-classification-catalogs) (product is the `product` layer that ADR deferred; it references vendor, driver, and capability). One divergence from the leaf catalogs' prediction: their deferred delete guard lands only as the `component.product` restrict (409), while a product's own vendor / driver references null out (`on delete set null`) rather than blocking the referenced row's delete. ### ADR-0046: The `event` log-kind sink - **Date:** 2026-07-20 | **Status:** Accepted | **Pages:** [core entities](/architecture/core-entities/), [datapoints](/architecture/datapoints/), [data collection](/architecture/collection/), [API](/architecture/api/), [Nodes and reachability guide](/guides/operator/collection/) - **Decision:** A collected **log**-kind observation now has a durable home. A new **`event`** table is the **log-kind sink** of the collection pipeline, the counterpart of `metric_datapoint` / `state_datapoint`: where a datapoint records a **sampled present value**, an `event` records a **past occurrence** (a device log line, a structured frame). It carries the **same datapoint owner exclusive-arc** (`owner_kind` plus `component_id` / `system_id` / `location_id` / `node_id`, one-set CHECK) and the **same provenance** vocabulary (`observed` / `calculated` / `intended` / `declared`, default `observed`) as the datapoint sinks, plus a **`message`** (text) and structured **`attributes`** (jsonb). The ingest consumer's `deriveDatapoints` now returns metrics, states, **and** events, and the persistence path calls **`InsertEvents`**: a **log**-kind datapoint that used to be **dropped** at ingest (it had no sink) is routed to `event`, riding `string_value` (its message) or `json_value` (its attributes), under the **same** owner-confinement and reject-not-project gates as the metric and state sinks. A boot-seed property **`syslog.line`** (kind `log`) is the canonical log-kind starter. The reserved **`event_id`** columns on `metric_datapoint` and `state_datapoint` are closed into **real foreign keys** to `event(id)` (`on delete set null`), so an **intended**-provenance datapoint references the `event` that produced it. Storage adds `InsertEvents` (batch, in-tx, provenance `observed`) and `ListComponentEvents(name, since, limit)` (newest first); the read route **`GET /components/{name}/events`** (operationId `list-component-events`, gated `component:read`, non-disclosing 404 out of scope) returns the last 24 hours, capped at 200, and is the log-kind mirror of the reachability read. The console component detail page gains an **Events** panel over it. - **Context:** The estate model is shifting toward property / event / command on the signal side; property landed in [ADR-0043](#adr-0043-the-property-catalog). Log was already a first-class datapoint **kind** in the registry, but the ingest consumer had **no sink** for it: a log-kind datapoint was silently dropped after the metric/state route split ([ADR-0038](#adr-0038-the-reachability-verdict-is-a-built-in-state)), a checkpoint gap rather than a design choice. This is the **P1 follow-up** of the estate-model roadmap: give the log kind a durable home so the third sink flows like the other two, and close the `event_id` stubs the datapoint tables reserved for exactly this. Reusing the datapoint owner-arc and provenance (not a bespoke shape) keeps a log occurrence owned, addressed, and traced identically to the values beside it, the primitive-first move. - **Deferred:** the **`datapoint` -> `sample`** rename (a naming cleanup that lands in a later slice, so the datapoint tables keep their current names here); **`property_value`** and the materialized current-value store (the [fold-fields slice](/architecture/datapoints/#reads-current-value-is-a-view)); the normalized **event_type** registry and the **promotion** of a raw `event` occurrence into a registered event ([events](/architecture/events/)); a scope-wide `event` read (this ships the per-component read only); and any `calculated` / `intended` / `declared` event producer (the write path is `observed` collection only). - **Supersedes:** the checkpoint behavior where a **log**-kind datapoint had no sink and was **dropped** at ingest (recorded in [ADR-0038](#adr-0038-the-reachability-verdict-is-a-built-in-state)); the log kind now persists to `event`. **Divergence from [datapoints](/architecture/datapoints/):** that page's present-tense design routes the log kind to a **`log_datapoint`** table and treats `event` as a strictly normalized, `event_type`-registered occurrence promoted from a raw line. The built log sink is the **`event`** table directly (the raw occurrence lands there, not in a separate `log_datapoint` table), and the `log_datapoint` table plus the promotion ladder stay `Design`; the pages carry an inline note pointing here until the two models are reconciled in the fold-fields / rename cleanup. ### ADR-0047: The fields fold: `product_property` and `property_value` - **Date:** 2026-07-21 | **Status:** Accepted | **Pages:** [core entities](/architecture/core-entities/), [config, secrets, and variables](/architecture/variables/), [API](/architecture/api/), [Properties guide](/guides/admin/properties/), [Products guide](/guides/admin/products/) - **Decision:** The standalone **fields** feature is **retired** and folded into the estate model, because a **field was never a primitive**: it was a **property with `declared` provenance**, and the same is true of **config** (a property with `intended` provenance). Two tables replace it. **`product_property`** is the product's declared-property **contract**: `(product_id -> product, property_name -> property, default_value jsonb, required bool)`, unique per `(product, property)`, so what a product's instances expose is data on the SKU rather than a catalog hung off a genus type. `data_type` and `validation` are **not duplicated** here; they stay in the [`property` catalog](#adr-0043-the-property-catalog), the single source. **`property_value`** is the value store: it carries the **same owner exclusive-arc** as `metric_datapoint` and [`event`](#adr-0046-the-event-log-kind-sink) (`owner_kind` plus `component_id` / `system_id` / `location_id` / `node_id`, one-set CHECK), plus `property_name`, an `instance` discriminator, a **`provenance`** (`observed` / `calculated` / `intended` / `declared`, default `declared`), and a jsonb `value`. Its series key is `unique nulls not distinct`, since the arc leaves three owner columns NULL and Postgres's default NULLS DISTINCT would let duplicate rows through. This slice writes only `owner_kind=component` with `provenance=declared`; the rest of the arc and the other three provenances are the seats later producers sit in. The resolver is **`EffectiveProperties(component, scope)`**, one SQL UNION of two arms: the **contract arm** (every `product_property` of the component's product, value = `coalesce(the component's declared value, the contract default)`, `from_contract=true`) and the **ad-hoc arm** (declared values the contract does not declare, `from_contract=false`), so a **productless component still resolves**, to its ad-hoc set alone. Six routes carry it: `GET /products/{id}/properties` and `PUT` / `DELETE /products/{id}/properties/{property}` (gated `product:read` / `:update` / `:delete`, an official product read-only 422), and `GET /components/{name}/properties` plus `PUT` / `DELETE /components/{name}/properties/{property}` (gated `component:read` / `:update`, ABAC-scoped with a non-disclosing 404 out of scope, audited). The console renames the operator word from **Fields** to **Properties**: a **Properties** panel on the component detail (contract rows, plus a dashed-bordered **off contract** group for the ad-hoc ones, an override toggle with an accent dot, a required property blocking Save) and a **Declared properties** contract editor on the product detail (declare, edit, withdraw, read-only for an official product). Retired with the feature: **`field_value`**, **`field_definition`**, **`component.component_type`**, and the **`component_type`** table itself with its routes (`/types/component`), its console registry section, and its seed. A component's shape now comes from its **product** ([ADR-0045](#adr-0045-the-product-catalog)), still optional: a productless component simply has no contract, and the category `component_type` used to carry (display, codec) is expressed by the **capabilities** that product provides. The seeded products ship a starter contract (`cisco-room-bar` and `samsung-qm55` declare `serial_number`, `firmware_version`, and `model_number` with defaults), and `roles.yaml` drops the now-unclaimed `field:*` permissions, since `property:*` already covers the tier. - **Context:** [ADR-0043](#adr-0043-the-property-catalog) made the catalog primitive-agnostic and deferred the one binding it needed, `field_definition.key`, so a field could draw its type from the catalog. Building that binding forced the realization that the binding was the wrong shape: once a field's name, type, and validation all come from a property, a "field" is a property the operator **declares** rather than the device **observes**, and the only thing left that was field-specific was **where the schema hangs**. The answer was already on the table: [ADR-0045](#adr-0045-the-product-catalog) made the **product** the source of a component's shape, so the per-type field catalog becomes the per-product **contract** over the property catalog, and the field value becomes an arc-owned, provenance-tagged **property value** beside the samples and occurrences it sits next to. Folding is primitive-first: one value store the cascade, reconciliation, and the current-value read can all be built on, rather than three parallel ones (`field_value` for declared, the datapoint tables for observed, an unbuilt `config` table for intended). - **Deferred:** **`standard_property`** and **`location_type_property`** (the other contract owners, each waiting on its owner entity, `standard` and a `location_type` schema); the **driver access/mode column** on `product_property` (whether a driver can get, set, or only declare a property, which lands with the driver slice); the **non-declared provenance producers** (`intended` config writing a desired value, `observed` materializing a current value out of the datapoint stream, `calculated` from a rule), which the provenance column seats but nothing writes yet; the multi-owner arc on `property_value` (only the component arm is written and scope-injected today); and the **`datapoint` -> `sample`** rename, still a later cleanup. - **Supersedes:** [ADR-0043](#adr-0043-the-property-catalog)'s deferred **`field_definition.key`** property binding. This **is** that binding, done differently: rather than a field definition gaining a key reference, the field catalog itself became the **product contract over the property catalog**, and `field_definition` retires. Also completes [ADR-0045](#adr-0045-the-product-catalog)'s partial supersession of the **`component_type`-as-shape** notion: that ADR repointed shape at the product but left the table standing; this one drops `component.component_type` and the `component_type` registry outright. - **Tracked under** epic [#266](https://github.com/hyperscaleav/omniglass/issues/266). This is **PR5** of the estate-model shift toward property / event / command plus vendor / product / driver / capability / standard / role / health. - **Amended by [ADR-0057](#adr-0057-the-cascades-least-specific-tier-is-platform-and-a-default-is-not-a-tier):** the contract default is unchanged, its vocabulary is. `coalesce(the instance's set value, the contract default)` is the fall-through to a **declaration**, not the bottom rung of a cascade, so `product_property.default_value` (and its two siblings) is the shipped instance of the off-axis default rather than a tier under `platform`. ### ADR-0048: The `standard` blueprint and the template-fork seed model - **Date:** 2026-07-21 | **Status:** Accepted | **Pages:** [core entities](/architecture/core-entities/), [API](/architecture/api/), [identity and access](/architecture/identity-access/), [storage](/architecture/storage/), [Standards guide](/guides/admin/standards/), [Types guide](/guides/admin/types/), [Properties guide](/guides/admin/properties/) - **Decision:** Three moves land together, because each one only makes sense with the others. **1. `system_type` is promoted to `standard`.** A **standard** is the **blueprint a system conforms to** (huddle room, classroom, auditorium): the system-side counterpart of [`product`](#adr-0045-the-product-catalog), not a label hung off a system. The table is renamed and gains **`parent_standard_id`** (a variant points at its base, mirroring `product.parent_product_id`) and a declared-property **contract**. `system.system_type` becomes **`system.standard_id`** and is now **optional**, exactly like `component.product_id`: a **one-off system that conforms to no standard is first-class** and carries only its own ad-hoc values. The seeded rows carry over unchanged. Because a standard now owns a contract, it leaves the shared `type:*` registry permission and takes its own **`standard:read` / `:create` / `:update` / `:delete`** Catalog resource (read on the viewer `*:read` floor, the writes at the admin tier, exactly like `product:*`), and its routes move from `/types/system` to **`/standards`**. **2. Two more contract tables, and one owner-generic resolver.** **`standard_property`** and **`location_type_property`** join `product_property` on the identical shape (`_id`, `property_name`, an optional `default_value`, a `required` flag, unique per pair). `data_type` and `validation` are **never** duplicated onto a contract; they stay in the [`property` catalog](#adr-0043-the-property-catalog). The resolver then generalizes: **`EffectiveProperties(ctx, ownerKind, ownerID, read)`** resolves **component, system, location, and node** off **one** parameterized SQL template driven by an **`ownerContract`** table (instance table, classifier column, contract table, contract key, arc column). Component reads its contract through `component.product_id`, system through `system.standard_id`, location through `location.location_type`; a **node has no classifier**, so it resolves ad-hoc values only. The query shape is unchanged from [ADR-0047](#adr-0047-the-fields-fold-product_property-and-property_value): a contract arm (`coalesce(the instance's value, the contract default)`, `from_contract=true`) UNION an ad-hoc arm. This is the primitive-first move: three classifier/instance pairs, one resolver, so they cannot drift. Alongside it, `guardOwnerScope` now scope-checks **every** owner arc on a value write (it previously returned nil for everything but the component arc), so an out-of-scope system or location is a non-disclosing 404 on the write path as well as the read. **3. The seed model: templates live in code, not the database.** A standard (and a location type) is created by **forking an in-code template**. The fork is **one-time, with no inheritance**, so nothing in any tenant ever points back at a template and templates can be improved in any release. That dissolves the shipped-defaults-versus-local-edits problem at the root: the thing the vendor updates (the template) and the thing the operator owns (the row) are **never the same object**. Four consequences follow. - **Forking applies to template -> standard, not standard -> system.** A system does not fork its standard, it **conforms** to it, with **live inheritance**: the standard's contract default resolves for every conforming system until that system overrides it, and revising the default moves every system that has not. - **Therefore a shipped standard or location type is operator-owned, not official.** Both seed with **`official: false`** through **seed-if-absent** paths (`SeedStandard` / `SeedLocationType`, `ON CONFLICT DO NOTHING`), never the authoritative `Upsert*`. They are freely editable and deletable from the moment they land. - **An authoritative upsert here would be a bug, not a policy.** `ON CONFLICT DO UPDATE` would silently revert an operator's edit on the next boot, which is the exact failure this model avoids. A regression test edits a seeded standard, re-runs the seed, and asserts the edit survived. - **The canonical catalogs are the exception** and keep the authoritative upsert with `official: true`: **`property`** (and later `command` and `event_type`) is the **shared vocabulary a driver maps onto**, so a release must be able to correct it. The classification catalogs (`vendor`, `driver`, `capability`, `product`, `interface_type`, `secret_type`) and `role` stay on that same authoritative path for now. Four route groups carry the contracts and the values, all regenerated into the OpenAPI document, the cobra CLI, and the typed client: `GET /standards/{id}/properties` plus `PUT` / `DELETE /standards/{id}/properties/{property}` (gated `standard:read` / `:update` / `:delete`); `GET /location-types/{id}/properties` plus `PUT` / `DELETE .../{property}` (gated `type:*`, since the location type registry is still a `type` registry); and the value sides `GET /systems/{name}/properties` plus `PUT` / `DELETE .../{property}` (gated `system:read` / `:update`) and the same for `/locations/{name}/properties` (gated `location:read` / `:update`). The value routes are **scope-injected**, so an out-of-scope system or location is a non-disclosing **404**. - **Context:** [ADR-0047](#adr-0047-the-fields-fold-product_property-and-property_value) deferred `standard_property` and `location_type_property` because neither owner was ready: `system_type` was a bare label registry with no contract to hang anything on. Building that contract forced the promotion, since a registry that declares what its instances expose **is** a blueprint, and a blueprint is the system-side `product`. Making `standard_id` optional followed immediately: a productless component was already first-class, and a system that matches no blueprint has the same claim. The seed question surfaced during the build and is the harder half. Shipping a room standard as an authoritative `official` row would make it **read-only** (an operator could not tune "Huddle Room" to their own estate) and would **revert local edits on every boot** if it were writable. Both failures come from one mistake: treating example content and canonical vocabulary as the same kind of thing. Splitting them (a template in code that is forked once, versus a catalog row that is upserted authoritatively) lets the release improve its examples forever without ever touching an estate's data, and keeps the one thing that genuinely must stay identical install to install, the property vocabulary, under release control. - **Deferred:** the **in-code template mechanism** itself and its create-from-template console affordance ([#317](https://github.com/hyperscaleav/omniglass/issues/317)); this slice ships the seed-if-absent behavior and the operator-owned rows that the mechanism will produce, with the shipped starter set still declared as seed YAML. The **official / community / private catalog tiering** for `product` / `driver` / `property` / `event_type` plus a **disable flag** ([#318](https://github.com/hyperscaleav/omniglass/issues/318)). Also still deferred: a standard's **role set** and health composition, the non-`declared` provenance producers, the cross-owner cascade over `property_value`, and the `datapoint` -> `sample` rename. - **Supersedes:** the **`system_type`-as-label** notion (a system's blueprint is a first-class Catalog entity with its own contract and its own permission, and it is optional), completing on the system side what [ADR-0045](#adr-0045-the-product-catalog) and [ADR-0047](#adr-0047-the-fields-fold-product_property-and-property_value) did on the component side. Also supersedes the assumption running through [ADR-0044](#adr-0044-the-component-classification-catalogs), [ADR-0045](#adr-0045-the-product-catalog), and [ADR-0047](#adr-0047-the-fields-fold-product_property-and-property_value) that **everything the seed ships is `official` and read-only**. That now holds **only for the canonical catalogs**: the shipped standards and the four shipped location types (`campus` / `building` / `floor` / `room`) are `official: false` and fully editable, so any prose promising a read-only seed-owned row for those two registries is stale. - **Tracked under** epic [#266](https://github.com/hyperscaleav/omniglass/issues/266). This is **PR6** of the estate-model shift toward property / event / command plus vendor / product / driver / capability / standard / role / health. ### ADR-0049: The system role: capability-gated staffing and the resolved capability set - **Date:** 2026-07-21 | **Status:** Accepted | **Pages:** [core entities](/architecture/core-entities/), [API](/architecture/api/), [glossary](/architecture/glossary/), [templates](/architecture/templates/), [health](/architecture/health/), [Standards guide](/guides/admin/standards/), [Capabilities guide](/guides/admin/capabilities/), [Work with an entity](/guides/operator/entities/) - **Decision:** A system says **what it needs filled**, and the platform **refuses** a component that cannot fill it. Four tables and two resolvers carry that. **1. A `system_role` is a slot, declared on the arc.** A role (a table microphone, a main display) is declared either on a **`standard`**, where **every conforming system inherits it live**, or **directly on one `system`** (ad-hoc, which is how a one-off system gets roles at all). The two owners ride the **same exclusive-arc pattern `property_value` uses**: an `owner_kind` plus `standard_id` / `system_id`, a one-set CHECK, and a `unique nulls not distinct` key over the arc columns and the role name (the default NULLS DISTINCT would let duplicates through the NULL arm). A role carries a **`quorum`**: how many components should fill it, at least one, because a role no component need fill is not a role. **2. `role_capability` is conjunctive.** A role requires a set of [`capability`](#adr-0044-the-component-classification-catalogs) rows, and a component must provide **every** one of them. Requiring nothing admits anything, which is the honest reading of an empty requirement, not a special case. **3. A component's capabilities become a resolved set.** **`component_capability`** (`component_id`, `capability_id`, `present`) is the component's **own** capability facts, layered over its product's: `present=true` **adds** one the product does not claim, `present=false` **suppresses** one it does. **`EffectiveCapabilities(component)`** is then the product's set UNION the additions MINUS the suppressions, and a **productless component resolves to just its own declarations**. This is the single definition of "what this component can do" for the whole platform, and it is the set the guard checks. **4. `EffectiveRoles(system)` merges both arms.** The roles the system's standard declares (marked `from_standard`) UNION those declared directly on it, each with its required capabilities, its quorum, and the components filling it here. A one-off system has only the ad-hoc arm. The resolver serves `Assigned()` and `Understaffed()` (quorum minus assignments, floored at zero) rather than leaving arithmetic to each surface, so staffing reads the same way everywhere. **5. The guard refuses, and the refusal names the gap.** `AssignRole` is a **422 when the component's resolved capabilities do not cover every capability the role requires**, and the message names the missing ones (`component "panel-1" cannot fill role "table-mic": missing microphone, speaker`), sorted so the same gap always reads the same way. It joins the **location placement constraint** as a refusal on **modeled** grounds, and follows the same rule that one set: **name the parties**. A bare "no" leaves the operator nothing to do, and the whole value of modeling capability is that the refusal is actionable. Assignment is otherwise idempotent, and **`role_assignment.component_id` is `on delete restrict`**, so a component staffing a role cannot be deleted out from under the system. Eight routes carry it, regenerated into the OpenAPI document, the cobra CLI, and the typed client: `GET /standards/{id}/roles` plus `PUT` / `DELETE /standards/{id}/roles/{role}` (gated `standard:read` / `:update` / `:delete`); `GET /systems/{name}/roles` (the resolved read) plus `PUT` / `DELETE /systems/{name}/roles/{role}` and `PUT` / `DELETE /systems/{name}/roles/{role}/assignments/{component}` (gated `system:read` / `:update`); and `GET /components/{name}/capabilities` plus `PUT` / `DELETE /components/{name}/capabilities/{capability}` (gated `component:read` / `:update`). Every system and component route resolves its owner **within the caller's scope first**, so an out-of-scope target is a non-disclosing **404**. The shipped `meeting-room` standard declares `room-mic` (microphone + speaker, quorum 2) and `main-display` (flat-panel-display, chosen so the shipped Samsung QM55 can actually fill it), **seeded if absent** on the [operator-owned lane](#adr-0048-the-standard-blueprint-and-the-template-fork-seed-model), so an operator's quorum retune survives a re-seed. - **Context:** The strict refusal was decided first: a role that names a requirement and then lets anything fill it is decoration. But a component's capabilities came **only from its product**, and `product` is deliberately **optional** on a component ([ADR-0047](#adr-0047-the-fields-fold-product_property-and-property_value)), so under a strict guard a **productless component could have filled no role at all**. Three ways out, and only one of them keeps both halves: make product mandatory (reverses a call made one slice ago for good reasons), soften the guard to a warning (throws away the point of the model), or let a **component declare its own capabilities over its product's**. Layering resolves the tension without touching either commitment, and it is not a new shape: it is exactly the **contract-plus-override** the declared properties already use, where a product declares a default and an instance overrides it, applied to capabilities instead of values. Quorum lands in this slice and impact does not, because **staffing is visible without health at all**: a role wanting 2 with 1 assigned is under-staffed today, on data the operator entered, with no engine reading anything. - **Deferred:** **`impact`** (`outage` / `degraded` / `none`, what an unfilled or failing role does to its system) and the whole **SLI rollup**, which land in **PR8** with the engine that reads them; the console surfaces for both arcs (the standard's role editor, the system's roles panel, the component's capability editor); **role-scoped config**, a value declared against a role slot and resolving onto whichever component fills it ([templates](/architecture/templates/) describes it, nothing builds it); a **cap at quorum** (a role may be over-staffed, and nothing refuses the extra assignment, because "more than enough" is not an error); and the **`system_member`** composition table, which stays `Design`. - **Supersedes:** the **`system_template_member` role-requirement** design on [templates](/architecture/templates/). That page still describes a role slot **frozen into a `system_template_version`**, whose requirement is a set of **canonical datapoints and commands** and whose instance assignment is a **`system_member`** row. What shipped puts the slot on the **standard / system arc** (a standard is the blueprint now, so the role belongs with it), states the requirement as a **capability** set (a coarser, operator-legible vocabulary that exists as a catalog today, where canonical commands do not), and records the assignment in **`role_assignment`**. Templates and their frozen BOM stay `Design`; the two models are reconciled when template pinning is built, and until then the built role model is the one on [core entities](/architecture/core-entities/). Also supersedes the reading, running through [ADR-0044](#adr-0044-the-component-classification-catalogs) and [ADR-0045](#adr-0045-the-product-catalog), that **a capability is a product-only fact**: a capability is now a fact about a **component**, which its product supplies a default for. - **Tracked under** epic [#266](https://github.com/hyperscaleav/omniglass/issues/266). This is **PR7** of the estate-model shift toward property / event / command plus vendor / product / driver / capability / standard / role / health. ### ADR-0050: Health is a recorded transition, computed from the alarm-capability-role chain - **Date:** 2026-07-21 | **Status:** Accepted | **Pages:** [health](/architecture/health/), [core entities](/architecture/core-entities/), [API](/architecture/api/), [glossary](/architecture/glossary/), [Standards guide](/guides/admin/standards/), [Work with an entity](/guides/operator/entities/) - **Decision:** Health is a **verdict** on a system or a location, **derived** from what is wrong with the components staffing it and **recorded as a transition** at the moment it changes. Five calls carry that. **1. Capability is the routing key, and an alarm is how a component loses one.** An **`alarm`** is **component-local** (`component_id`, a `severity` of `info` / `warning` / `critical`, a `message`, a `raised_at`, and a **nullable `cleared_at`**), and **`alarm_capability`** names the [capabilities](#adr-0044-the-component-classification-catalogs) it degrades. Clearing **keeps the row**, so the record of what was wrong and when survives the fix. The chain from there is one sentence per hop: a component **satisfies** a role only when it provides **every** required capability and **none of those is currently degraded**; a role with fewer satisfying components than its **quorum** is **impaired**; an impaired role contributes its declared **`impact`** (`outage` / `degraded` / `none`, a column on `system_role`, defaulting to `degraded`); a system takes the **worst** contribution among its roles, and a location the worst among the systems placed anywhere beneath it. That chain is why a capability is **flat** and why a role requires a **set** of them: capability is the only vocabulary shared by the thing that breaks (a component) and the thing that cares (a slot in a room), so it is the only honest place to route through. **Impact lives on the role**, not on the alarm or the component, because the same broken box matters differently depending on the slot it was filling: a dead confidence monitor is not a dead main display. **2. The judgement is a pure package.** **`internal/health`** takes resolved inputs and returns a verdict, with **no database**: `Component.Satisfies`, the quorum boundary, worst-wins at both levels, and the impact mapping are unit tests, not SQL. Two of its defaults are deliberate **safety** calls in opposite directions. An **unrecognized impact reads `degraded`**, so a bad value can never make an impaired role silently harmless. An **unrecognized recorded value reads `healthy`**, so one stray row cannot paint an estate broken. The rule behind both: fail loud about a **judgement**, fail quiet about a **record**. **3. Health is recorded as a transition-only state, on `state_datapoint`.** The requirement this whole design serves is an **accurate history of the edges**: exactly when a system stopped working, answerable weeks later. `state_datapoint` is already that primitive (the ingest path writes a row only when the value differs from the last one stored, and `StateTransitions` reads the ordered flips the reachability availability strip draws), so health reuses it rather than adding a history table: the **owner arc**, `provenance='calculated'`, and `source_rule='health-rollup'` (the lineage CHECK requires a non-null `source_rule`). The first value for an owner is always recorded, even `healthy`, so a reader can tell "healthy since we started watching" from "never evaluated". **4. Recompute happens at the writes that can change health, in the same transaction.** Every mutation that can move a verdict recomputes the affected chain before it commits: **raising** or **clearing** an alarm, **assigning** or **unassigning** a component, **declaring** or **withdrawing** a role, changing a role's **quorum** or **impact**, changing a component's **capabilities** or its **product**, **creating** a system, and changing the **standard** it conforms to or the **location** it sits in (recomputing **both** the old and the new location, since the one it left may have just improved). **A read never writes.** Two alternatives were considered and rejected, and both fail the same requirement. **Compute-on-read** keeps no history at all, so "when did this break" is unanswerable by construction. **Compute-and-write-through-on-read** keeps a history that is **sampled by whoever opens a page**: the edge timestamp becomes the moment somebody looked, not the moment the estate changed, and an estate nobody watched over a weekend has no weekend. A transition is only worth recording if it is recorded **where the change happened**. **5. A report computes the verdict it serves from the evidence it shows.** The health report originally served the **last recorded** verdict while resolving the contributing roles **live**, which let a system with nothing recorded yet report `healthy` beside an impaired `outage` role: the report contradicted itself. The served verdict is now derived from the same resolved rows the report displays, so the headline and the reason can never disagree. This is **not** self-healing on read: nothing is written, and the **recorded transitions remain the source for history**. A missing trigger can therefore cost an edge in the history, but it can never make a report lie about the present. **Routes**, regenerated into the OpenAPI document, the cobra CLI, and the typed client: `GET` / `POST /components/{name}/alarms` and `DELETE /components/{name}/alarms/{id}` (gated `component:read` / `:update`); `GET /systems/{name}/health` and `GET /locations/{name}/health` (gated `system:read` / `location:read`, scope-injected, an out-of-scope owner a non-disclosing 404), each returning the verdict, the contributing roles (with the degraded capabilities and the causing alarms) or the systems beneath, and the recorded transitions over the last 30 days. The CLI reads `omniglass component alarms|raise-alarm|clear-alarm`, `omniglass system health`, and `omniglass location health`. The seed adds a **`health`** state-kind property (`healthy` / `degraded` / `outage`) so the recorded series is typed like any other. - **Context:** The architect's requirement was stated plainly: *"The most important thing about health is that we have a real, accurate history of the edges. We need to know exactly when a system went from healthy to unhealthy, and be able to look back at it weeks later."* Every call above falls out of taking that literally. Once the history has to be **accurate**, the write side is the only correct place to compute from, and once the carrier has to be **edges**, `state_datapoint` is already the right table and a new `health_history` would have been a second, worse copy of it. Recording an **opening verdict at system creation** then surfaced a latent bug the rest of the schema had been quietly carrying: every system now had a `state_datapoint` row from birth, and **every rename failed on the owner foreign key**, because those FKs address the owner **by name** and declared no `ON UPDATE`. Migration `20260721170000` re-adds all four `state_datapoint` owner FKs with **`on update cascade`**, which is what name-as-address always meant: the history follows the entity rather than pinning its old name. Health did not create that bug, it made it **reachable for every system**, which is the useful kind of forcing function. - **Deferred:** the **same FK gap** on `metric_datapoint`, `event`, `property_value`, `alarm`, and the role tables' name-addressed columns, tracked in [#314](https://github.com/hyperscaleav/omniglass/issues/314). An alarm today is **written by an operator or an API caller**, not produced by an [`event_rule`](/architecture/alarms-actions/) over datapoints; the rule that opens and clears one automatically is the next tier. Also deferred: **system-** and **location-owned** alarms (the alarm arc is component-only today), the **`unknown`** verdict with its coverage and staleness reasons, the **`global`** estate top, the **SLI / SLO / SLA** family and the **KPI** set, an alarm's interaction with **operational mode** (maintenance suppressing a contribution), and **dependency suppression**. - **Supersedes:** three earlier calls on [health](/architecture/health/). (a) **The value vocabulary**: [ADR-0003](#adr-0003-health-reads-ok-not-up) named the healthy state `ok` over an ordered `ok < degraded < down`; the built domain is **`healthy` < `degraded` < `outage`**, keeping that entry's reasoning (name the verdict, not the ping) and changing only the words, since `outage` says what a broken room means to the people in it. (b) **Where impact is declared**: the design hung an optional `health` impact on the **`event_rule`**, so an alarm moved its owner's health directly. Impact now lives on the **role**, and an alarm reaches a system **only** through the capabilities it degrades. An alarm on a component that fills no role moves that component's own verdict and nothing above it, which is the correct answer and was previously an accident of tagging. (c) **`health_role`**: the `required` / `redundant` / `informational` member tag on a `system_template_member` is superseded by **quorum plus impact** on a `system_role`, which expresses the same three cases without a fourth vocabulary (required is quorum 1 with impact `outage`, redundant is a quorum below the number assigned, informational is impact `none`). It also closes [ADR-0049](#adr-0049-the-system-role-capability-gated-staffing-and-the-resolved-capability-set)'s deferral of `impact` and its "quorum ships without health" note. - **Tracked under** epic [#266](https://github.com/hyperscaleav/omniglass/issues/266). This is **PR8** of the estate-model shift toward property / event / command plus vendor / product / driver / capability / standard / role / health, and the slice that **closes the epic**: it is the one that consumes what the previous seven built. ### ADR-0051: Membership is the attachment, and a role is what it does - **Status:** accepted, built. - **Context:** a component's relationship to a system was **two unrelated facts that could silently disagree**. `component.system_id` was a single pointer, set once at create with no path to change it, which no authorization and no health path ever read; `role_assignment` was many-to-many and carried what the component actually does. Nothing reconciled them, and the console rendered the first under the heading "Components" while the panel directly below listed the second, so a fully staffed system displayed **`0 components`**. The contradiction was visible to operators before it was understood by us. - **Decision:** membership is a **first-class binding**, `system_member (system_id, component_id, is_primary)`, and a role attaches to it. **Staffing a role creates the membership**, because a component filling a job in a system that the system does not count as a member is a contradiction. The reverse is **not** symmetric: giving up a role leaves the membership, because the device is still in the room, and a member carrying no role (a power conditioner, a spare) is ordinary. - **Why membership cannot simply replace the pointer:** the cascade seeds its system band from **one** row and ranks with `row_number() over (partition by ... order by band desc, depth asc)`, which has **no tiebreaker after depth**. A many-valued seed would make an effective tag, variable, or secret resolve nondeterministically for precisely the shared-device case. Membership is therefore many-valued while **`is_primary`** keeps a single answer for callers with **no system in hand**. It is a **default, not a resolution rule**: anything naming a system resolves against that system, and a component's first membership takes the default with nobody asking, so the single-system case never meets the concept. - **Cascade from both ends, and deliberately no restrict on the component.** A binding is meaningless once either side is gone. `role_assignment` keeps its `on delete restrict` because deleting a component that fills a job would silently break a system's health; duplicating that restrict on membership would add a step to every component removal while protecting nothing new. - **Backfill reads both of the old places.** The role table alone drops every component that belonged to a system without filling a declared role; the pointer alone drops the shared device's other systems. The old pointer seeds `is_primary`, since answering which system chain feeds a component's config is exactly what it used to do. A component left with several memberships and no pointer gets **no** default, because there is no honest way to guess which one was meant. - **Thin cut:** resolution behaviour does not move in this slice. `component.system_id` stays and keeps feeding the four cascade resolvers unchanged, so this ships and is verified on its own. - **Supersedes:** [core-entities](/architecture/core-entities/)'s "a truly shared device **skips the system layer**", which was the best available answer while the only binding was a single pointer. A shared device is now a member of every system it serves. It also narrows that page's `system_member` design: the shipped row is the binding alone, without the role column or the pin to a frozen `system_template_version`, so a member can exist without a role. - **Tracked under** epic [#324](https://github.com/hyperscaleav/omniglass/issues/324), slice [#325](https://github.com/hyperscaleav/omniglass/issues/325). ### ADR-0052: The cascade resolves through membership, and secrets carry no system band - **Status:** accepted, built. - **Context:** [ADR-0051](#adr-0051-membership-is-the-attachment-and-a-role-is-what-it-does) made membership explicit but deliberately left resolution alone: the tag, variable, and secret cascades still seeded their system band from `component.system_id`, the write-once pointer. That left the pointer alive for one reason only, and left the "config differs per system" case unanswerable. - **Decision:** the system band is seeded from **`system_member`**. Tag resolution **takes the system to resolve against**, and resolves against it only if the component is a member: naming a system it has no binding to must not lend it configuration. With **no system given** it falls back to the **primary** membership, which is the entirety of what `is_primary` is for. `GET /components/{name}/effective-tags?system=` exposes the first case. - **The seed stays single-valued, as a correctness requirement.** The rank orders by band then depth with no tiebreaker after that, so two seeds in one band resolve nondeterministically. Membership is many-valued; the chain it feeds is not. This is the same fact that made the pointer worth keeping under ADR-0051 and is now satisfied without it. - **Secrets lose the system band entirely**, on ownership rather than determinism: an interface belongs to a component, a shared device has one password, and the room it serves is the wrong owner for a credential. It also removes the one case where an ambiguous inheritance would have been dangerous rather than merely wrong. - **`component.system_id` is dropped.** With nothing reading it, the column, its API field, and its console consumers go. The component body now reports `system` (the primary, by **name**) and `system_count`, which also retires one of the three places the API emitted a raw uuid for a field it accepts by name ([#328](https://github.com/hyperscaleav/omniglass/issues/328)). - **Written test-first because the failure mode is silent.** A mis-seeded `sys_chain` is still valid SQL that returns fewer rows: a system-owned tag would simply stop reaching its components, with no error and no 500, and the resolution blade would show the location winner as though the system band never had a candidate. - **Supersedes** [cascade](/architecture/cascade/)'s "the primary-system pointer is the single system chain that feeds the cascade", which described the mechanism when a pointer was the only binding available. - **Tracked under** epic [#324](https://github.com/hyperscaleav/omniglass/issues/324), slice [#327](https://github.com/hyperscaleav/omniglass/issues/327). ### ADR-0053: A name is the address, a uuid is identity - **Status:** superseded in part by [ADR-0056](#adr-0056-every-foreign-key-stores-a-primary-key). Its API half stands: a reference is addressed by name. Its schema half ("a new table references an estate entity by `name` with `on update cascade`") is reversed, and responses now carry the id **beside** the name rather than instead of it. - **Context:** the pattern was real and dominant but never applied to the original entities. Eleven tables keyed their estate references by `name`, six by `id`, split by age. Worse, the API **accepted names on write and returned uuids on read**: a component created with `{"parent": "rack", "location": "hq-b1"}` read back as `{"parent_id": "0198f...", "location_id": "0198f..."}`. The body did not round-trip, so every client fetched a second collection and joined by uuid to render one label. The console carried exactly that map until it was deleted in [#329](https://github.com/hyperscaleav/omniglass/issues/329). - **Decision:** every request **and response** addresses another entity by its **name**. A uuid appears only as an entity's own `id`. Two exceptions: an entity with **no name** (an interface, a stored value, an audit row, a grant, a principal) and a **slug-keyed catalog**, whose id already is a name. A new table references an estate entity by `name` with `on update cascade`. - **Normalized:** `parent_id` and `location_id` on component and system, `parent_id` on location, and the redundant `owner_id` on tag bindings, variables, and secrets, which already carried `owner_name` beside it. Nine fields, seven of them found by survey and **two by the guard test**, which caught a `SecretBody.owner_id` the survey missed. - **Enforced by contract, not prose.** `TestResponsesAddressEntitiesByName` walks the generated OpenAPI and fails on any field naming another entity by uuid. The failure it prevents is invisible otherwise: a body emitting `parent_id` still serves 200s, and the cost only appears in the clients. - **Deliberately not in scope:** `secret`, `variable`, and `tag_binding` still key their owner arcs by uuid in the schema, and the cascade resolvers compare those uuids directly. Converting them is a data migration plus a rewrite of resolution SQL reworked in [ADR-0052](#adr-0052-the-cascade-resolves-through-membership-and-secrets-carry-no-system-band). The API contradiction is what operators saw and is fixable without touching resolution. The rule binds new tables; the stragglers convert when something else needs to touch them. - **Breaking.** Response shapes change. At v0.0.0 this is the right moment, since the cost only grows. - **Tracked as** [#334](https://github.com/hyperscaleav/omniglass/issues/334), following [#328](https://github.com/hyperscaleav/omniglass/issues/328). ### ADR-0054: The shell owns a panel's action rail; the body registers and never draws - **Date:** 2026-07-21 | **Status:** Accepted | **Pages:** [design system](/contributing/design-system/) - **Decision:** A panel's action buttons are **declared as data, not laid out as markup**. A blade body binds `destructive` / `secondary` / `primary` (plus the Edit -> Save cycle) through `lib/blades`; a Drawer form body binds `submitLabel` / `submitIcon` / `submit` / `busy` / `disabled` / `cancel` through `lib/formactions`. `BladeStack` and `Drawer` each compose their own button vocabulary but draw it through the single `PanelFooter` rail. A form body renders **no footer markup at all**. - **Context:** The blade already worked this way. The Drawer did not: its rail was an opt-in `DrawerFooter` helper that every form body had to remember to import and wrap its buttons in. Fourteen forms remembered. Two did not, and hand-rolled their own right-aligned row instead, where they survived nine merged PRs unchanged while the helper was copied into six newly added pages around them. The cost was not only the two misses: among the forms that did use it, some rendered a Cancel and some did not, some spun a spinner and some swapped the label to "Creating...". A rail reached by convention drifts in both directions at once. - **Why a slot rather than a lint rule:** A lint rule finds the miss after it is written. A slot makes it unwriteable: there is no exported helper to forget, and a body that wants a button has exactly one way to ask for one. The enforcement is the deleted export, and `rail-ownership.test.ts` is the belt to that braces. - **Scope, honestly:** This converges **two** of the three rails. Full-page create forms (Locations, Systems, Components) still draw an inline `border-t ... pt-4` row of their own. That rail is inline in a scrolling page rather than pinned to the viewport, so it is a different layout problem, and it converges when the CRUD form primitive lands and owns both form factors. - **Deliberate convergences:** submit labels no longer change while in flight (the shell's spinner says it), and the new-interface blade lost its Cancel button, since a blade already dismisses two ways and no other blade in the stack carries one. - **Tracked under** [#332](https://github.com/hyperscaleav/omniglass/issues/332). ### ADR-0055: The tag, variable, and secret owner arcs key by name - **Status:** superseded by [ADR-0056](#adr-0056-every-foreign-key-stores-a-primary-key), which converts these nine columns back to uuids along with every other name-keyed foreign key. Kept in full because the reasoning below is a worked example of the mistake: it is internally consistent, it shipped green, and it is wrong at the premise. - **Context:** [ADR-0053](#adr-0053-a-name-is-the-address-a-uuid-is-identity) fixed what operators saw and deliberately left the schema alone. Three tables still keyed their owner arcs by uuid while every table from the collection era onward keyed by name, so the two conventions met inside single queries: the cascade resolvers walked chains of **uuids** purely to match these three, and a component's name had to be carried alongside its id to bridge them. - **Decision:** the nine arc columns on `tag_binding`, `variable`, and `secret` become `text references (name) on update cascade on delete cascade`. The columns keep their `_id` suffix, matching `role_assignment.component_id` and `state_datapoint.component_id`, which are likewise text referencing a name. - **`on update cascade` is the load-bearing clause.** A name is only safe as a key if a rename carries. `TestOwnerArcsSurviveARename` is the proof and is **mutation-checked**: with the clause removed the rename is refused outright with a foreign-key violation, so the test cannot pass vacuously. It also could not have failed before this change, since the arcs held uuids that a rename never touches. - **The resolvers now project names.** Each chain still **recurses on `parent_id`**, which stays a uuid, and only what it projects changed. `owner_id` in the `owners` CTE is a name, so the final joins that resolve a display name match on `name` rather than `id`. - **The scope walk still uses ids**, resolved from the name at the point of the check. Identity stays internal, and the walk is the only place that needs it, which is the rule working as intended rather than an exception to it. - **Not converted, deliberately:** `tag_binding.node_id` references `node.principal_id`, a node's enrollment identity and the only handle it has. `tag_binding.tag_id` is a genuine instance of the same rule (`tag` is uuid-keyed with a unique name) but is the binding's **subject** rather than its owner, and it touches the tag CRUD surface rather than resolution; tracked as [#340](https://github.com/hyperscaleav/omniglass/issues/340). Removing it from the guard test's slug-keyed allow-list, where it had been listed on a **false claim** that the tag catalog is slug-keyed, is part of this change. - **No `migrate:down`.** Reversing would have to resolve names back to uuids the forward migration no longer records, and any rename since would make that resolution wrong rather than merely absent. - **Tracked as** [#339](https://github.com/hyperscaleav/omniglass/issues/339). ### ADR-0056: Every foreign key stores a primary key - **Date:** 2026-07-22 | **Status:** Accepted; the slug-keyed carve-out below is retired by [ADR-0062](#adr-0062-a-registry-takes-a-uuid-primary-key-and-a-renameable-handle) | **Pages:** [storage](/architecture/storage/), [api-first](/contributing/api-first/) - **Decision:** every foreign key stores the target's **primary key**: a uuid for an estate entity, `principal_id` for a node, and the slug itself for a slug-keyed catalog (`product`, `standard`, `property`, `interface_type`) where the name already *is* the key. No column references a `name`. In exchange, the API accepts **either form** wherever a reference is written (a path segment or a join field in a body), trying the uuid first, and every response carries **both**: the name an operator reads and the id it resolves to. - **Context: this reverses a direction set two ADRs ago.** [ADR-0053](#adr-0053-a-name-is-the-address-a-uuid-is-identity) found eleven tables keyed by name and six by id, and resolved the split by declaring the majority correct. [ADR-0055](#adr-0055-the-tag-variable-and-secret-owner-arcs-key-by-name) then converted the six to match, and the follow-on work was converting the rest. The premise was never examined: a friendly, renameable key is valuable *because* it can change, which is the one thing a foreign key must not do. - **The tell was `on update cascade`.** ADR-0055 called it "the load-bearing clause" and was pleased that removing it made the test fail. That machinery exists only to fund the wrong choice: it is write amplification across every referencing row, on a rename, to protect a key that did not need to be a name. A uuid arc needs no clause, and the equivalent test passes because there is nothing to rewrite. - **A rename was not merely inefficient, it was refused.** With `interface.component` referencing `component (name)` and no `on update` clause, renaming a component that owned any interface failed outright with a foreign-key violation. The name-keyed convention had spread past the point where its own cascade covered it, and the operator-facing symptom was a rename that returned an error. - **Scope:** all 30 name-keyed foreign keys, converted across five slices grouped by subsystem rather than by table, so each file changed once: the estate arcs, then health and roles, then the collection tier (`metric_datapoint`, `interface`, `node`) and every node reference. - **What stays a name.** The columns whose target is slug-keyed are already conformant and were not touched. Health passes names internally on purpose: its advisory lock hashes `health//`, and a mixed currency would hash two keys for one owner and silently stop serializing. (The slug-keyed targets themselves later took uuid keys too, so those columns moved to the uuid; see [ADR-0062](#adr-0062-a-registry-takes-a-uuid-primary-key-and-a-renameable-handle).) - **Guarded both ways.** `TestResponsesAddressEntitiesByName` fails on a response that names an entity by uuid alone; the per-tier rename tests fail if an arc stops following a rename. Each conversion was **mutation-checked** rather than trusted: breaking the projection had to turn the suite red. - **Tracked as** [#343](https://github.com/hyperscaleav/omniglass/issues/343). ### ADR-0057: The cascade's least-specific tier is `platform`, and a `default` is not a tier - **Date:** 2026-07-21 | **Status:** Accepted | **Pages:** [cascade](/architecture/cascade/), [settings](/architecture/settings/), [config, secrets, and variables](/architecture/variables/), [tags](/architecture/tags/), [identity and access](/architecture/identity-access/), [scaling and deployment](/architecture/scaling/) - **Decision:** Six calls, one vocabulary. 1. **`global` becomes `platform`** as the cascade's least-specific **binding** tier, on **both** axes: the estate arc (`owner_kind` on `variable`, `secret`, and `tag_binding`) and the settings level (`setting_override.scope`). It occupies exactly the rung it occupied before (`segment_rank 0`). It is a decision like every other rung, what an admin set for the **whole install**, not a floor beneath the chain. 2. **`code` becomes `default`, and a `default` is off the axis on both engines.** A default is what a value **is** absent any decision: a column on a definition row, beside the unit, the kind, and the validation rule. It is not a rung, it shadows nothing, and nothing shadows it; the fold **falls through** to it when no rung bound anything, and the resolve view reports it as a declaration rather than as a winning source. A default is a column on a declaration row, so **a kind with no declaration row has no default**: a setting has one (its struct tag) and a property has one on its classifier's contract (`product_property.default_value` and its `standard_property` / `location_type_property` siblings, read as `coalesce(the instance's set value, the contract default)` by `EffectiveProperties`, [ADR-0047](#adr-0047-the-fields-fold-product_property-and-property_value)), while a variable, a secret, and a tag have none. Absent means absent. Note the property default sits on the **contract**, not on the `property` catalog entry: the catalog declares what a name means, a classifier declares what it is for the things that conform to it. That is a narrower claim than the one this ADR was drafted against, when the precedent was the retired `field_definition.default_value` hanging off a `component_type`, and it is the stronger one for the rule here, since the coalesce is the fall-through in the code path itself. 3. **There is no root location.** The location tree keeps N unparented tops. A tier above today's tops is a new `location_type` and a real node, never a magic one, and a top-level location is not a substitute for `platform`: binding at one top misses every sibling, and a top added later is silently uncovered. 4. **The install-wide tier survives on the estate axis**, uniform across kinds: `platform | location | system | component`. 5. **A write at the tier needs `platform:`**, checked in addition to the resource permission and published per route as an `x-omniglass-platform-permission` stamp. This separates full-estate **scope** from install-wide **authority**: a senior operator may hold an all-scope grant without being able to change the value that applies to the whole install. `platform:*` is seeded to `admin` (and reaches `owner` through `>`); `operator` and `deploy` hold no `platform` write, and nothing implies one. "Nothing implies one" is enforced by putting `platform` in the **sensitive-resource set** beside `secret` and `settings` ([ADR-0025](#adr-0025-secret-is-a-sensitive-resource-a-per-secret-admin_sensitive-flag-flips-a-secret-to-the-admin-tier)), so a bare single-token `*` never names it: a custom role carrying `*:update` holds every estate write and still no install-wide authority. Only a literal, a `platform:*`, or a `>` names the tier. 6. **`root` is not used as a tier name**, so `location_type.allowed_parent_types` keeps its reserved `"root"` sentinel meaning "top, no parent", unchanged. - **Context:** One word named two unrelated things, in two engines, with three spellings. On the estate axis `global` was both a **tier** an operator writes at and, in the prose, a **floor** where ship-with policy supposedly lived: [cascade](/architecture/cascade/) read "Ship-with default policy lives at `global`, the floor of the chain", three lines under a heading that says the registry is **outside** the cascade. That was drift, not design: `internal/seed/` writes eight YAML files and every one defines a **type**; none writes a **binding**, and there has never been a ship-with row at the tier. Meanwhile the settings engine had already split the two ideas and picked different words for them (`code` for the declaration, `global` for the install-wide override), so the same distinction existed twice under three names. Separately, `global` also names the singleton estate **owner** where health and KPIs roll up (a different concept that keeps the name), which made "global" ambiguous in the one place ambiguity is most expensive. Naming the binding tier `platform` and the declaration `default` gives each idea one word, and it drops an assumption the estate never had: that "everything" and "the planet the sites are on" are the same thing. - **What does not change:** no precedence change (every row keeps its rung under a new name, so no deployment resolves differently), no new rows (the migration renames a value, inserting nothing and adding no column), no reordering of the segment ranks or the comparison key, and no capability removed. - **Breaking change, accepted deliberately:** `secretAAD` binds a sealed field to its owner arc, `ownerKind|ownerID|name|field`. A secret sealed at the tier **before** this rename authenticates against `global|global|...`; after it, the derivation yields `platform|platform|...`, the AEAD check fails, and **that ciphertext never opens again**. Only the renamed tier is affected: a scoped secret carries a real owner id and is untouched. Accepted because no deployment holds tier secrets yet, and each alternative (freezing the AAD at the legacy string, a Go-side re-seal backfill, or a reveal-time fallback) buys compatibility nothing currently needs at the price of a permanent legacy branch. Recorded here rather than discovered later by a reader. - **Amends:** [ADR-0033](#adr-0033-settings-persist-only-the-override-level-base-layers-are-recomputed-in-memory), [ADR-0034](#adr-0034-the-settings-gateway-is-unscoped-only-the-permission-gates-it), [ADR-0035](#adr-0035-settings-resolve-as-a-cascade-over-principals-with-a-broader-wins-lock), and [ADR-0042](#adr-0042-field-cascade-and-the-type-default-floor), and [ADR-0047](#adr-0047-the-fields-fold-product_property-and-property_value). Each keeps its model; only the level names and the default's place in the vocabulary move. - **Closes:** issue [#316](https://github.com/hyperscaleav/omniglass/issues/316). ### ADR-0058: A run mode is a verb under its noun, and no command may be shadowed - **Date:** 2026-07-22 | **Status:** Accepted | **Pages:** [CLI guide](/guides/cli/) - **Decision:** the edge run mode becomes **`omniglass node run`**, a leaf under the generated `node` group, rather than a top-level `node`. A **guard test walks the assembled command tree and fails on any duplicate name**, so the hand-written and generated command sets can no longer collide silently. - **Context:** the hand-written run mode and the generated API group both registered as `node`. Cobra does not treat that as an error: both are added and lookup returns the first, so **every generated node command was unreachable**. `omniglass node list` resolved to the daemon and failed asking for `--token`, while the CLI guide documented it as working. - **Why a guard rather than a rename alone.** This is the third instance: `members` under the principal groups (#326), `type list` (#319), and now `node`. Each was found by a person typing it. The two command sets compose on one root, so no single file owns the namespace and no review of either set can catch it. The tree is the only place they meet, so it is the only place the check can live. The guard was written first and **found two more nobody had reported**: `grant create` and `grant delete`, where the principal-group variants shadow the principal ones, so granting a role to a principal has no CLI path at all (#357). - **The known collisions are an explicit list that may only shrink.** The guard fails on any name **not** on it, so a new collision cannot land, and it also fails on an entry that has **stopped** colliding, so a fix must delete its entry rather than leave that name unwatched. It is a ratchet, not an allow-list. - **Root cause, left for #357:** `commandWords` derives the group from a single path segment, so `/principals/{id}/grants` and `/principal-groups/{id}/grants` both become `grant`. Fixing that renames documented commands and is a naming decision, not a mechanical one. - **Cost accepted:** `omniglass node` is a documented invocation and it changes. At v0.0.0 that is a docs edit, and a mode reads as a verb anyway, beside `node list` and `node enroll`. - **Tracked as** [#354](https://github.com/hyperscaleav/omniglass/issues/354). ### ADR-0059: Every collection segment is a command level - **Date:** 2026-07-22 | **Status:** Accepted | **Pages:** [api-first](/contributing/api-first/), [CLI guide](/guides/cli/) - **Decision:** the CLI command path is derived from the **whole route**: every collection segment contributes a level and the verb is last, so a subresource is always addressed under the resource that owns it. `/components/{name}/properties` is `component property list`; `/principals/{id}/grants` and `/principal-groups/{id}/grants` are `principal grant create` and `principal-group grant create`. - **Context:** the old rule used only the collection **nearest the leaf**, so it could not tell two parents apart. Across 195 operations it produced **24 collisions**, `property list` seven ways. Cobra does not treat a duplicate name as an error: it registers both and returns the first, so the second was unreachable and the only symptom was a command that ran the wrong thing. Granting a role to a principal had no CLI path at all ([#357](https://github.com/hyperscaleav/omniglass/issues/357)). - **`nameOverride` was the rule, written out by hand fifty times.** It had grown to 53 entries, and the comment on nearly every one said the same thing: "the leaf-noun heuristic would collapse both into one group." Each was added after somebody typed a broken command. It is now **14 entries**, all of them the genuinely non-AIP `/auth` family, and none of them about a collision. - **A name depends only on its own route.** This is the property worth having: under a disambiguate-only-when-ambiguous rule, adding a route could rename an existing command. Here it cannot, so the naming is stable as the API grows. - **The grouping had to become a tree.** Fixing the derivation alone was not enough: the generator bucketed commands by their first word and used only the **last** word as the leaf name, so a three-word path rendered as two and collided again. It now builds an N-level tree, which is also what makes `node run` and `type secret list` render as written. - **`omniglass statu` shipped.** The depluralizer took `-s` off `status`. A small irregular set is declared instead; the route vocabulary is ours, so this is a known list, not an English problem. - **Cost accepted:** 67 of 202 commands are renamed, 135 unchanged. At v0.0.0 that is a docs edit, and the guides are corrected mechanically from the route map in the same change. - **Two guards, because regeneration does not fix prose.** `TestNoCommandNameCollisions` fails on any duplicate name (its known-collision list is now **empty**, and its second half forced those entries out once fixed). `TestDocsOnlyNameRealCommands` walks the guides and fails on a documented command that does not resolve; it immediately found `omniglass secret-type list`, which had never existed in any build, and two commands with no API route behind them ([#359](https://github.com/hyperscaleav/omniglass/issues/359)). - **Supersedes** the naming half of [ADR-0058](#adr-0058-a-run-mode-is-a-verb-under-its-noun-and-no-command-may-be-shadowed), whose guard this keeps and whose exception list this empties. - **Tracked as** [#357](https://github.com/hyperscaleav/omniglass/issues/357). ### ADR-0060: A resource is one kebab-case noun; nesting means ownership - **Date:** 2026-07-22 | **Status:** Accepted | **Pages:** [api-first](/contributing/api-first/), [API](/architecture/api/), [types](/guides/admin/types/) - **Decision:** a resource is addressed by **one kebab-case noun**, and a nested path segment means the nested thing is **owned** by it. The `/types` umbrella is retired: `GET/POST /location-types`, `PATCH/DELETE /location-types/{id}`, `GET /secret-types`. - **Context:** the location type registry was addressed **two ways**. Its CRUD lived under `/types/location` while its property contract lived on a flat `/location-types/{id}/properties`, so one entity had two command groups (`type location update` and `location-type property list`) and an operator had to know both. `/types/secret` had no flat form at all. - **The umbrella misused nesting.** A nested segment says the child belongs to the parent (`/principal-groups/{id}/members`). `location` is not owned by `types`; it **is** a registry that happens to be one of several. Grouping by category is a documentation concern, not an addressing one. - **Two mechanisms, now unambiguous.** A hyphen joins a noun that happens to be two words (`principal-group`, `location-type`, `audit-log`, `effective-tag`); a space means the thing beneath it (`location-type property`, `principal-group member`). Before this, the same registry used both, which is what made the rule unstateable. - **Found by asking what the rule was**, not by a failure. The CLI naming fix ([ADR-0059](#adr-0059-every-collection-segment-is-a-command-level)) made the two spellings sit next to each other in one command tree, where the contradiction was obvious. The generator was correct throughout; the routes disagreed with themselves. - **Addressing only.** Same handlers, same `:` gates, same scope injection, no storage change. The `type` command group disappears and `nameOverride` needs no entry for any of it. - **Breaking:** three route shapes change. At v0.0.0 that is a regeneration plus a docs pass, and `TestDocsOnlyNameRealCommands` fails on any guide left teaching the old names. - **Tracked as** [#361](https://github.com/hyperscaleav/omniglass/issues/361). ### ADR-0061: A calculated series is current at its highest id, not its newest timestamp - **Date:** 2026-07-22 | **Status:** Accepted | **Pages:** [datapoints](/architecture/datapoints/) - **Decision:** for a **calculated** series (health, and anything else the engine derives), the current value is the row with the **highest id**. `ts` records when the value was computed and is for display and history; it does not decide which row is current. For an **observed** series, `ts` still orders, because it is the observation time and a late arrival must not displace a newer reading, but `id` breaks a tie. - **Context:** `recordHealth` writes `select clock_timestamp(), ...`, so the timestamp is evaluated in the SELECT list while the id comes from the identity sequence applied when the row is inserted: the clock is read **before** the id is assigned. Two concurrent inserts can therefore commit with `ts` inverted relative to `id`, and a reader ordering by `ts` then disagrees with the writer about which row is current. - **Production was already right, the test was not.** Every production reader of a recorded verdict (`recordHealth`'s own transition check, `subtreeSystemHealth`) orders by `id`, so the writer and the readers agreed. The health test helper ordered by `ts`, which is why it reported verdicts the engine never produced. The intermittent failure was in the harness, not the product. - **`LatestState` is a real exposure and is fixed here too.** It backs the ingest transition guard and ordered by `ts` alone, so a poll cycle stamping several rows in one instant resolved to an arbitrary one and the guard could compare against a row that is not current. It now tie-breaks on `id`. - **Reproduced deliberately rather than waited for.** The failure needs contention: it never appeared in nine consecutive full-suite runs on an idle machine, and appeared within one or two attempts when six copies of the storage package ran at once. Under that same load the fix held for 24 runs. - **The regression test writes the inversion directly** rather than racing it into existence, and asserts the two orderings genuinely disagree before asserting the outcome, so it cannot pass vacuously. It reads through `LocationHealth`, which reports the **recorded** verdict; `SystemHealth` recomputes live and cannot witness the defect. - **Tracked as** [#356](https://github.com/hyperscaleav/omniglass/issues/356). ### ADR-0062: A registry takes a uuid primary key and a renameable handle - **Date:** 2026-07-22 | **Status:** Accepted | **Pages:** [storage](/architecture/storage/), [api-first](/contributing/api-first/) - **Decision:** a registry has a **uuid `id`** and a **unique, renameable `name`**, the shape `tag` and every estate entity already have. `product` and `vendor` convert first; the remaining seven follow, slice by slice, tracked as [#262](https://github.com/hyperscaleav/omniglass/issues/262). - **Context:** [ADR-0056](#adr-0056-every-foreign-key-stores-a-primary-key) says every foreign key stores its target's primary key, and epic #343 made that true everywhere **except** the slug-keyed registries, where the name *is* the key. That exception was the last place a foreign key referenced a mutable, human-authored string. A product id was a typo or a rebrand away from being wrong forever, and two device packs both defining `cisco-room-kit-pro` collide on the primary key itself. - **`name`, not `slug` or `key`.** Six tables and every estate entity already call the human handle `name`, and the API bodies already say `name`. A third word for the same concept would be worse than the inconsistency it fixed. Renaming the family to `slug` later is a separate, mechanical decision. - **The registries already disagreed with each other**, which is worth recording: `property` and `interface_type` call their slug `name`, while `capability`, `driver`, `location_type`, `secret_type`, and `standard` call theirs `id`. So the later slices are a **rename** for five of them and an addition for two, not one uniform change. - **`node` stays the exception.** Its primary key is `principal_id`, because a node is the detail row of a principal and its key IS that foreign key. It is deliberate and it is not changing. - **The API carries both and accepts either**, as the estate entities do: `id` (uuid) and `name` (handle) on every body, and a path or reference resolves whichever form it is given. A kebab handle can never look like a uuid, so the two cannot collide. - **The rename test is written first, each slice.** It renames a handle and asserts every reference still resolves and now reads the new one. That is the capability the epic buys, so it is what the slice proves. - **The exception is retired, not just the tables.** With all nine registries converted, a closing slice removes the slug-keyed carve-out from the doctrine: the [api-first](/contributing/api-first/) rule now states every foreign key stores a uuid with no exception, [ADR-0056](#adr-0056-every-foreign-key-stores-a-primary-key)'s carve-out is marked retired, and the `TestReferencesCarryBothForms` guard drops its slug-keyed allow-list (the registry references move into the both-forms rule, and the one that surfaced a real gap, a component response that carried `product_id` without the product's name, is fixed). The storage helper collapses too: the per-registry `productRefCol` / `vendorRefCol` and the `registryHandles` set fold into one `registryRefCol(ref)`, since every registry now behaves the same. ### ADR-0063: The telemetry model is typed registries over bare-noun data tables - **Date:** 2026-07-23 | **Status:** Accepted | **Pages:** [datapoints](/architecture/datapoints/), [events](/architecture/events/), [variables](/architecture/variables/), [storage](/architecture/storage/), [glossary](/architecture/glossary/) - **Decision:** every component interaction normalizes to one of three **registries**, each suffixed `_type` (`property_type`, `event_type`, `command_type`), over bare-noun **data tables** (`metric`, `state`, `property`, `event`, `command`). A registry is a classification, so it takes the `_type` suffix; the bare noun holds the instances. This retires the last confusion left by the [datapoint_type to property rename](#adr-0062-a-registry-takes-a-uuid-primary-key-and-a-renameable-handle): today's `property` registry becomes `property_type` (`kind` in `{metric, state}`), and today's `property_value` becomes `property`, the latest-value cache. `event` and `command` gain their own registries and are no longer modeled as a `property` kind. - **Context:** using bare nouns for registries was the root inconsistency. `property` named the registry while `property_value` named the data; `event` had been folded into the property registry as `kind=log`, the "false unification" [datapoints](/architecture/datapoints/) and [events](/architecture/events/) explicitly warn against; and the code said `property` while the pages still said `datapoint_type`. Suffixing the registry `_type` and freeing the bare noun for the data fixes all three at once, and it **vindicates the two-registry separation the pages always wanted**: `property_type` and `event_type` stay distinct catalogs, they were never one universal registry. - **The reusable pattern.** A **registry** (`_type`) defines canonical entries. A **realization** (the bare ``) records data referencing one registry entry by FK, over the same exclusive **owner arc** (component / system / location / node) the estate already uses, tagged with a **provenance** or **origin**. `metric`/`state`/`property` reference `property_type`; `event` references `event_type`; `command` references `command_type`. One rule, four tables, no bare-noun registries. - **A log is a collection of events, so there is no `log` table.** The row is an `event` (one occurrence); a log is the *stream* of them, the way a registry is a collection of keys. So the earlier plan to rename the occurrence table `event` to `log` is **reversed**: the table stays `event`, and "a component's log" is a **query** over its events (observed origin). Component-observed versus platform-derived is an `origin` on the row, not a separate table; promoting a raw line into a typed event is enrichment of the row, not a move between tables. - **The owner arc stays; owner-prefixed tables (`component_metric`, `system_metric`, ...) are rejected.** The exclusive arc already carries a component's and a system's metrics in one table, and it is the estate's established primitive (`property`, `tag_binding`, `secret`, `variable` all use it). Splitting by owner would multiply the firehose tables fourfold, fragment the hot path, and force a UNION for the query that matters most: a system's health rolling up its components' metrics wants one table. The only gain is a non-null single FK, which the arc's check already enforces logically. - **`property` is a latest-value cache; the firehose stays `metric`/`state`.** `property` holds the newest value per **series**, `(owner, property_type, instance, provenance)`, the same series identity the firehose uses, **upserted on intake**. `metric` and `state` remain the append-only samples. The cache exists to answer "what is it now" and "what did we last tell it" without scanning the firehose. - **Provenance in the cache is rows, not columns.** Each provenance is its own series row (`observed`, `calculated`, `intended`), so `observed=45` and `intended=50` are two rows, not two columns. Columns would put device-intake, command, and config all updating the same row (lock contention, lost updates on the hot path) and bake the provenance set into the schema. The "want / told / is" one-liner is the right **read** shape, delivered as a **pivot view** over the rows: read-shape is not write-shape. - **`declared` resolves on demand; `intended` is stored.** The config setpoint (`declared`) is resolved live from the cascade and never rows into the cache, because it is always current and needs no history. The last commanded value (`intended`) **is** stored, because settlement needs the fact plus its `ts`. So the cache's provenance set is `{observed, calculated, intended}`. - **Two different drifts fall out of that split.** **Command settlement** compares `observed` to `intended`, is **windowed**, and is short-lived ("did my last command take?"). **Config drift** compares `observed` to `declared` (resolved live), is **ongoing** ("is it where config wants it?"). This is exactly why `intended` is stored and `declared` is not. - **The settle window is a driver fact, carried on `command_type`.** How long a command takes to actuate (an input switch is near-instant, a lamp warmup is tens of seconds) is device-physical, so it lives on the **driver**, on the `command_type` the driver populates (the driver as a declarative menu of canonical properties, events, and commands), not on the abstract `property_type`. Settlement is **computed**, never a stored flag: within `now - intended.ts < settle_window` the value is pending and drift is suppressed; past the window, `observed` matching `intended` is settled and a mismatch is a failed command. - **Staging.** The **name foundation** is cheap and lands first: `property` to `property_type`, `property_value` to `property`, and the `metric`/`state` FK repoint, a wide but mechanical sweep with no behavior change. The **event family** (`event_type`, the `origin` and causation columns, pulling `kind=log` out of `property_type`) rides the calculation and promotion layer, still `Design`. The **command pillar** (`command_type`, `command`, the settle window, command settlement) is greenfield. Each architecture page is rewritten to this model in the slice that builds its part, per [docs with everything](/contributing/docs-with-everything/); until then the pages carry an inline note pointing here. --- # Events URL: /architecture/events/ Events: our semantic assertion that something happened, the event_type registry, and the four ways an event arrives. :::caution[Direction: ADR-0063 keeps `event` and `event_type` separate] [ADR-0063](/architecture/decisions/#adr-0063-the-telemetry-model-is-typed-registries-over-bare-noun-data-tables) confirms the separation this page describes, and renames the datapoint registry it references to `property_type` (built). A log is a *collection* of events, so the occurrence table stays `event`. Still directional, not yet built: the richer `event` occurrence (origin, causation, correlation) and its `event_type` payload schema, plus the `command` / `command_type` pillar the action layer drives. ::: An **event** is *our semantic assertion that something happened*, in our vocabulary: a discrete, point-in-time occurrence the action layer reacts to, owned through the same exclusive-arc as a datapoint. It is **not** a datapoint (a datapoint records a value; an event records an occurrence, see [the has-a-value-now razor](/architecture/datapoints/#the-has-a-value-now-razor-datapoint-vs-event)). Datapoints are what rules read; events are what event rules produce. The rules that produce events live on [calculations](/architecture/calculations/); the alarms paired events drive, and the actions that respond, live on [alarms and actions](/architecture/alarms-actions/). ## The event_type registry A datapoint and an event are different shapes (a datapoint has a value; an event is an occurrence), so each gets a registry named for what it holds. The datapoint half is [`property_type`](/architecture/datapoints/#the-property_type-registry); the event half is `event_type`. We do **not** force them into one universal registry, that would be the false unification the rest of the model avoids. **`event_type`** describes every event key: `(name, display_name, payload_schema, scope, ...)`, with the same **`scope`** (template / org / official) as the datapoint registry; a template can define a template-local event. Declaring event types (`call.started`, `cable.unplugged`, `command.sent`) is first-class and valuable: it gives events a known schema, makes them inspectable, and is what lets an event rule promote a raw log line into a *registered* event. An event key is registered here; an unregistered occurrence stays a `log_datapoint` line until a rule promotes it. The naming convention is consistent: a `_type` registry defines what a thing *is*, named for the thing (`property_type`, `event_type`, like `location_type`, `interface_type`). Events get their own registry because an event is a different shape from a datapoint. The `scope` axis works the same way as for datapoints: see [key scope](/architecture/datapoints/#key-scope-template-org-official). ## Events: caught, caused, derived, scheduled An event arrives one of four ways; none is auto-manufactured from a state flip (a transition is already two consecutive datapoint rows, derivable by query). 1. **caught**: a structured occurrence arrives (xAPI Event channel, a webhook, a trap), or an event rule **promotes** a `log_datapoint` line into a normalized event. 2. **caused**: we issued a command, recorded as an event; this is what opens an [intended](/architecture/datapoints/#intended-the-declared-effect-of-a-command) datapoint. 3. **derived**: an event rule fuses signals into an operator-meaningful fact ("codec in-call + traffic spike + room booked, so meeting started"), inferred without instrumenting the control system. 4. **scheduled**: the clock fired a schedule. A schedule fire *is* an event with `origin=scheduled`, manufactured by the clock (a leader-elected singleton held via a NATS KV CAS lock, exactly one active, failing over on death); there is no separate schedule log table. So `action_rule` subscribes to events uniformly (**schedule to event to action**: digests, synthetic checks, SLA resets are all schedule fires an action subscribes to). Caught/caused/derived/scheduled is the event's **origin**, a small vocabulary on the event table; it is not the same enum as datapoint provenance. The discipline that keeps an event-driven system from rotting is that events are declared (registered event keys) and rules are inspectable (the blast-radius preview in the UI). ## Storage The `event` row is the semantic-occurrence log; `event_type` is its key registry. The physical layout (partitioning, the owner arc, lineage) lives on [storage](/architecture/storage/). An event is **born in a Postgres transaction**, on the record lane. When an `event_rule` fires, the consumer writes the `event` row and its paired alarm transition to PG in one transaction (the alarm edge is serialized per `(event_rule, owner)`); the event is the durable record, the alarm is the stateful edge. The event is **not** published directly from the rule (no dual-write): a leader-elected CDC publisher (logical decoding of the WAL) fans the committed change out to JetStream, where the `action_rule` consumers react. Postgres is the system of record; JetStream carries the committed event onward. This is the opposite lane from datapoints, which live on NATS and sink to PG asynchronously (see [datapoints](/architecture/datapoints/)). | Table | Key columns | Notes | |---|---|---| | `event` | id, ts, key, **origin** (caught/caused/derived/scheduled), owner arc, payload (jsonb), correlation_id, **caused_by_event_id** (nullable), **alarm_id** (nullable), + lineage | the semantic-occurrence log; a momentary event has null `alarm_id`, an alarm edge carries it; `caused_by_event_id` is the parent edge: the **durable, read-side** causation pointer (the live cycle guard at dispatch walks the NATS header chain, this is its persisted form), while the flat `correlation_id` threads the chain. A schedule fire is an event with `origin=scheduled` (no separate schedule table) | | `event_type` | name, display_name, **payload_schema (jsonb)**, **scope** | the event-key registry; lets an event_rule promote a raw log line into a registered event. `scope` (template / org / official) works the same way as `property_type` | Related: [calculations](/architecture/calculations/) (the `event_rule` that produces events), [alarms and actions](/architecture/alarms-actions/) (alarms and the response layer), [datapoints](/architecture/datapoints/) (the data events read), and [the glossary](/architecture/glossary/). --- # Expressions URL: /architecture/expressions/ Omniglass expressions: one engine built on Expr and extended with Omniglass functions, behind every operator-authored expression leaf. Expressions let an operator reshape and judge collected values in plain text wherever the platform needs a small computation, and there is exactly one language to learn for all of them. Omniglass evaluates these small operator-authored expressions in many places: an extractor's `value` leaf, a step's `when` guard, an `event_rule`'s fire/clear criteria, a `calc_rule`'s reduce escape, a rule's `scope` predicate, a view/list `filter`, and a dynamic group's membership filter. All of these go through **one engine, Omniglass expressions**, built on **Expr** ([expr-lang/expr](https://github.com/expr-lang/expr)) and **extended** with Omniglass functions. ## One engine, built on Expr and extended There is one expression engine. It is **Expr** at the core, chosen because it is transform-oriented, fast, and sandboxable: it is expression-oriented with a rich built-in function and operator set well suited to reshaping collected values (arithmetic, string ops, slicing, mapping over arrays, null handling, things collection extractors do constantly like `raw / 100.0`, `int(groups[1])`, `node.gain`, `groups[2] == 'true'`), it compiles to a fast program, and it is straightforward to sandbox. On top of that base we add **Omniglass functions**: helpers the platform needs that Expr does not ship, including frame **`encode` / `decode`** and the output-format helpers (**hex / ascii / base64**) that binary and raw-TCP protocols need to pack and unpack wire bytes. The engine is **not pluggable**: there is one dialect everyone authors in, and a compiled program is cached by `(source, env-shape)` so compile cost is paid once. Keeping it to one engine is deliberate (YAGNI on multiple engines); where an expression is not even needed, prefer a straightforward native path over reaching for the engine at all. ## Unit conversion: `convert(value, "")` Stored values are always in their `property_type`'s **canonical unit**, so an operator who wants to author against a non-canonical unit converts at the expression. **`convert(value, "")`** is the stdlib function for this: the **source unit is inferred** from the bound datapoint's canonical unit, and the **target** is a registered unit that must be in the **same family** (a compile error otherwise, since units only convert within one dimension). The conversion itself comes from the [unit registry](/architecture/datapoints/#units-one-canonical-unit-per-key): the target's `to_canonical` and `from_canonical` transforms, **affine** (a factor plus offset) for the common case or an **Expr** for the rare nonlinear one. So an operator can write `convert(value, "fahrenheit") > 100` while storage stays in canonical celsius: the threshold reads in Fahrenheit, the firehose never changes unit. The function form is chosen over a per-unit method like `value.toFahrenheit()` (which would need a method per unit); it is data-driven and general, available wherever expressions run, including `event_rule` / `alarm` criteria, `calc_rule` leaves, and view/list filters. ## Where expressions are used | Site | Leaf | What it evaluates | |---|---|---| | extractor | `value` | reshape a located raw value into the typed datapoint value | | step | `when` | the explicit branch guard (a false guard skips the step and dependents) | | `event_rule` | `fire_criteria`, `clear_criteria` | open/close an alarm-paired event off a datapoint change | | `calc_rule` | `reduce` (escape), `filter` | the named reducers (`worst` / `majority` / `average`, plus windowed `time_in_state` for SLIs) and the Expr escape, with per-input filters | | rule | `scope` | which instances a rule fires for (the Expr scope escape) | | views / list | `filter` | the structured-query predicate operators compose | | dynamic group | membership `filter` | recomputed membership | Because `filter` is the same engine everywhere, an operator who can write a group filter can write a list filter and a rule scope. One language across the surface. ## In-scope bindings Within a function run the engine environment exposes the documented namespaces: `$var:` (config/secret through the cascade), `$dp.` (datapoints, emitted and readable for branching), `$steps..*` (ephemeral scratch), `$event` (a listen payload), and the extractor-local inputs a step prepares for its `value` leaf (`raw`, `groups`, `node`, `item`). Rule and view contexts bind their own documented environments (the candidate entity, the datapoint, the resource row). ## Safety Expressions are **sandboxed**: no I/O, no network, no unbounded loops, bounded execution. Operator-supplied configuration values are bound as **data in the environment**, never spliced into expression text, so a hostile value is evaluated literally and never executed. Secret fields rendered into a request are masked at interpolation time and never surface in a log line, error string, or datapoint label. --- # Files and blobs URL: /architecture/files/ A searchable file handle over a content-addressed blob store, behind the Storage Gateway. :::note[Partial] The first slice ([ADR-0029](/architecture/decisions/#adr-0029-files-slice-1-a-content-addressed-blob-store-and-a-tenant-wide-file-handle), [epic #242](https://github.com/hyperscaleav/omniglass/issues/242), [#244](https://github.com/hyperscaleav/omniglass/issues/244)) is **built**: the content-addressed **`blob`** store as a Storage Gateway primitive (default **pgblobs** backend, dedup on the hash), the **`file`** handle CRUD over the API, generated CLI, and typed client, and the Files operator directory. Access is two layers with no new machinery: the **`file:`** permission plus a per-file **`sensitive`** flag reusing the secret sensitivity tier. Deferred to later slices: **attaching** a file to entities and types ([the many-to-many relation](https://github.com/hyperscaleav/omniglass/issues/242)), async mark-sweep **GC** of aged or event-referenced blobs (a file delete already frees its own blob synchronously when no other handle references it), the **S3** and **disk** backends behind the same seam, **tags-on-files** ([#191](https://github.com/hyperscaleav/omniglass/issues/191)), and search/filter beyond the basic list. The general **resource-classification + clearance** lattice that will subsume the binary `sensitive` flag is [its own epic (#243)](https://github.com/hyperscaleav/omniglass/issues/243). ::: Files let an operator keep the opaque bytes that go with an estate, a firmware image, a config dump, a runbook, a packet capture, searchable and deduplicated, with a searchable **`file`** handle over a content-addressed **blob** store, behind the same Storage Gateway as everything else. ## Two layers: the file handle and the blob - **`file`** is **indexable metadata**: name, content-type, size, `sha256`, tags. The searchable handle an operator references and finds (a firmware image, a device config dump, a runbook doc, a screenshot, a packet capture). It owns no bytes; it points at a blob by hash. - **the blob store** holds the **bytes**, **content-addressed by `sha256`**. The hash is the key, so identical bytes are one blob. Splitting them means search and inventory operations (list, filter, tag) never touch bytes, and the same blob can back many file handles. `file` tags reuse the `tag` **key** registry (the same tenant-wide governed vocabulary, so `category` means the same thing on a firmware image as on a component, [config and credentials](/architecture/variables/)), but bind as a **flat per-file set**: a file is not on the structural exclusive-arc, so there is no parent to cascade from. The vocabulary is shared; the cascade is not. ## Access: a permission and a sensitive tier, no placement A file carries **no estate placement**. Unlike a [secret](/architecture/variables/), which is *for* one component or system and so sits on the exclusive arc, a file relates to *many* things (a firmware image documents many device types) or to nothing (a loose upload), so its locality is its future **attachments** (a many-to-many relation), not a single owner column. A file is therefore **tenant-wide**, and two existing layers gate it, with no new machinery: - the **`file:`** permission on every route (`file:create`, `file:read`, `file:delete`; download rides `file:read`); - a per-file **`sensitive`** flag that reuses the secret sensitivity tier ([ADR-0025](/architecture/decisions/#adr-0025-secret-is-a-sensitive-resource-a-per-secret-admin_sensitive-flag-flips-a-secret-to-the-admin-tier)): a flagged file is lifted to the **`:admin` tier** (`file:read:admin`), hidden from a lister without it and answered with a **non-disclosing 404** to a reader without it. So a competitive quote or a config dump with internal detail is a *sensitive global file*, admin/owner-only, while a firmware image or a runbook is an ordinary shared one. Unlike a secret (which defaults sensitive, being a credential), the flag **defaults `false`**: a file is shared unless marked. And unlike a secret, `file` is **not** in the sensitive-resource set, so the viewer floor (`*:read`) reads ordinary files; only the flag fences the confidential ones. When the [resource-classification + clearance](https://github.com/hyperscaleav/omniglass/issues/243) lattice lands, it subsumes this binary flag (a 2-rung case of it) across files and secrets alike; the external-principal class it introduces is what will keep an outside integrator from ever seeing an internal file. ## Content-addressing earns four properties A blob is keyed by the hash of its bytes, not a UUID, which buys: - **dedup**: identical bytes collapse to one blob (two operators uploading the same firmware, the same `raw` payload seen twice); - **integrity**: the hash verifies the bytes on read, tamper-evident by construction; - **immutability**: bytes cannot change without changing the key, like the append-only ground-truth logs; - **backtest-stability**: an event referencing a hash still resolves under a backtest, because the hash is stable across a backtest. So **rows reference a hash, never inline bytes.** Inline `bytea` would kill the hash-ref stability property and bloat the firehose row. Small structured values (a datapoint, its labels) stay inline in the row's jsonb; **large or opaque payloads become a blob hash-ref** (a dedicated **indexed** `blob_sha256` column on the referencing row, so GC can probe it, not buried in jsonb): a big `log_datapoint` body, and especially a **`collection.failed` event's raw** when the wire payload is large (a full SNMP walk, a big HTTP body, a capture). Raw stays inline when small; the size threshold is the switch. :::caution[Open question] The inline-versus-blob size threshold: one global cutoff, or per-kind (`raw` versus log body versus operator upload). ::: ## Dedup is database-scoped The blob key is **`sha256`**, the bare content hash. There is no `tenant_id`: isolation is per-database (a database per tenant), so each tenant's blobs live in a separate database and dedup is global *within* that database. One tenant can never detect another's content by hash collision, because the blobs never share a store. The efficiency cost of not sharing bytes across databases is the right price for physical isolation. ## Backends, swappable behind the gateway The bytes live behind the Storage Gateway, so the backend swaps with no model change (the same seam as the columnar and object tiers): - **default: `pgblobs`** (a dedicated Postgres blob table), the single-binary, no-external-dependency story; - **scale: an S3-compatible object store**; - **disk** for local and dev. The `file` and the hash reference are identical across backends; only `storage_ref` resolution differs. :::caution[Open question] Chunking and streaming for very large blobs (firmware images, captures) on the `pgblobs` backend. ::: ## Reference-counted GC, not age-based A blob is collectable **only when no live reference points at its hash AND a grace or retention floor has passed**. Age-based GC alone is wrong: dedup means a blob uploaded long ago can be the one a *recent* event references, so collecting by the blob's own age would orphan a live hash. References come from: - a **`file`** handle; - a large `log_datapoint` body; - a `collection.failed` raw hash-ref; - an **attach event** (a `state` or `audit_log` recording "this component was attached to this file at T"). References disappear two ways: a `file` is deleted, or a referencing **event ages out** (a retention partition drop). So GC is **coupled to retention**: dropping a partition releases its references, after which a now-unreferenced blob past the grace floor is collectable. **Mechanism: index-probe mark-sweep by default.** GC enumerates blobs past the grace floor and, for each, probes the indexed hash-ref columns on the referencing tables; a blob with no live reference is collected. A **maintained refcount column or `blob_ref` table is a measured optimization**, earned only if the per-blob probes profile too expensive (the same ship-the-simple-thing discipline as the storage projections). The grace floor is the safety margin against an in-flight reference, so GC never races a just-written event. :::caution[Open question] The grace-floor duration relative to the backtest window (long enough that a prospective backtest re-deriving over the window cannot reference a collected blob). ::: ## Storage The handle and the content-addressed bytes; the physical layout (the gateway, GC) is above and on [storage](/architecture/storage/). | Table | Key columns | Notes | |---|---|---| | `file` | id, name, content_type, size, **sha256**, sensitive, (later tags) | **Partial.** Searchable metadata handle; points at a blob by hash. Tenant-wide (no placement arc); `sensitive` lifts the row to the `:admin` tier (defaults false). Tags-on-files is [#191](https://github.com/hyperscaleav/omniglass/issues/191) | | `blob` | **sha256**, bytes / storage_ref, size | **Partial.** Content-addressed bytes; dedup on the hash. Default pgblobs (inline `bytea`); S3 / disk behind the same `blob.Store` seam. Content type lives on the file, not here (content-addressing is about the bytes). A file delete frees its unreferenced blob synchronously (dedup-aware); async mark-sweep GC of aged/event-referenced blobs is deferred | --- # Glossary URL: /architecture/glossary/ The authoritative glossary: every official term in the architecture, defined once. This is the **authoritative glossary**: every official term in the architecture, defined once. The other pages introduce these terms in **bold** as the story reaches them; this is where you look any of them up. | Term | Definition | |---|---| | **node** | Edge process (`--mode node`); pulls and runs tasks and commands over interfaces; carries placement, heartbeat, bound credential. | | **node mode** | The Storage Gateway's placement-scoped write mode for node-driven ingest, between `scoped` and `system`: visibility is the node's placement-derived `visible_set`, not all-visibility. See [identity and access](/architecture/identity-access/). | | **placement visible_set** | The owners a node may write, derived from its materialized worklist (the owners of the tasks assigned to it). A node's writes are confined to this set; an emitted owner outside it is an orphan / discovery candidate, never an authoritative write. See [collection](/architecture/collection/). | | **function** | A trigger plus a DAG of steps, declared in a component template; the unit of edge collection. Triggered by a schedule (poll), incoming data (listen), or a command. See [collection](/architecture/collection/). | | **flow** | A multi-step **action** (branching, parallel steps, waits); an escalation is the canonical case. See [alarms and actions](/architecture/alarms-actions/). | | **task** | A node's unit of collection: **poll** (we ask) or **listen** (we wait), over a stateless or stateful (session) interface. Content-addressed. | | **interface** | A connection to a component, declared once per protocol; transport stateless or stateful (to a session). | | **interface_type** | Protocol-and-style registry (ssh, http, snmp, mqtt, webhook...); built-flag + param schema. | | **session** | A stateful interface's live held-open connection; a current-state view over `session_log`. | | **collection.failed** | The event emitted when a parse or validation rejects; carries the raw payload for diagnosis and backfill-after-fix. There is no stored telemetry table; raw is not otherwise persisted (a dev raw-mode taps it live). | | **raw_sample** | An opt-in raw-retention policy, cascade-resolved on interface / task / template: `off` (default), `all`, or `1-in-N` (sampled). Short TTL, range-partitioned and cold-tierable like metric partitions. The kept window is re-parsable against the immutable function version, so a corrected extractor re-derives it; outside the window a wrong-but-conforming parse is forward-fixable only. Bounded, sampled, short-lived: not a telemetry table. See [collection](/architecture/collection/). | | **datapoint** | An observation: a key's value on one owning entity at one time, with provenance + source + on-row lineage. Kinds: metric, state, log. | | **metric** | Numeric (float8) datapoint. Continuous, aggregatable. The firehose. | | **state** | Categorical/text/object datapoint. Discrete, dwell-measurable. [Config](/architecture/variables/) is keyed to one as its observed side. | | **log_datapoint** | A component's own log lines; value = the line. A stream; also the holding pen for un-normalized occurrences. | | **kind** | What a key is: metric, state, or log. Fixed per key at definition. | | **key** | The identity of what is measured or asserted; registered in `property_type`. | | **canonical signal** | A registered, owner-agnostic measurement name (`power.state`, not `room.power`); one comparable signal across every vendor. | | **owner / owner_kind** | A datapoint/event/alarm's subject, the exclusive-arc: `owner_kind` + the matching typed FK (`component_id`/`system_id`/`location_id`/`node_id`), or the singleton `global` (no FK), + CHECK. | | **property_type** | Registry for datapoint keys: name, `scope`, kind, value_type, unit, fusion_policy, validation. `scope` (template / org / official) decides where the name is unique: `(template_id, name)` at template scope, `name` at org/official. Every datapoint is typed by one (the FK is non-null). Promotes template -> org -> official by re-scope/re-point. | | **canonical unit** | The one `unit` a `property_type` stores in: stored values are always in it, so the firehose is single-unit and every threshold / calc / fusion compares like with like. Native unit is a collection-time fact (normalized in by the alignment value-transform), display unit a presentation fact (converted out on read); neither is stored. See [datapoints](/architecture/datapoints/). | | **unit registry** | A `unit` registry grouped by family / dimension (temperature, data-size, bitrate...), each family one **canonical unit** plus alternates; each alternate carries a `to_canonical` / `from_canonical` transform, **affine** (factor + offset) or an **Expr** (the rare nonlinear case, dB). Official / org scoped. Drives both edge normalization and read-side display conversion. See [datapoints](/architecture/datapoints/). | | **`convert(value, "")`** | The expression stdlib conversion fn: returns the value in a registered same-family unit (a compile error otherwise). Source unit inferred from the bound key's canonical unit, target looked up in the unit registry, so `convert(value, "fahrenheit") > 100` authors a threshold in F while storage stays C. Available wherever expressions run (event_rule / alarm criteria, calc leaves, list filters). See [expressions](/architecture/expressions/). | | **scope** | A key's uniqueness-and-trust axis on `property_type`: **template** (`(template_id, name)`, the template author's, local), **org** (`name` within the deployment, the operator's custom canonical), **official** (`name` globally, shipped with the distro). `official` = the top scope (folds in the prior `official` boolean). | | **template-scoped / org-scoped** | A key minted at `scope=template` (local to one template, `(template_id, name)`) or `scope=org` (a deployment's own canonical, unique by `name`). The promotion ladder lifts template -> org -> official. | | **event_type** | Registry for event keys: name, display_name, payload_schema, `scope`. Supports the same template / org / official `scope` as `property_type` (a template can define a template-local event). | | **provenance** | How we know a value: observed, calculated, intended. Per row. Declared intent is [config](/architecture/variables/). | | **observed** | Measured from a component. On-row lineage: `source_rule` (+ version), the edge function. | | **calculated** | Derived from other datapoints by a calc_rule. On-row lineage: `source_rule` (+ version), the calc_rule. Distinguished from observed by the `provenance` column. | | **intended** | A command's declared effect, pending reconciliation. Lineage: the command `event_id`. Only commands set it. | | **source** | Which sensor/path produced an observed value; distinct from provenance; enables multi-source rows + fusion. A `source` registry carries default weights. | | **correlation_id (datapoint) / caused_by_event_id** | Nullable trace columns on the datapoint tables, orthogonal to the exclusive-lineage CHECK (not lineage pointers). A command propagates its originating `correlation_id` onto the adaptive-poll's observed datapoint, so the `event_rule` that fires off it inherits the id and the cycle-guard walk crosses the command -> device -> observed round trip. Distinct from the read-side [correlation id](/architecture/datapoints/) trace. See [datapoints](/architecture/datapoints/). | | **perspectives** | The source-tagged observed rows for one signal: multiple sources reporting one value, all preserved; a reduce-on-read policy produces the effective value, while every perspective stays queryable. | | **fusion_policy** | Per-key reduce-on-read **default/hint** for multi-source observations (mode + tie-break + source weights), not a mandate: a policy may default from the type but can be source-weighted, per-instance, or left to read time (keep all perspectives, decide on read). Applied on read. | | **fusion** | Reading one effective value from multiple **perspectives** on a signal: same-key multi-source reduces by a policy (read-time, defaulting from the key's fusion_policy); cross-key/system-level = a calc_rule. Perspectives are always preserved. | | **config** | The declared side of a canonical signal: an operator-set value keyed to a `property_type`, reconciled against the observed datapoint via the template's get/set functions and a per-item `reconcile` policy. See [config and credentials](/architecture/variables/). | | **credential** | An access secret with a structured shape, a pluggable `SecretProvider` (inline or external), and a lifecycle (refresh / rotation / expiry); read is `secret:read`-gated and every decrypt audited. Template-driven. | | **variable** | A free interpolated value (a macro): `$var:`, resolved platform→template→instance down the cascade; org-keyed, not signal-bound, no observed side. | | **drift** | The gap between config's declared value and its observed datapoint, on one signal key. | | **reconcile** | Per-[config](/architecture/variables/) item policy for drift, one of three modes: `observe` (record drift, no alarm), `warn` (alarm at warning severity), `enforce` (call the set function to converge, alarm on set failure). Adopting the observed value as declared is a separate one-shot import action, not a mode. | | **cascade** | Resolves the effective config / variable value by folding **bindings** (things somebody decided): platform, component_template, system_template, then the location / system / component trees (weight-free, pure depth); most-specific (deepest) wins. Falls through to the type's **default** when no rung bound anything. Type is not a layer (it resolves via a group filter); groups are placed by weight on the same specificity scale. | | **segmented precedence key** | The cascade's precedence comparator as a segmented / lexicographic key `(segment_rank, depth, group_weight, creation_order)`, so a structural segment never overruns into another regardless of tree depth or stacked group weights. The presentation numbers (e.g. 0 / 100 / 300s / 400s) are presentation-only, not the comparison key. See [cascade](/architecture/cascade/). | | **platform** | The cascade's **least-specific binding tier**, on both the estate axis (`owner_kind = platform`, no FK) and the settings axis (the `platform` level): what an admin set for the whole install. `segment_rank 0`. A write there needs `platform:` on top of the resource permission. Distinct from the `global` estate owner, from a "platform credential" (a vendor account secret), and from the settings **`platform` domain** (a namespace classifier: a namespace only ever set at this level, never further down the principal axis, named after the level but not the same thing as it). See [cascade](/architecture/cascade/). | | **default** | What a value **is** when nobody bound it: a column on a declaration row (a setting's struct tag, a classifier contract's `default_value` on `product_property` and its siblings), beside the unit and the kind. **Not** a cascade tier: it shadows nothing, nothing shadows it, and the fold falls through to it. A kind with no declaration row has none (variable, secret, tag: absent means absent). | | **edge parse** | A function parses a raw payload into datapoints on the node, the edge half of [collection](/architecture/collection/). There is no server-side transform rule. | | **calc_rule** | datapoint(s) to datapoint (calculated): cross-key / system-level derivation. (Same-key multi-source reconcile is the key's fusion_policy.) | | **event_rule** | datapoint change to event: fire_criteria + optional clear_criteria (clear makes events alarm-paired). No separate alarm or condition rule. **Still `Design`**: an alarm today is raised by a caller, not by a rule. The rule declares the **capabilities** its alarm degrades; it carries no health impact of its own, since impact now lives on the [system role](/architecture/core-entities/#system-roles-the-slots-a-system-needs-filled) ([ADR-0050](/architecture/decisions/#adr-0050-health-is-a-recorded-transition-computed-from-the-alarm-capability-role-chain)). | | **for_clear** | A recovery sustain on an `event_rule`, mirroring the fire-side `for`: `clear_criteria` must hold for `for_clear` before the alarm resolves, so a source flapping at the cadence boundary does not churn open/clear. Default 0 (immediate). See [alarms and actions](/architecture/alarms-actions/). | | **action_rule** | A subscription (Expr over events; alarms via edge events) wiring occurrences to actions. | | **identity binding** | How a shared-API / multiplexed source's emitted rows bind to the right owner: a value->owner index `(property_type, value) -> owner` (an identity arc on identity config), resolved in a cascade scope. Precedence: a declared identity config value wins, falling back to the observed identity datapoint sharing its key. See [collection](/architecture/collection/). | | **discovery_rule** | observed data creates components/systems/locations + their identity config; carries the `official` boolean. Input is the orphan / unmatched stream (including out-of-placement labels), idempotent on re-discovery (re-seeing the same identity does not duplicate). See [collection](/architecture/collection/). | | **event** | A discrete semantic occurrence the action layer reacts to. Keyed, point-in-time, owned via the arc. Not a datapoint. | | **origin** | How an event arose: caught, caused, derived, scheduled. | | **alarm** | One raised condition on a **component**: a stateful row with a `severity` (`info` / `warning` / `critical`), a message, a `raised_at`, and a nullable `cleared_at`. Clearing **keeps the row**, so what was wrong and when survives the fix. It reaches a system **only** through the capabilities it degrades (below). Not event-sourced. The ITSM anchor. **Design**: the open-to-close lifecycle driven by an `event_rule`'s paired events, and an alarm owned by a system, location, or node rather than a component. | | **alarm capability** | The capabilities one alarm **degrades** (`alarm_capability`). The single route out of a component: a component that provides a capability but has it degraded does **not** satisfy a role requiring it. An alarm naming none is a note on the device that reaches no system. | | **dependency suppression** | Muting a child alarm whose owner's parent entity (on the exclusive-arc structural tree) is itself down, so one upstream failure does not emit N child pages. Expressible over the exclusive-arc tree. See [alarms and actions](/architecture/alarms-actions/). | | **action grouping** | Coalescing alarms sharing owner / label / `correlation_id` into one action dispatch (one ticket, N members), so a storm is one notification, not N. See [alarms and actions](/architecture/alarms-actions/). | | **severity** | An alarm's alert importance, set to a **severity level** by id; distinct from health (a different axis). It drives the **component's own** verdict (any active alarm degrades it, a `critical` one is an outage) and nothing above: what reaches a system is the degraded **capability** set. Rules and action_rule predicates compare by level (resolved via the level's order). | | **severity level** | A registry row: `id`, `label`, `color`, and an integer `order` (for comparison only). Official defaults ship spaced; an operator can add, relabel, or recolor. Carries the `official` boolean. | | **action** | An ordered sequence of steps (`notify`, `command`, `wait`, `branch`). A single-step `notify` or `command` is the simple case; a multi-step shape (including remediate-verify-escalate) is a **flow**. | | **command** | A `run`-action declaration in a component_template version (not a table); an instance is an `action` with `kind=command`. | | **disagree(A,B)** | A condition operator comparing two provenances or sources of one key. Drift, config drift, conflict. Keeps the DAG. | | **divergence** | Any two provenances or sources of one key that disagree. The universal anomaly signal. | | **lineage (on-row)** | A derived row carries its own lineage; no execution table. The rule version is the backtest hinge. | | **correlation id** | A read-side trace id threading one causal chain end to end: the originating event through every downstream event and action it caused (event -> alarm -> flow/action -> command). Built on the causation lineage; `alarm_id` links one alarm's open/clear events, the correlation id links the whole chain. DX/observability sugar, not a datapoint kind or a stored span subsystem. | | **schedule** | Config: a recurring definition (cron/rrule + IANA tz + what it triggers). | | **timer** | The clock singleton's pending-fire working set (schedule-tick / for-sustain / runbook-wait / watchdog); a Postgres table scanned by the leader-elected clock, each fire realized onto its lane; not history. | | **component** | A deployed instance (device/app/service); owns datapoints; a variable-depth tree; pins a component_template_version; points at the `product` it is, the source of its shape. | | **product** | The concrete SKU (Cisco Room Bar): binds a vendor, a driver, a kind, the capabilities it provides, and the property contract its instances carry. Carries the `official` boolean. | | **capability** | A flat catalog name for what a component can do (`microphone`, `flat-panel-display`). Claimed by a **product** (the default for its instances) and by a **component** (its own facts over that default), and **required** by a system role. Carries the `official` boolean. | | **product_property** | One line of a product's declared-property contract: a catalog property, an optional default, and whether an instance must set it. | | **standard** | The blueprint a system conforms to (Huddle Room, Classroom): the system-side counterpart of `product`, carrying variants (`parent_standard_id`) and the property contract its systems inherit. Optional on a system. | | **standard_property / location_type_property** | The same contract line as `product_property`, declared by a standard for its systems and by a location type for its locations. | | **system role** | A **slot a system needs filled** (a table microphone, a main display), declared on a `standard` (inherited live by every conforming system) or directly on one `system` (ad-hoc), on the same exclusive arc `property` uses. Carries a **quorum**, a required **capability** set, and an **impact**. **Not** an IAM role (below). See [core entities](/architecture/core-entities/#system-roles-the-slots-a-system-needs-filled). | | **role (disambiguation)** | The word is overloaded, and the two senses never cross. A **system role** is a slot in a system, filled by a component (`system_role`, `system_role_assignment`, storage methods `ListSystemRoles` / `SetSystemRole` / `DeleteSystemRole`). An **IAM role** is a capability set granted to a principal (viewer / operator / admin / owner; the `role` table, `ListRoles` / `UpsertRole`, the `/roles` routes). A **health_role** was a third, narrower sense (how a member counts in its system's health rollup), now superseded by the system role's **quorum** and **impact**. | | **role capability** | One line of what a system role requires (`system_role_capability`). The set is **conjunctive**: a component must provide **every** listed capability to fill the role. A role that requires nothing admits anything. | | **component capability** | A component's **own** capability fact (`component_capability`), layered over the ones its product declares: `present=true` adds one the product does not claim, `present=false` suppresses one it does. What lets a **productless** component be staffed while the assignment guard stays strict. | | **effective capabilities** | What a component actually provides: its product's capabilities, UNION its `present=true` facts, MINUS its `present=false` facts. The single definition of "what this component can do" platform-wide, and the set the assignment guard checks. | | **role assignment** | The row that puts a component in a role **in one system** (`system_role_assignment`). Refused (422) when the component's effective capabilities do not cover the role's requirement, and the refusal **names** the missing capabilities. The component FK is `on delete restrict`: a component staffing a role cannot be deleted out from under the system. | | **quorum** | How many components a system role wants filling it, at least one. Two readings ride it: **staffing** (a role with quorum 2 and one assignment is **understaffed** by one, on operator-entered data alone) and **health** (a role with fewer *satisfying* components than its quorum is **impaired**). It is also the **redundancy knob**: quorum 1 with two assigned tolerates one failure. | | **impact** | What an **impaired** system role means for its system: `outage`, `degraded`, or `none` (a column on `system_role`, defaulting to `degraded`). It lives on the **role**, not the alarm or the component, because the same broken box matters differently depending on the slot it was filling: a dead confidence monitor is not a dead main display. The one input the health rollup takes from the declaration side. | | **impaired** | A system role with fewer **satisfying** components than its quorum. A component satisfies a role only when it provides every required capability **and none of those is currently degraded** by an active alarm. An impaired role contributes its `impact`. | | **effective roles** | The resolved read for one system: the roles its standard declares (`from_standard`) UNION those declared on it directly, each with its required capabilities, quorum, impact, assignments, and its served `assigned` / `understaffed` counts. A one-off system resolves only the ad-hoc arm. | | **property** | A stored value for a `property_type` on one arc owner (the latest-value store, was `property_value`), tagged with its provenance (observed / calculated / intended / declared). | | **component_template / _version** | The device shape (collection, commands, property_types, defaults, alarms); the **immutable version** instances pin. | | **system** | A composition of components/subsystems (the service tree); pins a system_template_version; located at a location; **conforms to** an optional `standard`. | | **system_template / _version** | The system shape; the immutable version is the snapshot instances pin. Carries a frozen BOM: per role, its requirement (required canonical datapoints + commands) + health_role. **Still `Design`**, and both halves are superseded by the built `system_role` (a capability set, a quorum, and an impact). | | **template signature / attestation** | An optional author signature on a `template_version`, verified on import; authenticity (who authored it), distinct from the content-hash integrity (that it is unaltered). The hosted / marketplace path verifies signatures regardless of the self-host runtime stance. See [templates](/architecture/templates/). | | **capability manifest** | A declaration on a template of which write-commands and credential shapes it exercises; shown and approved at `:apply`, and the gate behind which `latest` / channel auto-update for device-mutating templates requires an explicit operator re-pin. See [templates](/architecture/templates/). | | **role requirement** | What a `system_template_member` declares for a role: the canonical datapoints and commands a member must provide (plus `health_role`). A component qualifies when its template aligns the required set; pairing filters to qualifiers and the API validates on assign. No allow-list of templates: declare what you need, any qualifying component fills it. **Still `Design`**: the requirement that is built today is a **system role**'s capability set, declared on a standard or a system rather than frozen into a template version. See [templates](/architecture/templates/) and [ADR-0049](/architecture/decisions/#adr-0049-the-system-role-capability-gated-staffing-and-the-resolved-capability-set). | | **location** | A place tree; classified by location_type; no template. | | **global** | The singleton estate **owner**: the top owner above every location where estate-wide health and KPIs roll up. One per deployment, no FK. Ownership only, **not** a cascade tier (that is `platform`) and not a location (the location tree has N unparented tops and no root). | | **operational mode** | A cascade-resolved entity state: **active** / **maintenance** / **disabled**. Maintenance keeps collecting but suppresses consequences (no action dispatch, no drift enforce, no health rollup impact, no SLA count); disabled is the same suppression but also stops collecting (the Zabbix host-disable). Maintenance is windowed and audited. See [core entities](/architecture/core-entities/). | | **decommission / purge** | Delete is **decommission** by default (soft delete: tombstone, retain history, re-commissionable, in-flight cleanup); **purge** is the privileged hard erase. The cascade does not delete members: a system delete unbinds members, an occupied location delete is refused (re-home first), a node delete re-places its tasks. See [core entities](/architecture/core-entities/). | | **KPI** | A shipped derived datapoint (a calc / SLI) owned at system / location / global: availability (health over time) and the utilization family (occupancy, time, booking, ghost). An official default set with an escape hatch. | | **SLI** | Service Level Indicator: a `time_in_state` calc datapoint over a window (e.g. `system.availability`). See [health](/architecture/health/). | | **SLO** | Service Level Objective: the target config value the SLI must hold (availability >= 99.9%). See [health](/architecture/health/). | | **SLA** | Service Level Agreement: meeting the SLO, an `event_rule` firing on breach; compliance over the window is itself an SLI. See [health](/architecture/health/). | | **tag** | An operator `key: value` label. The key is a tenant-wide governed vocabulary (the `tag` registry; new keys need `tag:create`, autocompleted in the UI); values bind per entity (`tag_binding`) and resolve **union on key, override on value** down the cascade. See [config and credentials](/architecture/variables/). | | **group** | A named set (component/system/location/principal), static or dynamic, weighted; a cascade overlay + access scope. A `principal_group` is the principal-subject case. | | **health** | The first-class operational state of every entity, carried as a *calculated* `state` on the owner arc (`source_rule` = `health-rollup`): an alarm degrades a capability, a role missing one falls below quorum and is impaired, its `impact` sinks its system, and a location takes the worst of its systems. A model, not just a rule. See [health](/architecture/health/). | | **verdict** | One health value: **`healthy` < `degraded` < `outage`**, ordered so "worst" has a meaning. `outage` rather than `down`, because a device is down and a room has an outage. Distinct from severity (a different axis). | | **health transition** | One recorded **edge**: the moment an owner's verdict changed and what it changed to. Health is written **transition-only** on `state`, so its history is edges and only edges, which is what makes "when did this break" answerable weeks later. See [health](/architecture/health/). | | **recompute at the write** | The rule that a verdict is recomputed by every mutation that can change it (alarm raise / clear, assign / unassign, role declare / withdraw, quorum or impact change, component capability or product change, system create, standard change, relocation), **in the same transaction**. A **read never writes**: computing on read keeps no history, and writing through on read stamps the edge at the moment somebody looked. See [health](/architecture/health/). | | **health impact** | Superseded by **impact** on a system role (above). The design hung an optional `down` / `degraded` tag on an `event_rule`; an alarm now reaches a system only through the capabilities it degrades ([ADR-0050](/architecture/decisions/#adr-0050-health-is-a-recorded-transition-computed-from-the-alarm-capability-role-chain)). | | **health_role** | Superseded by **quorum plus impact** on a [system role](/architecture/core-entities/#system-roles-the-slots-a-system-needs-filled). The design tagged a member `required` / `redundant` / `informational` on the system_template_member; the same three cases are quorum 1 with impact `outage`, a quorum below the number assigned, and impact `none`, with no fourth vocabulary. A third sense of "role" (see the disambiguation above). | | **health coverage / uncovered** | **Design.** Whether any health-impacting rule resolves against an entity's properties. Covered + none firing + data fresh resolves `healthy`; uncovered resolves `unknown`, not falsely green. See [health](/architecture/health/). | | **unknown reason** | **Design.** A discriminator carried as metadata on health `unknown`, leaving the ordered domain (`healthy < degraded < outage`, `unknown` off-order) unchanged: `stale` (had data, went stale), `uncovered` (no health-impacting rule resolves), `no-data` (covered but never reported). See [health](/architecture/health/). | | **baseline reachability alarm** | **Design.** A reachability alarm seeded per collected component (via the collection / template default), so a freshly-collected device is covered immediately and resolves `unknown -> healthy`/`outage` on first poll; bare `unknown(uncovered)` is then the rare honest "you have not told me what failure looks like" state. See [health](/architecture/health/). | | **view** | A named query returning a uniform `{columns, rows}`; the read side, executed through the scoped gateway. | | **Storage Gateway** | The single door to the database; every read and write goes through it, and scope is injected here. | | **audit_log** | Who-did-what ground truth; one row per operator write, same-tx; the lineage target for operator writes, including config changes. | | **session_log** | Connection-lifecycle transitions (node-reported, diagnostic). | | **internal_log** | Platform self-narration (startup, reconcile, migration, node-reg, config-sync). | | **ground truth** | Immutable append-only records: log_datapoint, audit_log, session_log, internal_log. | | **principal / role / grant** | IAM subject (kind `human` / `service` / `node`; identity is an opaque uuid, never a name); an RBAC capability set crossed with a scope. The base `principal` holds identity + kind only; a human's `display_name` lives on the `human` per-kind table. A `principal_group` is a group of principals used as a grant subject. An AI tool acts via OAuth as a `human` / `service` principal (first-class agent identity is deferred, [identity and access](/architecture/identity-access/)). See [identity and access](/architecture/identity-access/). | | **secret:read** | The IAM permission to read a credential in plaintext; gated per role, and every decrypt is audited. | | **file / blob** | Searchable metadata over content-addressed bytes (pgblobs/S3/disk); dedup. | --- # Groups URL: /architecture/groups/ Named sets of component, system, location, or principal: static or dynamic membership, weighted, a cascade overlay and an access scope. A group lets an operator gather entities that the structural trees keep apart, so you can configure or grant access to "all AV displays" or "everything in this pilot" by attribute or by hand. A **group** is a named set of entities that cuts across the structural trees. The structural tree handles config by position and kind; groups handle config by attribute or by a hand-picked set. One "set of entities" primitive serves two jobs: a [cascade](/architecture/cascade/) overlay and an [access](/architecture/identity-access/) scope, which an anonymous predicate never could. ## What a group is A group: - is **component / system / location / principal** kind (matching the structural levels, plus principals for access); - has **static** membership (an explicit list) or **dynamic** membership (a filter, re-evaluated live as attributes change, so a device leaves the moment it stops matching); - has a **weight** (its specificity on the shared scale, see *Placement*; the only weights in the system); - carries **variable / tag / rule** bindings, with the same per-kind combinators the cascade uses; - is also the unit of **access control**: a visibility / permission scope (see [identity and access](/architecture/identity-access/)). | Table | Key columns | Notes | |---|---|---| | `group` | id, kind (component/system/location/principal), membership (static list or dynamic filter), **weight** | cascade band and access scope ([cascade](/architecture/cascade/), [identity and access](/architecture/identity-access/)) | ## Placement: one specificity scale Structural layers auto-derive a specificity from position, weight-free, and the operator never tunes it: `platform` lowest, then the templates, then the location / system / component trees by depth, then the entity's own **instance** at the ceiling. A **group's weight is its specificity on that same scale**, so a group sits wherever its weight lands relative to the structural bands: a high weight beats deployment (a must-apply override), a low weight loses to it (a default that deployment overrides). The instance ceiling beats any group; equal specificity breaks by creation order. A typed group applies at its own level: a component-group to components directly; a system-group reaches a component **through the system layer** of its cascade. ## Multiple membership An entity belongs to a flat **set** of groups. Collect all their bindings and fold by specificity (weight): highest wins for variables and tag-values; rules accumulate, with weight resolving any add-vs-suppress conflict; equal weights break by creation order. There is no second precedence axis. So however many groups an entity is in, the group band collapses to one weighted list on the shared scale, fully predictable, and the [resolve view](/architecture/cascade/) names the winner. ## No nesting Groups do not contain groups. A dynamic filter already expresses a union (`type in (codec, display)`) and multiple membership covers the rest, so nesting would earn only a narrow "DRY union of static sets" case, not worth its transitive-membership and cycle-guard cost. Whether to add it is an open question, gated on that case actually biting. ## Types are not layers A `_type` (device/app, AV-System, room) is a classification attribute, resolved by a **group** filter (`type == X`), never a tree position. The tree is structural; attributes are groups. This is the bridge from the structural [cascade](/architecture/cascade/) to type-based policy: instead of a type layer in the tree, you author a dynamic group filtered on type and place it by weight. ## What groups are for In operator terms, the same user stories the cascade serves, the group-specific ones: - **A fleet-wide fix that auto-clears.** Cisco firmware 11.2 has a memory leak, so I make a dynamic group `model == "Room Kit Pro" && firmware < "11.5"` at high weight, slow its poll and suppress the false high-memory alarm; the 23 affected codecs across 6 floors get it at once, and each drops out the moment it upgrades. *(dynamic group above deployment, live membership, rule suppression)* - **A broad policy as a floor, not a ceiling.** I put baseline stricter thresholds on a low-weight "PCI-scope" group, so a lab on Floor 3 that needs its own values still wins; the policy is a default the specific deployment overrides. *(low-weight group below deployment, the shared specificity scale)* - **A hand-picked set no filter can name.** I drop the 5 executive briefing rooms into a static "Exec Rooms" group for premium escalation, and grant the exec-support team visibility to that same group. *(static membership; groups double as the access scope)* --- # Health, KPIs, and service levels URL: /architecture/health/ Health as a verdict rolled up from alarms through capabilities and roles, recorded as a transition so the edges are accurate, plus the KPIs every estate should track and SLI / SLO / SLA. Health gives an operator the one answer that matters most, "is this system working right now?", and the one that matters next, "since when?". Omniglass is **opinionated about health**: it is a **first-class capability**, not a byproduct of a customizable rules engine. The *model* is deliberate (an alarm degrades a capability, a capability failure impairs a role, an impaired role sinks its system by a declared impact); the *carrier* is the ordinary datapoint pipeline, so health is stored, queried, and trended like any other signal, with no parallel subsystem. :::note[Partial] Built today: the **`alarm`** table (component-local, with the capabilities it degrades), **`impact`** on a `system_role`, the **rollup** from component through system to location, and the **recorded transition history** that answers "since when". Two reads serve it (`GET /systems/{name}/health` and `GET /locations/{name}/health`) alongside the alarm write surface on a component. Still `Design`: alarms raised by an [`event_rule`](/architecture/alarms-actions/) rather than by a caller, system- and location-owned alarms, the `unknown` verdict and its coverage reasons, the **`global`** estate top, and the whole **SLI / SLO / SLA** and **KPI** tier below ([ADR-0050](/architecture/decisions/#adr-0050-health-is-a-recorded-transition-computed-from-the-alarm-capability-role-chain)). See [implementation status](/architecture/status/). ::: ## The chain: capability is the routing key Health is not "the worst thing happening near this room". It is a chain with one hop per question, and every hop is a thing an operator already models: ```text alarm on a component -> degrades named capabilities -> a component no longer satisfies a role that required one -> the role falls below its quorum and is impaired -> the role contributes its declared impact -> the system takes the worst contribution -> the location takes the worst of its systems ``` - An **[alarm](#the-alarm-what-is-wrong-with-one-component)** is **component-local**: something is wrong with this box. - It names the **[capabilities](/architecture/core-entities/#system-roles-the-slots-a-system-needs-filled)** it degrades. That naming is the only route out of the component. An alarm that degrades nothing is a note on the device and reaches no system, which is the honest reading, not a special case. - A component **satisfies** a [system role](/architecture/core-entities/#system-roles-the-slots-a-system-needs-filled) only when it **provides every** capability the role requires **and none of those is currently degraded**. A capability it has on paper but cannot deliver right now does not count. - A role with fewer satisfying components than its **quorum** is **impaired**. - An impaired role contributes its **impact**, and the system takes the **worst** contribution among its roles. A location takes the worst among every system placed anywhere in its subtree. This is why a **capability is flat** and why a **role requires a set** of them. Capability is the only vocabulary shared by the thing that breaks (a component) and the thing that cares (a slot in a room), so it is the only honest place to route a failure through. Everything else about a component (its vendor, its model, its properties) describes it; only its capabilities say what a room loses when it stops working. ## Impact lives on the role **`impact`** is a column on `system_role`: `outage`, `degraded`, or `none`, defaulting to `degraded`. It answers "what does this slot being empty mean for this room?". It lives on the **role**, not on the alarm and not on the component, because the same broken box matters differently depending on the slot it was filling. **A dead confidence monitor is not a dead main display**, and the difference is not a property of the display, it is a property of the job it was doing. Declaring impact on the role puts the judgement exactly where the operator already made it. | impact | an impaired role means | use it for | |---|---|---| | `outage` | the system is not working | the slot the room cannot run without | | `degraded` | the system is working, worse | the slot that costs quality, not the meeting | | `none` | nothing | a slot you track but do not depend on | **Quorum is the redundancy knob.** A role with quorum 1 and two components assigned tolerates one failure with no verdict change, because one satisfying component still meets the quorum. A role with quorum 2 and two assigned is impaired the moment either one degrades. Redundancy is therefore not a separate concept with its own vocabulary, it is the gap between how many you staffed and how many you need. ## The verdict vocabulary A verdict is one of three values, ordered so "worst" has a meaning: ```text healthy < degraded < outage ``` **`outage`, not `down`.** A device is down; a room has an outage. The word is chosen for what a broken system means to the people standing in it, which is the same reasoning that once picked `ok` over `up` ([ADR-0003](/architecture/decisions/#adr-0003-health-reads-ok-not-up)), applied one level further out. Health is **distinct from severity**. Severity is an alarm's alert importance ([alarms and actions](/architecture/alarms-actions/)); health is an entity's operational state. They correlate but they are different axes: a `critical` alarm on a component filling no role moves nothing above that component, and an `info` alarm that degrades the one capability a required role needed takes the room out. ## The rollup is a pure function The judgement lives in a **pure package** (`internal/health`) that takes resolved inputs and returns a verdict, with **no database access at all**. Storage resolves the inputs and records the answer; the package decides. The subtle cases (a quorum boundary, a role nobody staffed, an alarm degrading a capability no role wanted) are exactly the ones that go quietly wrong in SQL and are trivial to pin down in a unit test, which is the whole argument for the split. Two of its defaults are deliberate safety calls, and they point in **opposite** directions: - An **unrecognized impact reads `degraded`**, never `healthy`. A bad value must not make an impaired role silently harmless. - An **unrecognized recorded value reads `healthy`**. One stray row must not paint an estate broken. The rule behind both: **fail loud about a judgement, fail quiet about a record**. A judgement that cannot be trusted should raise its hand; a record that cannot be parsed should get out of the way. Two more defaults follow the same instinct. A **system with no roles is `healthy`**, because nothing has been claimed about it and painting every unmodelled system red would train operators to ignore the color. A **quorum below one is treated as one**, because a role no component need fill is not a role. ## Health is recorded as a transition This is the load-bearing part of the design. > The most important thing about health is that we have a real, accurate history of the edges. We need to > know exactly when a system went from healthy to unhealthy, and be able to look back at it weeks later. Everything else follows from taking that literally. If the history has to be **accurate**, the only correct place to compute a verdict is the **write** that changed it. If the history has to be **edges**, the right carrier already exists. Health lands in **`state`**, which is **already transition-only**: the ingest path writes a row only when the value differs from the last one stored for that owner, and `StateTransitions` reads the ordered flips that draw the reachability availability strip. Health reuses that primitive exactly as it is, on the **[owner arc](/architecture/core-entities/#ownership-the-exclusive-arc)** (a component, a system, or a location owns its own health series), with `provenance='calculated'` and `source_rule='health-rollup'` naming the producer in the row's lineage. There is **no `health_history` table**, because it would have been a second and worse copy of one that already exists. The first value for an owner is **always** recorded, even `healthy`. An owner whose history starts at its first health-relevant write has a defined beginning; recording only once something goes wrong leaves a reader unable to tell "healthy since we started watching" from "never evaluated". ### Two alternatives, and why both fail **Compute the verdict on read.** Cheap, always current, no writes. It keeps **no history at all**, so "when did this break?" is unanswerable by construction. That is not a smaller version of the requirement, it is the opposite of it. **Compute on read and write the result through.** This looks like it solves the first one, and it is the more dangerous idea because it produces a history that **looks** real. The history is **sampled by whoever opens a page**: the recorded edge is stamped at the moment somebody **looked**, not the moment the estate **changed**. A room that broke on Friday night and was opened on Monday morning reads as breaking Monday morning, and a weekend nobody watched has no weekend at all. A history whose timestamps mean "when a human navigated here" is worse than no history, because it will be trusted. ### Recompute at the write, in the same transaction A verdict is recomputed by **every mutation that can change it**, inside the caller's transaction, so the cause and the verdict commit together or not at all: | the write | why it can move health | |---|---| | raise or clear an **alarm** | a capability is taken away or given back | | **assign** or **unassign** a component | a role reaches or falls below its quorum | | **declare** or **withdraw** a role | a system gains or loses a slot it can be short of | | change a role's **quorum** or **impact** | the same staffing crosses a different line | | add, suppress, or clear a **component capability** | what the component provides is half of satisfying a role | | change a component's **product** | the product supplies its default capabilities | | **create** a system | its opening verdict gives its history a beginning | | change the **standard** a system conforms to | the whole inherited role set is swapped | | change a system's **location** | the contribution moves between rollups, so **both** are recomputed | | **delete** a system | the location it sat in loses a contributor and may have just improved | | change a **product's capabilities** | every component built to it provides a different set, in systems nobody touched | A declaration change on a **standard** moves **every conforming system** at once, since they inherit it live. The relocation case names the location the system **left** explicitly, because that location's row no longer points at the system and its rollup may have just **improved**: a recovery is an edge as real as a failure. **Deleting** a system is the same shape: the system's own rows go with it, but the location's history is the location's, and it has to say when it got better. The **product** case is the one that reaches furthest. A product is a contract, so withdrawing a capability from it can drop a role below quorum in systems no operator went near, and each of those is a real transition. A catalog edit is a health event across the estate. Deleting a **location** is not on the list, and does not need to be: a location that still holds systems, components, or child locations cannot be deleted at all (every one of those references is `on delete restrict`), so a deletable location is empty, its verdict is already healthy, and removing it cannot move its parent. A **missing trigger is a hole in the history**, which is the honest cost of this design and the reason the trigger list is enumerated rather than inferred. ### A read never writes Self-healing on read would stamp the edge at read time, which is precisely the inaccuracy this whole model is built to avoid. So the reads write nothing. They do, however, **compute the verdict they serve from the same rows they display**. This was a correctness fix: the report originally served the **last recorded** verdict while resolving the contributing roles **live**, which let a system with nothing recorded yet report `healthy` while listing an impaired `outage` role right beside it. The report contradicted its own evidence, which is worse than no report. Deriving the served verdict from the resolved rows costs nothing (they are already loaded) and makes that class of contradiction impossible. **Recorded transitions remain the source for history**, which is a different question. A missing trigger can therefore cost an **edge**, but it can never make a report **lie about the present**. ## Reading health Two reads, both scope-injected, both a non-disclosing 404 for an owner outside the caller's scope ([API](/architecture/api/#health-the-verdict-and-why)). **A system's report** is the verdict, every role it needs filled, and for an impaired role the causing chain: which required capabilities an active alarm has taken away and which alarms took them. That chain is the point. A bare "degraded" gives an operator nothing to do; "the `room-mic` role wants 2 and has 1, because `mic-pod-2` lost `microphone` to a critical alarm raised at 14:02" tells them where to walk. A role can also be impaired with **no alarm to name**: nobody was assigned, or the assignments never provided what it requires. The report says so by naming no degraded capability, which distinguishes **short-staffed** from **broken**, two very different jobs. **A location's report** is the verdict plus every system beneath it with its own verdict, as the drill-down. The system read explains the rest, so the location report stays a map rather than duplicating the explanation at every level. Both reports carry the **recorded transitions** over the last 30 days, oldest first: one entry per change, never a sample. That is the availability strip's data, and the answer to "since when". ## The alarm: what is wrong with one component An alarm is a row on a component with a **`severity`** (`info`, `warning`, or `critical`), a **message**, a **`raised_at`**, and a **nullable `cleared_at`**. Clearing sets `cleared_at` and **keeps the row**: what was wrong, and when, outlives the fix. Clearing an alarm that is already cleared is an explicit miss, not a silent success. Severity drives the **component's own** verdict (any active alarm makes the component `degraded`, a `critical` one makes it an `outage`) and **nothing above it**. What reaches a system is the **capability set**, not the severity. This separation is deliberate: severity is how loudly to page somebody, impact is what the room lost, and conflating them is how a monitoring system ends up with a critical alert about a spare device nobody uses. A component's verdict is recorded on its own arc like any other, so a component that fills no role still carries an accurate history of what was wrong with it. ## Still design: where alarms come from Today an alarm is written by an **operator or an API caller**. The full model has them produced by the detection tier: an [`event_rule`](/architecture/alarms-actions/) watches datapoints, fires an event, and an alarm **opens** and stays open while its condition holds, closing on the paired clear event. Health is **ack-independent**, because ack is not close: an acknowledged alarm stays open while its condition holds, so acking annotates and never makes a broken room look healthy. That tier is what turns health from a modelled verdict into a **measured** one. The chain above does not change when it lands: a rule-opened alarm names the capabilities it degrades exactly as a hand-raised one does. :::caution[Open question] Whether a rule declares the degraded capability set directly, or derives it from the datapoint it watched. ::: ### Alarms owned by a system or a location The alarm arc is **component-only** today. The design gives an alarm the same [exclusive-arc owner](/architecture/core-entities/#ownership-the-exclusive-arc) every datapoint and event has, so a **system-scoped** rule can raise a **system-owned** alarm over member data. The canonical case: a display sitting on **input 2** is a perfectly normal state *for the display*, but in a specific room it means the wrong source is on screen. The system template owns the conditions only the system cares about, and the component stays generic. The same discipline governs **SaaS and vendor status** (a UCC platform mapped to system-owned datapoints, [shared-API collection](/architecture/collection/)): a vendor's reported "offline" is an *observed signal from one source*, not a verdict on the room. Author the system condition over it, **corroborated** where you can, rather than trusting it. The vendor's opinion is an input to health, not health itself. The acyclic discipline holds either way: an alarm that **feeds** health degrades a capability, and the "system is down" alarm that fires **off** health (a rule watching the `health` state) degrades nothing. Inputs route, consequences do not, and health rolls up only, so there is no loop. ### `unknown`, and honest coverage The built domain has three values and no `unknown`. The design adds a fourth reading, **off the order**, for "nothing here knows what failure looks like": - **covered, nothing firing, data fresh -> `healthy`.** Something measures what broken means here, it is watching, and it is silent. - **not covered -> `unknown`.** No health-impacting rule resolves. Reporting `healthy` would be a false green. `unknown` carries a **reason** discriminator as metadata, so an operator can tell a measurement gap from a coverage gap: **`stale`** (had data, it went stale, the no-data machinery in [time](/architecture/time/)), **`uncovered`** (no health-impacting rule resolves), **`no-data`** (a rule covers it, but it has never reported). To keep `uncovered` rare rather than default, every **collected component** is seeded with a baseline reachability alarm, so a freshly-collected device is covered on its first poll. :::caution[Open question] How `unknown` composes upward. A required role whose only component is unmeasured is not `healthy`, but calling the system an outage overstates it. ::: ### The `global` estate top The rollup ends at a location today. The design adds the singleton **`global`** owner above every location, the estate-wide verdict leadership reads and the owner that estate-wide KPIs hang off. The same `health` key flows the whole way up: the **owner** gives a reading its level, so one key serves component, system, location, and global without cross-triggering. ## SLI: indicator over a window A **Service Level Indicator** is a `time_in_state` calc over a window (`time_in_state(s)` = the fraction of the window the entity held state `s`, derived from the health transitions this page records), emitted as its own datapoint (the temporal reducer, [expressions](/architecture/expressions/)): ```yaml # availability = fraction of the last 30 days the system was healthy source: { datapoint: health, over: 30d } reduce: time_in_state when: "value.healthy / value.total" # an Expr leaf shapes it into a ratio # -> emits system.availability ``` An SLI is therefore just another derived datapoint, queryable and trendable like any other. It is also the clearest payoff of transition-only recording: `time_in_state` over a stream of edges is exact and cheap, where the same calc over samples is an approximation whose accuracy depends on who was looking. ## SLO and SLA: the target, and meeting it Three terms, not two. The **SLI** is the *measured indicator* (the `system.availability` calc above). The **SLO** (Service Level Objective) is the **target**: the number you intend to hold (availability >= 99.9%), a [config](/architecture/variables/) value on the entity or standard, not machinery. The **SLA** (Service Level Agreement) is **meeting the SLO**: an `event_rule` fires when the SLI breaches the target, and compliance over the contractual window is itself an SLI. ```yaml event_rule: scope: 'system.standard == "meeting-room"' datapoint: system.availability when: "value < $var:availability.slo" # the SLO target, a config value severity: high ``` So the target is config (the SLO), the breach is an event and alarm (the SLA edge), and compliance is a calc (an SLI over the SLA). No new machinery. Windowing is the SLI's concern: a **rolling** window (last 30d) for trends, or a **calendar** window (the billing month) for a contractual SLA; the calendar reset is the one piece that leans on the time primitive. :::caution[Open question] The SLA calendar-window boundaries and timezone, co-designed with the time primitive. ::: ## KPIs: what every estate should track A **KPI** is a derived datapoint (a calc or SLI), registered as a canonical property and owned at the level it describes (system, location, or **global**). It is no new primitive: a KPI is a shipped calc the same way health is. Omniglass ships an opinionated **default set** so the data is there out of the box, with the escape hatch to author your own. **Availability** is health over time: the SLI `time_in_state(healthy)` above. Health is the substance, availability is its ratio, so it ships free at every level up to global. **Utilization** is the AV-native family, over occupancy and booking data: - **occupancy**: current people / capacity (an instant ratio); - **time-utilization**: used vs idle minutes; - **booking-utilization**: booked vs unbooked minutes; - **ghost**: occupied vs booked, so booked but nobody showed (the wasted-room signal). Both inputs are **ordinary components**, no special integration: an occupancy sensor emitting `occupancy.*` and the booking system, a component whose interface is the calendar API, emitting `booking.*`. The KPIs are then calcs over those datapoints, owned at room / system / location / global like any rollup. A booking API is just an interface; a ghost meeting is just `occupied < booked`. :::caution[Open question] The full default KPI set and each one's exact calc. Availability and the utilization family are named, but the precise reducers and windows are unsettled. ::: :::caution[Open question] The `occupancy.*` and `booking.*` canonical signals, and the occupancy-sensor and booking-system component templates that feed the utilization KPIs. ::: ## Why this is the Zabbix service tree, done right Zabbix bolts services, SLA, and the service tree on as a separate subsystem. Omniglass does the opposite: health is **first-class but not separate**. The model is opinionated (an alarm degrades a capability, a role declares its impact, the rollup is engine behavior rather than an editable reducer) and it rides the one datapoint pipeline, so the **system tree is the service tree**: the verdict is a state datapoint, the history is its transitions, the SLI is a calc over them, and the SLA is an alarm. One model, composed, instead of a parallel feature. An operator who understands alarms and datapoints already understands health. Related: [core entities](/architecture/core-entities/#system-roles-the-slots-a-system-needs-filled) (the role, the capability, and the quorum), [alarms and actions](/architecture/alarms-actions/) (the detection tier that will raise alarms), [datapoints](/architecture/datapoints/) (the state datapoint and the owner arc), and the [Standards](/guides/admin/standards/) and [Work with an entity](/guides/operator/entities/) guides for the operator loop. --- # Identity and access URL: /architecture/identity-access/ How principals authenticate, how grants combine roles with scopes, and how the app enforces capability at the route and ABAC scope in the Storage Gateway. Identity and access is how an operator controls who may call the platform and which slice of the estate each caller can see and act on, enforced entirely in the app so "forgot to filter" cannot happen. Enforcement is **two in-app layers**: the capability check (`:`) runs as **API route middleware** before the handler, and the **ABAC scope** filter is injected by the **Storage Gateway** (the only path to the database), where a row-level filter holds by construction. Scope is built on the cascade's groups ([cascade](/architecture/cascade/)). This doc says what IAM **is**. :::note[Partial: what is built, and where it diverges] Built and tested today: the `principal` (+ per-kind `human` / `service`) and `credential` tables, the `role` / `principal_grant` model, the `audit_log`, the capability fast-reject, **local password auth (argon2id) behind an httpOnly session cookie** (`POST /auth/login` and `/auth/logout`, the public `GET /auth/status`), the self-service `GET` / `PATCH /auth/me` and `POST /auth/me:changePassword`, the self-service **session list and revoke** (`GET /auth/me/sessions`, `POST /auth/me/sessions/{id}:revoke`: a signed-in user sees their own live bearer credentials, labelled `session` or `token` by their stored `purpose`, with the current one flagged, and revokes any of them scoped to their own principal so another principal's credential id is a non-disclosing 404; revoking the current one signs it out), the admin **session management** (`GET /principals/{id}/sessions`, `POST /principals/{id}/sessions/{sid}:revoke`, gated by the new `principal:revoke-session` capability: an administrator lists another principal's active sessions and tokens in the same non-secret shape, with `current` always false, and revokes one, bounded to the target so a credential id that is not theirs is a non-disclosing 404, behind the same **takeover guard** as impersonation and the password reset so an owner's sessions cannot be revoked by a lesser admin, and audited with the admin as the actor), the admin **principal directory** (`GET /principals`, `GET /principals/{id}`), human **create** (`POST /principals`) and **update** (`PATCH /principals/{id}`: display name, email, username), an admin **password reset** (`POST /principals/{id}:resetPassword`, gated `principal:reset-password`, policy-enforced, no current password, audited as the admin, refused on self (change your own password from your profile, which verifies your current one), behind the same **takeover guard** as impersonation so an owner cannot be reset and a caller cannot reset a principal whose capabilities exceed their own, and **force-logout**: a reset revokes every one of the target's **sessions** so it takes effect at once (its API **tokens** survive, a token being its own bearer secret, not tied to the password), and a self-service change revokes the caller's other **sessions** while keeping the current one and leaving its tokens intact, and **force-change-on-next-login**: a reset sets `human.must_change_password`, and until the user changes it (which clears the flag) the `authn` choke point refuses **every** route except reading their own principal and the change itself with a 403, so the admin-known secret is short-lived and cannot be used to act), **role assignment** (`POST` / `DELETE /principals/{id}/grants`) with the **owner-invariant trigger** enforcing that the last `owner @ all` grant cannot be revoked, and the **principal lifecycle**: reversible **disable** (`POST /principals/{id}:disable` / `:enable`, which refuses authentication for a disabled principal), a stronger **archive** (`:archive` / `:restore`, a soft delete that hides the account from the directory and blocks authentication, reversibly until purged), and **purge** (`:purge`, an irreversible hard delete, gated on prior archival and on the admin-sensitive `principal:purge:admin`); disabling or archiving the last active owner is refused, and a purge preserves the audit trail by denormalizing the actor's label into each row (the audit foreign keys go `ON DELETE SET NULL`), so the history survives even after its actor is gone. And the per-action `visible_set` resolver enforced in the Storage Gateway across locations, systems, and components, and **principal groups** (`GET` / `POST` / `PATCH` / `DELETE /principal-groups`, membership, and group grants) whose members **inherit** the group's grants through the grant-loader union, gated by `principal_group`, and **profile pictures** (a human's avatar: self-managed via `POST /auth/me:setAvatar` / `:removeAvatar`, admin-managed via `POST /principals/{id}:setAvatar` gated `principal:set-avatar`, normalized server-side to a 256x256 JPEG and stored base64 on the human row). Still `Design`: OIDC / SAML auth, the node / NATS path, the permission cache, custom-role management, and the tenant-policy lever. The per-slice breakdown is on [implementation status](/architecture/status/). Where the build currently differs from the present-tense design below (each logged in the [decision log](/architecture/decisions/)): - **Credentials are `bearer` or `password`.** `credential.kind` is `bearer` or `password` (argon2id, PHC-encoded, one password per principal); the `oidc` / `nats` methods and the full `(method, identifier)` lookup are still deferred. The minted bearer token prefix is `ogp_`. - **Every credential is time-bounded.** `credential.expires_at` bounds a bearer's lifetime, and `AuthenticateBearer` treats a passed expiry as absent (the credential authenticates nothing). A **web login** installs a session cookie with a fixed absolute lifetime (12h), so a stolen cookie is not valid forever, and the cookie's `Max-Age` matches. A CLI-minted **API token** (`omniglass token`) and the **bootstrap token** now expire too: a **90-day default** with a `--ttl` override, hard-capped at **365 days** (a `--ttl` above the cap errors), so no eternal secret sits in the field ([ADR-0017](/architecture/decisions/#adr-0017-every-credential-is-time-bounded-token-purpose-not-expiry-shape), reversing the earlier tokens-never-expire choice). Because both kinds now carry an expiry, they are told apart by a **`credential.purpose`** column (`session` vs `token`), not by whether `expires_at` is set. Enforcement is **lazy**: an expired row is simply refused at auth, there is no background sweep, and the self-service list shows only **live** credentials (`expires_at is null or expires_at > now()`, the same filter). A sliding idle timeout, a housekeeping sweep of long-expired rows, and nearing-expiry notifications are later refinements. | credential | `purpose` | lifetime | | --- | --- | --- | | web-login session | `session` | 12h absolute (fixed) | | CLI/API token (`omniglass token`) | `token` | 90d default, `--ttl` up to 365d max | | bootstrap token (`omniglass bootstrap`) | `token` | 90d default, `--ttl` up to 365d max | - **Failed logins lock the account.** A run of wrong passwords on a real account is throttled by a per-username lockout: `human.failed_login_count` counts consecutive misses and, on the 5th, sets `human.locked_until` to 15 minutes out. Inside that window `AuthenticatePassword` refuses every attempt (even the correct password) with a distinct internal signal that the login handler maps to the **same generic 401** as a bad credential, so the lock is not an enumeration oracle; only the audit (`login_locked`, attributed to the principal) records it. The lock decision is made **after** the argon2 verify, so a locked account is not a measurably faster probe, and a correct password below the threshold clears the counter. **Rotating the password clears the lock**: an admin reset (`SetPrincipalPassword`) or a self-service change (`SetPassword`) sets `failed_login_count = 0` and `locked_until = null` in the same transaction as the new secret, so the account is usable at once rather than waiting out the window (the lock otherwise clears only lazily at the next login). The threshold and window are fixed for now; per-IP throttling and a configurable policy are later refinements. - **A password policy gates the API password surfaces.** A single pure validator (`auth.ValidatePassword`) enforces the policy on the running-server paths that set a password: **create a user** (`POST /principals`) and **self-service change-password** (`POST /auth/me:changePassword`): **at least 12 characters, not on a common-password denylist, and not containing the username**. No character-class composition rules (NIST 800-63B favors length and a blocklist). The API maps a violation to 422; the console mirrors the length and username rules inline and offers a crypto-strong **Generate** action, while the denylist stays server-side. The **direct-DB break-glass lanes** (`bootstrap` and `set-password`) are deliberately **exempt**: they already require database access (fully trusted) and are the recovery path, so the policy never blocks initial setup or a lockout recovery. A breached-password check (HIBP k-anonymity) is a planned enhancement over the embedded list. Because break-glass is a **lockout**, `set-password` also revokes the target's live **sessions** (a stolen login stops at once), and revokes its API **tokens** too with `--revoke-tokens`. This is the only in-product way to fully cut off a compromised **owner**: the API reset and revoke are all 403 on an owner target (the takeover guard, owner-to-owner included), so an owner can only be recovered from the direct-DB lane. - **The `iam` command namespace is not built.** Owner creation is `omniglass bootstrap [--password ]` ([Bootstrap](#bootstrap)), not the `og iam create-owner` path; the broader `iam` admin CLI is deferred with the admin user surface. - **The `agent` principal kind** is already reserved in the schema's `kind` CHECK, although no `agent` identity is issued yet (AI still acts as a `human` or `service`). - **The owner invariant** is upheld by the bootstrap path today; the deferrable Postgres trigger described under [the owner invariant](#the-owner-invariant) is not yet built. - **The required permission is published per route in the OpenAPI spec.** Every gated route registers through the `gated(op, tokens...)` helper, which stamps the operation with `x-omniglass-permission`, so `api/openapi.json` names the capability each request needs. The set of stamps is the **permission universe**, which the **Roles view** reports; a role blade renders it as a **net** view (held alongside missing, with a `Held / Missing / All` toggle). Two build-time guards keep it honest: the published-gate guard (every gated route is stamped, allow-listed routes are not) and a seed-drift guard (every seed-role grant resolves into the universe or sits in an `aheadOfRoutes` allow-list). ::: ## The model in one breath A **principal** is the polymorphic subject of authN/authZ. Identity is the principal's opaque uuid, never an email or name. Each principal has one or more **credentials** (how it authenticates). Each principal holds zero or more **grants**, each a `(role x scope)` pair: the role contributes the verbs, the scope contributes the entities. Permissions are **additive** across grants. The API middleware checks RBAC capabilities before the handler runs; the Storage Gateway injects ABAC scope on every query. ## Principal kinds A principal carries a `kind` value; the same role machinery works across all kinds. Identity is uniform; authN methods and per-kind domain attributes differ. | kind | what it represents | authN | |---|---|---| | `human` | a person | local password + session, OIDC, SAML | | `service` | scripts, integrations, SDKs, bots | bearer token | | `node` | the edge daemon running in the field | NATS JWT/nkey credential | **AI acts as a user; a first-class `agent` principal is deferred.** An AI tool authenticates via **OAuth as a `human` or `service` principal** and acts with exactly that principal's grants, no separate identity. A dedicated `agent` principal kind may be added later; it is not in the initial architecture. Everywhere else AI is simply a scoped, audited user ([AI](/architecture/ai/)). Each kind that needs structured domain attributes gets a **1:1 per-kind table** linked by `principal_id`: `human`, `service`, and `node`. The base `principal` table holds identity + kind only; the per-kind tables hold the rest, including the kind's human-facing label (a human's `display_name`, a service's label, the node's name). ## Credentials One `credential` row per authN method per principal. A principal can hold many (a human with a password + an OIDC link; a service with a rotating token). `(method, identifier)` is the lookup key. | method | identifier | secret_hash | who uses it | |---|---|---|---| | `password` | `principal.id` (uuid) | argon2id of the password | humans | | `oidc` | `iss\|sub` (issuer + subject) | null (IdP verifies) | humans | | `token` | `sha256(token)` | null (identifier IS the verifier) | service | | `nats` | nkey public key | null (NATS verifies the signed nonce) | nodes | The password identifier is the `principal.id` (not the username), so a username change does not invalidate the credential. Service bearer tokens are 256-bit `crypto/rand` payloads with a human-readable prefix (`ogp_`) for secret-scanners and audit clarity; the server only ever stores `sha256(token)`. Cleartext is returned exactly once at mint time. A `node` enrolls with a per-tenant **NATS JWT/nkey** instead: the credential row stores the nkey public key, NATS verifies a signed nonce, and the JWT carries the node's subject permissions (its placement-derived `visible_set`, see [The node path](#the-node-path)). :::caution[Open question] OIDC delegates MFA to the IdP; whether to add a local-account TOTP path for installs not on OIDC is undecided. ::: ## Subjects `human`, `service`, `node`, and **`principal_group`s**. Roles attach to principals regardless of kind; the same `principal_grant` rows mean the same thing whether the principal is a person, a service, a daemon, or an AI tool acting as one. ## Group kinds The `group` membership mechanism (static list or dynamic filter) is shared across kinds, but the kinds are kept **distinct** (not one polymorphic primitive yet, because their usage differs): - **`component` / `system` / `location` groups** are **entity-groups**: they carry config bindings (the cascade) and serve as ABAC **scopes**. - **`principal_group`** is a collection of principals (SCIM-synced or local): a grant **subject**, carrying no config. It groups over principals, not just humans (members can be any principal kind); in practice it is humans synced from the IdP. A grant attaches to a group the same way it attaches to a principal (the one `principal_grant` table, keyed by a group instead of a principal), and every member **inherits** it: the grant loader unions a principal's group grants with its direct grants, so an inherited grant flattens to permissions and resolves to scope identically to a direct one. Membership is static (an explicit join) and flat (no nesting) in the first cut; a group grant is bounded by the same escalation cover-check as a direct one (a granter cannot confer a tier above its own). So `group` appears on **both sides of authZ**: `principal_group`s as subjects, entity-groups as object scopes. :::caution[Open question] Whether to unify the group kinds into a single polymorphic `group` primitive; revisit if their usage converges. ::: ## Roles and the role hierarchy :::note[One word, two models] A **role** on this page is an IAM role: a capability set granted to a principal, in the `role` table, on the `/roles` routes. A **[system role](/architecture/core-entities/#system-roles-the-slots-a-system-needs-filled)** is a slot a system needs filled, in `system_role`, on the `/standards/{id}/roles` and `/systems/{name}/roles` routes. They are separate namespaces with no crossing, which is why the storage methods for the estate side are named `ListSystemRoles` / `SetSystemRole` / `DeleteSystemRole` rather than colliding with `ListRoles` / `UpsertRole`. ::: A role is a **capability set**: permissions per `(resource, action)`. Roles live in a `role` table with a uuid `id` and a globally unique, renameable `name` (the handle `owner` / `viewer` / `admin` / ... are addressed by), each carrying an **`official` boolean**: - **`official: true`**: ship-with the binary, seeded via the boot phase. A release can patch a default permission via `ON CONFLICT DO UPDATE` on the seed. - **`official: false`**: operator-created via the IAM API. **No overrides**: a role name is globally unique across both kinds (the create paths refuse an `official: false` role whose name matches an `official: true` one, and the seed phase fails-safe with a loud warning if it would collide with an existing operator role). This is a deliberate divergence from the shadowable registries (where an org-scoped name may shadow an official one): role override risks lockout with no compensating use case, so a role name resolves to exactly one row. ### The five official roles Each role carries a `display_name` and a `description` alongside its permissions (surfaced in the console's Roles view and the grant-builder tooltips). Inheritance (transitive): each role's **effective** permissions are the union of its own and all transitively-inherited roles' permissions, with wildcards and the `:read` floor resolved. `viewer` is the common floor; `operator` and `deploy` are two branches off it, and `admin` extends `operator`: ``` viewer <- operator <- admin <- owner \ <- deploy ``` | role | what it can do | |---|---| | `viewer` | Read every operator-facing resource within scope. The IAM directories (`principal`, `role`, `principal_group`) are **not** operator-facing: their reads are admin-tier (`:read:admin`), so `viewer`'s `*:read` does not reach the Users, Roles, or Groups pages ([ADR-0023](/architecture/decisions/#adr-0023-the-iam-directory-reads-principal-role-principal_group-are-admin-tier)). Secrets are likewise off the floor: `secret` is a **sensitive resource** a bare `*` does not reach, so `viewer` reads no secret directory ([ADR-0025](/architecture/decisions/#adr-0025-secret-is-a-sensitive-resource-a-per-secret-admin_sensitive-flag-flips-a-secret-to-the-admin-tier)). | | `operator` | viewer + create/update on components, interfaces, rules, config; **read, reveal, create, and update the operational secrets in scope** (`secret:read,reveal,create,update`); ack/snooze/resolve alarms. Secret **delete** stays admin-only, and an **admin-sensitive** secret (a platform credential, `admin_sensitive = true`) is invisible in the directory and a non-disclosing 404 on reveal regardless of scope, so an operator sees device secrets but never a platform key at the same scope ([ADR-0025](/architecture/decisions/#adr-0025-secret-is-a-sensitive-resource-a-per-secret-admin_sensitive-flag-flips-a-secret-to-the-admin-tier)). Creating an admin-sensitive secret needs the admin tier, so an operator can mint only operational ones. | | `deploy` | viewer + create/update on locations, systems, and components (the integrator / field-tech role, typically granted with the `subtree_excl_root` operator to build out a subtree without editing its root). No delete. Carries the same scoped `secret:read,reveal,create,update` as `operator`, for the device secrets a field tech sets up during a site build. | | `admin` | operator + delete on managed resources + manage IAM (principals, credentials, grants, custom roles) + curate registries (`:create`) + **`platform:*`**, the install-wide authority a write at the cascade's `platform` tier needs ([below](#install-wide-authority-is-not-estate-scope)); `operator` and `deploy` hold no `platform` capability, so an all-scoped operator runs every site without being able to change the install-wide value under them. Holds `secret:>` (the tail wildcard, not the two-token `secret:*`), so it reaches the **admin-sensitive** secret tier (`secret:reveal:admin`) that a two-token wildcard cannot, and sees and reveals platform credentials. IAM management is meaningful only from an `@ all` grant (a scoped `admin @ subtree` keeps the operator powers within its subtree but gets no IAM); registry curation is a plain capability, so a custom role can carry `:create` alone for a non-admin curator. Deliberately **not** the superuser: it cannot grant a role above its own tier ([ADR-0013](/architecture/decisions/#adr-0013-a-grant-cannot-confer-capabilities-the-granter-lacks)), so it cannot make itself owner, and it cannot delete `official` roles. | | `owner` | The break-glass superuser (`>`, the tail wildcard, covering every capability at every tier, including admin-sensitive ones and future resources). The unkillable role: at least one active `owner@all` grant must exist at all times (enforced by DB trigger), and an owner account cannot be impersonated. The bootstrap creates the first owner. | The console **Roles view** (`GET /roles`, gated `role:read:admin`) lists these read-only with each role's display name, description, inheritance, and **effective permissions**, so an operator sees exactly what a role grants before assigning it. The role blade shows permissions as a **net** view: the response carries the **permission universe** (every capability the route surface enforces, see [the permission universe](#the-permission-universe-published-per-route)) plus, per role, the **held** subset (resolved server-side by the same `rbac.Set.Allows` matcher as the effective set, so wildcards, the `:read` floor, and the `>` tail are honoured). The blade renders the universe one-per-line, lexicographically, with a `Held / Missing / All` toggle, so an operator reads not only what a role holds but what it is missing. Custom-role editing is a later slice. ### Custom roles Operators create `official: false` roles via the IAM API with a chosen permission set, optionally inheriting from `viewer` (or any other role). Inheritance rules: - An `official: true` role may inherit only from other `official: true` roles (enforced at seed time). - An `official: false` role may inherit from any role. Because of the no-override rule, `inherits: [viewer]` is unambiguous (every id resolves to exactly one role). ### Permission format Permissions are **topic patterns**, matched like [NATS](/architecture/messaging/) subjects (which the node path already uses, so the whole stack shares one wildcard convention): a colon-delimited token path where a **literal** matches itself, **`*` matches exactly one token**, and **`>` matches one or more tokens and must be last** ([ADR-0015](/architecture/decisions/#adr-0015-permissions-are-topic-patterns-single-token-and-tail-wildcards)). One entry per resource per role; the action segment may be comma-separated (a shorthand that expands to one permission each). ``` component:read <- one permission component:create,update <- expands to component:create and component:update alarm:ack,snooze,resolve <- domain verbs alongside CRUD property_type:create <- a registry curator capability (tag/unit/event_type/severity_level/source likewise) principal:* <- any single action on this resource (a two-token pattern) audit:read:admin <- an admin-sensitive permission (three tokens) > <- everything, at every tier (the owner superuser) ``` A normal permission is `resource:action` (two tokens); an **admin-sensitive** one carries a third `admin` token (`audit:read:admin`). Because `*` matches exactly one token, a two-token pattern like `*:read` (viewer) or `*:*` or `principal:*` **structurally cannot** match a three-token `:admin` permission: admin-sensitivity is a **deeper token**, not a special case in the matcher. The IAM directory reads (`principal:read:admin`, `role:read:admin`, `principal_group:read:admin`) use this to keep the Users, Roles, and Groups pages off the `viewer` floor, alongside `audit:read:admin` and `principal:purge:admin`; `admin` carries each explicitly, since its `principal:*` (two tokens) cannot reach them. The whole-estate superuser is `>` (owner); `:>` grants everything under one resource including its admin tier. Actions are HTTP-aligned: `read` (GET), `create` (POST), `update` (PATCH/PUT), `delete` (DELETE), plus resource-specific verbs (`ack`, `snooze`, `resolve` for alarms; future kinds add their own). The aggregate `write` does not exist as an alias. There is one further carve-out: a small **sensitive-resource set** that a bare single-token `*` does not reach, in **both** places `*` can grant (the direct topic match and the `:read` floor). It exists for a resource that is not `:admin`-tier wholesale (an operator legitimately holds a two-token grant on it) but that a bare wildcard must not sweep up. The set is `{secret, settings, platform}`, one reason each: an operator holds a literal `secret:read` and reads its scoped device secrets while a `*:read`-only `viewer` reads none; `settings` is install configuration whose admin read-with-provenance is not part of the viewer floor (an ordinary user sees only the client-visible namespaces, through the authn-only `/settings/me`); and `platform` is not a resource anyone reads at all but install-wide **authority** ([below](#install-wide-authority-is-not-estate-scope)), which full-estate **reach** must not confer. A literal (`secret:read`), a resource wildcard (`secret:*`), and owner's `>` still name a sensitive resource; only the **bare** `*` is turned away ([ADR-0025](/architecture/decisions/#adr-0025-secret-is-a-sensitive-resource-a-per-secret-admin_sensitive-flag-flips-a-secret-to-the-admin-tier)). This is distinct from, and composes with, the `:admin` tier: `secret` uses **both**, a per-secret `admin_sensitive` flag flipping an individual platform credential to the `:admin` tier (see [config and credentials](/architecture/variables/)), while the set keeps the whole resource off the viewer floor. The IAM directories are **not** in the set (they have no legitimate sub-admin reader, so the `:admin` tier alone suffices). Inheritance composes permissions **by union**: ``` parent: component:create,update child: component:delete child effective: component:create, component:update, component:delete ``` There are no negative permissions. To narrow a parent's capability set, define a fresh role rather than inherit. The escalation guard (`rbac.Set.Covers`) uses **pattern subsumption**: a broader pattern covers a narrower one, `>` covers everything, and no partial wildcard covers `>` (so an admin, holding no `>`, can neither impersonate nor grant an owner). ### The permission universe, published per route Every capability-gated route registers through one helper, `gated(op, tokens...)`, which sets the `authn` + `require` middleware, **stamps the operation** with the `x-omniglass-permission` OpenAPI extension, and records the permission in an in-process registry. Because the stamp lands on the Huma operation, the required permission for each request is **published in the generated `api/openapi.json`** (and the YAML): the authz contract is part of the API-first spec, machine-readable by the CLI, the typed client, the docs, and any external reader, not buried in Go middleware. The **permission universe** is the sorted, deduped set of every stamp: exactly the capabilities the API enforces, derived from the routes with no hand-kept catalog to drift. The Roles view reports it (above) as the denominator for the net held-vs-missing view. A permission a role **grants** but the universe does **not** contain is a capability that is not yet routed (enforces nothing). These are legitimate but ahead of their subsystems (for example `alarm:*`, `interface:*`, `task:*` before the collection and event engines expose HTTP surfaces). They show as held-nothing on a role blade, which is the honest signal, and a **drift test** requires each such seed grant to sit in an explicit `aheadOfRoutes` allow-list until its route lands. ## Authorization: grants = role x scope A principal holds grants in `principal_grant`. Each grant is a `(role, scope_kind, scope_id)` triple. A principal can hold many grants; they are **additive**: ``` canDo(P, action, E) iff exists grant g in grants(P) such that action in perms(g.role) AND E in expand(g.scope_kind, g.scope_id) ``` **Action and scope bind per grant, not globally.** The `action` and the `E`-membership test are satisfied by the **same** grant `g`. It is **not** sufficient that the action appears in *some* grant and the entity in *some other* grant: a principal with `operator @ group-A` (which carries `alarm:ack`) and `viewer @ all` (read-only) can ack only alarms whose component falls in `group-A`, never estate-wide, because no single grant pairs `ack` with an all-scope. Flattening permissions into one global set and entities into one global visible set is **not** equivalent to `canDo` and over-permits; the enforcement layers below preserve the per-grant binding. So the same role applied at different scopes composes naturally; mixing roles (e.g., `operator @ HQ` + `viewer @ all` for a site lead who needs read-only visibility outside their primary site) is the intended pattern. Grants from `principal_group` memberships compose the same way. ### Scopes | scope_kind | scope_id | expansion | |---|---|---| | `all` | null | every entity in the database | | `location` | location id | subtree(L): L + its systems + their components + descendants | | `system` | system id | subtree(S): S + its components + descendants | | `component` | component id | exactly { C } | | `group` | group id | members(G) at resolution time (dynamic groups re-resolve) | `expand` realizes a scope to a **bound id set** the gateway injects as a parameterized `owner IN (...)` predicate (or a closure-table join for deep trees), never string-built. The structural-tree walk carries a cycle guard, and the set is **fleet-size-bounded** (entities), so it stays an indexed membership filter. `scope_kind` is enumerated (`all`, `location`, `system`, `component`, `group`); adding a new kind requires a schema change (CHECK constraint) and a new case in the gateway's `expand` function. `scope_id` is operator data. :::caution[Open question] Whether a scope may mix include and exclude (e.g. "all except group X"). ::: ### Install-wide authority is not estate scope The [cascade](/architecture/cascade/)'s least-specific tier is **`platform`**: the value an admin set for the **whole install**, above every location tree top. A write there needs **two** permissions, the resource's own and **`platform:`**, checked together ([ADR-0057](/architecture/decisions/#adr-0057-the-cascades-least-specific-tier-is-platform-and-a-default-is-not-a-tier)): ``` secret:create + platform:create <- seal a secret at the platform tier variable:update + platform:update <- change the install-wide value of a variable tag:update + platform:update <- POST /tags/{name}:setPlatform settings:update + platform:update <- every settings write, which is install-wide by definition ``` The two answer different questions. A **scope** says *how much of the estate* a grant reaches; `platform:` says whether the principal may change *the value under all of it*. Collapsing them would mean any all-scope grant silently carried install-wide authority, so a senior operator trusted with every site could also change the install-wide value every one of those sites inherits. `platform:*` is seeded to **`admin`** (and reaches `owner` through `>`); `operator` and `deploy` hold no `platform` write, and **nothing implies one**. That last part is mechanical, not a convention: `platform` is in the [sensitive-resource set](#permission-format), so a bare single-token `*` never names it, in either place `*` can grant. Only a literal (`platform:update`), a resource wildcard (`platform:*`), or owner's `>` does, which is why a custom role carrying `*:update` holds every estate write and still no install-wide authority. The console mirrors the same set, so it offers the tier controls (the Platform scope on the secret and variable create forms, Edit and Delete on a tier row, the settings write affordances) only to a caller holding both halves, and names the missing capability rather than letting the operator earn a 403. The tier gate is **published per route** like every primary gate: a route that can write at the tier carries an `x-omniglass-platform-permission` extension beside its `x-omniglass-permission` stamp, and both land in the route-derived [permission universe](#the-permission-universe-published-per-route). Where the request already names the tier the handler refuses up front (403); where only the stored row knows its tier (an update or delete by id) the resolved capability is passed into the Storage Gateway beside the ABAC scope, so a caller who may read the row but not act at the tier gets 403 and one who cannot read it gets a non-disclosing 404, the same split every other write uses. ## Visibility cascades down the structural tree A scope of entity E includes E **and everything structurally beneath it** (a location -> its systems -> their components -> their datapoints and alarms). The visible set is **parameterized by action**: `visible_set(P, action)` = the union, over **only the grants whose role carries `action`**, of each scope entity plus its descendants. There is no single global visible set. **`:read` is an implicit floor on every grant**: holding any grant on an entity confers `read` on it, so `visible_set(P, read)` is always the widest set and `visible_set(P, action)` is always a subset of it. The floor is realized as a **capability injection at role-index build** (next): every `:` permission implies `:read`, so the implied reads are present in the fast-reject union, in `canDo`'s `perms`, and in `/auth/me.permissions`, not only in the scope layer. A verb-only role (`alarm:ack` without `alarm:read`, no `viewer` inheritance) is therefore **not** hard-403'd on the read. The asymmetry runs one way only: a principal can **read** an entity it cannot **act** on (in `visible_set(P, read)` but outside `visible_set(P, ack)`, via a read-only grant), but never the reverse. So there is no "actionable but not readable" case, and the status split below stays three-way. Dynamic-group scopes recompute as membership changes. Each per-action set is bounded by **fleet size (entities)**, not data volume. ### Scope operators (how a grant's root matches the tree) A grant carries a **`scope_op`** that says how its root matches the tree, a small operator instead of a pile of boolean modifiers. It lives **on the grant**, not as a new scope kind, so it composes with the additive-grant model and confines the change to one predicate. It is moot for the `all` scope. | operator | glyph | in scope | for | | --- | --- | --- | --- | | `subtree` (default) | ≥ | the root **and** everything beneath it | every action | | `subtree_excl_root` | > | the root's descendants; **not** the root itself | update / delete (read and create keep the root) | | `self` | = | **exactly** the root row, no descendants | read / update / delete (**not** create: no children) | `subtree` is the ordinary case. `subtree_excl_root` is the integrator / deploy grant: `deploy @ location:room-42 (>)` lets a field tech add and edit the systems and components inside room-42 without being able to rename or delete room-42. It narrows only the **modify** actions to the descendants; read and create-placement still include the root so the holder can see the boundary of its scope and place children under it. A `PATCH` on the root is then the readable-but-out-of-write-scope **403** (not a 404: the target is readable), while a `POST` under the root and a `PATCH` on a descendant succeed. `self` is the tightest grant, a leaf-lock on one node: exactly its own row for read, update, and delete, never a descendant, and (unlike the two subtree operators) **not** create-placement, so it grants no authority to grow the tree under the node. So `operator @ location:room-42 (=)` sees and edits only room-42, cannot add a child under it (a `POST` under it is a **403**), and the list returns only room-42. Operators combine by union across grants, resolved per action: an inclusive `subtree` grant on a root wins over an excluding one, and a `self` grant re-admits a root that a `subtree_excl_root` grant stripped (the subtree walk still skips the root; the self predicate matches its row). The operator is part of a grant's identity: the same role at the same root with a different operator is a **distinct** grant, so changing an operator is a revoke plus a grant. ## The owner invariant At least one active `owner @ all` grant must exist at all times. Enforced as a deferrable constraint trigger in Postgres (fires at `COMMIT`, so the swap-owners pattern works in one transaction): ``` BEGIN; INSERT INTO principal_grant (... role='owner', scope_kind='all' ...); -- new owner DELETE FROM principal_grant WHERE principal_id= AND role='owner'; -- old COMMIT; -- trigger fires here, sees the new grant, passes. ``` Attempting to remove the last owner (by grant delete, principal delete, principal disable, or role change) raises a check-violation. The Gateway translates this into a 400 with a clear remediation message. ### Grants cannot exceed the granter Creating a grant is refused (403) when the granted role's capabilities are not **covered** by the granter's own **all-scope** capabilities (`rbac.Set.Covers`, the same primitive as the impersonation escalation guard). So no caller can promote anyone, including itself, to a tier above its own: an **admin cannot grant `owner`** (`>`), because admin is an enumerated role whose patterns do not subsume the superuser tail, and it therefore cannot self-promote to the superuser tier. Only the caller's **all-scope** grants count, so a capability held through a narrower grant cannot be conferred estate-wide. This makes the owner tier a real capability firewall: an admin is deliberately bounded (the top management role, not the superuser), and a self-grant is not a path from admin to owner. The same rule will apply to role editing when it lands (you cannot edit a role above your own tier). ## Impersonation (view-as and act-as) An owner or all-scope admin holding `principal:impersonate` can temporarily see and act through another principal, for troubleshooting. Two modes: **view-as** resolves reads under the target's `visible_set` and refuses every write (read-only), while **act-as** is full, and its mutations are attributed to **both** the impersonated principal and the real admin. `POST /principals/{id}:impersonate` mints a bounded (default 30 minutes, revocable) bearer token stored as an `impersonation_session`, a table deliberately distinct from `credential`: a credential authenticates a principal **as itself**, a session authenticates one principal **as another on someone's behalf**, a materially different fact with its own expiry, revoke, and "who is impersonating whom" listing. The client sends that token, and `authn` resolves it on a bearer miss to the **target** principal, tagging the request with the real actor and the mode; `POST /auth/me:stopImpersonation` revokes it. Two guarantees make it safe, over a hard floor. **Owner protection**: a principal holding `owner @ all` is un-impersonatable by **anyone**, including another owner, in either mode; an owner is the highest-trust account, so impersonating one is a full-takeover vector, removed entirely rather than left to the cover arithmetic. The **escalation guard**: a caller may impersonate a (non-owner) target only when the caller's capabilities **cover** the target's (`rbac.Set.Covers`), so impersonation can never confer a capability the caller lacks (a lesser admin cannot impersonate an owner, and owner protection makes that absolute). Scope is where the modes differ: view-as is cross-scope (read-only grants no write authority), but **act-as** additionally requires the caller's **all-scope grants alone** to cover the target, since an impersonated request resolves its scope from the target: a capability the caller holds only through a narrower grant does not count. Without it a split-grant admin (all-scope user management, campus-scoped infra) could act-as a different campus's admin and gain write there. The rule is resource-agnostic, so it also closes escalation through non-tree writes (`principal_grant`, `role`) whose scoped grants resolve to an empty effective scope: a user-admin who cannot create a single grant directly cannot launder all-scope grant authority by acting-as a grant admin either. And **accountability**: every audited mutation taken while impersonating records `real_actor_principal_id` alongside the impersonated `actor_principal_id`, so the true actor is never lost (the self-service `/auth/me` profile and password edits audit too). Self-impersonation is refused, nesting is refused, and disabling either the target or the real admin kills the session on its next request (the same per-request `active` re-read that makes disable hard revocation). ## Enforcement: where each check lives There is **no RLS and no direct database access** (no PostgREST). The **Storage Gateway is the only door to the database** and the API is its only caller, so authz lives entirely in the app. A targeted mutation passes three checkpoints in order: the **capability fast-reject** at the route, the **`canDo` decision** in the handler, and the **per-action scope plus audit** injected by the gateway. Each is one code seam: ```d2 direction: down classes: { node: { style.border-radius: 8 } group: { style.border-radius: 8 } } client: "Client: SPA / CLI / MCP" { class: node } api: "API process (one binary)" { class: group mw: "Route middleware\nrbac.Require('alarm:ack')" { class: node } mwq: "action in\nANY grant?" { class: node; shape: diamond } e403a: "403 capability missing" { class: node } handler: "Handler" { class: node } hq: "canDo(P, ack, X) ?" { class: node; shape: diamond } e403b: "403 cannot act on target" { class: node } e404: "404 non-disclosing" { class: node } mw -> mwq mwq -> e403a: "no" mwq -> handler: "yes: fast-reject passed" handler -> hq hq -> e403b: "readable, not ack-scope" hq -> e404: "out of read-scope" } gwbox: "Storage Gateway: the only DB door" { class: group gw: "inject visible_set(P, ack)\nplus audit_log in one txn" { class: node } db: "Postgres" { class: node; shape: cylinder } ok: "200 plus action row" { class: node } gw -> db: "parameterized predicate" db -> ok: "1 row changed" } kv: "NATS KV cache\ngrants plus role index\nCDC-invalidated" { class: node; shape: cylinder } client -> api.mw: "POST /alarms/X:ack" api.hq -> gwbox.gw: "yes" gwbox.db -> api.e403b: "0 rows: backstop fires" kv -- api.handler: "composed per request" { style.stroke-dash: 4 } kv -- gwbox.gw { style.stroke-dash: 4 } ``` The capability check is **necessary not sufficient** (it only rejects), the `canDo` check is the **authoritative decision**, and the gateway predicate is the **enforce-by-construction backstop**: handler and gateway return the same status for the same input, so a forgotten handler check cannot leak a write. The detail of each: - **Capability (RBAC) in the API middleware is a FAST-REJECT, never an authorization.** It answers one necessary-but-not-sufficient question: does the action appear in **any** of the principal's grants? If not, 403 before the gateway is ever touched. Answered from an in-process cache (the flattened union of permissions across all grants). It never grants access: passing the fast-reject only means "not categorically forbidden", scope still decides. Routes declare their required permission with `rbac.Require("component:create")`. - **Scope (ABAC) in the Storage Gateway is per-action.** Every query carries `visible_set(P, action)` for the **specific action** being performed (read for a list/get, ack for an `:ack`, command for a `:command`), and the gateway filters rows by their exclusive-arc owner against that action-specific set (the owning `component`/`system`/`location`). A read uses `visible_set(P, read)`; a write uses `visible_set(P, write-action)`, the union of scopes of **only** the grants whose role carries that write action, never the read set and never a global union. This is the enforce-by-construction backstop: an `:ack` whose target lies outside `visible_set(P, ack)` matches **0 rows** even if the handler forgot its up-front check. A gateway write whose action-scoped predicate affects 0 rows is **never a silent success**: the gateway reports the miss to the handler, which returns 404 (target also outside `visible_set(P, read)`, non-disclosing) or 403 (target readable but outside the action scope), matching the up-front `canDo` decision for the same input. A silent 200/no-op is a correctness bug and is forbidden. Each per-action set is bounded by **fleet size (entities), not data volume**, so it stays an indexed membership filter even on the firehose; and because it is an owner filter in app code, not a DB policy, it works identically on Postgres, the columnar tier, or object storage. - The gateway has three query **modes**: **scoped** (an API request carrying a principal's visible set), **node** (a node-driven write confined to the node's placement-derived `visible_set`, the owners of the tasks assigned to it from its NATS subject grants), and **system** (trusted internal work: the CDC publisher, the datapoint persistence sink, reconcile / migrate / seed, all-visibility). Node mode sits between scoped and system: a node is trusted to write platform internals on behalf of itself, but only for the owners it actually covers, so a compromised node cannot write arbitrary owners intra-tenant. System mode is an explicit, audited choice, never the default. There is no fourth path: any storage caller is one of these three. - **Targeted mutation on a known id evaluates `canDo` up front.** A custom method against a specific id (`POST /alarms/X:ack`) evaluates `canDo(P, action, X)` in the handler **before** dispatch, so the decision is clean and explicit, with the gateway per-action predicate as the backstop for a forgotten check. The status split is fixed and three-way, not binary: (a) action in **no** grant -> 403 at the middleware fast-reject (capability missing entirely); (b) target in `visible_set(P, read)` but **outside** `visible_set(P, action)` -> **403** (the principal can read X but cannot perform this action on this target); this 403 leaks no existence, because the caller can already read X. (c) target **outside** `visible_set(P, read)` -> **404**, non-disclosing, exactly as an out-of-scope read. The up-front check and the gateway backstop return the **same** status for the same input. - **Scope is structural, not per-handler**: the principal's scope is a required input to the gateway's query layer, so no code path can query unscoped by accident. With no RLS backstop for in-database scope the gateway is the sole guarantor, so "forgot to filter" must be impossible by construction, not by discipline. - **Coverage scales with the surface, by test, not by discipline.** Three conformance tests keep authorization honest as entities and routes are added. An **authz conformance matrix** runs the full assertion set (capability 403, the over-permit scope 403 on a readable-but-out-of-write-scope target, the non-disclosing 404, in-scope success, the read/act asymmetry) against **every** scoped entity from a registry: a new scoped entity is one registry line and inherits the whole matrix. A **route-gating guard** enumerates the generated OpenAPI and drives each operation with an authenticated zero-permission principal, asserting a 403 for every route outside a short, justified allow-list (the public probe, and the authn-only self-service `/auth/me` routes); a route that forgets its capability gate fails the build. A **published-gate guard** is its spec-side companion: it asserts every route outside that same allow-list carries an `x-omniglass-permission` stamp (and every allow-listed route carries none), so "gated" and "published" are the same set and the OpenAPI is a faithful map of the authz contract. So the capability and scope cores are written once and proven for the whole surface, rather than re-tested per feature. **Worked example (per-grant binding denies estate-wide ack).** Principal P holds two grants: `operator @ group-A` (role carries `alarm:ack`) and `viewer @ all` (read-only). Alarm X is owned by a component in **group-B**. P calls `POST /alarms/X:ack`: 1. **Middleware fast-reject**: `alarm:ack` appears in *a* grant (the `operator @ group-A` one), so it passes. (This is why fast-reject is necessary-not-sufficient: it cannot see that the ack-carrying grant does not cover X.) 2. **Up-front `canDo(P, ack, X)`**: the only grant whose role carries `ack` is `operator @ group-A`; X is not in `expand(group-A)`. `viewer @ all` carries `ack` = no. So `canDo` = **false**. 3. **Status**: X is in `visible_set(P, read)` (via `viewer @ all`) but outside `visible_set(P, ack)`. Branch (b): **403**, "cannot ack this alarm", not a 404 (P can already `GET /alarms/X`, so non-disclosure does not apply). 4. **Backstop**: had the handler skipped step 2, the gateway's `:ack` write carries `visible_set(P, ack)`, X is outside it, the UPDATE matches 0 rows, and the gateway returns the same 403, never a silent success. The flattened-set model would have wrongly allowed this: `ack` is "in the permission set" and X is "in the global visible set", so the per-grant binding is exactly what stops estate-wide ack. - **Non-entity resources** have no entity `E`, so `canDo` cannot scope by owner. Three governance classes: - **IAM subjects** (`principal`, `role`, `principal_grant`, and a principal's **login credential** create/delete): the action must appear in a grant whose `scope_kind` is `all`. A scoped grant confers **no** IAM capability, so `role:create` carried by an `operator @ HQ` grant does not let you create roles. Typically `owner @ all` / `admin @ all`. (Device secrets are a different resource: a **credential variable** is entity-scoped, so its `secret:read` plaintext decrypt and its rotation are ordinary scoped actions against the credential's owner, [config and credentials](/architecture/variables/).) - **Data registries** (`property_type`, `tag`, `unit`, `event_type`, `severity_level`, source): governed by a distinct **`:create` curator capability** (`property_type:create`, `tag:create`, `unit:create`, `event_type:create`, `severity_level:create`, `source:create`). A registry entry has no owner entity, so the grant's `scope_kind` is irrelevant: the check is simply whether the principal holds the capability. Granting it to a curator role lets a principal mint registry entries **without** IAM admin; a minted entry carries its own `scope` (an org-scoped entry shadows an official one, the [namespace-shadow pattern](/architecture/datapoints/#key-scope-template-org-official)), and `official`-scoped entries are reserved to `owner` and the boot seed. - **Type registries** (`location_type`, `secret_type`): governed by a single shared **`type`** resource carrying the full verb set (`type:read`, `type:create`, `type:update`, `type:delete`), unlike a data registry's create-only curator capability. `type:read` needs no separate grant, since `viewer`'s `*:read` floor already covers it; `type:create,update,delete` is granted to `admin`. The **`location_type` property contract** (`/location-types/{id}/properties`) hangs off the same resource, so declaring a property on a type is `type:update` and withdrawing one is `type:delete`. As with data registries, `scope_kind` is irrelevant and the check is simply whether the principal holds the capability, since a type row has no owner entity to scope by. Unlike a data registry entry, a type row carries no namespace-shadow `scope`: an `official` (seed-owned) row is read-only (create/update/delete 422), an operator-created row is `official: false` and freely editable, and deleting a row still referenced by a location is refused (409), first by a Gateway pre-count for the friendly error and, as a backstop, by the parent foreign key. The `official` read-only treatment is reserved for the **canonical catalogs**; the **shipped location types seed as `official: false`**, so they are operator-owned and editable ([the seed model](/architecture/core-entities/#the-seed-model-forked-templates-versus-canonical-catalogs)). - **Catalog entities** (`vendor`, `driver`, `capability`, `product`, **`standard`**): each takes its **own** resource with the full verb set (`product:read` / `:create` / `:update` / `:delete`, `standard:read` / ..., and so on) rather than the shared `type:*`, because each declares more than a label. A **component** carries no type: its shape comes from its `product`, whose delete guard is the `component.product_id` restrict FK. A **system** likewise carries no type: it **conforms** to a `standard`, which is why `standard` graduated out of the `type` registry when it gained a property contract ([core entities](/architecture/core-entities/), [ADR-0048](/architecture/decisions/#adr-0048-the-standard-blueprint-and-the-template-fork-seed-model)). Each catalog's property contract rides its owner's permission (`product:update` declares on a product, `standard:update` on a standard). The fast-reject still only rejects; for these resources the authorization is the grant-class check (an `all`-scoped grant for IAM, the `:create` / `type:` / `:` capability for registries and catalogs), the one place the decision is capability-shaped because there is no entity to scope. Both layers operate **within one database**. Tenant isolation is **per-deployment**: a tenant is one database plus one **NATS account** plus one deployment, so per-database isolation (storage) and per-account isolation (messaging) are the same boundary. There is no `tenant_id` column anywhere, so the cross-tenant boundary is the database / account boundary itself, not a row predicate. Intra-database scope (above) is the only app-enforced layer; there is no RLS backstop. :::caution[Open question] Whether to add a **third authorization lever**: a declarative **tenant-level policy** layer, evaluated at the **highest priority** above RBAC and ABAC, expressing **negative guardrails** an admin declares centrally, the things that must **never** happen. A grant plus scope might permit `system:delete`, yet a tenant policy ("no member of the `integrator` group may ever delete a system") **denies** it, and the deny wins. This is where negative authorization would live, keeping [roles](#roles-and-the-role-hierarchy) additive and positive (a role still carries no negative permissions). Open: whether to add it at all, the policy shape (deny rules over resource + action + subject / scope conditions), the evaluation order, and whether it is deny-only or can also force-allow. ::: ## Caching strategy The hot path must not hit the DB for RBAC. Three layers, in-process, no persisted "effective permissions" projection (which would invite the stale-join class of cache-coherence bug; the grant and role caches below still carry a bounded staleness, the contract for which is stated at the end): 1. **Role index**: at boot, the `role` table is loaded into a Go map with `inherits` resolved transitively, wildcards expanded, and the **`:read` floor injected** (each `:` adds the implied `:read`, so the floor is in the flattened union the fast-reject reads, not only the scope layer). Refreshed on a NATS KV watch keyed on `role` changes. 2. **Principal cache**: at session establish (or first token-auth), the principal's **grants** and the `role -> perms` index are cached by `principal_id`; the flattened `Set[resource:action]` (used only for the fast-reject and `/auth/me`) is derived from them. Invalidated on a NATS KV watch keyed on `principal_grant`, `principal`, or `role` changes. **Group membership is resolved live in-query** (no materialized member-set cache), so a dynamic group's expansion is always current. 3. **Per-request**: the per-action authorization is **composed at request time** from the cached grants + `role -> perms`. The middleware does an O(1) Set-membership fast-reject on the flattened permissions; the gateway builds `visible_set(P, action)` for the **specific action** by unioning the scopes of only the grants whose role carries it. The flattened set never authorizes; it only fast-rejects. Both O(1)-with-a-prefactor in the common case. The DB is the source of truth; caches are derived views with explicit invalidation events. The principal/permission cache, config, and distributed locks live in **NATS KV** (not Postgres `LISTEN/NOTIFY`): a committed change to `role` / `principal` / `principal_grant` reaches NATS through the leader-elected CDC publisher, which updates the KV keys those watches observe. The same KV contract holds whether the design runs single-binary (embedded NATS) or against an external NATS cluster at scale. **Staleness contract.** Both the handler `canDo` and the gateway predicate read the **same** cached grants, so the gateway backstops a *forgotten* check, not a *stale* one: a revoked-but-not-yet-invalidated grant authorizes at both layers. The grant cache therefore carries a **bounded max-staleness**, a TTL floor independent of CDC invalidation, so a CDC-publisher outage or failover cannot extend the revoke-lag window unbounded. For **high-sensitivity mutations** (IAM changes and deletes of IAM objects) the gateway **re-resolves grants in the transaction** against source-of-truth, trading a round trip for zero revoke-lag; that round trip is off the read and firehose **hot path** (which never hits the DB for RBAC). Other control-plane mutations (`:ack`, `:command`, a config `PATCH`) take the cached path and so accept a **bounded revoke-lag** (the TTL floor above): documented and bounded, not closed. An open SSE session **re-checks on every grant-cache invalidation** for its principal (next section's relay) and closes if `:read` is lost. The freshness asymmetry is deliberate: grant membership (the **subject** side) is cached and is the binding staleness constraint, while group membership (the **object** side) is resolved live in-query, so it can only tighten, never loosen, a stale grant. ## The /auth/me contract The web app (and any CLI client) gets the principal + their effective permissions in one call: ```json GET /api/v1/auth/me { "principal": { "id": "...", "kind": "human" }, "human": { "username": "jordan", "email": "jordan@example.com", "display_name": "Jordan Rivera", ... }, "permissions": [ "component:read", "component:create", "component:update", "alarm:read", "alarm:ack", "alarm:snooze", "alarm:resolve", ... ], "grants": [ { "role": "operator", "scope_kind": "location", "scope_id": "HQ" }, { "role": "viewer", "scope_kind": "all", "scope_id": null } ] } ``` The `/auth/me` family is also where a principal manages **its own** identity: `PATCH /api/v1/auth/me` edits the caller's own `display_name` (email is an administrator-set field, not self-editable), and `POST /api/v1/auth/me:changePassword` (an AIP `:verb` custom method) verifies the current password and installs a new one. Both are **authn-only and self-scoped**: they resolve the target from the session, never a path id, so they need no capability and join the route-gating allow-list next to the `GET`. Acting on **another** principal (create, disable, reset, regrant) is the admin surface and does carry capabilities. The same self-scoped family is where a principal manages **its own sessions**: `GET /api/v1/auth/me/sessions` lists the caller's **live** bearer credentials (a web login is a `session`, a CLI/API credential a `token`, told apart by the stored `credential.purpose` since both now carry an expiry; the request's own credential is flagged `current`, and an expired row is omitted the same way `AuthenticateBearer` refuses it) returning only non-secret metadata (the `sha256(token)` is compared in-query to mark `current` and never leaves the database), and `POST /api/v1/auth/me/sessions/{id}:revoke` deletes one of them, **bounded to the caller's own principal** so a credential id belonging to another principal is a non-disclosing 404 rather than a cross-principal revoke; revoking the current credential is permitted and signs that session out. A **bulk** self counterpart, `POST /api/v1/auth/me/sessions:revokeAll` with a `{ purpose }` body (`session` or `token`), ends all of the caller's own sessions or all its tokens at once, **always keeping the credential that made the request** (so a user is never signed out of the one they are on), and returns the count; it reuses `RevokeBearersByPurposeExcept` (the current session hash in `keep`), the same primitive the self-service change-password force-logout uses. A user can also **mint its own** API token with `POST /api/v1/auth/me/tokens` (a **required** description and an optional `ttl_days`, default 90, hard-capped at 365 by validation), which returns the secret **once**. All are authn-only and self-scoped. Every bearer credential now carries **identifying metadata**: a token's **description** (required on a token, empty on an auto-created session), the **user-agent** and **client-ip** that created it (captured by a small middleware before Huma, so both the public login and the token mint see them), and a **`last_used_at`** bumped on authentication (throttled to at most once a minute so a burst of requests is not a burst of writes), so the list shows a device label, the creating address, and a last-active time. Location from the IP (a GeoIP lookup) is **deferred**; v1 records the raw IP and User-Agent only. The console splits the list into a **Sessions** section (web logins) and an **API tokens** section (CLI/API credentials), each rendering the same list primitive, with a **Revoke all** on each section header and a **Create token** action on the tokens section. The **admin** counterpart lets an administrator see and end **another** principal's sessions, so a lost laptop or a leaked API token can be cut off without resetting the account. `GET /api/v1/principals/{id}/sessions` lists the target's active bearer credentials in the same non-secret shape (`session` vs `token`, the `ogp_` locator, created and expiry), with `current` **always false** (there is no "this request's own session" when viewing someone else: the list passes a nil `currentHash`, so no row is ever flagged). `POST /api/v1/principals/{id}/sessions/{sid}:revoke` ends one (204). Both are gated by the new **`principal:revoke-session`** capability, a normal two-token permission held by `admin` and `owner` through their `principal:*` / `>` wildcards, kept separable so a future help-desk role can be granted only it. The revoke reuses the same principal-scoped delete as the self-service one, so a `sid` that is not the target's matches nothing and is a non-disclosing 404, never a cross-principal revoke. It sits behind the same **takeover guard** as impersonation and the password reset (fetch the target, then check): an **owner's sessions cannot be revoked by anyone** (a 403, so a lesser admin cannot sign an owner out from under them), nor can a caller revoke a principal whose capabilities exceed its own. The revoke is **audited with the acting admin as the actor** (the real actor rides context when impersonating), recorded as an auth-domain event (`verb = revoke_session`). The list itself is read-only and carries no takeover guard, so an admin can *see* an owner's sessions even where it cannot end them. A **bulk** counterpart, `POST /api/v1/principals/{id}/sessions:revokeAll` with a `{ purpose }` body (`session` or `token`), ends **all** of one kind at once (a purpose-filtered `RevokeBearersByPurpose`, so revoking sessions never touches tokens) and returns the count; it carries the same `principal:revoke-session` gate, the same takeover guard, and the same audit, so an owner's credentials cannot be bulk-revoked by a lesser admin either. `permissions` is flat and wildcard-expanded, ready for O(1) `useCan(...)` checks in the web app. It is a **fast-reject / UI hint only**, the union over all grants: it answers "could this principal ever do X anywhere", never "can it do X to **this** entity". List visibility likewise (a row in `GET /alarms` is read-scoped) does **not** imply per-action authority on that row. Per-row action affordances (the ack/snooze button on a specific alarm) must be computed against `visible_set(P, action)` for that target, which the `grants` array drives: `grants` is the source for advanced UI logic (scope chips, deciding per-row actionability, explaining why a button is or is not shown). The server is the only authority regardless; the flat list and the list view are hints, the scoped gateway decides. ## Profile pictures A human principal can carry a **profile picture**, managed on two lanes that mirror the rest of the identity surface. **Self**: any signed-in user sets or removes their own avatar (`POST /auth/me:setAvatar` / `:removeAvatar`), authn-only and self-scoped, needing no capability, on the same ungated lane as the profile edit and change-password. **Admin**: `principal:set-avatar` (an all-scope capability, held by `admin` through `principal:*` and `owner` through `>`) sets or removes **any** principal's avatar (`POST /principals/{id}:setAvatar` / `:removeAvatar`), audited with the administrator as the actor. An avatar is not a capability, so the admin lane carries no takeover guard (unlike a password reset). The one-line catalog entry: `principal:set-avatar` is *set or remove any principal's profile picture; self-management needs no capability*. The upload is **normalized server-side and authoritatively** by the pure `avatar.Normalize` primitive, so the client cannot bypass it: it accepts JPEG, PNG, or WebP (GIF and everything else is rejected), refuses a payload over 8 MiB or any source dimension over 8000px (two decompression-bomb guards, one on the input bytes and one on the decoded pixels), center-crops to the largest centered square, resizes to 256x256, and re-encodes as JPEG at quality 82. A bad or oversize image is a **422**. The one normalized size is stored **base64 on the human row** (`avatar`, alongside `avatar_updated_at`); the bytes are **never** loaded on the `loadPrincipal` hot path, which selects only `avatar is not null` (a bool), so the read models carry a cheap `has_avatar` flag and the console knows whether to render an image or fall back to initials without paying for the payload per row. The **read side is a JSON endpoint** (`GET /principals/{id}/avatar` gated `principal:read:admin`, `GET /auth/me/avatar` on the self lane) returning `{ image_base64 }`, which the console renders as a `data:` URL; a principal without a picture is a **404**. It is deliberately JSON and **not** a raw `image/jpeg` handler ([ADR-0018](/architecture/decisions/#adr-0018-the-avatar-read-endpoint-is-json-not-raw-image-bytes)): a raw-bytes chi handler would sit **outside** the Huma authz middleware, breaking the permission-on-every-route invariant, and a bare `` cannot carry a bearer, so a token-only session could not authenticate it. The typed client fetches the JSON (a session cookie or a bearer both work) and builds the data URL. ## The node path Nodes do not use general role x scope. A node authenticates with a per-tenant **NATS JWT/nkey** credential bound to its `node.name` and is authorized only to **its own assignments**: publish telemetry, heartbeat, consume the commands addressed to it. It is an identity-scoped narrow path, and the scope is carried by **NATS subject permissions**, not a route authorizer: - A node is a NATS client over the WAN (outbound only). The connection resolves the principal (kind=`node`) from the nkey, and the JWT's subject permissions are the node's placement-derived `visible_set`: it may publish only to its own ingress and report subjects and consume only from its own durable command queue. The general RBAC permission matrix does not apply. - Datapoints land on the JetStream **raw ingress** subject (the admission consumer confines owner to the trusted stream); the node receives commands from a durable, server-side JetStream command queue rather than polling a route. Placement (the [cascade](/architecture/cascade/)) compiles directly into the account's subject grants, so a node can address only the owners it actually covers. - A node's published datapoints are owner-bound at **stream-consume time, ahead of any evaluation**, by the **admission consumer** at the head of the data lane: for a node it checks the payload owner against the node's placement-derived `visible_set`; for a central webhook, against the interface's declared owner (the per-class confinement is specified in [messaging](/architecture/messaging/)). It re-publishes only confined datapoints to the trusted stream the rule engine, calc, and persistence sink consume; an owner outside the set is an orphan / discovery candidate, never an authoritative datapoint (see [collection](/architecture/collection/)). The fence cannot live only at the durable write, because the rule engine consumes the stream **live**: a forged owner must be caught **before** it can open an alarm or fire an action. **Trusted server-internal producers** (calc, the action layer's intended write) publish to the trusted stream directly, no admission pass. The admission consumer itself runs in **system mode** (its owner lookup is a system-mode gateway read); the persistence sink is then a trusted **system mode** `COPY` relying on confined owners upstream, with no per-row scope predicate of its own. A `node` credential whose subject permissions do not cover a subject is rejected by NATS at publish/subscribe time; a non-`node` principal cannot hold a node account's subject grants. ## One model, never duplicated Authorization is **two in-app layers, each enforced in one place and re-derived nowhere else**: the `:` **capability** check runs as API route middleware before the handler, and the **ABAC scope** filter is injected by the Storage Gateway on every query (a row filter belongs at the data path, where it holds by construction; the gateway also writes the in-transaction `audit_log`). The gateway owns **scope and audit**, not capability. The invariant is that no third surface re-implements either: - **The live UI relay calls these, it does not copy them.** Operators never connect to NATS. The SSE subscribe is a normal route, **capability fast-rejected** at open (not authorized there); the server-side [SSE relay](/architecture/messaging/) then runs each candidate message through the **same** gateway scope a read uses, filtering by `visible_set(P, read)` against each message's exclusive-arc owner, so a live tile gets exactly the rows the operator could have fetched. The session **re-checks on every grant-cache invalidation** for its principal and closes if `:read` is lost, so a mid-stream scope shrink tears the stream down rather than leaking. - **Node subject permissions gate the subject; the admission consumer gates the owner.** A node's NATS grants are mechanically derived from its placement as a coarse transport gate on the WAN edge. But subject permissions constrain the subject **string**, while a datapoint's owner lives in the **payload** (a multi-owner function resolves owner from labels server-side), so the subject grant is **not** a redundant copy of the owner fence: the **admission consumer** (above) is the authoritative owner fence, checking the payload owner against placement at consume time. Subject perms keep a node off subjects it has no business on; the admission consumer keeps a forged owner label out of the trusted stream. The bus carries no operator (`kind=human`) clients at all; an AI tool acting as one reaches the platform only through the API. ## Encryption in transit TLS on the HTTP API (terminated at the binary when given a cert + key, or at the operator's reverse proxy) and on the NATS connection that carries node telemetry and commands. **BYO PKI.** "TLS off" is a deliberate dev-mode flag, never a silent default. ## Audit Every API operation records the resolved **actor** (the principal id) in `audit_log`. Secret decrypts are always audited, never filterable. Node-mode writes record the node principal as actor; system-mode writes record `actor = 'system'` (or `'bootstrap'` for the seed phase) so the audit trail distinguishes operator action from platform internals. An AI tool acts via OAuth as a `human` or `service` principal, so its writes record that principal as actor. When a request is **impersonated**, the row also records `real_actor_principal_id`, the true admin behind the impersonated actor, so accountability survives impersonation (see [impersonation](#impersonation-view-as-and-act-as)). **Two write paths, one read path.** Estate mutations write their `audit_log` row **in the same transaction** as the change (via the Storage Gateway, so a committed change always has its audit row). **Auth events** (login, logout) fire on read/no-tx paths, so they emit through a separate non-transactional seam and record `resource = 'auth'`. The read side is `GET /audit-log` (newest first, filterable by resource and verb, backward-paged by a `before` timestamp), which resolves each actor and real-actor to a username. It is **admin/owner-only**: the route requires the admin-sensitive `audit:read:admin` (three tokens), which a two-token wildcard like `viewer`'s `*:read` cannot match, so only `admin` (which carries it explicitly) or `owner` (`>`) reaches it ([ADR-0015](/architecture/decisions/#adr-0015-permissions-are-topic-patterns-single-token-and-tail-wildcards)), and a read-only operator cannot see the security trail. **Failed sign-ins** are captured for accountability: a wrong password on a **real** account is `login_failed` (attributed to that principal, a brute-force signal), and a correct password against a **disabled** account is `login_denied`. An attempt on an **unknown** username is deliberately **not** written, so scanning random usernames cannot flood the log; bounding a targeted brute force against a real account is the job of endpoint rate limiting (a later slice), not suppressing the audit. ## Bootstrap The first install runs `omniglass bootstrap ` with optional `--password `, `--email `, and `--display-name ` flags. This creates the first operator as a `human` principal with an `owner @ all` grant and a bearer credential (the cleartext token is shown once, only `sha256(token)` is stored) in one transaction; with `--password` it also installs a password credential (argon2id, PHC-encoded) so the owner can sign in to the web console. That operator logs in via the web UI or CLI and begins minting other principals. There is no implicit default principal; the bootstrap is the only path to the first owner. ## Worked example Sam is an AV support tech. SCIM syncs Sam into the **`AV-Support`** `principal_group` (or Sam is a local `human` principal). The group holds one grant: `operator @ "AV-devices" (component-group), viewer @ "HQ" (location)`. Result: - Sam can **operate** (create / update / ack alarms) on AV devices fleet-wide (the cross-cutting entity-group), and **read** everything at HQ (the location node + its subtree). - The gateway's scope filter hides every row outside those scopes; the API middleware blocks Sam from, say, creating a principal (no `principal:create` capability in `operator`). - The day a device joins the `AV-devices` dynamic group, it enters Sam's scope; the day Sam leaves `AV-Support` in the IdP, SCIM removes the grant. :::caution[Open question] The SCIM mapping detail: which IdP attributes drive `principal_group` membership and grants. ::: ## Storage The IAM subjects and their grants; the physical layout lives on [storage](/architecture/storage/). | Table | Key columns | Notes | |---|---|---| | `principal` (+ per-kind `human` / `service` / `node`) | id, kind | base `principal` is identity (opaque uuid) + kind only; per-kind tables hold the rest, including each kind's label: `human.display_name` (the person's real name) + username + email, the `service` label, the `node` name (+ labels, last_heartbeat_at, bound credential) | | `role` | id, **official**, permissions (jsonb: `:`) | RBAC capability set; ship viewer/operator/admin/owner + custom | | `principal_grant` | (principal_id, role, **scope**) | role x scope; scope = a structural node, an entity-group, or `all`; additive | --- # Messaging URL: /architecture/messaging/ The internal and edge NATS subject contract, the sibling to the public API: JetStream streams and consumers, the two lanes, request-reply, KV, the live UI relay, and per-tenant subject isolation. Omniglass has **two typed contracts**. The [public API](/architecture/api/) is the north face (HTTP and OpenAPI: operators, the SPA, the CLI, integrations, MCP). This is its sibling: the **internal and edge transport**, a **NATS subject contract** over JetStream. Service-to-service traffic, the edge, and the live UI ride it. **Postgres stays the system of record; NATS moves.** The deployment topology and the inter-service diagram are on [scaling](/architecture/scaling/). ## Two lanes, one bus Internal traffic splits by what is moving: - **Data lane (NATS-native): datapoints.** Untrusted publishers (a node, an external webhook sender) publish to a **raw ingress subject**; an **admission consumer** at the head of the lane owner-confines each datapoint and re-publishes only confined ones to the **trusted** datapoints stream. The confinement set is **per publisher class**: a **node**'s payload owner is checked against its placement `visible_set`; a **central webhook**'s against the interface's declared owner (from the trusted server-set `interface` label). The republish copies the original `Nats-Msg-Id`, `correlation_id`, and `caused_by_event_id` headers verbatim, so dedup survives the hop. **Trusted server-internal producers publish straight to the trusted stream**, no admission pass: calc output (owner from the validated `calc_rule` scope) and the action layer's intended write (owner from the command target) are already inside the trust boundary. The rule engine consumes the trusted stream directly, and a **persistence consumer** batch-writes it to Postgres as an async sink. Confinement is at **consume time, ahead of evaluation**, because the rule engine reacts live: a forged owner must be dropped before it can open an alarm, not just before it is persisted. The admission consumer itself runs in **system mode** (its owner lookup is a system-mode gateway read; a dropped datapoint is logged as a discovery candidate, [identity and access](/architecture/identity-access/)). Datapoints do not go through CDC, they are already on the bus, idempotent on `(series, ts)`. - **Record / state lane (Postgres-first, CDC-out): events, alarms, actions, operator mutations.** Born in a Postgres transaction (a firing `event_rule` writes the event plus the alarm transition atomically; the API writes config, ack, settings). A **leader-elected CDC publisher** (logical decoding of the WAL) publishes those committed changes to JetStream, where `action_rule`, reconcile, and projection consumers react. No dual-write: born in the commit, the bridge fans it out. ## Streams and consumers - **datapoints** (data lane): untrusted publishers (node, external webhook) publish to a **raw ingress** subject; the **admission consumer** owner-confines per publisher class and re-publishes to the **trusted** datapoints stream that the rule engine, calc, and the persistence consumer read. Trusted server producers (calc, the action layer's intended write) publish to the trusted stream directly. A **work-queue consumer group** scales horizontally (each message to exactly one consumer), so adding worker replicas adds throughput with no leader. - **records** (events, alarms, actions): published by the CDC publisher from Postgres commits; consumed by `action_rule`, reconcile, and projection consumers. - **commands**: a durable, per-node **command queue** the edge holds a consumer on ([nodes](/architecture/nodes/)). - **telemetry** (control-plane, not the datapoint firehose, which lands on raw ingress above): the edge publishes `node.self`, `session_log`, and command results. Durable consumers track their own position; delivery is at-least-once with `Nats-Msg-Id` dedup plus double ack, which with the idempotent sinks (a datapoint on `(series, ts)`, an action transition on `(alarm, action, transition)`, the CDC idempotency key) gives exactly-once **outcomes**. This triple (`Nats-Msg-Id` dedup, double ack, idempotent sink) is the canonical exactly-once mechanism the other pages refer to. The edge stamps `ts`, so the system is ts-authoritative and needs no strict ordering on the wire. ## Subjects, accounts, and scope Subjects are hierarchical and **scope is expressed in them**, not bolted on: - **Tenant = one NATS account.** Per-account isolation (messaging) is the same boundary as the per-database isolation (storage): no shared subjects, no shared rows ([identity and access](/architecture/identity-access/)). - **Subject permissions gate the subject string; the admission consumer gates the owner.** A node may publish and subscribe only the subjects for its placement; the grant is **mechanically derived from placement**, a coarse transport gate, not a second copy of the ABAC model. But a datapoint's owner lives in the **payload** (a multi-owner function resolves owner from labels), which subject permissions cannot see, so the **admission consumer** (above) is the authoritative owner fence, and authorization stays authoritative in the [Storage Gateway](/architecture/storage/). **Operators never connect to the bus**, so there is no operator subject-permission model to keep in sync (see the live UI relay below). ## Request-reply: service to service Synchronous internal calls use **NATS request-reply**: an in-process call in single-binary mode, a request over the bus when modes are split across pods. The public API never uses request-reply (it is HTTP); request-reply is the east-west wire only. ## KV and object store - **KV** holds config, **distributed locks and leader-election** (the CDC publisher and the clock are leader-elected singletons), and the principal and permission cache (replacing Postgres `LISTEN/NOTIFY` invalidation, [identity and access](/architecture/identity-access/)). - **Object store** holds internal artifacts (a compiled per-node runtime unit, for example). User files stay on the content-addressed [blob store](/architecture/files/), not here. ## The live UI relay The web UI gets real-time data by **subscribing to the server, not to the bus**, and never through a polling loop on the API. **Operators do not connect to NATS** (the bus is internal-plus-nodes only), so the live path introduces **no second authorization model**: - **Server-side relay.** The SSE subscribe is a normal route, capability-checked before it opens. The server then holds the internal JetStream subscription, runs every candidate message through the **same Storage Gateway scope** a read would use (the one authoritative ABAC filter, in-process), and streams only what passes down to the browser. The scope filter executes in exactly one place; the live path **calls** it per message instead of re-encoding it as subject permissions. - **Transport is SSE.** The browser opens a **Server-Sent Events** stream on the same authenticated, same-origin HTTP seam as the rest of the API (same cookie or bearer, same proxy, same TLS), and the server pushes. One-way fits a live read: subscribe is one request, data flows down, and mutations and commands keep their own paths (the API action row, the internal bus). Over HTTP/2 the stream multiplexes, so there is no connection-count ceiling. There is **no NATS-WebSocket path and no fallback**: SSE is the one live transport. - **Seed then stream.** A [view](/architecture/views/) over HTTP paints current state; the SSE stream keeps it live with deltas. Bulk reads stay on the views BFF; live deltas come over the relay. - **Where it shines:** a live fleet tile, the alarm console, and the **template-debug / dev-tap** surface, where an operator watches datapoints arrive in real time as a template runs (the learning-tool "render the real engine against live data" surface, [the learning tool](/contributing/learning-tool/)). Related: [API](/architecture/api/) (the public HTTP contract), [scaling](/architecture/scaling/) (the deployment topology and the diagram), [nodes](/architecture/nodes/) (the edge as a NATS client), [workers](/architecture/workers/) (the JetStream consumers), and [storage](/architecture/storage/) (Postgres as the system of record). --- # Nodes URL: /architecture/nodes/ How the edge runtime pulls its worklist, runs tasks and commands, manages sessions, gates reachability, and ships telemetry. A node is the edge runtime that lets an operator collect from and control gear no matter where it sits, by pulling its worklist from the server, running it on the spot, and shipping results back. This page covers how it gets its instructions and runs them: worklist pull, placement, executing tasks and commands, sessions, inbound demux, the task queue, reachability, and shipping telemetry. The declarative shape it executes lives in [templates](/architecture/templates/) and [collection](/architecture/collection/). :::note[Partial] Built today: `omniglass node` enrolls (a node is created server-side, its enrollment token minted at `POST /nodes/{name}:enroll` and exchanged for its NATS credential at `POST /nodes:claim`), connects outbound-only to the server's in-process NATS server, pulls its worklist over a `og.v1.worklist.` request-reply (its enabled tasks plus a `config_generation`), and heartbeats on `og.v1.heartbeat.` (the server stamps `last_heartbeat_at`). **Per-node subject isolation is enforced**: each node's NATS credential is permitted only its own `` subjects, so a node cannot publish or pull as another. Still `Design`: running tasks (the probes), shipping telemetry (the JetStream `Event`), commands and the durable command queue, sessions and inbound demux, the reachability gate, config-generation-driven cache invalidation, `node.self` self-telemetry, and the `node.down` sweep. The credential is a shared secret (the enrollment token doubles as the NATS password); the decentralized nkey/JWT model is deferred. See [implementation status](/architecture/status/) and [decision log](/architecture/decisions/) (ADR-0036). ::: ## The node The node is the edge process (`omniglass --mode node`), one per site, or the **server itself** for work with no site-local edge (see *Placement*). Its identity is **bound to `node.name`**; a compromised node cannot impersonate another (see [identity-access](/architecture/identity-access/) for the node auth path). It holds no config of its own: it pulls what to do, runs it, and ships results. A node's writes are confined to its **placement-derived `visible_set`** (the owners of the tasks assigned to it), so a node ingests in **node mode**, not all-visibility system mode (see [identity-access](/architecture/identity-access/)). A node carries the same **name / display_name** identity as a component, system, or location: `name` is its immutable key and estate address (the NATS subject token and enrollment identity, so it cannot be renamed), and `display_name` is an operator label the console titles it by (falling back to the name). It also holds an optional `location`, a **descriptive** placement (which room the box sits in), **not** a scope: a node stays estate-wide and its `location` clears if that location is deleted (`ON DELETE SET NULL`). The console blade is read-edit-save like the other inventory entities (Edit is the primary action, gated `node:update`, editing display_name, description, and location; the name is read-only); enrolling is a secondary action. Editing goes through `PATCH /nodes/{name}`. A node is also a **taggable owner**: governed [tags](/architecture/tags/) whose `applies_to` includes `node` bind to it (estate-wide, all-scope, `node:update`), and its effective tags are the `platform` layer plus its own direct bindings, no cascade (a node is not a scope tree). The blade carries a **Tags** panel and the list a Tags column and per-key filter facet, the same shape as the component list. **Decommissioning** a node (`DELETE /nodes/{name}`, `node:delete`) is a hard delete that cascades its interfaces, their derived tasks, its node-owned tags and self-telemetry, and its enrollment credential; the component telemetry it collected (owned by the component, not the node) is untouched. ## Getting its instructions The node pulls a **worklist**: the tasks and commands resolved for the components **placed on it**, over a NATS request-reply config pull. It **heartbeats** separately, on its own subject (see [the protocol](#the-node-server-protocol)), so the server tracks liveness independently of the pull. The server, not the template, decides placement (next), and resolves the cascade (config / `$var:` values, effective `interval`, credentials) before handing the node concrete work. The node never sees a template; it sees materialized, resolved task and command instances. The full wire contract, the channels, the command queue, delivery, buffering, credentials, and enrollment, is **[the node-server protocol](#the-node-server-protocol)** below. ### Config propagation (declared change to running node) An interface's connection config (endpoint, snmp community, http auth header) is a **projection** of the component's declared config through its template. The node re-pulls the worklist (tasks) every tick, but **caches interface config for its process lifetime**, so a changed connection input must be propagated, not just written: - **Reconcile on the server.** Changing a declared input (via `/components/{name}:apply`, or a direct write to the component's config) re-renders the affected interfaces from the component's *current* declared config and upserts them, preserving placement. So the materialized interface always reflects the latest declared config, regardless of which path changed it. - **Invalidate on the node.** The worklist reply carries a per-node **config generation** (a `config_generation` field on the reply, not an HTTP header: the node path is NATS): the max `updated_at` across the interfaces the node polls. When it advances, an interface's rendered config changed, so the node drops its interface cache and re-fetches this tick. A steady generation serves from cache; a real change forces a refresh within one tick, no restart. The generation moves at **operator-config pace, not telemetry pace**: it is a read-side aggregate over interface config, and the high-volume datapoint-write path never touches `interface.updated_at`. A no-op re-apply (identical rendered config) does not advance it, so nodes are never woken for nothing. ## The node-server protocol The edge is **outbound-only**: a node sits behind NAT at a site, so the server never dials it. A node is a **NATS client over the WAN**: it opens one authenticated, outbound connection to the bus (an nkey/JWT credential bound to `node.name`, [identity and access](/architecture/identity-access/)), and everything server-to-node arrives as messages on subjects the node is permitted to consume. Three flows share that connection: - **Telemetry up** (node to server): the node **publishes** `Event` batches (`{datapoints, labels}` plus the `(task, ts)` envelope, [below](#shipping-datapoints)) to a **JetStream raw ingress subject**; JetStream acknowledges each publish (at-least-once), and a `Nats-Msg-Id` lets the server dedup a replay (the admission consumer preserves it when it republishes to the trusted stream). The firehose from the edge. - **Control down** (server to node): the node holds a **durable JetStream consumer** on its **command queue** (commands to run) and subscribes to **worklist-change signals** (the config-generation bump, so the node re-pulls). Subjects the node may consume are scoped by its placement (next). - **Control up** (node to server): heartbeat (liveness, feeding the `node.down` sweep), command-execution results (the `action`-row status), `session_log` transitions, and the `:report` self-telemetry, each published on its own subject rather than a separate HTTP path. ### Commands: a durable server queue, a stateless edge A command is **issued server-side** (the action layer records it and writes intended state, [alarms and actions](/architecture/alarms-actions/)) and dispatched onto a **durable server-side JetStream command queue**. The **edge holds nothing durable**: the node is a worker that pulls the next command from its durable consumer on that queue (and on reconnect resumes from its last ack, draining whatever the queue still holds), runs it, and reports the result back up, which updates the `action` row. Durability lives where the source of truth is, the server, so a node restart loses no command. The held consumer delivers commands as they arrive, so there is no poll latency. ### Delivery: at-least-once, idempotent by nature The node publishes **at-least-once** and reconnects by **resuming unacked publishes** (JetStream ack plus `Nats-Msg-Id` dedup); the server makes replay safe **without a separate idempotency layer**, because everything the edge ships is idempotent by its own key: - **datapoints** dedup on **`(series, ts)`**: a replayed point at the same timestamp is the same point, an idempotent upsert. The edge stamps `ts`, so the server is **ts-authoritative** and reorders out-of-order arrivals for free, so there is **no strict-ordering requirement** on the wire. - **command results** are an **idempotent status update** on a known `action` row (by id): applying "done" twice is "done". **Events are not shipped from the edge**, so there is nothing to dedup for them: an event is **derived server-side** (an `event_rule` over datapoints, or a `log_datapoint` promoted by a rule, [events](/architecture/events/)). The edge produces datapoints (including log lines) and command status; the server derives the events. "We do not re-raise the same event next poll" is the **alarm** model's job (one stateful open alarm, fire and clear), not a delivery concern. ### Buffering and retention are cascade settings When the server is unreachable the node **buffers in memory**, bounded; the buffer is **not durable at the edge** (the edge is a worker, the durable side is the server). Both the **buffer** (size, shed policy) and **retention** are **cascade-resolved** ([cascade](/architecture/cascade/)) with an **install-wide `platform` binding**, overridable down the tree, so a chatty site gets a bigger buffer and a sensitive class a longer retention, tuned like any other setting rather than per-node flags. When the buffer fills the node **sheds oldest metrics first and surfaces it** as a `node.buffer` datapoint (depth, drops), so shedding is visible, never silent. ### Credentials at the edge The worklist materialization resolves credentials server-side, so **device secrets travel to the node** (over TLS). They are held **decrypt-on-use**: in memory, or encrypted at rest in a scratch dir with the key from the [`SecretProvider`](/architecture/variables/), **never persisted in plaintext**, scoped to the node's placement, and re-fetched on the config-generation bump. A field node is physically less trusted, so a secret never lands on edge disk in the clear. ### Enrollment Day one, a node is **created server-side first** (its `node.name` and properties), and the UI mints a **per-node enrollment token**; the token is handed to the edge deployment, and the node **claims its identity** on first connect (the token is exchanged for its **NATS credential**, a per-node JWT signed for its nkey, scoped to the subjects its placement allows, [identity and access](/architecture/identity-access/)). Later, a **shared enrollment token** plus a **`discovery_rule`** can auto-enroll a fleet: the node's **own properties** (stable facts, selected ENV) derive its name, editable server-side after deploy, so a rollout mints no per-node token. ## Placement (ETL, cascaded) Collection follows **ETL**: extract **and transform** (including the extractor's Expr transform) default to the **edge**, then the shaped datapoints are **loaded** to the server, where resolve / bind / calc / evaluate default to **central**. Placement is a **cascaded property** ([cascade](/architecture/cascade/)), not a special mode: `placement: central` makes the **server itself the node target**, for cloud APIs, SaaS pollers, and inbound webhooks from external sources. A listener endpoint lives where placement puts it: the on-site node for LAN devices (lower latency, survives a WAN outage), the server for cloud sources, which is why a registered callback URL resolves to the placed listener's address, not a hardcode. ## Running tasks For each task the node runs the protocol over the interface's connection, then **normalizes at the edge**: it applies the locate + Expr extraction ([collection](/architecture/collection/)) to produce datapoints and stamps labels (cascading union + override); it keeps the original wire bytes as `raw` only on a parse or validation failure (for `collection.failed`) or under dev raw-mode, and drops them on success. A task runs in one of **two modes** ([collection](/architecture/collection/)); a held-open connection is a **stateful interface transport**, not a third task type: - **poll**: we ask. On the resolved `interval`, send the command/request, read the response (SNMP get, HTTP GET, an SSH-exec or xAPI `xStatus` on a held session); - **listen**: we wait. Receive data pushed to us, whether to an endpoint we expose (webhook, syslog) or as feedback on a held connection (MQTT subscribe, xAPI feedback on a stateful interface). Both assemble the same telemetry payload (below). The built interface types (poll protocols and listeners), their per-task params, and the fixed datapoints each emits are the collection **type catalog**: see [built interface types and their config](/architecture/collection/#built-interface-types-and-their-config). This page covers how the node *executes* them; the rest of this section is the runtime that wraps that catalog (reachability gating, sessions, the task queue, tick scheduling). ## Sessions A stateful interface (`ssh`, `mqtt`, anything held open) becomes a **session** at runtime: one connection keyed by `(node, interface)`, shared by every task and command under it, so the handshake and auth are paid once and reused. A session pool holds the connection open across poll ticks (reconnect, backoff, keepalive), and a listener runtime wakes on its inbound. The live socket is ephemeral and lives on the node; the node **reports lifecycle transitions as `session_log` rows** to the server, where the `session` entity projects current state (a current-state view over `session_log`, ground-truth side; see [storage](/architecture/storage/)). :::caution[Open question] The exact `session` lifecycle state enum and pooling parameters (idle timeout, max lifetime, pool size per interface, a shared versus dedicated session for a stream). ::: Generic lifecycle: - **establish**: connect, authenticate, **subscribe** if a stream rides this session; - **operate**: run pollers and receive stream events over the held connection, demuxed (next); - **recover**: graceful retry on connect, **especially auth failures** (backoff, surface as a `session_log` error, never hammer, since hammering a rejected credential risks lockout; ties to credentials); a subscription is session-scoped, so a reconnect **re-subscribes**; - **teardown**: on error or when told, exit cleanly and set up again. **Where failures land.** `session_log` owns **connection health** (cannot connect, auth rejected, dropped, timeout). The **data event owns parse health**: a parse failure (connected, got bytes, the extraction did not match) emits a `collection.failed` event carrying the `raw` (the caused `event` + the `action` row for commands), and surfaces as a collection-health datapoint so it is alertable. A command timeout can touch both. ## Inbound handling on a shared connection When one connection carries heterogeneous inbound frames (a session with pollers + a stream, or one webhook taking many payload types), an arriving frame is **not** self-evidently the response to the last command. Frames route through an **ordered matcher set**: - every task contributes a matcher (a poller's awaited-response shape, a listener/stream's `match:` predicate); each inbound frame is tested **in order**, first match routes it to that task's extraction; - while a poll is **outstanding**, its response matcher is tried **first**, then the standing matchers in declared order, so an event arriving mid-poll falls through to its stream instead of being mis-eaten as the response; - where the protocol **frames** responses vs events (xAPI tags `*r` vs `*e`, a request id correlates), framing drives routing and the regex only extracts within the matched frame; otherwise ordered content-matching is the fallback; - an **unmatched** frame lands as `raw` (orphan, logged), so a missing matcher is a fixable gap that surfaces rather than failing silently. ## The component task queue The node's work is the **component task queue** (distinct from the central **rule engine** that consumes datapoints off NATS and does derivation; see [workers](/architecture/workers/)). It holds **poll tasks** (produce datapoints) and **command tasks** (from `run` actions, produce a caused `event` + `action`-row status), and splits work by shape: - **discrete tasks** (pollers, commands): scheduled or triggered, request/response, **serialized into per-component lanes**. Component, not host, is the contention key: a server with two IPs is one component, and a reboot takes out both interfaces, so a per-host lane would run parallel work against the box you just rebooted. A shared poller that fans to many components runs once on its parent and fans out at binding. - **standing receivers** (listen tasks): always-on, event-driven, **not lane-serialized**; they normalize as events arrive, sharing a held session with pollers (demuxed) or owning their connection. **Smart-wait gate.** After a disruptive command, the lane blocks until reachability reports the host back up, then releases the next task. The gate is a condition over live reachability read from the node's **local** copy, not a round-trip to the datapoint store; a fixed timeout is only the backstop. Tasks within a single interface run serially (one probe, then its tasks in order); only distinct interfaces run concurrently. :::caution[Open question] Whether to add intra-interface concurrency, given that connection and order semantics differ per protocol. ::: The node-side queue is **not** durable: the edge is a stateless worker, and durability lives **server-side** (the JetStream command queue, and the cascade-configurable telemetry buffer). On reconnect the node re-pulls its worklist, resumes its durable consumer on the command queue, and replays its unacked telemetry publishes (idempotent on `(series, ts)`). See [the node-server protocol](#the-node-server-protocol). ## Implicit reachability Any interface with a host address gets reachability for free: the node pings the host and checks the declared port(s) are listening, continuously and out of band. Smart default, **bypassable per interface** (endpoints that drop ICMP or have no port to check opt out or override the probe). The results come back as `reachable` / `port_open` **datapoints** usable in rules and dashboards, and they feed the smart-wait gate from the node's local copy, so the connection detector and the dashboard signal are the same always-on probe. **The layered availability gate.** The gate is an **OSI-layered** set of cheap checks run as a **concurrent pre-pass** (its own high concurrency, short timeouts) before a connection-interface's poll tasks. All applicable checks run (they are cheap), each ships a built-in datapoint, instanced (the ping by host, the rest by interface) and owned by the queried component, and the interface's **`interface.reachable`** verdict is their AND. The pre-pass is separate from the bounded poll phase, so a node pinned to `--workers 1` (to trickle telemetry past the queue) still gates a large fleet in ~one wave. | Layer | Check | Datapoint | Notes | |---|---|---|---| | L3 network | ICMP ping, **batched once per host** per tick | `icmp.reachable` / `icmp.rtt_avg` | **informational** (see verdict below); shared by every interface on the host | | L4 transport | TCP connect (tcp-family) **or** UDP presence (snmp/UDP) | `tcp.open`/`tcp.connect_time` · `udp.open` | a closed UDP port answers ICMP port-unreachable, so absence of that is "present"; this is why SNMP's transport check is L4, not its auth-dependent get | | L7 app | protocol handshake: SNMP `sysUpTime` get (**`snmp.reachable`**, default-on) · SSH handshake+auth · telnet login chain | (verdict) | the SNMP get is the **primary, default** SNMP liveness (ICMP-independent); SSH/telnet are **opt-in** (`ssh_check`/`telnet_check="on"`) because their liveness credential can differ from the device's | **The verdict respects each layer's definitiveness.** A TCP connect and any L7 handshake (SSH/telnet auth, the SNMP get) are **definitive** proof of reachability, so they stand on their own and the **ping is informational**: an ICMP-filtered host (a hardened device or a cloud API that drops echo) still reads up from its port/protocol check, instead of the whole interface going dark. A UDP "present" is a **read timeout** (open|filtered) and so is **ambiguous**; the only thing that disambiguates it is the ping. So a failed ping fails the verdict ONLY for an SNMP interface that has *opted out* of the L7 get (`snmp_check=off`), leaving the ambiguous UDP probe as its only signal (`pingGates`); by default the SNMP get is the signal and the ping is informational. A definitively *down* layer (TCP refused, UDP ICMP-unreachable, an L7 auth/no-answer) fails the verdict regardless; an inconclusive probe (a setup/resolve error, a missing credential) does not gate. **Off gates (the enable/disable convention).** Every check is toggled by `params._check = "on" | "off"`, overriding its default; `params.liveness = "off"` disables the whole gate. The default split is by **auth dependence**: - **auth-independent layers default ON (opt-out):** `ping_check`, `port_check` (and `tls_check` when TLS lands). Cheap and credential-free, so safe to gate on. - **`snmp_check` defaults ON** (opt-out), the one auth-dependent exception: the get reuses the *same* community the poll already needs, so a get failure means the device is genuinely unpollable, the right verdict, and it's the only ICMP-independent SNMP signal. Opt out to fall back to ping+UDP. - **`ssh_check` / `telnet_check` default OFF** (opt-in): a service whose *liveness* credential differs from the device's must not read as down, so the operator opts in per interface. The honest limit on SNMP status: a v2c wrong community is a **silent drop** (the agent answers the *manager*, not us), so a get failure alone can't separate down from wrong-community. Cross-referencing the layers does: host pings + UDP not refused + get silent ⇒ "reachable, SNMP not answering this community" (auth/ACL/ wedged), distinct from "host down"; with ICMP fully blocked that inference is lost and it's honestly reported as "host down or fully filtered." SSH verifies auth (a rejected handshake is down); telnet completes the `login:`/`Password:` chain (service-up, not a verified shell). Override the SNMP probe OID with `params.liveness_oid` when a community view excludes the system group. **Poller** tasks run only if the verdict is up; **listener** (`mode=listen`) tasks are inbound and run ungated (and are never pinged); **inline probes** (`icmp`/`tcp` with the host on the task, no interface endpoint) *are* the check and run ungated. A down interface's gate datapoints all ship in **one** batched call. L5 (socket), L6 (TLS), and further L7 handshakes slot in by extending the check stack: one `append` in `ifaceChecks`, gated by its own `_check` param. ## Shipping datapoints The node ships a native `Event`: `{ datapoints, labels }` plus an envelope (`task`, batch `ts`), **published to the JetStream raw ingress subject** (protobuf-encoded message, the proto surviving as the NATS message schema), buffered with retry/backoff. On a parse or validation failure it also ships the **raw** wire bytes so the server can emit a `collection.failed` event; on success raw is omitted (there is no telemetry table), unless a **dev raw-mode** is on. An **OTLP adapter** at the edge accepts OTLP from third-party tools and translates to the native shape. ```d2 direction: right classes: { node: { style.border-radius: 8 } } worklist: "pull worklist\n(placed tasks + commands)" { class: node } execute: "execute:\nprotocol + locate/Expr extraction" { class: node } normalize: "normalize: datapoints + labels\n(+ raw on failure)" { class: node } ship: "buffer + publish\nraw ingress subject" { class: node } admission: "admission: bind owner\n(consume time) → trusted" { class: node } worker: "rule engine + persistence\n(trusted stream)" { class: node } failed: "collection.failed\n(event, carries raw)" { class: node } worklist -> execute execute -> normalize normalize -> ship ship -> admission admission -> worker ship -> failed: "raw on failure" { style.stroke-dash: 4 } ``` The node has already produced the datapoints at the edge; an **admission consumer** binds owner (registry lookup, owner attribution against the node's placement) at **consume time** and republishes to the trusted stream the rule engine and persistence read, so a forged owner is dropped before evaluation, not at the durable write. On a parse or validation failure it emits a `collection.failed` event carrying the raw; on success there is no raw to store. The server does not re-derive observed datapoints; only calc and event rules derive. The node's job ends at the ship. ## Tick scheduling, concurrency, and self-observability A tick groups the worklist **by interface** and runs in three phases: the L3 ping pre-pass (batched per host), then the per-interface gate-verdict pre-pass, then the poll phase. The two gate pre-passes run at a **high fixed concurrency** (`gateConcurrency`, the checks are cheap short-timeout socket probes), while the poll phase fans out across the **bounded poll pool** (default 16, `--workers`). Splitting them is the point: the cheap gate is never throttled by a small `--workers` (a node pinned to one poll worker still gates a large fleet in ~one wave), and a node facing many dead or slow targets is bounded by concurrency, not the serial sum of every probe timeout (a dead SNMP get costs `timeout * (retries+1)`, configurable via `--snmp-timeout` / `--snmp-retries`; default 3s x2). Each poll task additionally runs under a per-task deadline (`--task-deadline`, default 30s). :::caution[Open question] Per-task schedule dispatch: the resolved `interval` exists, but honoring distinct per-task cadences within one node tick is unsettled. ::: The loop is **overrun-aware**: instead of a fixed ticker that silently drops ticks when one runs long, it reschedules relative to each tick's finish. A tick that exceeds its interval is flagged and the next fires immediately, so a node falling behind **surfaces** the overrun rather than stalling its cadence silently. Each tick the node reports its own execution by publishing a `node.self` envelope: tick duration, task attempted/ran/skipped/failed counts, interface probed/up/down counts, and the `node.overrun` state. It is **not special-cased**: `node.self` is node-owned datapoints (the seeded `node.*` types) that ride the **same raw-ingress -> admission -> trusted** path as any other node datapoint, and the rule engine derives node-health from them like any other datapoint. A node carries no operator-authored template; its self shape is **built into the binary** (the seeded `node.*` datapoint types and node-health rules), and the `node.self` shape selects that built-in template at derive time. The one node-specific piece is owner resolution: the **admission consumer** binds `node.self` to the **reporting node** (`owner_kind = node`, a `node` owner arc, the `node_id` arm of the exclusive arc alongside component/system/location/global), the node-arc analogue of a per-component interface binding its datapoints to its component. So node datapoints land node-owned, and the rule engine's batching + concurrency + amortized rule refresh apply for free. This is the operator-visible health of the collection layer itself. Self-telemetry is best-effort (a failed report is logged, never fatal; it must not break collection). A node that goes dark, though, reports nothing, so a degraded-but-alive signal is not enough. A **node-liveness sweep** runs server-side alongside the rule engine: a node whose last heartbeat (or its registration, if it has never checked in) predates the staleness window (`OMNIGLASS_NODE_DOWN_AFTER`, default 90s) gets a node-owned **`node.down` alarm**, auto-resolved the moment it heartbeats again. The alarm is raised directly by the sweep (no event_rule: a dead node emits no datapoint to evaluate), keyed by `(node.down, node owner)` so it is idempotent across sweeps. This is why the node owner arc reaches `event` and `alarm`, not just datapoints: "the node isn't working" is a first-class node-owned incident. A degraded-but-alive node, by contrast, *does* report, so it alarms through the ordinary **event_rule** path the rule engine runs over every arriving datapoint, no node-specific evaluation: a rule on a `node.*` key opens a node-owned alarm. Two are seeded by default: `node-overrun` (fires while `node.overrun` is true) and `node-tasks-failing` (fires while `node.tasks.failed > 0`), both resolving implicitly on the next clean tick. This works because the trigger engine is owner-general: `Evaluate` opens and resolves alarms for the datapoint's actual owner (component, system, location, or node), which also unlocks system- and location-owned alarms. --- # Roadmap URL: /architecture/roadmap/ The directional layer: the epics and the architectural arc ahead, and how forward-looking work converges into the spine as it ships. The [architecture](/architecture/) is written as one timeless design and converges **in place** as the code catches up: a page moves `Design` to `Partial` to `Built`, it is never forked into a separate "future" copy that later gets deleted. So this page is not a shadow architecture. It is the **readable index of direction**: the epics in flight, the big architectural arc still ahead, and pointers to where each lands in the spine. Three surfaces carry the time axis, and this page ties them together: - **[Implementation status](/architecture/status/)** is the live map of what is built (the per-page badge and the per-slice log). - **[Decision log](/architecture/decisions/)** is the dated history of calls, reversals, and divergences. - **GitHub epics** are the **source of truth** for scope and sequencing; this page links them and is not a substitute for them. Nothing here is a commitment that a detail ships unchanged; an epic is where the scope is actually argued and approved ([no branch before the issue](/contributing/slice-workflow/)). ## What has landed The foundation and the first vertical tiers are built: the single binary with `server` / `migrate` run modes, the auth and Storage Gateway foundation (capability + per-action ABAC scope), the [generation pipeline](/contributing/api-first/) (OpenAPI to typed CLI and SPA client), and the structural [estate](/architecture/core-entities/) (`location`, `system`, `component` as scoped trees, their classifier catalogs and property contracts, the roles a system needs filled, and the [health](/architecture/health/) verdict rolled up over them) with the operator console's inventory surfaces. The per-slice detail is on [implementation status](/architecture/status/). ## Near-term epics The work currently scoped, each tracked as a GitHub epic. Outcomes are summarized; the epic is authoritative. | Epic | Outcome | Lands in | |---|---|---| | [Identity tier (#27)](https://github.com/hyperscaleav/omniglass/issues/27) | Password login over an httpOnly cookie session, self-service profile and password change, and admin user / grant management. Closes the bearer-only and bootstrap divergences ([ADR-0004](/architecture/decisions/#adr-0004-credentials-ship-bearer-only), [ADR-0005](/architecture/decisions/#adr-0005-the-first-owner-is-omniglass-bootstrap)). | [identity and access](/architecture/identity-access/) | | [Estate model: groups + dynamic scope (#10)](https://github.com/hyperscaleav/omniglass/issues/10) | Slice 5: entity-groups as scope anchors and dynamic-membership scope, plus the cross-tier cascade (a location scope reaching its systems and components). | [identity and access](/architecture/identity-access/), [groups](/architecture/groups/), [cascade](/architecture/cascade/) | | [Deploy spine: PR previews (#41)](https://github.com/hyperscaleav/omniglass/issues/41) | Every open PR gets an ephemeral, Access-gated preview of the console, provisioned by Argo CD from the Helm chart (the chart is also the production deploy artifact). | [scaling and deployment](/architecture/scaling/) | | [Public releases (#57)](https://github.com/hyperscaleav/omniglass/issues/57) | Signed and notarized binaries for every major OS/arch and one-line installs (Homebrew / Scoop / winget), so a first-time user runs with no security warning. | [scaling and deployment](/architecture/scaling/) | | [Embedded Postgres run mode (#19)](https://github.com/hyperscaleav/omniglass/issues/19) | An opt-in single-binary mode with a managed embedded Postgres, for edge, demo, and learning installs with zero external database. | [scaling and deployment](/architecture/scaling/) | ## The architectural arc ahead Most of the spine is still `Design`: the platform's reason for being, the monitoring pipeline, has its data model and edge runtime specified but not yet built. The broad order the slices will follow, each pointing at the page that already describes the target: 1. **Collection at the edge.** The [node](/architecture/nodes/) runtime, [templates](/architecture/templates/) (the reusable device shape), and [interfaces](/architecture/collection/), so a reading can come off real gear and be parsed at the edge. 2. **The data model.** [Datapoints](/architecture/datapoints/) (the canonical-signal registry and the exclusive-arc owner columns), [config and variables](/architecture/variables/) resolved down the [cascade](/architecture/cascade/), and the [expression engine](/architecture/expressions/) the rules and filters share. 3. **Detection and verdict.** [Events](/architecture/events/), [alarms and actions](/architecture/alarms-actions/), [calculations](/architecture/calculations/), and the [health](/architecture/health/) rollup that answers "is this system working?". The **verdict half has landed**: an alarm degrades a capability, an impaired role sinks its system by its impact, and the transitions record when it changed. What remains here is what **produces** an alarm (the `event_rule` tier) and what **acts** on one. 4. **The machinery underneath.** [Messaging](/architecture/messaging/) (the NATS subject contract and the data lane), the [workers](/architecture/workers/) draining the worklists, [time](/architecture/time/) as a primitive, and the CDC bridge on [storage](/architecture/storage/). 5. **The read side, in full.** [Views](/architecture/views/) and the `ViewResult` renderer, composable dashboards, and the exploration surfaces on the [UI](/architecture/ui/), plus the [MCP](/architecture/api/#also-an-mcp-surface) and [AI](/architecture/ai/) seams over the same gateway. This arc is a reading order, not a schedule. Each numbered band becomes one or more epics with their own scope and approval before any branch; when a slice in it ships, the relevant spine page moves off `Design` and, if the build differs from the prose, the difference is logged in [decisions](/architecture/decisions/). That is how directional intent becomes built architecture without ever maintaining two copies of it. --- # Scaling and deployment URL: /architecture/scaling/ One binary that runs a laptop demo or a Kubernetes fleet: two run modes, embedded Postgres and NATS, the CDC bridge, horizontal scale, high availability, platform configuration, and per-database multi-tenancy. Omniglass is **one Go binary**, and that is a packaging decision, not a scale ceiling. The same artifact runs an all-in-one container on a laptop and a horizontally-scaled fleet on Kubernetes; you scale by **topology**, not by swapping products. This page is the deployment and scale model: the two run modes, the embedded services, what replicates, the coordination substrate, platform configuration, high availability, and multi-tenancy. :::note[Partial] Built today: the single Go binary with the `server` and `migrate` run modes, the multi-arch container image, and the Helm chart. Still `Design`: the embedded Postgres and NATS, the `node` and `worker` / `controller` modes, the CDC bridge, horizontal scale-out, and high availability. See [implementation status](/architecture/status/). ::: ## Two run modes, one binary The binary is a **modular monolith**: one codebase, one artifact, modules behind clean seams (the Storage Gateway is the only path to the database, coordination rides NATS, collection runs at the edge). It runs two ways, the **same binary**, no fork: - **All-in-one (the modular monolith).** One process runs every role, with **Postgres and NATS embedded** (below), against nothing external. The desktop, single-binary, small-estate case: download, run, done. - **Split by run mode (Kubernetes).** The same binary launched **per mode** as separate Deployments, against an **external** Postgres and an external NATS cluster. A Helm chart wires it up, and each role scales independently. Splitting a mode onto its own pods is a **deployment choice, not a rewrite**, because the modules already talk over NATS and the gateway rather than in-process calls that would need untangling. The roles: - **server**: the public HTTP API ([API](/architecture/api/)) and the views read path; it serves the **SPA embedded in the binary** (`go:embed`), so the web UI is not a separate service. Stateless. - **worker**: the **JetStream consumers** (rule engine, reconcile, notify, [workers](/architecture/workers/)). Stateless competing consumers; add replicas for throughput. - **controller**: the leader-elected **singletons** (the clock and the CDC publisher, below). A role, not necessarily its own pod. - **node**: collection; at the edge it runs **at the sites** (outside the cluster) and connects back, with a central `node` for cloud-API and SaaS sources (`placement: central`, [nodes](/architecture/nodes/)). ## Embedded services (single-binary mode) In all-in-one mode the binary brings its dependencies up in-process, so an operator runs **one container, zero external setup**: - **NATS + JetStream, embedded as a library** (`nats-server` in-process, file-backed). The app is always a NATS client; embedded versus external is a config flag, not a code path. - **PostgreSQL, embedded as a managed subprocess** ([embedded-postgres](https://github.com/fergusstrange/embedded-postgres)): a **real** Postgres, so logical decoding (the CDC bridge below), JSONB, partitioning, and the exclusive-arc CHECK constraints behave identically to at-scale. Pinned to **Postgres 18.3.0 or newer** for ARM and x86. **Not SQLite**, which has no logical replication and would fork the data layer into a second, lesser architecture. So "single binary" is the binary orchestrating a real Postgres and NATS for you, not a different datastore. The data and coordination architecture is identical at any size. ## Coordination: NATS moves, Postgres remembers The split is firm. **Postgres is the relational system of record** (entities, datapoints, events and alarms, audit, and the queries the cascade, fusion, views, and scope need). **NATS (JetStream) is the nervous system**: work distribution, the durable command queue, the telemetry buffer, and fan-out, plus **KV** (config, locks, leader-election) and an object store for internal artifacts. The two meet through **change data capture**: Postgres tells us *what changed* (logical decoding of the WAL), and NATS carries the queue. A single **leader-elected CDC publisher** reads committed changes from a replication slot and publishes them to JetStream (an idempotency key per change yields exactly-once outcomes downstream). **Postgres is never a message bus**; it only emits its changes. The replication **slot and publication are ensured idempotently in the boot phase, not a migration**, since dbmate migrations run exactly once. ### Inter-service communication Service-to-service traffic rides **two lanes on the one JetStream bus**, by what is moving: - **Data lane (NATS-native).** Observed and calculated **datapoints** live on NATS. The edge and central nodes publish observed datapoints to a **raw ingress** subject; an **admission consumer** owner-confines them per publisher class and republishes to the **trusted** datapoints stream, which the rule engine consumes directly from NATS (calc publishes derived datapoints onto the trusted stream as a trusted producer). A **persistence consumer** batch-writes datapoints to the Postgres metric, state, and log tables as an async **sink**. Datapoints do not pass through CDC: they are already on NATS, idempotent on `(series, ts)`, and the firehose, so rules never wait on Postgres. Postgres is the durable record, NATS is the live signal. - **Record and state lane (Postgres-first, CDC-out).** **Events, alarms, actions, and operator mutations** (config, ack, snooze, settings, manual commands) are **born in a Postgres transaction**: when an `event_rule` fires, the consumer writes the event record and the alarm transition (serialized per `(event_rule, owner)`) in one transaction, and the API writes config, ack, and settings the same way. The leader-elected CDC publisher then fans those committed changes out to JetStream, where `action_rule`, reconcile, and projection consumers react. **No dual-write**: born in the commit, CDC fans out. ```d2 direction: down classes: { node: { style.border-radius: 8 } key: { style: { border-radius: 8; bold: true } } group: { style.border-radius: 8 } } north: "North plane: public API (HTTP / AIP)" { class: group direction: right c1: "SPA" { class: node } c2: "CLI" { class: node } c3: "MCP / AI agent" { class: node } c4: "integrations · webhooks" { class: node } } binary: "one Omniglass binary: modular monolith (1..N replicas)" { class: group api: "API / server (per-replica)" { class: node } gw: "Storage Gateway (the only DB path)" { class: node } wk: "JetStream consumers: rule engine · reconcile · notify · persistence (per-replica, competing)" { class: node } clk: "clock (singleton)" { class: node } cdc: "CDC publisher: WAL to JetStream (singleton)" { class: node } nats: "embedded NATS: JetStream · KV · Object store" { class: key } } pg: "PostgreSQL: system of record" { class: node; shape: cylinder } edge_nodes: "edge nodes (distributed · NATS clients)" { class: node } ext: "external NATS cluster (optional BYO at scale)" { class: node } north -> binary.api: "HTTPS" binary.api -> binary.gw binary.gw <-> pg pg -> binary.cdc: "WAL (logical decoding)" binary.cdc -> binary.nats: "publish committed changes" binary.nats -> binary.wk: "east-west: work + events" binary.wk -> binary.gw binary.clk -> binary.nats: "schedule fires" binary.nats <-> edge_nodes: "South: telemetry up · commands down" { style.stroke-width: 3 } binary.nats -- binary.clk: "KV: config · locks · leader-elect" { style.stroke-dash: 4 } binary.nats -- ext: "swap embedded for BYO" { style.stroke-dash: 4 } ``` ## Horizontal scale: what replicates - **server** is **stateless**: replicate it behind a load balancer; state lives in Postgres. - **workers** are **JetStream consumers**: a work-queue stream delivers each message to exactly one consumer, so adding replicas adds throughput with no leader and no cross-worker chatter (NATS is the coordinator, [workers](/architecture/workers/)). - **edge nodes**: distribution is the design, one or many per site, connecting back; adding sites adds nodes ([nodes](/architecture/nodes/)). - **singletons** (the clock and the CDC publisher) are **leader-elected via a NATS KV lock**: exactly one active, the rest stand by and take over on failure. One mechanism, no separate election service. ## Platform configuration Configuration is **two tiers**, and platform settings are deliberately **centralized**, not scattered across dozens of tables and APIs: - **Bootstrap (env, optional).** The irreducible minimum needed before the database exists: the Postgres DSN, the NATS embed-or-external choice and address, the `SecretProvider` key, the run mode, and the listen address. In all-in-one mode these have working defaults, so a desktop run needs **no configuration at all**; env vars override when you need them. - **The platform settings store (one place).** Everything else lives in a single, audited **settings store**: feature flags, the buffer and retention defaults, CDC routing, integration settings, UI defaults, official-registry overrides. This is now the [settings engine](/architecture/settings/), which generalizes "one place" from a flat table into ordered layers resolved most-specific-wins down the principal hierarchy (platform to group to user), with per-key provenance and top-down locks. An operator **settings file** (`settings.json` or YAML) is the GitOps layer, read into memory at boot and mounting cleanly as a **Kubernetes ConfigMap** (and a future operator); only the **override** an operator sets through the API is persisted in Postgres (audited), while the file and the embedded defaults are recomputed each boot, so restore is a delete and the file never drifts into a second authoritative copy ([ADR-0033](/architecture/decisions/#adr-0033-settings-persist-only-the-override-level-base-layers-are-recomputed-in-memory)). The same declarative source drives a laptop and a fleet. This is distinct from estate [config and variables](/architecture/variables/), which describe the *estate* and resolve down the cascade. The settings store describes the **platform itself**, and there is exactly one home for it, the single source of truth core settings deserve. :::note[Partial: the settings engine's platform level] The settings store is built at the **platform** level (the install-wide rung, renamed from `global` by [ADR-0057](/architecture/decisions/#adr-0057-the-cascades-least-specific-tier-is-platform-and-a-default-is-not-a-tier)): the pure resolver, the `setting_override` table, the admin API, the two `settings:` permissions, and the Admin settings page, with `ui.theme` wired through to re-theme the SPA (see [settings](/architecture/settings/)). The **group** and **user** cascade rungs, and the `platform`-domain settings (`retention`, CDC routing, integrations) named above, are the fast-follow. ::: ## Vertical scale and high availability Replicas are the **HA** story: the server and worker tiers have no single point of failure (any replica can serve or consume), the singletons fail over by re-electing on the NATS KV lock, Postgres HA is the database's concern (CNPG, a managed cluster), NATS HA is the JetStream cluster's, and the **edge survives a WAN outage on its own** (the bounded buffer plus the durable command queue, [nodes](/architecture/nodes/)). Vertical scale is the simple first lever (a bigger Postgres, more worker CPU); horizontal removes the ceiling. ## Multi-tenancy: per database, per account, per deployment Tenant isolation is **physical, not a row predicate**: a tenant is **one database, one NATS account, and one deployment**. There is no `tenant_id` column anywhere, no shared row store, and no shared subjects, so per-database isolation (storage) and per-account isolation (messaging) are the **same boundary**. The data model stays single-tenant-shaped; multi-tenancy lives at the orchestration layer (CNPG-per-tenant). One noisy or compromised tenant cannot reach another because there is nothing shared to reach across ([identity and access](/architecture/identity-access/)). ## The one-binary promise The same binary and the same code paths run the demo and the fleet. You do not adopt a different product to scale: you run more roles, on more pods, against an external Postgres and NATS, with more edge nodes. Simplicity at the small end, a real horizontal ceiling at the large end, one artifact across the range. --- # Settings URL: /architecture/settings/ A cascade-resolved, lockable settings engine: ordered layers merged into an effective document, with per-key provenance, top-down locks, and a platform-versus-profile domain split. :::note[Partial] Slice-0 ships the **platform** rung of the cascade end to end: the pure `settings` merge and resolve primitive, the single unscoped `setting_override` table, the Huma routes, the two `settings:` permissions, the two seeded `profile`-domain namespaces (`ui`, `keybindings`), `ui.theme` wired through to re-theme the SPA, and the Admin settings page (namespace sections, provenance badges, lock chips, restore) ([ADR-0033](/architecture/decisions/#adr-0033-settings-persist-only-the-override-level-base-layers-are-recomputed-in-memory), [ADR-0034](/architecture/decisions/#adr-0034-the-settings-gateway-is-unscoped-only-the-permission-gates-it), [ADR-0035](/architecture/decisions/#adr-0035-settings-resolve-as-a-cascade-over-principals-with-a-broader-wins-lock)). Deferred to the fast-follow: the **group** and **user** override rungs and the Profile preferences tab, the `settings:lock` split for group-admins, `platform`-domain namespaces (`retention`, `integrations`) with their features, a GitOps read-only mode, and live file reload (SIGHUP) instead of restart-to-reload. Slice-1 makes a setting a reflected **typed struct** ([ADR-0041](/architecture/decisions/#adr-0041-settings-are-a-reflected-typed-struct-with-generated-client-and-server-validation)): one canonical `Settings` type is the single source for the default, the OpenAPI schema, the typed client, and validation, and both the console write path and the settings form now validate against that generated schema (the `defaults.yaml` and hand-kept namespace list are retired). ::: Omniglass resolves a **setting** the same way it resolves a secret or a variable: down a cascade, most-specific-wins, with provenance. The difference is the axis. The [estate cascade](/architecture/cascade/) resolves down location to system to component; the settings engine resolves down the **principal** hierarchy, platform to group to user. It is the same primitive (doctrine 5) pointed at identity instead of the estate, and its least-specific level carries the same name for the same reason: `platform` is what an admin set for the **whole install** ([ADR-0057](/architecture/decisions/#adr-0057-the-cascades-least-specific-tier-is-platform-and-a-default-is-not-a-tier)). This generalizes the narrower "platform settings store" the [scaling](/architecture/scaling/) page sketched (see [ADR-0033](/architecture/decisions/#adr-0033-settings-persist-only-the-override-level-base-layers-are-recomputed-in-memory)): platform settings become one **domain** within the engine (set only at the platform level, admin-owned), and user preferences become the other (settings that cascade to groups and users). ## Layers and levels An effective value is resolved from ordered contributions, plus one thing that is not a level at all. **`default` is off the axis.** It is the value reflected from the canonical `Settings` struct's `default:` tags (see [the single-source struct](#the-single-source-struct)): the setting's own **declaration**, what it *is* when nobody set it. It is never a row, nobody writes it, and it shadows nothing; every settable key has one, so the effective document is always complete. It is the **fall-through**, not the bottom rung ([cascade](/architecture/cascade/#bindings-cascade-declarations-do-not)). **The base layer** is recomputed into memory on every boot and never stored in the override table: 1. **`file`**: an operator settings file (`settings.json` or YAML) at a bootstrap-configured path, optional (a laptop run has none). This is the GitOps / Kubernetes ConfigMap layer; a change lands on pod restart. **Override levels** are rows in Postgres, the identity cascade: 2. **`platform`**: the install-wide admin override. **Slice-0.** 3. **`group`**: per user-group override. **Fast-follow.** 4. **`user`**: per-user override. **Fast-follow.** ### Most-specific wins Absent any lock, a more-specific level wins: `user > group > platform > file`. Where no level set the key at all, the value is the setting's `default`, which provenance reports as a **declaration** (the console shows no origin badge at all and names it "Declared default" in the layer stack) rather than as a level. Merge is a **deep merge in JSON map-space**, so key **presence** decides an override, not a Go zero-value: a key set to `false` overrides, a key absent inherits the layer below. A write is an RFC 7386 JSON Merge Patch, so `null` on a key deletes it from that level's override (restoring it to the layer below, or to the declared default when no level below set it). ## Locking: enforced from above An admin **locks** a key at a level. A lock at level L pins L's contributed value and forbids any more-specific level from overriding it: lock `ui.theme` at `platform` and no group or user can change it. **Lock conflict: broader wins.** A `platform` lock supersedes a `group` lock; top-down admin authority is absolute. The editability rule falls out of it: a principal may edit a key at level L if and only if no broader level has locked it. ## Provenance Every resolved key reports **where it came from** and its **lock state**. The admin read returns the effective document plus a sibling `sources` map (`namespace.key` to the winning level, or `default` when no level set it) and a `locks` map (`namespace.key` to the locking level). This reuses the estate cascade's effective-values vocabulary (the winning level per key), extended from the estate bands to the principal levels plus a lock chip. The Admin page badges a key that somebody set (`From settings file` / `Set in console`) and deliberately badges nothing for a declared default, since nobody set it; a row expands to teach the full layer stack, where the default reads as "Declared default" below `file` and `platform` (doctrine 4: the page teaches the cascade it operates). ## Domains: platform versus profile Each namespace carries a `domain` classifier. The `platform` domain is **named after the level**: a platform-domain namespace is one only ever set at the `platform` level, never further down the principal cascade. - **`profile`**: cascades platform to group to user, **client-visible**, lockable, user-overridable in the fast-follow. `ui` and `keybindings` are the two seeded `profile` namespaces (`ui.theme` and `ui.default_landing`; the default keymap as data). - **`platform`**: set only at the platform level, admin-only-read, does not cascade (for example `retention`, `integrations`). None is seeded in slice-0; the mechanism exists and is unit-tested, exercised when the first platform setting lands with its feature. ## Storage: one override table, unscoped The declared defaults and the file layer live in memory, so Postgres holds **only the override levels**: a single `setting_override(scope, principal_id, namespace, doc, locks, ...)` table with a `unique nulls not distinct (scope, principal_id, namespace)` identity (a surrogate `id` is the primary key because `principal_id` is nullable, and Postgres forbids NULL in a PK column). `scope` is under a CHECK naming the levels that are actually persisted, today `platform` alone: the declared defaults and the file layer are recomputed in memory and can never be rows, so a level the resolver would never read cannot be written, and a future rename of the tier fails loudly at the database instead of orphaning every override in silence. Restore semantics fall out of the layer model: **restore a namespace** is a `DELETE` of its row, **restore everything** truncates the scope, and the file layer plus the declared defaults re-supply the values. The table is **never boot-seeded**: it is operator data, and the seeding doctrine's "operator rows untouched" rule applies. Persisting only the override (not the file) is a recorded call ([ADR-0033](/architecture/decisions/#adr-0033-settings-persist-only-the-override-level-base-layers-are-recomputed-in-memory)), diverging from the scaling page's "materialized in Postgres" sketch. ### The unscoped-Gateway carve-out The two-layer authorization model (a `:` permission on every route, ABAC **scope** on every applicable query) has one deliberate exception here. Settings Gateway methods are **unscoped**: platform and cascade settings describe the platform and its principals, not the estate, so the ABAC storage-scope invariant is **not applicable**, the same as the registry-type reads (`GET /types/...`). Only the `settings:` permission gates them. This is a recorded carve-out ([ADR-0034](/architecture/decisions/#adr-0034-the-settings-gateway-is-unscoped-only-the-permission-gates-it)), not a missed invariant. The group and user levels will constrain override reads and writes by the acting principal (a user edits only their own `user` row), a per-principal ownership check that is a different mechanism than estate ABAC. Every override write and delete writes an `audit_log` row in the same transaction (the existing `writeAuditRes` pattern), so every settings edit carries change history. ## The single-source struct A setting is declared **once**, as a tagged field on a canonical Go struct in `internal/settings/schema.go`. That one declaration is the whole source of truth: reflection over the struct builds the `default` layer and the namespace registry, Huma reflects the struct into the OpenAPI schema, and the schema generates the typed SPA client and the write validator. There is no second place (no hand-kept `defaults.yaml`, no hand-kept `Namespaces()` slice) to drift. ```go // Settings is the canonical settings document: one field per namespace. type Settings struct { UI UISettings `json:"ui" settings:"profile,client"` Keybindings Keybindings `json:"keybindings" settings:"profile,client"` } // UISettings is the ui namespace. Adding a setting is one tagged field. type UISettings struct { Theme string `json:"theme" enum:"omniglass-dark,omniglass-light" default:"omniglass-dark" doc:"Console color theme"` DefaultLanding string `json:"default_landing" default:"/" doc:"Route the console opens to"` } ``` Each namespace is a struct, a closed set of developer-defined keys. The `settings:","` tag carries the metadata: `domain` is `profile` or `platform`, and `client` marks a client-visible namespace fed to `/settings/me`. A small reflect pass in the pure `settings` package produces two things from the tags, so the tags are the only declaration: - **`Defaults()`** walks each leaf's `default:` tag and coerces it to the field's Go kind (string, int, float, bool), building the `default` layer as a generic map. A field with no `default:` tag contributes no default. This replaces the retired embedded `defaults.yaml`. - **`Namespaces()`** reflects the top-level fields: the `json` tag names the namespace, the `settings:` tag carries its `domain` and client-visibility. This replaces the hand-kept slice. Reflection walks a compile-time type, so a malformed tag is a boot panic (a compile-time asset, like the old embedded YAML), never a runtime branch. ### Typed at the edges, maps in the middle The cascade merges **partial** layers (the file and the DB override each carry only the keys an operator set), and a Go struct cannot express "unset" versus a zero value, so the layers stay generic maps and the merge engine is unchanged. Typing lives only at the edges. The effective (fully-merged) document unmarshals into `Settings`, so the API `values` field is the typed struct (the generated client reads `values.ui.theme` as the enum union), and Go code calls `settingsSvc.EffectiveTyped(ctx)` and reads `s.UI.Theme` typed, anywhere in the codebase. `sources` and `locks` stay flat maps keyed by `namespace.key`, since provenance is inherently dynamic. ## Adding a setting Everything about a setting lives on its struct field in `internal/settings/schema.go`. Add the field, run `make gen`, and it is discovered everywhere. There is no registry to update, no `defaults.yaml`, no second place. **Add a key to an existing namespace.** Add one tagged field to the namespace's sub-struct. The tags are the whole declaration: ```go type UISettings struct { Theme string `json:"theme" enum:"omniglass-dark,omniglass-light" default:"omniglass-dark" doc:"Console color theme"` DefaultLanding string `json:"default_landing" pattern:"^/" default:"/" doc:"Route the console opens to (an absolute path)"` // add a field here. } ``` - `json:""` (**required**) is the setting's key: its name in the merge-patch, the API, and the client. Use snake_case. The key is the `json` tag, not the Go field name. - `default:""` is the setting's **declared default**, coerced to the field's Go kind (string, int, float, bool). Omit for no default. Do not seed a default anywhere else. - `enum:"a,b,c"` constrains the value to a set. It renders as a select in the console and is rejected (inline, and 422 on the server) otherwise. - `pattern:"^regex$"` constrains a free-string value. A value that fails it is rejected inline and 422 on the server. - `doc:"..."` is the human description, carried into the schema and the generated client. **Add a namespace.** A namespace is a struct. Define the sub-struct, then add it as a field on `Settings`: ```go type Settings struct { UI UISettings `json:"ui" settings:"profile,client"` Keybindings Keybindings `json:"keybindings" settings:"profile,client"` Retention RetentionSettings `json:"retention" settings:"platform"` // new: platform-level only, admin-read } ``` The `settings:"[,client]"` tag carries the namespace metadata: - `domain` is `profile` (cascades to groups and users, user-overridable) or `platform` (set only at the platform level, admin). - Add `client` to make the namespace's effective values readable at `/settings/me` (the SPA's boot read); omit it for admin-only-read (a `settings:read` gate). **Then run `make gen`** and commit the drift. That one field now drives, with no further edits: the declared default (`Defaults()`), the namespace registry (`Namespaces()`), the OpenAPI schema, the typed SPA client (`values..`), the server write-validator, the inline form validation (`web/src/api/settings.schema.gen.ts`), and the typed Go accessor `settingsSvc.EffectiveTyped(ctx)`. **Rules and gotchas.** - Every namespace is a struct, a closed set of developer-defined keys; there is no operator-open namespace. - A malformed tag is a boot panic (the struct is a compile-time asset), so a typo surfaces immediately, never as a silent runtime branch. - Prefer `enum` or `pattern` over a bare string whenever the value is constrained: one tag buys the console picker, the inline validation, and the server 422 together. - Never seed a default outside the tag (no `defaults.yaml`, no boot-seed `ON CONFLICT`); the `default:` tag is the declaration, and a second source is exactly the drift the single-source struct exists to prevent. ## Generated validation, one rule set from the struct A write is validated against the **same reflected schema** on both sides, so the client and the server enforce identical rules from the single Go source, with no hand-authored second copy. - **Server (the backstop).** `PATCH /settings/{namespace}` validates the merge-patch before storing it. An unknown namespace in the path is a **404**; an unknown key, a wrong type, or an `enum` or `pattern` violation is a **422** naming the offending `namespace.key`. A `null` value is a delete and is always allowed. The validator reflects the namespace's sub-struct into a Huma schema and checks each non-null key against its field schema. This closes the slice-0 write-validation thin cut, where the PATCH accepted any namespace, key, or value and stored it as-is. - **Client (caught before submit).** `make gen` gains a step that slices the settings field constraints (the per-field `type`, `enum`, `pattern`, `minLength`, and so on) out of the generated `api/openapi.json` into a committed artifact, `web/src/api/settings.schema.gen.ts`. It is diff-checked exactly like the other generated artifacts, so a struct-tag change reflows to the form with no hand edits. In edit mode each row validates its draft against that field's generated constraints and shows an inline error, an `enum` field renders as a select of the generated options (retiring the hard-coded theme list), and Save is blocked while a field is invalid. The server 422 remains the backstop for anything the client does not catch (a direct API call, a stale client) and maps back to the same field. The generation chain is the Go struct to OpenAPI to `settings.schema.gen.ts` to inline form validation, one rule set with the server 422 behind it. ## API surface Two read audiences, two read endpoints, and merge-patch writes: - **`GET /settings`** (admin, `settings:read`): the full effective document, all namespaces, **with provenance** (`sources` and `locks`). Feeds the Admin settings page. - **`GET /settings/me`** (any authenticated user): the caller's resolved settings, **client-visible namespaces only, no provenance**. Feeds the SPA at boot (theme, landing, later keybindings). Parallel to `/auth/me`, and correct as the cascade grows (it is the caller's own effective cascade). Dedicated, not folded into `/auth/me`, so a settings change invalidates a settings cache without disturbing the identity cache. - **`PATCH /settings/{namespace}`** (`settings:update` **and** `platform:update`): an RFC 7386 JSON Merge Patch onto the namespace's override at the acting scope (`platform` in slice-0); `null` on a key restores it. - **`DELETE /settings/{namespace}`** (`settings:update` **and** `platform:update`): drop the override, restoring the whole namespace to defaults. - **`POST /settings:restoreDefaults`** (`settings:update` **and** `platform:update`): an AIP custom method, a factory reset of the acting scope. Every settings write lands at the **platform** tier by definition (a setting applies to the whole install, never to part of the estate), so all three carry `platform:update` on top of `settings:update`, the same install-wide authority a platform-tier variable, secret, or tag binding needs. The console gates its Edit and Restore controls on **both**, and a principal holding only `settings:update` reads a note naming the missing capability rather than meeting a 403 on Save. Per doctrine 1 the effective document is a Huma struct, so the OpenAPI, the typed SPA client, the CLI command, and the JSONSchema all generate from it (`make gen`). The `values` field is the typed `Settings` struct: the generated client reads a known field like `values.ui.theme` as a union (slice-0 exposed `values` as a free-form object). Because the declared defaults fill every key, the effective document is always fully populated; only the override **storage** is raw JSONB partials. The two resource permissions live on the admin role: `settings:read` (admin read with provenance) and `settings:update` (write, restore, lock and unlock), paired on every write with `platform:update` (also admin and owner). The store is a singleton, so there is no create or delete-of-resource permission; the client-safe values reach ordinary users through `/settings/me`, which is authn-only, not `settings:read`. ## The cascade-over-principals model Reusing the [cascade](/architecture/cascade/) primitive on the principal axis, rather than writing a second resolver, is the deliberate call ([ADR-0035](/architecture/decisions/#adr-0035-settings-resolve-as-a-cascade-over-principals-with-a-broader-wins-lock)): resolution, provenance, and the broader-wins lock are one mechanism the estate and the settings engine share. The engine itself is a **pure `settings` package** (no I/O beyond reading the operator file): the deep merge, the merge-patch, the cascade resolution, and the lock enforcement are the primary unit-test target, and the DB layer is supplied by the caller (the Storage Gateway) through a narrow function seam, so the package never imports storage. ## Slice-0 boundary **In:** the platform level (file plus DB), the full cascade-shaped payload, the platform lock stored, shown, and enforced. The pure engine, the override table, the Gateway methods, the API (read with provenance, client-safe effective read, PATCH / DELETE / `:restoreDefaults`), the two permissions, the two seeded `profile` namespaces, `ui.theme` wired end to end, and the Admin settings page. **Fast-follow (not this slice):** the group and user override rungs and the Profile preferences tab (editable, user-scoped Gateway reads), the `settings:lock` permission split for group-admins, `platform`-domain namespaces (`retention`, `integrations`) with their features, a GitOps read-only mode (a setting that locks the page to file-only editing), and live file reload (SIGHUP) instead of restart-to-reload. ## Slice-1 boundary Slice-1 makes settings a reflected typed struct without touching the merge engine, the cascade precedence, the permissions, or the routes ([ADR-0041](/architecture/decisions/#adr-0041-settings-are-a-reflected-typed-struct-with-generated-client-and-server-validation)). **In:** the canonical `Settings` struct as the single source; reflected `Defaults()` and `Namespaces()` (the embedded `defaults.yaml` and the hand-kept namespace slice retired); the typed effective read (`values` is `Settings`, plus the `EffectiveTyped` app accessor); server write validation (404 unknown namespace, 422 bad key / type / enum); and the generated client constraint artifact (`web/src/api/settings.schema.gen.ts`) driving schema-derived inline form validation with Save blocked on an invalid field. **Deferred (future slices, tracked on [#270](https://github.com/hyperscaleav/omniglass/issues/270)):** the declarative operator-file machinery (a generated JSONSchema for the operator `settings.json`, validation of the **file** layer at boot, and letting the file layer take precedence over the database, the GitOps-wins / read-only lever); operator-open namespaces (a typed map with a `Default()` method); and the group and user cascade rungs, all unchanged by slice-1. --- # Storage URL: /architecture/storage/ How storage works: the Storage Gateway, views by default, per-database isolation, append-only partitioning and tiering, and the on-row lineage pattern. Storage is the set of patterns every entity in Omniglass lands on, so an operator can trust that scope, audit, retention, and lineage behave the same way no matter which table the data lives in. This page describes **how storage works**, the patterns every other leaf's entities land on, not a per-table column dump. :::note[Partial] Built today: the Storage Gateway as the only door to the database, dbmate migrations (run-once, embedded, idempotent), the per-action scope predicate and the in-transaction `audit_log` write, and the shared scoped-tree and scoped-CRUD primitives. Still `Design`: the CDC publisher and persistence consumer, the data-lane / record-lane split, partitioning and tiering, the `current_value` views, and the go-jet typed query builder. See [implementation status](/architecture/status/). ::: Postgres is the **relational system of record**: it holds the entities, events, alarms, actions, audit, config, and the platform settings store. It is the record/state/intent lane. It is **never a message bus**: the live signal travels on NATS JetStream, and Postgres earns its place as the durable record. Two writes paths land here, and only one is the request path. **Operator mutations and the record/state/intent lane** (config, ack/snooze, settings, manual commands, plus the `event` and `alarm` rows an `event_rule` consumer commits in one transaction) are written synchronously through the Storage Gateway. **The datapoint tables are an async SINK**: a NATS **persistence consumer** batch-writes datapoints off the data lane ([datapoints](/architecture/datapoints/)), idempotent on `(series, ts)`, so the rule engine never waits on a datapoint reaching Postgres. Committed changes on the record lane are fanned out by a leader-elected **CDC publisher** (logical decoding of the WAL) to JetStream; there is no dual-write, the change is born in the commit and CDC carries it. The column schemas live with each owning feature: [datapoints](/architecture/datapoints/#the-datapoint-tables) (the three kind-tables), [events](/architecture/events/#storage) (the `event` row), [alarms and actions](/architecture/alarms-actions/#storage) (`alarm` / `action`), [config and credentials](/architecture/variables/#storage) (`variable` / config / tags), [core entities](/architecture/core-entities/) and [templates](/architecture/templates/) (the structural and template tables), [collection](/architecture/collection/#storage) (interfaces and tasks), [calculations](/architecture/calculations/#storage) (the rule families), [files](/architecture/files/), [time](/architecture/time/#storage), and [identity and access](/architecture/identity-access/#storage). ## Conventions - **No `tenant_id`.** Isolation is per-database (a database per tenant); there is no tenant column anywhere. The key registries `property_type` and `event_type` carry a **`scope`** (template / org / official) deciding where the name is unique ([key scope](/architecture/datapoints/#key-scope-template-org-official)), and the non-template registries and catalogs (`interface_type`, `location_type`, `secret_type`, `vendor`, `driver`, `capability`, `product`, `standard`) carry an **`official` boolean**, the same axis minus the template layer: `official: true` rows are the ship-with canonical set distributed with the binary, and `official: false` rows are operator- or org-authored, local to this deployment. The boolean is about **authority, not provenance**: a row the binary seeds is not automatically `official`. A `standard` and a `location_type` ship as `official: false` and are installed **only if absent**, because they are example content an estate owns and edits; the canonical catalogs (`property` above all) ship `official: true` through an authoritative `ON CONFLICT DO UPDATE`, so a release can correct the shared vocabulary ([the seed model](/architecture/core-entities/#the-seed-model-forked-templates-versus-canonical-catalogs)). - **Three storage shapes.** **Ground-truth records** are append-only and immutable, each named for what it is: `log_datapoint` (a datapoint kind), `audit_log` (operator actions), and the standing `*_log` ground-truth logs (`session_log`, `internal_log`, plus the `collection_log` / `node_log` companions). There is **no `telemetry` table**: datapoints are published to the JetStream data lane, not synchronously inserted, so the raw payload is not persisted in steady state; the persistence consumer sinks the typed datapoint, and raw appears only on a `collection.failed` event or a dev raw-mode tap ([datapoints](/architecture/datapoints/)). A schedule fire is not a record here: it is an `event` with `origin=scheduled`. There is no separate rule-execution table: derived rows carry their lineage on the row. **Datapoints** (`metric` / `state` / `log_datapoint`) are the typed observation firehose. **Stateful entities and projections** (`alarm`, `action`, current-value) hold state directly or are rebuildable read models, **views by default**. The model is **not event-sourced**. - **Provenance and lineage on every datapoint**: `provenance` (observed / calculated / intended), `source` (which sensor or path, for observed), and a lineage pointer. observed and calculated both carry `source_rule` (+ version), the function or calc_rule that produced the row; intended carries `event_id` (the command). A CHECK enforces the pointer per provenance; **observed vs calculated is the `provenance` value itself**, not a column-presence trick. Declared config is not a datapoint provenance; it lives in [config](/architecture/variables/), keyed to the same signal. - **Ownership is the exclusive-arc** on every datapoint table, `event`, `alarm`, and `variable`: `owner_kind` enum plus the matching typed FK (`component_id` / `system_id` / `location_id` / `node_id`, or none for the singleton `global`) plus a CHECK that exactly the matching column is set (or all null for `global`). System-, location-, node-, and global-level datapoints are first-class. The full pattern is on [core entities](/architecture/core-entities/#ownership-the-exclusive-arc). - **Keys**: datapoints and events use a surrogate id plus `ts`; the key registry `property_type` carries a **`scope`** (template / org / official) deciding where the name is unique (`(template_id, name)` at template scope, `name` at org/official); structural entities are name-keyed; a `task` is **content-addressed** (`hash(interface, kind, schedule, params)`); a `node` by its `principal_id`, its enrollment identity. Every foreign key stores the target's primary key, so a rename is free ([ADR-0056](/architecture/decisions/#adr-0056-every-foreign-key-stores-a-primary-key)). ## How the records relate The relationships, not the columns. The columns of each table live on its owning leaf (linked above). ```d2 direction: right classes: { node: { style.border-radius: 8 } } metric: metric { class: node } state: state { class: node } event: event { class: node } alarm: alarm { class: node } action: action { class: node } current: current_value { class: node } variable: variable { class: node } metric -> metric: calc_rule state -> event: event_rule event -> alarm: fire opens · clear resolves event -> action: action_rule alarm -> action metric -> current: view: latest per key+provenance state -> variable: linked_state (observed side) ``` The structural and template entities (`component` / `system` / `location` and the `*_template` / `*_template_version` / `system_template_member` / `system_member` families) relate as shown on [core entities](/architecture/core-entities/) and [templates](/architecture/templates/); the collection entities (`interface_type` / `interface` / `task`) on [collection](/architecture/collection/#storage). ## Two lanes land in Postgres differently Every row in Postgres arrives on one of two lanes, and the lane decides how the row is written and how the rest of the platform learns it changed. - **The data lane (a sink).** Observed and calculated datapoints live on the JetStream data lane. The rule engine consumes them directly off NATS; Postgres is the durable record, not the live signal. The **persistence consumer** is a durable JetStream consumer that batch-writes the `metric` / `state` / `log_datapoint` tables as an async sink, idempotent on `(series, ts)`, so a redelivery lands the same row and the firehose never blocks on the database. Datapoints do **not** flow through CDC: they are already on NATS. - **The record/state/intent lane (PG-first, CDC-out).** Events, alarms, actions, and operator mutations (config, ack/snooze, settings, manual commands) are born in a **Postgres transaction**. When an `event_rule` consumer fires, it writes the `event` row and the `alarm` transition in one transaction (the alarm transition is serialized per `(event_rule, owner)`); the API writes config, acks, and settings the same way. There is no row-lock single-fire worklist and no `LISTEN`/`NOTIFY` fan-out: the change is committed once, and the **CDC publisher** carries it outward. The CDC publisher is **leader-elected** (exactly one active, fail over on death) via a NATS KV CAS lock, the same singleton pattern the clock uses ([time](/architecture/time/)). It reads the WAL by logical decoding and publishes each committed change to JetStream, where `action_rule`, reconcile, and projection consumers react. The replication **slot** and **publication** it reads are **ensured in the idempotent boot phase** (the same phase that upserts ship-with reference data), **not** a run-once migration: boot creates them if absent and leaves them untouched if present, so a fresh database and an existing one converge to the same state. Delivery is at-least-once with an idempotency key per change, so a consumer that sees a change twice is a no-op. ## Ground-truth records The immutable, append-only records, each named for what it is. They are the lineage targets and what a backtest reads; none is derived. The detailed columns of `audit_log` live on [audit](/architecture/audit/), `session_log` on [nodes](/architecture/nodes/#sessions); the rest is a compact list here because storage is their natural architectural home: - **`log_datapoint`** (a component's own words, a datapoint kind, [datapoints](/architecture/datapoints/)); - **`audit_log`** (operator actions: actor, verb, resource, `old -> new`; the lineage target for operator writes; secret decrypts always recorded, [audit](/architecture/audit/)); - **`session_log`** (connection-lifecycle transitions, node-reported; the connection log, [nodes](/architecture/nodes/#sessions)); - **`internal_log`** (platform self-narration: startup / reconcile / migration / node-reg / config-sync, [workers](/architecture/workers/)); - the **`collection_log`** / **`node_log`** companions (the cheap per-run execution record and the node's operational narration). There is **no separate rule-execution table**: a derived row *is* the evidence of its rule's run, carrying its lineage on the row (below). ## The lineage CHECK (the pattern) Lineage lives on the derived row, no separate execution table. This is the **pattern** every derived row follows: `source_rule` (+ version) is set for observed and calculated (the function or calc_rule that produced the row); intended carries the command `event_id`. The pointer per provenance is enforced so e.g. "intended with no command event" is impossible at the storage layer. One example, the datapoint tables: ```sql CHECK ( (provenance IN ('observed','calculated') AND source_rule IS NOT NULL AND event_id IS NULL) OR (provenance = 'intended' AND event_id IS NOT NULL AND source_rule IS NULL) ) ``` Observed and calculated both carry `source_rule`; they are distinguished by the **`provenance` column**, not a pointer-presence trick (an edge function versus a calc_rule). The intended split is the one the CHECK enforces. This is one of three layers: the CHECK enforces *which pointers are populated*, foreign keys enforce *the ids are real*, and the app enforces *the value type matches the key's kind*. The datapoint tables also carry nullable **`correlation_id`** and **`caused_by_event_id`** trace columns. These are orthogonal to the lineage pointers above: they are not lineage pointers, so they do not participate in the exclusive-lineage CHECK. They carry causation across the command -> device -> observed-datapoint round trip so the cycle guard walks a real id ([datapoints](/architecture/datapoints/), [alarms and actions](/architecture/alarms-actions/)). On the wire these ride in **NATS message headers**: a datapoint published to the data lane carries its `correlation_id` / `caused_by_event_id` in the message header alongside the `Nats-Msg-Id` dedup key, and the persistence consumer lands them into these columns, so the trace is unbroken from the live signal to the durable record. ## Current value and projections: views by default `alarm` and `action` are **stateful entities** that hold their own current state in a real table (not event-sourced). Everything else that is "current state" is a **read model**, and the default is a **plain SQL view** (always-correct, never stale, zero maintenance). A worker-maintained table is a **measured optimization**, earned only when a read profile shows a view too slow. | Read model | Of | Shape | Notes | |---|---|---|---| | `current_value` | latest datapoint per (owner, key, **instance**, **provenance**), fused across sources per the key's `fusion_policy` | **view** | the dashboard read; per-provenance so observed and intended are both visible (the divergence model needs both), per-instance so siblings of one key stay distinct, fusion applied on read. The one table candidate if a profile earns it, metric kind only | | `session` | `session_log` | **view** | low-volume; node, interface, status, opened_at, last_activity_at, command/error counts | **When the view stops scaling.** A latest-per-key view's cost scales with the number of **distinct keys** (a loose index scan), not total rows. Point and scoped reads ("current value of X on Y") are a covering-index probe, fast at any size. A full-fleet "every current value" is O(distinct keys): comfortable to hundreds of thousands, painful past a few million. A naive `DISTINCT ON` scans the whole log and dies on the firehose; never that plan. So only `current_value` for the **metric** firehose is even a table candidate, and only when frequent full-fleet reads meet low-millions-plus distinct keys. The sparse kinds (`state` / `log`) stay views indefinitely. A worker-maintained table costs **one upsert per datapoint write** (write amplification, hot-key contention) and reintroduces a staleness window; that cost must be earned by a read profile, not assumed. **Never a materialized view**: a PG MV is stale between refreshes and has no incremental refresh, so a refresh is a full firehose recompute. The choice is plain view (default) versus inline table (profiled). :::caution[Open question] If `current_value` is ever materialized, is it one wide table or a table per kind, keyed per (owner, key, instance, provenance)? ::: ## Partitioning and retention - **Append-only tables are range-partitioned by `ts`** (native declarative partitioning; `pg_partman` where the provider permits, else a documented manual roll). The firehose (`metric`) is the partitioning-critical one. - **Retention is per table**, set by policy, not one blanket TTL: `metric` short, `state` / `log_datapoint` longer, `audit_log` longest (compliance), `internal_log` short. On-row lineage ages out with its datapoint. The per-table defaults are **cascade-resolved** ([cascade](/architecture/cascade/)) with an install-wide `platform` binding, so a class or entity can hold longer or shorter without changing the whole install. - **The `raw_sample` buffer** (the opt-in raw-retention policy, [collection](/architecture/collection/)) is range-partitioned by `ts` and cold-tierable like the metric partitions, on a short retention. It is bounded, sampled, and short-lived; it is not a telemetry table. - **Views are not partitioned** (bounded by fleet size, not time) and are computed from the underlying tables, never the source of truth. :::caution[Open question] The index strategy per datapoint table beyond the obvious (BRIN on metric `ts`, GIN on log body), tuned against real volume. ::: :::caution[Open question] The append-only id type under partitioning: bigint identity versus uuid v7. ::: ## The Storage Gateway and tiering The **Storage Gateway is the only door to the database** (no direct access, no PostgREST); it is also where IAM scope is injected, **per action**: every query carries `visible_set(P, action)` for the specific action it performs, so a read filters by read-scope and an `:ack` write filters by ack-scope. A write whose action-scoped predicate matches **0 rows** is surfaced to the handler as a 403 or 404, never a silent success, matching the up-front `canDo` decision ([identity and access](/architecture/identity-access/)). Isolation is per-database (one database per tenant, paired one-to-one with one NATS account, [datapoints](/architecture/datapoints/)), so there is no tenant context to set. Every read and write lands here: the synchronous request path runs in **scoped** mode, and the persistence-consumer datapoint sink and the CDC publisher run in **system** mode (trusted internal work, all-visibility), the same three-mode contract identity and access describes. The CDC publisher reads committed changes by **logical decoding of the WAL**, a replication-protocol stream beneath the table surface; that is how it learns of a change without re-querying, not a second application path around the Gateway. Because every application read and write goes through the Gateway, the physical backend is swappable beneath it: - **default**: Postgres for everything (datapoints, ground-truth records, views, registries). In single-binary mode the one binary embeds a real Postgres (the same code path runs an external Postgres at scale); the data lane's persistence consumer and the record lane's CDC publisher both target this one backend. - **tiering**: the firehose does not stay in hot Postgres forever. Aged `metric` / `log_datapoint` partitions tier out to a **columnar or object store** (Parquet on S3-compatible, or an embedded columnar engine) behind the same gateway, so historical queries fan across hot and cold with no model change. The cold tier is partitioned by `ts`. - **blobs**: opaque bytes (a firmware image, a config dump, a capture, and later a large `log_datapoint` body or a `collection.failed` raw payload) live in the content-addressed [blob store](/architecture/files/), a `blob.Store` seam behind the same gateway. The default **pgblobs** backend holds bytes inline in Postgres; an S3-compatible or disk backend swaps in with no model change, since a row references a blob by its `sha256`, never inline bytes. :::caution[Open question] Which cold engine backs the tier, what triggers tier-out (age versus a partition-detach hook), how queries federate across hot and cold, and whether projections ever tier. ::: ## Query construction: typed, parameterized, generated The gateway builds every query with **[jet](https://github.com/go-jet/jet)**, a type-safe SQL builder whose column and table types are **generated from the dbmate-managed schema** (dbmate stays the single schema authority; jet regenerates after `migrate`). The shape is dynamic (the per-action scope predicate, the [filter expression](/architecture/expressions/), order, pagination compose at runtime) but the safety is **structural, not by discipline**: - **Values are always bound parameters**, never interpolated into SQL text. - **Identifiers (columns, tables) are typed constants** from the generated schema, so a wrong or attacker-supplied column name is a **compile error**, never a string. The filter language's field names resolve against those same generated columns before they become a predicate. - **Operators are a closed set.** A wrong column or type fails the build, so the compiler and tests catch a bad query before runtime, which is what keeps the gateway safe to evolve and safe for an AI to edit. Because all dynamic construction lives in this one module, the injection-safe discipline is a single reviewable chokepoint. The one carve-out is the high-volume datapoint insert (the persistence consumer), which may use `pgx` `COPY` for throughput, still inside the gateway. It runs in all-visibility **system mode**, not per-row scoped: its safety rests on the typed column targets plus the upstream **admission consumer** having already confined owners ([identity and access](/architecture/identity-access/)), not on a per-write scope predicate. --- # Tags URL: /architecture/tags/ A governed key vocabulary and per-entity value bindings that resolve union-on-key, override-on-value down the cascade. :::note[Partial] The first slice ([ADR-0021](/architecture/decisions/#adr-0021-tag-slice-1-a-governed-key-registry-with-entity-update-gated-bindings)) is **built**: the governed **`tag`** key registry, the per-entity **`tag_binding`** cell on the exclusive arc, and the union-on-key / override-on-value **cascade** resolver, over the API and the generated CLI. On top of it, the **console Tags directory** (mint, edit governance fields, delete a key), the per-entity **binding editor** (apply and remove values from an entity's detail blade), **value-domain enums** that constrain a key's values ([#190](https://github.com/hyperscaleav/omniglass/issues/190)), and the directory **Tags column** with per-key **tag filtering** (narrow a directory by any tag's effective value, plus **is set** / **is absent**) are built too ([#189](https://github.com/hyperscaleav/omniglass/issues/189), [#226](https://github.com/hyperscaleav/omniglass/issues/226)). Deferred to later slices: the winner-plus-shadowed **cascade provenance** panel on an entity's detail (the rest of [#189](https://github.com/hyperscaleav/omniglass/issues/189)), binding through [groups](/architecture/groups/) and a `template`-scoped binding (the shared-resolver work in [#184](https://github.com/hyperscaleav/omniglass/issues/184)), binding onto a [file](/architecture/files/) ([#191](https://github.com/hyperscaleav/omniglass/issues/191)), and a stored per-key color override. Those divergences are logged in the [decision log](/architecture/decisions/). ::: 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 same [cascade](/architecture/cascade/) as [config, secrets, and variables](/architecture/variables/), but with a **union-on-key, override-on-value** combinator rather than a single most-specific value. ## Two layers: the key vocabulary and the value binding - **`tag`** is the **governed key vocabulary**: one row per key (`category`, `environment`), shared across the whole tenant (one registry per database, which is the tenant boundary). It owns no value. - **`tag_binding`** is the **value cell**: it sets a value for a key at one owner on the exclusive arc (`platform | location | system | component | node`), exactly the arc a [variable](/architecture/variables/) or a secret is owned at. Splitting them is what keeps the vocabulary **normalized**: the key `environment` is minted once and reused, so no one invents `env` beside `environment` beside `Environment`. The key name is validated as a lowercase identifier in a pure `internal/tag` package, so the canonical spelling is enforced on the way in, not by convention. ## The governance split is the point Minting a key and setting a value are **two different permissions**, and that split is deliberate: - **Minting a key is a tenant-wide governance action**, gated by an all-scope **`tag:create`** grant (an admin or curator, [identity and access](/architecture/identity-access/)). Editing a key's governance fields is `tag:update` and deleting it is `tag:delete`, both all-scope. - **Setting a value is the ordinary entity write.** Binding `environment: prod` onto a component is a **`component:update`**, onto a system a `system:update`, onto a location a `location:update`, the same write an operator already holds on the entity. Binding needs **no new permission**: an operator who may edit a component may tag it, using the keys the vocabulary already governs. A **platform** binding (the install-wide value for a key) has no owning entity to defer to, so it is gated by `tag:update` plus the install-wide [`platform:update`](/architecture/identity-access/#install-wide-authority-is-not-estate-scope). So the vocabulary stays curated while tagging stays routine. Reading the vocabulary and an entity's tags rides the viewer floor (`tag:read`, `component:read`). ## A key governs where it applies and whether it cascades Two fields on the key shape how its bindings behave: - **`applies_to`** narrows a key to a subset of entity kinds (`component`, `system`, `location`, `node`). An empty set is **universal** (the key applies everywhere); a non-empty set rejects a binding on any other kind at write time (a `rack_position` key that `applies_to: [location]` cannot be bound onto a component). The vocabulary carries its own scoping rules, so a key means the same thing wherever it is legal. - **`propagates`** says whether a bound value cascades. A **propagating** key (the default) resolves down the tree: tag a location once and every system and component beneath it inherits the value. A **non-propagating** key binds as a **flat per-entity set**: it resolves only from a binding on the entity itself, never from an ancestor. That flat case is the shape a [file](/architecture/files/) needs (a file is not on the structural arc, so it has no parent to cascade from); the same key model serves both, toggled by this one field. ## Values resolve union-on-key, override-on-value The effective tags for a component resolve down the structural cascade (`platform -> location tree -> system tree -> component tree`), the same walk the variable and secret resolvers use, but with a different combinator: - **Keys union.** An entity surfaces **every** key bound at or above it. Two different keys set at two different scopes both appear; they do not compete. - **Values override.** For a **single** key set at several scopes, the **most-specific** binding wins (highest band, then nearest depth), exactly like a variable. The shadowed candidates come back too, so the surface can teach the override. A non-propagating key is admitted into the resolve only from the target entity itself, so its ancestor bindings never leak downward. The `GET /components/{name}/effective-tags` route returns the resolved set (winner plus shadowed candidates); `GET /components/{name}/tags` returns only the bindings set **directly** on the component. **Systems and locations resolve too.** A component walks the full arc, but every entity has an effective set. A **location** resolves `platform` plus its own location tree. A **system** resolves `platform`, its own system tree, and **the location it is placed at** (its `location_id` tree): a system in a PCI building surfaces `compliance: pci`, the same way a component picks up its own location's tags. A **node** is estate-wide, not a scope tree, so it resolves `platform` plus its own direct bindings only (no inheritance). This is the read behind the directory **Tags column**: the list routes (`GET /components`, `/systems`, `/locations`, `/nodes`) each carry an **`effective_tags`** map (key to winning value, winners only) per row, resolved for the whole page in **one batched query** (a `Gateway.EffectiveTags` per kind, no per-row fetch). Provenance (which scope a value came from) stays in the per-entity effective-tags detail view, not the column. **A key may constrain its values to an enum.** A key carries an **`allowed_values`** set: empty (the default) leaves it free text, and a non-empty set is the enum a bound value must belong to, so `environment` can be declared as one of `prod`, `staging`, `dev`. The binding write enforces membership (a value outside a key's allowed set is a 422), and the add control renders a strict dropdown for an enum key. A **free** key instead autocompletes the **distinct values already in use** for it (a `GET /tags/{name}:values` read), so an operator reaches for `prod` instead of retyping it, without the key having to declare the set up front ([ADR-0024](/architecture/decisions/#adr-0024-a-tag-key-may-constrain-its-values-to-an-enum)). :::caution[Open question] The rest of value-domain governance. Beyond the string enum, whether a key may carry a typed **`value_type`** (int, bool, date, validated like a [`property_type`](/architecture/datapoints/) domain) and whether it may **normalize** values on input (lowercase, trim, fold synonyms, so `Prod`, `prod `, and `PROD` resolve to one value) stay open. The enum is the first, most-asked-for slice; the rest is deferred. ::: ## Storage The key vocabulary and the value cell; the physical layout (the owner arc, the cascade key) lives on [storage](/architecture/storage/). | Table | Key columns | Notes | |---|---|---| | `tag` | name, applies_to, propagates, allowed_values | **Built.** The tenant-wide governed key vocabulary; minting a key needs `tag:create`. `applies_to` narrows a key to entity kinds (empty = universal); `propagates` toggles cascade versus flat per-entity binding; `allowed_values` is the value enum (empty = free text), enforced on the binding write | | `tag_binding` | (tag_id, **owner arc**), value | **Built.** The `key: value` binding at one owner on the exclusive arc (`platform / location / system / component / node`); resolves **union on key, override on value** down the [cascade](/architecture/cascade/). Setting a value is the owner's own `update` write, except at `platform`, which takes `tag:update` plus `platform:update` | --- # Templates URL: /architecture/templates/ The immutable, versioned shapes that instances pin: the component_template (the device shape) and the system_template (the composition shape with its frozen BOM). Templates let an operator define a device or system class once and stamp it onto many instances, with each instance pinned to a frozen version so its keys and roles never shift underneath it. Templates are the **immutable, versioned, content-hashable shapes** that instances pin. A [component](/architecture/core-entities/) pins a `component_template_version`; a [system](/architecture/core-entities/) pins a `system_template_version`. Editing a template mints a **new version**; an instance pins one frozen version, or tracks `latest`, or follows a channel (`stable` / `beta`), and re-pointing is explicit, so the keys and roles never change under a pinned instance. ## The component_template: the device shape A **`component_template` is the direct mirror to a Zabbix template**: it bundles, as one versioned unit, everything needed to monitor and control a class of device. Where a Zabbix template ships items, triggers, macros, and tags, ours ships: - **collection** authored as [functions](/architecture/collection/) (inputs, interfaces, functions), below; - **commands** (command-triggered functions the device supports, e.g. `reboot`, `set-input`), detail in [collection](/architecture/collection/); - **`property_type`s** (kind / unit / validation live on the registry, see [datapoints](/architecture/datapoints/#the-property_type-registry); a template declares its keys at **template** scope, or references an **org** / **official** key, see [Template-scoped keys](#template-scoped-keys-and-optional-alignment)); - required **[config](/architecture/variables/)** and defaults, and the **credential shapes** it needs (see [config and credentials](/architecture/variables/)); - default **tags**; - default **alarms / health** (the trigger mirror; [alarms and actions](/architecture/alarms-actions/) owns the detail). A template is authored once and **assigned to an existing component**; the node then executes the result. | Family | What it is | Examples | |---|---|---| | `product` | classification: the concrete SKU a component **is**, and the source of its shape | Cisco Room Bar, Samsung QM55 | | `component_template` | the **device shape**: everything about a class of device | Polaris DSP 16, Cisco Room Kit Pro, Q-SYS Core | | `component` | a deployed instance | `dsp-boardroom-3` | ### Collection is built from functions A template's collection is authored as [functions](/architecture/collection/): `inputs` (typed parameters), `interfaces` (connections declared once, possibly persistent), and `functions` (each a trigger plus a DAG of steps that parse at the edge and emit datapoints). A command is a command-triggered function in the same model. See [collection](/architecture/collection/) for the full schema; this page covers the rest of the device shape. ### Template-scoped keys and optional alignment A template declares its datapoints **and** commands at **template scope** by default: auto-discoverable, no registry friction, identified by `(template_id, name)` so two templates can both declare an `input` with no collision ([key scope](/architecture/datapoints/#key-scope-template-org-official)). It may **optionally align** each datapoint to an org or official canonical key. Alignment is just **referencing** a canonical `property_type` (plus an optional value transform), which is what buys cross-fleet comparability, dashboards, and AI; the shipped official set covers the common signals, so most templates align by referencing one. That value transform is also where the device's **native unit** is normalized to the key's **canonical unit** before the datapoint is emitted (a Fahrenheit display's template emits celsius), so storage stays single-unit ([datapoints](/architecture/datapoints/)). Commands are template-scoped (the functions live on the template); a canonical **command type** (the abstract `reboot` to per-model layer) follows the same promotion ladder. :::caution[Open question] The `args` typing vocabulary for commands (which scalar and structured arg types it admits) and how command results beyond `success-when` map to the `action` row fields. ::: ### The rest of the shape - **Config.** The template declares the [config](/architecture/variables/) a component *requires* (connection and inventory facts, e.g. `ip-addr`, `serial`) and their defaults. Effective values resolve through the cascade ([cascade](/architecture/cascade/)). - **Credential shapes.** The template declares the *kinds* of credential the device needs (`basic_auth`, `snmp_community`, `bearer_token`); these are [`variable_type`](/architecture/variables/) shapes, bound to actual secret values at assignment (credentials). - **Tags.** Default org labels seeded onto the component (`category: audio-dsp`). - **Alarms / health.** Default `event_rule`s the template ships, the conditions worth catching for its device class (the Zabbix-trigger mirror: a fan-stall on a DSP template, a person-entered event on an occupancy template). The alarming policy lives on the **template**, not on the `property_type`: a `property_type` is pure identity (kind / unit / domain / validation / fusion) and carries no event rules. A *truly universal* default (e.g. `cpu.utilization > 0.9` everywhere) is an official **rule-set scoped by a group or key filter**, resolved through the cascade (the rule accumulation mechanism), not a `property_type` attribute. Owned in detail by [alarms and actions](/architecture/alarms-actions/). - **Function trigger params are cascade bases.** A function's `interval: 30s` is the floor of the cascade, overridable by a location, group, or the instance (the `poll_interval` example in [cascade](/architecture/cascade/)), not a hard value. :::caution[Open question] Whether a template's default `event_rule`s are declared inline on the version or referenced (the policy is template-authored either way; this is the storage shape, co-designed with the alarms-and-actions model). ::: ### Deploy: assign a template to an existing component Assigning a template to a component materializes its collection in one action: it binds the template's required [`inputs`](/architecture/collection/#inputs-the-templates-typed-parameters) (the `:apply` gate, a 422 lists any unmet required fields), writes the supplied inputs as the component's [config](/architecture/variables/) (declared, audited), resolves the interfaces, and compiles the functions to the per-node runtime unit at the server-chosen node. Re-applying converges. The 80% case is one action, as cheap as "add host". ### Integrity, authenticity, and the capability gate A template carries two distinct trust properties, and they are not the same property. - **Integrity** is the **content hash** every version already carries: it answers "is this the exact bytes that were authored", and it makes a `component_template_version` a stable, addressable artifact. It says nothing about *who* authored those bytes. - **Authenticity** is a separate, **optional author signature / attestation** on the `component_template_version`. The signature is over the content hash, so a verified signature binds an authoring identity to those exact bytes. The signature is **verified on import**: an unsigned template imports as unattributed (the operator owns the risk), a signed template imports with its author identity and verification result recorded, and a signature that does not verify against the content it claims to cover is rejected. A template also declares a **capability manifest**: the set of **write-commands** it exercises and the **credential shapes** it requires (the `reboot` / `set-input` commands it issues, the `basic_auth` / `snmp_community` / `bearer_token` shapes it binds). The manifest is derived from the template, not operator-asserted, so it cannot understate what the template does. At [`:apply`](#deploy-assign-a-template-to-an-existing-component) the manifest is **shown and approved**: an operator sees exactly which device-mutating commands and credential shapes they are authorizing before the template materializes onto a component. Approving the manifest is the consent record for that capability set. A **device-mutating** template (one whose manifest declares any write-command) does **not** silently follow `latest` or a channel into a new capability set. Tracking `latest` or a channel still moves a read-only template forward automatically, but a new version of a device-mutating template that changes its capability manifest is gated behind an **explicit operator re-pin**: the operator re-approves the manifest at the new version before it takes effect. Auto-update never expands what commands run against a device without a human approving the expansion. The **hosted / marketplace** path verifies author signatures and enforces the capability gate on every import regardless of the self-host runtime stance. Self-hosters still own the risk of what they import (governance is curation, [collection](/architecture/collection/)): the runtime does not refuse an unsigned self-hosted template, but the signature state and the approved capability manifest are recorded either way. ## The system_template: the composition shape A **`system_template`** is first-class and **fully parallel to the component_template**: it declares a system's composition, its members and their roles, and the system-level rules and KPIs that only the system can see. Like a component template it is mutable, **versioned**, and **content-hashable** (each `system_template_version` is a stable artifact, addressable by a content hash); **editing mints a new version** and a system pins the **immutable** `system_template_version` snapshot. - **The frozen bill of materials.** A `system_template_version` carries a **`system_template_member`** for each role: the role, its **requirement** (the canonical datapoints and commands a member must provide), and the `health_role` (how it counts in the rollup). The role list, the requirements, and the health roles are **frozen into the version**; what an instance actually assigns is any component whose template meets the role's requirement, so the role validates against the system's frozen version and an assignment never expires under it. - **Pin, latest, or a channel.** A system (the instance) either pins a **specific `system_template_version`**, or tracks **`latest`**, or follows a **channel** (e.g. `stable` / `beta`). This is the same pin-vs-channel choice a component makes. - **Edits never silently change a pinned system.** An edit **mints a new version** and does **not** move a pinned system or its frozen role requirements; only instances tracking `latest` or a channel pick the change up (and a channel only when the new version is promoted into it). The immutability guarantee is explicit: a frozen system and its frozen BOM stay exactly as pinned until an operator re-points them. - **`health_role` rides the frozen version.** Each member declares `required` / `redundant` / `informational`, the knob for the built-in role-aware health rollup ([health](/architecture/health/)). It lives on the `system_template_member` (not on the component) because the same device can be required in one system and redundant in another. - **System-level rules, flows, and KPIs.** The system template owns the conditions only the system cares about: system-scoped `event_rule`s over member data (a display on input 2 is fine for the display but wrong for the room), the [flows](/architecture/alarms-actions/) that respond, and the system-owned [KPIs](/architecture/health/) (availability, the utilization family). These are stated here briefly; the rule and KPI detail live on [alarms and actions](/architecture/alarms-actions/) and [health](/architecture/health/). - **System-level config.** Config declared on a system template (or a role slot) resolves onto whichever component fills the role, so `video.input = HDMI1` for the main-display role applies to whatever display is assigned ([config and credentials](/architecture/variables/)). ### Role requirements :::note[What shipped is the system role] This section is `Design`. The **built** role slot is [`system_role`](/architecture/core-entities/#system-roles-the-slots-a-system-needs-filled): declared on a **standard** (inherited live by every conforming system) or on one **system**, not frozen into a `system_template_version`; requiring a set of **capabilities**, not canonical datapoints and commands; and assigned through `system_role_assignment`, not `system_member`. The strict validate-on-assign this section calls for is built and does refuse by name, over the capability vocabulary. The two models reconcile when template pinning lands ([ADR-0049](/architecture/decisions/#adr-0049-the-system-role-capability-gated-staffing-and-the-resolved-capability-set)). ::: A role declares **what a member must provide**, in canonical terms; any component whose template meets it can fill the role: ```yaml role: main-display requires: datapoints: [display.power, video.input] # canonical property_types commands: [set-input, power] # canonical command types health_role: required ``` - **A checklist, not a matching engine.** A component's template **qualifies** when it aligns the required canonical [property_types](/architecture/datapoints/) and command types (its set is a superset of the requirement). The requirement is stated in **canonical** keys, because it only means the same thing across templates when it names a canonical signal, not a template-local one. - **Qualify, then assign.** Pairing a component to a role **filters the picker to qualifying templates**, and the [API](/architecture/api/) **validates on assign** (a clean 422 if the component's template is missing a required datapoint or command). Mixed-vendor falls out for free: an LG and a Sony display both qualify for `display` if both align the required signals, with nothing to enumerate. - **No allow-list of templates.** A role names *what it needs*, never *which templates*. Declare the requirement on the system template, and any qualifying component, today's or a future vendor's, fills it. **Removing a capability is gated at adoption, not authoring.** Dropping a required datapoint from a component template means **minting a new `component_template_version`** without it, which is never blocked. The old version is **immutable**, so every live assignment pinned to it keeps working. The new version simply **no longer qualifies** for any role requiring the dropped signal, so it cannot be adopted into that role (the same validate-on-assign check fires at re-point), and a role tracking `latest` or a channel will not auto-jump to it. Removal surfaces at adoption against frozen versions, never as a silent break. (Deleting an org-canonical `property_type` a requirement references is a registry-governance warn or block, the same surfaced-not-silent pattern.) **The runtime backstop.** Validate-on-assign is prevention; detection covers anything it misses (an optional input, a drifted alignment). A system calc that reads a datapoint it cannot find **fails loudly**, a `calc.failed` event and a health-impacting "misconfigured" alarm, never a silent empty result. Two layers: gate at assignment, shout at runtime. This resolves **flexibility versus reliability** without trading either: templates evolve freely and any qualifying component fills a role (flexibility), while the requirement gates adoption against immutable versions with a noisy backstop (reliability). | Table | Key columns | Notes | |---|---|---| | `system_template` | name, type, **spec (jsonb)** | the mutable system shape; editing mints a new version | | `system_template_version` | (template, **version**), frozen **spec** | the **immutable** snapshot a system pins; roles never change under it | | `system_template_member` | (system_template_version, **role**, **requires** (canonical datapoints + commands), **health_role**) | the frozen **role requirement**: role -> the canonical datapoints and commands a member must provide + health role (required / redundant / informational, [health](/architecture/health/)). Any component whose template meets it can fill the role, validated on assign ([role requirements](#role-requirements)) | ```d2 direction: right classes: { node: { style.border-radius: 8 } } ct: component_template { class: node } ctv: component_template_version { class: node } st: system_template { class: node } stv: system_template_version { class: node } stm: system_template_member { class: node } dt: property_type { class: node } component: component { class: node } system: system { class: node } ct -> ctv: versions st -> stv: versions stv -> stm: frozen BOM (role + health_role) dt -> stm: required by role component -> ctv: pins system -> stv: pins ``` Locations have no template: the `location_type` is the only shape-definer ([core entities](/architecture/core-entities/)). :::caution[Open question] Whether a `LocationTemplate` (`kind` is reserved in the collection apiVersion) is ever introduced, or locations stay template-less. ::: --- # Time URL: /architecture/time/ The one primitive that manufactures events from the passage of time, so the rest of the pipeline stays purely event-driven. Time lets an operator alarm on things that produce no event of their own, "10 minutes elapsed", "it is 8am Monday", "the data stopped", by turning the passage of time into events the rest of the pipeline consumes. ## Why time needs a primitive Everything else is **push-driven**: an event arrives, rules fire. Time is the one input that **arrives as nothing**. "10 minutes elapsed," "it is 8am Monday," and especially "the data *stopped*" produce no inbound event, so nothing would ever fire on them. This primitive's whole job is to turn the passage of time into events the normal pipeline consumes. ## The pair: schedule, timer - **`schedule`** (config): a recurring definition, a cron or rrule plus an IANA timezone and what it triggers. Config, like a rule. :::caution[Open question] The recurrence surface a `schedule` accepts: a full iCalendar rrule, or a cron subset plus calendar anchors like month-start and month-end. ::: - **`timer`** (mechanism, working-set): every *pending* fire, kind-discriminated (`schedule-tick | for-sustain | runbook-wait | watchdog`), with a `fire_at` and a pointer to what it is for. A PG row, the durable working set. The clock singleton scans due rows and realizes each fire on its lane (a record-lane fire is written to PG and CDC fans it out to JetStream; a watchdog's staleness enters the data lane as a derived datapoint); rows are then consumed and rescheduled. A mutable working-set, like the outbox, **not** a history log. A schedule fire is **not** a separate log table: it is an ordinary **`event` with `origin=scheduled`**, manufactured by the clock into the `event` log. The event is born in a PG transaction (record plus any alarm transition) the same as any other event, never published directly (no dual-write), and the history of schedule fires lives in the `event` log alongside caught, caused, and derived events. The leader-elected CDC publisher fans the committed event out to JetStream, where an `action_rule` consumer reacts to it exactly as it reacts to any other event. ## One mechanism, three patterns All time behavior is the one `timer` table scanned by the clock singleton (sorted by `fire_at`, woken by a ticker with a crash-recovery backstop), each due row's fire realized on its lane (a record-lane fire born in PG and CDC-fanned to JetStream, a watchdog's staleness onto the data lane): - **recurring** (a schedule): reschedule the next `fire_at` after firing. Digests, synthetic checks, SLA calendar resets. - **armed and cancellable** (a relative one-shot): armed by an event, fires later, cancelled if the condition clears. The `for`-duration sustain, runbook waits, escalation delays. - **reset-on-arrival** (a watchdog): pushed to `now + tolerance` on each datapoint, fires if it lapses. No-data and staleness. Durable (a table, survives restart), single-fire across replicas: the clock is a leader-elected singleton, exactly one active at a time, held by a NATS KV CAS lock and failed over on death, so no replica races another to claim a row. :::caution[Open question] Whether a runbook's per-step waits each get their own `timer` row, or one row is advanced per step. ::: :::caution[Open question] The clock singleton's wake strategy: wake-on-insert for near-term fires plus a coarse backstop ticker, so a far-future schedule needs no frequent ticks. ::: ## A fire is recorded once, on the log of what it produces The `timer` table is mechanism; the **event is the product**. Each fire lands on the log of whatever it drives, never twice: | Timer kind | Produces | Logged on | |---|---|---| | schedule-tick | a trigger | an `event` (`origin=scheduled`) | | for-sustain | the alarm opens | an `event` (alarm edge) | | runbook-wait | the action advances | the `action` row | | watchdog | the datapoint goes stale | `datapoint` | So every schedule fire is an `event` with `origin=scheduled`, and every other timer fire is on the entity it advances. No untracked fires, no double-logging, and the high-churn watchdog never floods an event log with its resets. ## The backtest split Time divides cleanly across the backtest boundary: - **Schedules and armed timers are ground truth.** The wall clock genuinely advanced and a digest genuinely went out at 8am; a backtest does not re-run the clock, it reads the recorded `origin=scheduled` events as-is. - **No-data is derived.** The gap is *already in the recorded data* (the absence of datapoint rows in a window), so a backtest re-detects the same gaps and would re-emit the same staleness, no clock needed. At runtime it needs a real watchdog (you cannot know data is missing until the deadline passes), but logically it is a `calc_rule` reading arrival times. ## A schedule fire is the `origin=scheduled` event An `action_rule` consumer reacts to a schedule fire exactly as it reacts to an alarm, so `origin=scheduled` is the uniform "rules consume events" model, not special wiring: ```yaml action_rule: on: event when: 'origin == "scheduled" && schedule == "daily-digest"' action: email-open-alarms-summary ``` A synthetic check, an SLA window reset, and a digest are all schedules whose fire an action (or a check) subscribes to. ## No-data: stale vs unknown Absence of data is two conditions, and the why matters: - **`stale`**: we *had* a value and it has aged past its expected cadence. The watchdog's product (it can only arm after a first arrival). The last value and its **age are retained**; usually **actionable**, because a signal that stopped most often means lost visibility (the source died). The watchdog emits a derived staleness datapoint (`X stale at T`, and `fresh again` on resume). - **`unknown`**: **never** observed. No baseline, no last value. A static "not monitored yet" condition (a fresh device, a property_type never reported), detected by "no observations exist," not by a watchdog. Gray, not actionable. `current_value` carries `value, as_of_ts, freshness (fresh | stale)`; staleness is a quality of the datapoint with the last value preserved. **[Health](/architecture/health/) treats them differently**: a *stale required member* defaults to `unknown` (lost visibility, so the system rolls to `unknown`, [health](/architecture/health/)), an *unknown member* is gray and does not down the system. Whether stale means "last value still valid" (a slow config signal) or "lost visibility, alarm" (a liveness signal) is **per-datapoint-type policy**: the property_type declares its staleness tolerance. These two absences surface on the [health](/architecture/health/) side as `unknown` reasons: a went-stale datapoint is the `stale` reason, and a covered-but-never-reported datapoint is the `no-data` reason (distinct from `uncovered`, where no health-impacting rule resolves at all). **Cadence is inferred for pollers, declared for heartbeats.** A poller's expected interval is its `interval` times a tolerance. A listen-triggered function is **opt-in**: watched only if it declares an expected heartbeat interval (an MQTT keepalive, a source that pings); silence on a listener with no declared heartbeat is normal and unwatched. :::caution[Open question] The watchdog tolerance defaults (the multiplier on a poller's `interval`) and whether to debounce a missed-poll burst before declaring stale. ::: ## Timezones Every stored instant is a **`timestamptz`** (UTC, tz-aware), universal everywhere. A **`schedule` additionally carries an IANA timezone** (`America/New_York`) for computing recurrence and calendar boundaries, because DST means "8am" and "the 1st of the month" cannot be precomputed as fixed offsets. The resolved `fire_at` is a `timestamptz`; the recurrence is computed in the schedule's timezone. ## Digests A digest is a **schedule that fires an aggregating action**: the `origin=scheduled` event triggers an `action_rule` whose action queries (open alarms, the day's events), renders a Go-template body ([alarms and actions](/architecture/alarms-actions/)), and sends. No new machinery: schedule plus action, composed. ## Storage The recurring trigger config and the clock singleton's pending-fire working set; the physical layout lives on [storage](/architecture/storage/). | Table | Key columns | Notes | |---|---|---| | `schedule` | id, rrule/cron, **tz (IANA)**, target, enabled | config: a recurring trigger | | `timer` | id, **fire_at (timestamptz)**, kind (schedule-tick / for-sustain / runbook-wait / watchdog), ref, payload | the clock singleton's pending-fire **working-set** (the durable PG working set, mutable, scanned for due rows and the fire realized on its lane: a record-lane fire born in PG and CDC-fanned to JetStream, a watchdog's staleness onto the data lane), not a history log; fires are logged on the entity they produce | --- # UI URL: /architecture/ui/ The operator console: one renderer library in two composition modes, reads through views, and an identity-based information architecture. The UI is where an operator actually does the work, so it is built as one renderer over the same views the rest of the platform reads, with an information architecture organized around the entities you care about. This page covers the renderer / page / dashboard model and the information architecture. The stack, the typed client, the build pipeline, and the concrete reusable primitives are the [design system](/contributing/design-system/). :::note[What shipped vs the model below] The first surfaces built are the **inventory tier** (Systems, Components, Locations) and the shell. These ship as **config-driven `ListView` pages over the typed CRUD client**, not as the `ViewResult` renderer described next: an inventory page is CRUD over a scoped resource, so it reads the resource directly and renders one configurable shell. The `ViewResult` / views model, the renderer library, and composable dashboards below remain the intended **read side** for the analytical and dashboard surfaces (alarms, datapoint history, the cascade view, fleet dashboards), which are not built yet. The realized inventory shell and its primitives are in the [design system](/contributing/design-system/); how to operate it is the [operator guide](/guides/operator/), and the per-slice breakdown is on [implementation status](/architecture/status/). ::: ## The renderer contract: ViewResult and the views BFF The whole console rests on one contract. **All UI reads go through [views](/architecture/views/)** (the read-side BFF), CRUD for writes; the operator never queries raw tables. Every view returns a uniform **`ViewResult`** (`{columns, rows}`), and the SPA renders any view through **one renderer per view**: adding a view does not add a bespoke renderer. This is what decouples the render layer from any specific query and keeps the read contract uniform whether a page is coded or a dashboard widget is configured. The **dense-ops layout is an architectural pattern**, not a one-off page: list surfaces follow one shape (a summary of facets over the full set, then a keyboard chip filter, then a tree/list table, then a click-row detail blade plus a full detail page), and the facets drive the filter while the summary stays whole so click-to-filter is stable. The inventory tier realizes this pattern as the one config-driven `ListView` shell (with `FilterBar`, `Drawer`, `Donut`, and the faceted-filter engine); the concrete shipped primitives live in the [design system](/contributing/design-system/), and the pattern is the model the analytical surfaces will reuse. ## One renderer library, two composition modes The factoring avoids both "every screen is hand-coded" and "everything must be a dashboard": - **Renderer library** (coded once): `stat`, `table`, `status-grid`, `timeline`, `heatmap`, `line` / `area`. Each takes a **view result plus a field-mapping** (which column is the value / label / time / series key), so a renderer is decoupled from any specific view, and any view of the right shape can feed it. The set is closed but grown reactively, the same discipline as the reducer vocabulary. :::caution[Open question] The field-mapping contract between a view result and each renderer (the column roles per renderer type). ::: - **Coded pages** compose renderers plus custom interaction: the built-in information architecture (overview, drill-downs, config forms, exploration). - **Composable dashboards** (config-driven): operator-built grids where each **widget = a view ref + a renderer + a field-mapping + params**, no code per dashboard. Dashboard-level params flow into widget view-params, so one "system overview" dashboard works for any system. :::caution[Open question] The composable-dashboard schema: the widget placement grid, the view binding, and the dashboard params. ::: :::caution[Open question] Whether dashboards are themselves resources (carrying the `official` boolean, saved like views) or a thin layer over saved views. ::: The contract underneath both: **all UI reads go through [views](/architecture/views/)**, CRUD for writes. The renderer library serves coded pages and dashboard widgets identically; the only difference is whether the composition is code or config. ## Coded pages and dashboards share one view layer Coded pages give the complete operator console; composable dashboards are the customization layer on top (a grid editor, widget config, and the view-binding UI), and the view layer is what makes them cheap. A built-in page **queries a default view, not a raw resource** (the Alarms page reads the `firing-now` view, not `GET /alarms` directly), so the read contract is uniform and the same view backs a dashboard widget unchanged. ## Live updates: polling by default Live data is **query polling** (a refetch interval; slow-changing config uses a long stale time). A read can also **stream over the view layer (a server-side SSE relay)** where latency or fan-out earns it, the same earn-it-with-a-profile discipline. Presentation that depends on config (a severity level's id to its label and color) resolves client-side from the config view. A datapoint value resolves the same way: on read the UI converts canonical to the operator's preferred display unit, looked up from the unit registry by the [datapoints](/architecture/datapoints/) property_type's canonical unit, so storage stays single-unit while one operator sees Celsius and another Fahrenheit. :::caution[Open question] Which high-frequency surfaces move from polling to the SSE relay, and what latency earns it. ::: ## Configuration UIs CRUD forms over the typed resource API, one per primitive (components, templates, types, tags, rules, config, groups, schedules, severity levels, and the IAM resources). **Types** is the first of these to span several registries rather than one per primitive: a segmented tab per kind (location, system, component, secret), each tab its own directory over that registry, CRUD on the three writable kinds, and a read-only view of the fourth ([implementation status](/architecture/status/#build-progress)). Editing a setting is editing **[config](/architecture/variables/)**, an audited mutation, not a separate prop store ([audit](/architecture/audit/)). The standout is the **rule-authoring page**: - an **Expr editor** for the predicate or condition, with the prepared-input contract surfaced ([expressions](/architecture/expressions/)); - a **live blast-radius preview** (which entities a scope selects, which datapoints a rule would have fired on), so a rule is validated against reality before it is saved; - the **AI-suggestion seam** ([AI](/architecture/ai/)): AI may propose a rule pre-filled with provenance; the operator edits and approves, and approval is the ordinary audited create. AI never saves a rule itself. ## Exploration UIs Coded pages with rich interaction, all reading through views: - **The cascade resolve view** (the standout): "why did this value win", rendered from the [cascade](/architecture/cascade/) resolve output: the effective value, the winning source, and the ordered shadowed bindings it beat. The feature that makes an opinionated cascade explainable. - **Datapoint history**: a `line` or `heatmap` over a chosen time range, with the stale / unknown distinction surfaced ([time](/architecture/time/)). - **Alarm drill-down**: the alarm, its triggering datapoint and history, the actions it fired, and ack / snooze / resolve controls. - **Inventory and topology**: the location / system / component trees, navigable, with [health](/architecture/health/) (`status-grid`) at each level. - **Event exploration**: query the event log by entity / time / category, with the audit trail. ## Information architecture The IA has two layers, deliberately decoupled: 1. **Routes are flat and identity-based.** Every entity page is a top-level path (`/systems`, `/components`, `/templates`, `/config`); a page's URL addresses the *entity*, never its place in the menu. This is the contract we refuse to churn: bookmarks, deep links, and cross-links stay stable however the menu is later reorganized. There are no taxonomy-nested routes and no redirects to maintain. 2. **The sidebar groups those flat routes into clusters for browsing**: Home, Dashboards, Alarms, Inventory (locations, systems, components, nodes), Values (variables, secrets, config), Catalog (templates, types, tags, rules), Explore, Learn, Admin (users, roles, groups, audit, and a soon Settings leaf). Grouping is pure presentation: a cluster is not a destination and carries no route of its own. It can be rearranged, and is user-customizable, without touching a single route. **Values is its own top-level group**, standing beside Inventory rather than nested inside it as a band. Variables, secrets, and config are values an operator sets on estate entities and resolves down the cascade, a distinct genus from the estate entities themselves. **Config is the CI store**: operator-set desired component and system configuration, optionally observed back from the device to detect drift and reconcile ([config, secrets, and variables](/architecture/variables/)), distinct from platform Settings (preferences: severity scales, schedules, retention, defaults) and from Variables (free interpolated values with no observed side). **Inventory holds the estate entities**: locations, systems, components, and **nodes**, the collection daemons that gather datapoints. A node is a monitored, scope-controlled entity like any other estate member (gated on `node:read` plus ABAC scope once its backend lands; an ungated **soon** stub until then), so it stays in Inventory rather than Admin. **Interfaces and tasks are not nav items**: an interface is a panel on a component (its device endpoints), and a task is a panel on a node (its collection assignments), each a facet of its owning entity's detail page rather than a directory of its own. Admin is the renamed Settings group: it holds the platform-administration surfaces (Users, Roles, Groups, Audit) plus the Settings leaf itself, dimmed **soon** until the platform-preferences page ships. **Home is distinct from Dashboards.** Dashboards monitor the *fleet* (datapoint views over the inventory). Home monitors the *monitor*: the operator and admin situation room for config lifecycle (stale or out-of-date templates), control-plane health (rules failing to evaluate, datapoints dropped with no matching rule), and proactive suggestions. A dashboard cannot model that, so Home earns its own slot; "Overview" is the name of the default dashboard, not the landing. The theme is **dark-first** (the NOC aesthetic) on the brand palette (teal `#21CAB9`, navy `#080c16`), semantic tokens only, no hardcoded colors in components. --- # Config, secrets, and variables URL: /architecture/variables/ Three kinds of operator-set value resolved by one cascade: config keyed to a signal, secrets encrypted at rest, and free variables. :::caution[Direction: ADR-0063 makes the value store a provenance-keyed cache] The store is now named `property` per [ADR-0063](/architecture/decisions/#adr-0063-the-telemetry-model-is-typed-registries-over-bare-noun-data-tables) (was `property_value`, built). Still directional, not yet built: making it a latest-value cache keyed by `(owner, property_type, instance, provenance)`, with `declared` config resolved on demand from the cascade rather than stored, while `intended` is stored for command settlement. ::: :::note[Partial: the secret and variable members are built; config is Design] Two members are built. The **`secret`** member ([ADR-0017](/architecture/decisions/#adr-0017-credential-is-renamed-secret-the-cascade-is-the-reuse-mechanism), [#155](https://github.com/hyperscaleav/omniglass/issues/155)): the typed encrypted-at-rest cell owned on the exclusive arc and resolved down the cascade, the `secret_type` shape registry, envelope AES-256-GCM crypto behind a pluggable KEK provider, the masked-with-audited-decrypt read path, the two-axis visibility (placement scope plus the per-secret `admin_sensitive` flag, with a scope-filtered directory, [ADR-0025](/architecture/decisions/#adr-0025-secret-is-a-sensitive-resource-a-per-secret-admin_sensitive-flag-flips-a-secret-to-the-admin-tier)), and the operator surfaces. The **`variable`** member ([#183](https://github.com/hyperscaleav/omniglass/issues/183)): the typed **plaintext** cell on the same exclusive arc, resolved down the same cascade, with a Variables directory. The built operator surfaces for both members are the Secrets and Variables **directories** (browse, create, edit the cells), plus the **property** slot that consumes a cell into a component's value surface; the standalone per-component effective-secrets and effective-variables panels were retired ([#281](https://github.com/hyperscaleav/omniglass/issues/281), [decision log](/architecture/decisions/)). Each member's section below marks what is built versus deferred; the [build progress](/architecture/status/#build-progress) note carries the shipped shape. A related primitive, the **`property`**, also lands `Partial`: the typed-name catalog, the **classifier contracts** that say which properties an instance carries (a product's, a standard's, a location type's), and the arc-owned **value store** are built for the `declared` provenance on a component, system, location, and node, while the cross-owner cascade, macro interpolation, and the other provenance producers are deferred; its section is below. (The standalone **fields** feature folded into it: a field was a property with `declared` provenance, [ADR-0047](/architecture/decisions/#adr-0047-the-fields-fold-product_property-and-property).) The **config** member stays `Design`, so this page is `Partial`. (`secret` was renamed from `credential`; the ADR anchor keeps the old term.) ::: Everything an operator **sets** resolves the same way: a typed value, owned at a scope, resolved most-specific-wins down the [cascade](/architecture/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** (a `property_type`), so it has an observed side and can be reconciled. - **secret** (**built**): an access secret, encrypted at rest. Its own `secret_type` shape 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 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](/architecture/templates/) 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 When a config item has both a declared and an observed value, their gap is **drift**: the same [`disagree(declared, observed)`](/architecture/datapoints/#disagree-and-divergence) 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 through [`disagree`](/architecture/datapoints/#disagree-and-divergence) and 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 an [`intended`](/architecture/datapoints/#intended-the-declared-effect-of-a-command) datapoint, 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. :::caution[Open question] The `reconcile: enforce` execution (the set-function push and the enforcement-failure alarm) and the separate one-shot import action (observed-becomes-declared): the controller shape behind the reserved seam. ::: 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](/architecture/alarms-actions/)), with a per-item backoff so a device that refuses a write does not hammer. ### 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. :::caution[Open question] Resolving a value scoped to a role slot (the `system_template_member` where `health_role` already lives) may need a new cascade level between system and component, alongside the per-item get/set binding shape on the template. ::: ## 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](/architecture/decisions/#adr-0017-credential-is-renamed-secret-the-cascade-is-the-reuse-mechanism)): 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](/architecture/audit/) row**. **Two axes decide who reaches a secret** ([ADR-0025](/architecture/decisions/#adr-0025-secret-is-a-sensitive-resource-a-per-secret-admin_sensitive-flag-flips-a-secret-to-the-admin-tier)). **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 `/secrets` **directory 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_sensitive` needs `secret: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. `admin` holds `secret:>` (reaching the `:admin` tier a two-token `secret:*` 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 `oauth2` access 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](/architecture/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) :::note[Built: slice 1, plaintext cell + cascade] The first slice ([#183](https://github.com/hyperscaleav/omniglass/issues/183)) is **built**: the typed plaintext cell (a `value_type` of `string` / `int` / `float` / `bool` / `json`, its value stored as jsonb and 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. Three parts of the design below are deferred: the **`template`** owner scope (slice 1 mirrors the secret arc, `platform | location | system | component`; template scope and cascade groups are [#184](https://github.com/hyperscaleav/omniglass/issues/184)); a **`variable_type` registry** (slice 1 types inline with the `value_type` enum, no registry table, matching the "operator-defined, not curated" model); and the **`$var:` consumer** and the **secret-flagged** variable. These divergences are logged in the [decision log](/architecture/decisions/). ::: 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:` 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](/architecture/identity-access/)) 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 `platform` macro (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 :::note[Partial: the catalog, the three classifier contracts, and the declared value on an instance] Three pieces are **built**. The **`property` catalog** is the primitive-agnostic registry of typed names (`name`, `data_type` over `string` / `int` / `float` / `bool` / `json`, optional label, unit, and a JSON Schema `validation`, plus a nullable observed `kind`), with seed-owned `official` rows read-only ([ADR-0043](/architecture/decisions/#adr-0043-the-property-catalog)). Three **classifier contracts** carry the declaration, all the same shape: **`product_property`** (for a component), **`standard_property`** (for a system), and **`location_type_property`** (for a location), each naming a catalog property with an optional `default_value` and a `required` flag, unique per `(classifier, property)`. **`property`** is the value store, on the **same owner exclusive-arc** as the datapoint sinks and `event`, carrying an `instance` discriminator and a **`provenance`**; the write path fills `provenance=declared`. The read is **`EffectiveProperties(ownerKind, ownerID)`**, one parameterized query serving **component, system, location, and node**: the contract resolved to `coalesce(the instance's value, the contract default)`, plus the off-contract values set directly on the instance, so a **productless** component and a **one-off** system still resolve, and a classifier-less **node** resolves ad-hoc values alone ([ADR-0048](/architecture/decisions/#adr-0048-the-standard-blueprint-and-the-template-fork-seed-model)). The vertical is whole: storage (transactional, audited), the API (each contract gated by its owner catalog's permission, the values gated by the owning entity's `:read` / `:update` and ABAC-scoped on **every** arc), the generated CLI and typed client, and the console (a **Declared properties** editor on the product, standard, and location type blades, and a **Properties** panel on the component, system, and location details). The rest of the design below is **deferred**, listed plainly so a built badge never hides drift ([ADR-0047](/architecture/decisions/#adr-0047-the-fields-fold-product_property-and-property)). ::: 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), and `intended` (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 `sources` model**: the per-property allowed origins (`literal` / `variable` / `secret` / `datapoint` / `file`), the inline pickers they drive, and the override rules. - **File typing** (a `file` `data_type` with accepted MIME types and formats), which would give an attached file a semantic role by the property it fills (a `floor_plan`, a `firmware`). - **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 :::note[Built: slice 1, the registry, the bindings, and the cascade. See the [tags](/architecture/tags/) page.] The tag primitive's first slice is **built** ([ADR-0021](/architecture/decisions/#adr-0021-tag-slice-1-a-governed-key-registry-with-entity-update-gated-bindings)): the governed `tag` key registry, the per-entity `tag_binding` cell on the exclusive arc, and the union-on-key / override-on-value cascade resolver. The [tags](/architecture/tags/) page is its home; this section is the conceptual frame for how tags share the cascade with config, secrets, and variables. ::: 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](/architecture/identity-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](/architecture/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](/architecture/templates/) 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](/architecture/groups/) can key a rule-set off `compliance: pci`, an action can read a `maintenance_window`. :::caution[Open question] Value-domain normalization. Key normalization is settled (the governed registry plus the `tag:create` gate). The open part is the **value** side: whether a tag key may **constrain** its values (an enum or `value_type` on the key, so `environment` accepts only its allowed set, validated and autocompleted like a `property_type` domain), and whether it may **normalize** them on input through an Expr transform (lowercase, trim whitespace, fold synonyms) so `Prod`, `prod `, and `PROD` resolve to one value. Free-text values ship either way; the question is how much governance a key places on its values. ::: ## 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](/architecture/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 not `node`-owned). - **Typing.** A secret takes a structured `secret_type` shape registry (per-field secrecy and origin); a variable types inline against its `value_type` (a scalar, validated in the app, no registry); config instead borrows the `property_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. ```d2 direction: right classes: { node: { style.border-radius: 8 }; key: { style: { border-radius: 8; bold: true } } } operator: operator { class: node } declared: "config\ndeclared (spec)" { class: key } device: device { class: node } state: state { class: node } observed: "config\nobserved (status)" { class: key } command: "command (intended)" { class: node } operator -> declared: declares (cascade) device -> state: observed (get fn) state -> observed: observed-value worker declared -- observed: "disagree = drift" { style.stroke-dash: 4 } declared -> command: reconcile: enforce (set fn) command -> device ``` ## 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](/architecture/datapoints/#provenance-how-we-know-a-value)); 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 The shape registries, the value cells, and the operator-label tables; the physical layout (the owner arc, the cascade key) lives on [storage](/architecture/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](/architecture/decisions/#adr-0025-secret-is-a-sensitive-resource-a-per-secret-admin_sensitive-flag-flips-a-secret-to-the-admin-tier)) | | `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](/architecture/decisions/#adr-0043-the-property-catalog)) | | `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](/architecture/identity-access/)). No `_type`, no namespace; values bind via `tag_binding`. See [tags](/architecture/tags/) | | `tag_binding` | (tag, **owner arc**), value | **Built.** The `key: value` binding: **union on key, override on value** down the [cascade](/architecture/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 | --- # Views URL: /architecture/views/ The read side: a view is a named, parameterized, scope-checked query returning a uniform ViewResult, the backend-for-frontend every read goes through. Writes go through typed resource CRUD; **everything read goes through a view**. A view is a named query that returns a uniform **`ViewResult`** (`{columns, rows}`) and executes through the scoped [Storage Gateway](/architecture/storage/), so the read side is a safe backend-for-frontend that the console, the [API](/architecture/api/), and an AI agent all hit without ever touching raw tables or writing SQL. This page is the read contract; the [API](/architecture/api/) is the surface that exposes it, the [UI](/architecture/ui/) is the renderer that consumes it, and [API first](/contributing/api-first/) is the doctrine behind both. ## Why a view layer - A single resource reads through its typed `GET` (the [API](/architecture/api/) standard methods). Anything richer, a cross-entity aggregate, a fleet-health grid, the cascade "why did this value win" explainer, is a **view**: a named query the platform ships or an operator saves, not a bespoke endpoint per page. - **One shape, one renderer.** Every view returns `ViewResult` (`{columns, rows}`), so one renderer per shape serves every view ([UI](/architecture/ui/)); adding a view never adds a bespoke renderer or a raw query path. - **One safety boundary.** A view runs through the **scoped gateway**, so a caller sees only the rows in its visible set, exactly as for any read. The read side can be a public BFF precisely because no view ever runs unscoped or as raw SQL. ## What a view is A `view` carries an id, a typed **params schema**, the query it runs, a **default / private** flag, and the `official` boolean: - **Default views** ship with the binary (curated, PR-governed, optionally backed by a Postgres view). They are the read surface the console's coded pages query: the Alarms page reads the `firing-now` view, not `GET /alarms` directly, so the read contract stays uniform and the same view backs a dashboard widget unchanged. - **Private views** are operator-saved **structured** queries (filter + order + fields + params), **never raw SQL**. They follow the official / private [namespace shadow](/architecture/datapoints/#key-scope-template-org-official) like the registries. - A view is **parameterized**: it declares typed params bound at run time. The [API](/architecture/api/) runs one at `/views/{id}:run?param=`; an undeclared or missing-required param is a clean 400. ## ViewResult: the uniform shape `ViewResult` is `{columns, rows}`: each column carries a name and type (plus role hints a renderer maps), and rows are the records. The shape is uniform so the renderer library is decoupled from any specific view; a **field-mapping** tells a renderer which column is the value, label, time, or series key ([UI](/architecture/ui/)). - **Cursor-paginated** like any [API](/architecture/api/) list (`page_token`), over the already-scoped result. - **Views by default, materialized only when earned**: most views are live queries; a hot view becomes a materialized projection only when a read profile proves the live query too slow (the same discipline as [storage](/architecture/storage/)). ## Scope and safety - Every view runs in the gateway's **scoped mode**: the caller's `visible_set` ([identity and access](/architecture/identity-access/)) filters the rows, on every view, with no per-view code. A private view an operator saves **cannot widen their scope**: it resolves against their visible set at run time, so a saved query is never a privilege escape. - A view is **read-only** by construction: it never writes and has no side effects, which is what makes exposing views broadly (to the API, an MCP tool, a shared dashboard) safe. - Presentation that depends on config (a severity level's label and color) resolves client-side from the config view, not baked into the result. ## How views are consumed One read contract, three consumers: - **The console** renders a view through the renderer library ([UI](/architecture/ui/)): coded pages and dashboard widgets both bind `view ref + renderer + field-mapping + params`. - **The API** exposes every view at `/views/{id}:run` ([API](/architecture/api/)); views are part of the public contract. - **An AI agent** reads through view-backed tools on the [MCP surface](/architecture/api/) (the agent's search and query tools *are* views), scoped and audited like any caller. The read side is one contract whether a human, a script, or an agent asks. ## Live updates A view read is **query-polling by default** (a refetch interval; slow-changing config uses a long stale time). A view may **stream** over a server-side [SSE](/architecture/messaging/) relay where latency or fan-out earns it, the same earn-it-with-a-profile discipline ([UI](/architecture/ui/), [time](/architecture/time/)). ## Versioning A default view evolves **additively** within the API version (new columns, new optional params, never a removal or a meaning change); a breaking change to a shipped view is a new view. A private view is operator-owned data. :::caution[Open question] The structured view-definition grammar for private views (filter + order + fields + params), shared with the [API](/architecture/api/) list filter language ([expressions](/architecture/expressions/)). ::: Related: [API](/architecture/api/) (the surface and `/views/{id}:run`), [UI](/architecture/ui/) (the renderer and the field-mapping), [identity and access](/architecture/identity-access/) (the scope a view runs in), [storage](/architecture/storage/) (materialize-when-earned), and [API first](/contributing/api-first/) (the doctrine). --- # Workers URL: /architecture/workers/ One worker machinery over several JetStream consumers, plus the backtest capability and the reconcile desired-state loop. Workers are how Omniglass does the steady background work, deriving datapoints, sending actions, firing timers, reconciling drift, on one machinery instead of a pile of bespoke loops, so the operator gets crash recovery and exactly-once outcomes for free everywhere. ## One machinery, several consumers There is one worker machinery, a **JetStream work-queue consumer** over a configurable concurrency pool (pull a message, do work, ack, with at-least-once delivery plus `Nats-Msg-Id` dedup and an idempotent sink so it inherits crash recovery, exactly-once outcomes, and event-time semantics for free). It is instantiated over several consumers rather than separate loops: - **the admission consumer**: owner-confines raw-ingress datapoints (node and webhook) against the publisher's placement, preserving `Nats-Msg-Id`, and republishes to the **trusted** datapoints stream, so the rule engine and persistence read only confined points (system mode, [messaging](/architecture/messaging/)); - **the rule engine** (datapoint consumers): consume arriving datapoints from the **trusted** JetStream datapoints stream, apply `calc_rule`s and `event_rule`s, publish derived datapoints back onto the trusted stream (a trusted producer, no admission pass), and write events and alarm transitions to Postgres in one transaction; - **the action sender** ([alarms and actions](/architecture/alarms-actions/)): consumes action work fanned out by CDC, sends at-least-once, advances action step state (PG-first, CDC-out); - **the persistence consumer**: a batch sink that consumes the **trusted** datapoints stream and writes datapoints to the Postgres metric/state/log tables asynchronously, so rules never wait on PG; - **the clock** ([time](/architecture/time/)): fires schedules and armed timers (a leader-elected singleton, below); - **reconcile**: the desired-state loop (below). Each consumer is the "produces new work, needs independent durability" exception applied: a subsystem that consumes the same message is **a stage, not a second loop**. Competing consumers in a group scale horizontally with no leader: JetStream hands each message to exactly one member, and adding instances just adds throughput. Alongside the consumers, a **node-liveness sweep** runs on its own ticker. Unlike a consumer it is a *poll*, not a drain: a down node produces no message, so it is found by scanning heartbeat freshness, raising and resolving the node-owned `node.down` alarm idempotently (the one-open index). There is no separate projector either: current state is **views by default** ([storage](/architecture/storage/)), and `alarm` / `action` hold their state directly. ## Consumer groups versus singletons Most of the machinery is competing consumers, but two pieces must run as exactly one active instance: the **CDC publisher** (logical decoding of the WAL, fanning committed events, alarms, actions, and operator mutations out to JetStream) and the **clock** (firing schedules and armed timers). These are **leader-elected singletons** via a **NATS KV CAS lock**: each candidate races to compare-and-set a KV key, the winner holds the lease, and on its death the lease expires and another candidate takes over. Same pattern for both, no separate election service and no SKIP-LOCKED row claim. A singleton that produces work still publishes onto the bus, where the competing consumers scale it out. ## Re-entry, not one mega-pass The pipeline `datapoint -> alarm -> action` is **not one transaction**. A datapoint arrives on the datapoints stream; `event_rule`s evaluate it (the stateless then stateful stages below); two edges re-enter: **calc** (a `calc_rule` produces *new* datapoints) re-enters by publishing the derived datapoints back onto the data lane, where the consumers pick them up again, and **actions** are born when an `event_rule` writes the event and alarm to PG in one transaction, after which CDC fans the committed change out to the action sender. So the rule engine never recurses unboundedly in one transaction; a cross-producing stage hands off to the bus, which is also what makes it independently durable. Calc re-entry **terminates by write-on-change** (a recompute that lands the same value publishes nothing, the fixpoint) with a depth cap as a cyclic-rule backstop, carrying a rollup one hop per pass. **[Health](/architecture/health/) is the exception, and deliberately not a worker stage**: its rollup (component -> system -> location) runs **inside the write transaction** that changed it, because a verdict recomputed a hop later would record its transition at the wrong moment. Parsing into datapoints is **not** a worker stage; it happens at the edge ([collection](/architecture/collection/)). ## The stateless / stateful fork This is the axis that decides almost everything else about a subsystem. - **Stateless** (owner resolution, calc): output is a pure function of (input, rules, snapshot). Order-free, safe to backtest for free, no cross-event state. Write pattern: **append** (a batched multi-row INSERT). - **Stateful** (the alarm lifecycle): maintains persisted state across events (the open alarm), so open and resolve depend on prior state. Consequences: - **Order-sensitive.** JetStream does not promise strict ordering (the server is ts-authoritative) and competing consumers can hand same-key messages to different members, so a stateful subsystem must either be idempotent and tolerate reorder (an as-of conflict rule) or serialize per state key. The alarm transition is serialized per `(event_rule, owner)`: that ordered write lands in the same PG transaction as the event record. - Write pattern: **guarded conditional upsert** (`INSERT ... ON CONFLICT` / `UPDATE ... WHERE`), with a **partial unique index** as the concurrency-correctness backstop. - **Backtest is harder**: it must process each entity's series in order. ## Lineage the engine stamps Every derived datapoint carries its lineage **on the row** (a `provenance`, `source_rule` plus version, and the one provenance pointer; see [storage](/architecture/storage/), [datapoints](/architecture/datapoints/)). There is no separate execution table: a derived row is itself the evidence of its rule's run, and a fan-out (one execution to N datapoints) stamps the same `source_rule` on each. The rule version is the hinge for backtest. ## Backtest: re-run a changed rule over retained datapoints The model is **not event-sourced**: current state lives in the datapoint tables and the `alarm` / `action` rows directly, never reconstructed from a log. Omniglass does **not** re-run history to rebuild events or state. But a changed `calc_rule` or `event_rule` can be **backtested**: a read-only what-if that re-runs the new rule version over the **retained datapoints** and diffs its output against what the old version produced, purely as DX sugar, without writing a new event or touching live state. Only the **calculated** and **event-derived** slices are server-rule-derived, so only they re-derive. Everything else does not: - **observed** datapoints are parsed at the edge and are not re-derived server-side (the raw payload is not stored, so there is no server-side re-parse); - **operator alarm transitions** (ack, snooze) come from `audit_log`; - **action delivery status** comes from the action rows (the real-world send is not re-done); - **no-data staleness** re-derives from the datapoint gaps ([time](/architecture/time/)). Two modes, switched by the `source_rule` version: **historical** uses the original rule versions recorded on each derived row (showing what the system actually computed, for audit), and **prospective** uses the current rule versions (re-deriving as if today's rules had always applied, for testing a rule change). **A backtest writes to a shadow, never live**: promoting a result to live is a separate, explicit, audited step. A prospective backtest is **windowed by default** (over the last 30 days), with whole-history the explicit, heavier option. ## Reconcile: the desired-state control loop Reconcile is another JetStream consumer: it projects **declared desired state** onto the things that drift, the system-level form of [config](/architecture/variables/)'s `reconcile: enforce` policy. - **Inputs**: the desired declarations (templates, component assignments, config declared values) plus the observed state. Config changes are operator mutations born in a PG transaction; CDC publishes the committed change to JetStream ([audit](/architecture/audit/)), so reconcile is a CDC consumer plus the current projections. - **Output**: it asserts the delta as **node config** (which tasks and commands each node runs, derived from placements) and as **reconciled `run` actions** (the desired-state commands that must stay asserted, for example a codec's feedback registration). - **Idempotent**: assert-equals-observed is a no-op; it acts only on drift. Its runs log an `internal_log`, using the same worker machinery without a bespoke loop. Open: the reconcile cadence (continuous versus on-audit-change versus a periodic full sweep) and backoff on a flapping target. --- # API first URL: /contributing/api-first/ The Go API is the single integration contract; the SPA, CLI, and YAML tooling are generated clients of it. The Go HTTP API is the **single integration contract**. The SPA, the CLI, the node worklist, and the YAML authoring tooling are all **generated clients** of it. Nothing but the API talks to the database, and the API is described by one machine-readable spec that cannot drift from the implementation. ## The source of truth is the Go API Request/response types are Go structs (Huma). The OpenAPI 3.1 document is *generated* from them, server-less, and committed. Everything downstream is generated from that document. This is the rule: **you change a Go route or shape, you regenerate, you commit the derived artifacts.** A drift check in CI fails the PR if the committed artifacts are stale. ## The generation pipeline | Generator | Input | Output | Consumer | |---|---|---|---| | `cmd/openapigen` | Huma Go structs | `api/openapi.json` (+ `.yaml`) | everything below | | `web pnpm gen:api` | `openapi.json` | `web/src/api/schema.gen.ts` | typed `openapi-fetch` SPA client | | `cmd/cligen` | `openapi.json` | `internal/cli/api_gen.go` (cobra) | the CLI, patched via `api_hooks.go` | | `cmd/mcpgen` | `openapi.json` | the MCP server (a curated tool catalog) | AI agents over the [API contract](/architecture/api/) | | `cmd/schemagen` | authoring structs | `schema/*.schema.json` | YAML editor validation (VSCode) | | `gen-proto` | `proto/og/v1/*.proto` | committed `*.pb.go` | the gRPC ingest path | One command runs them all (`make gen`); each has a focused target (`make gen-api`, `gen-cli`, `gen-schema`, `gen-proto`). The committed `*.pb.go` and JSONSchema let a contributor build without protoc or a running server. ## A name is the address, a uuid is identity **Every response carries both forms of a reference: the name an operator reads and the id it resolves to.** `{"parent": "rack", "parent_id": "0198f..."}`. The name is what a human types and what a body round-trips; the id is the stable handle that survives a rename. A response that carries only the uuid is the failure this rule names. The test is a **round trip**: a response body can be fed back to the write that produced it. Create a component with `{"parent": "rack"}` and read it back as `{"parent": "rack"}`, not as `{"parent_id": "0198f2c4-..."}`. When that fails, every client has to fetch a second collection and join by uuid to render one label, and they each do it slightly differently. One exception, narrow: **an entity with no name** is legitimately addressed by id, an interface (its name is unique only within its component), a stored property value, an audit row, a grant, a principal. A **registry** used to be a second exception, a slug-keyed catalog whose id *was* its name (`product_id: "cisco-room-bar"`); that is gone. Every registry now has a uuid primary key and a renameable `name` ([ADR-0062](/architecture/decisions/#adr-0062-a-registry-takes-a-uuid-primary-key-and-a-renameable-handle)), so it obeys the rule like any estate entity. **Every foreign key stores the target's primary key**, a uuid, with no exception. A rename then has nothing to rewrite: the friendly name is free to change precisely because nothing points at it. A `_id` column holding a name, kept alive by `on update cascade`, is the shape this rule exists to prevent; the cascade is machinery that only exists to fund the wrong choice. That machinery is now retired everywhere, including the last place it lived, the registries. **A path or a join field accepts either form.** `GET /components/{ref}` and a body's `{"parent": "..."}` both take a uuid or a name; the uuid is tried first, so an id never collides with a name. Operators type names, scripts hold ids, and neither has to convert. `TestReferencesCarryBothForms` enforces this over the generated OpenAPI in both directions, so a body cannot silently reintroduce a uuid-only reference (a `*_id` with no name) nor a name-only registry reference (a registry handle with no id). Its exempt list is the whole of the remaining exception (the nameless entities and a still-slug-keyed taxonomy), and adding to it is a decision: if the target has a name, carry the name. ## Conventions (AIP-style) These are the conventions a route follows while you write it; the complete [API contract](/architecture/api/) (the error envelope, idempotency, long-running operations, versioning, and the authorization status mapping) is the architecture of record. Every operation lives under `/api/v1/*`. The path shape is derivable, not special-cased: - **Plural collections**, standard CRUD by primary key: `POST` creates (409 on PK collision), `GET` reads, `PATCH` updates by PK (AIP-134, partial), `DELETE` removes. No upsert/register shortcuts. - **`:verb` (not `/verb`) for non-CRUD custom methods**: `/alarms/{id}:ack`, `/nodes/{name}:heartbeat`, `/rules/calc:validate`, `/components/{name}:apply`, `/views/{id}:run`. - **Singular kind sub-segments**: `/rules/calc`, `/datapoints/metric`, `/location-types`, `/types/event`. - **official / private namespace** on every registry and rule family (below). - **List conventions** (AIP-132 target): `filter` / `orderBy` / `pageSize`+ `pageToken` (cursor, never offset) / `fields`. The `filter` runs through the one pluggable expression engine ([Expr by default](/architecture/expressions/)), the same language across rule scopes, dynamic groups, and list filters. The API is **self-describing**: the running server serves `GET /api/v1/openapi.json`, `/openapi.yaml`, and a human reference page. ## The read side is views (backend-for-frontend) Writes go through resource CRUD (each emitting an `audit_log` row in the same transaction). **Reads beyond a single resource go through views**, and views are part of the public API: - a **view** is a named query backing a page or widget, returning a uniform `ViewResult` (`{columns, rows}`) so one renderer contract serves every view; - **default views** ship with the binary (curated, may be Postgres-view-backed, PR- governed); **private views** are operator-saved *structured* queries (filter + order + fields), never raw SQL; - `GET /views/{id}:run?param=` binds declared params; undeclared or missing-required params are a clean 400; - views execute through the **scoped Storage Gateway**, so IAM scope applies to a view's results exactly as to any read. This is the safety boundary that lets the read side be a public BFF without handing operators raw SQL. ## The per-route gate Every typed route carries a per-route coverage test (an `openapi_coverage_test.go`-style gate) and the CLI-covers-every-route test, so the generated clients never fall behind the API. After any route change: `make gen-api && make gen-cli`, add the per-route test, keep the coverage tests green. --- # UI and the design system URL: /contributing/design-system/ The SolidJS and daisyUI console, a config-driven ListView shell over a generated typed client. The operator console is a **SolidJS** SPA styled with **daisyUI 5** on **Tailwind CSS 4**. It is a generated client of the API (typed via `openapi-fetch` off the committed `openapi.json`). The same surfaces are also the **learning surfaces** (see [the learning-tool restriction](/contributing/learning-tool/)). :::note[What shipped] Styling is **daisyUI 5 component classes + Tailwind utilities** on the `omniglass-dark` brand theme defined through the daisyUI plugin from the design-system tokens. The console ships **dark-only for now**: an `omniglass-light` theme is still defined but not reachable (the toggle was removed while the tokens settle, since a brand teal that reads well as a fill does not as text on white, making a second theme ongoing churn without enough payoff). It is revivable by restoring a persisted `setTheme` and the toggle. Bespoke CSS is kept to what daisyUI has no slot for: the domain severity/health colors, the density lever, the column-resize handle, and the live pulse. Accessible interactive widgets (dialog, combobox, select, popover) are built on **Kobalte**, styled by daisyUI, pulled in primitive-first. The first consumers are the ⌘K command palette and the form/detail `Drawer` (Kobalte `Dialog`). ::: ## The stack | Concern | Choice | |---|---| | Framework | SolidJS (`solid-js`, `@solidjs/router`) | | Components / theme | daisyUI 5 on Tailwind CSS v4 (the `omniglass-dark` / `omniglass-light` themes) | | Interactive primitives | Kobalte (`Dialog` for the palette and Drawer; daisyUI `dropdown` for menus), styled by daisyUI | | Data fetching | `@tanstack/solid-query` over a typed `openapi-fetch` client | | Build / test | Vite, Vitest, `@solidjs/testing-library` | | Flow / graph viz (future) | for the learning + explore surfaces; not built yet | | Dashboards (future) | a widget grid for the dashboards surface; not built yet | The typed client is generated, never hand-written: `openapi-typescript` turns `openapi.json` into `schema.gen.ts`, so a route or shape change surfaces as a TypeScript error in the SPA. The cobra CLI is generated the same way. `make gen` regenerates all of it; a non-empty diff fails the slice. ## Core UI contracts - **One inventory shell: `ListView`.** Every inventory page (Components, Systems, Locations) is a `ListConfig` over the one shell, **never a fork**. The shell owns the faceted filter header, the action rail (tree/list toggle, expand/collapse, column visibility + drag reorder, the primary create), tree and flattened rendering, the stacked detail blades, the full-page detail, the create/edit `Drawer`, and an optional summary widget board. Adding an entity of this class is a data layer + a config + a route (see the `add-inventory-view` skill). - **The faceted filter is a tested engine.** `lib/predicate` is the pure matcher: values within a chip are OR, chips across keys are AND, clicking an active facet removes it. `FilterBar` is the thin staged combobox over it; the genuinely tricky list derivations (index, ancestor paths, flatten-vs-tree rows, client-preference parsing) are pure in `lib/listmodel`. Both are unit tested; `FilterBar` has a component test. - **`can(me, resource, action)` from `/auth/me`.** The console reads the principal's flat, wildcard-expanded `permissions` once and gates UI affordances with O(1) checks; `ListView` gates create/update/delete by the entity's resource name. The server is the authority; this is a hint only. - **Blades are ephemeral, the full page is addressable.** A row opens a stacked blade (the Azure model); Maximize promotes it to the `//:name` URL. The blade stack holds node ids, so a blade survives a refetch. - **The shell owns the action rail; the body registers, never draws.** A panel's buttons are declared, not laid out: a blade body binds through `lib/blades` (`destructive`, `secondary`, `primary`, plus the Edit/Save cycle) and a Drawer form body binds through `lib/formactions` (`submitLabel`, `submitIcon`, `submit`, `busy`, `disabled`, `cancel`). `BladeStack` and `Drawer` each draw the resulting bar, both through the one `PanelFooter` rail, so spacing and chrome cannot drift between them. A body that renders its own button row is a bug, and `rail-ownership.test.ts` fails on it. This replaced an opt-in `DrawerFooter` helper that each form had to remember to wrap its buttons in: two forms forgot, and stayed wrong for months while the helper was copied into six new pages around them. A convention can be forgotten; a slot cannot. Full-page create forms still draw their own inline rail and converge when the CRUD form primitive lands. - **Client preferences in localStorage, for now.** Column order/visibility and the widget board persist per browser; the eventual home is a per-principal user-preferences endpoint (a read/write swap), not the cascade. - **Learning surfaces ride the real engine.** A concept page renders the actual pipeline against real or lab-simulated data, not a static diagram. The flow/graph library for these lands with the explore/learn surfaces. ## Button vocabulary Buttons use a small set of **semantic intent classes** defined in `app.css`, never the raw daisyUI color/emphasis classes, so styling is unified and a future theme restyles every button from the theme tokens in one place. One intent per button; structural `btn`, size (`btn-sm` / `btn-xs`), and shape (`btn-square`) still come from daisyUI. | Intent | Class | Use | |---|---|---| | Primary action | `btn-action` | the main action (Save, Create, Edit, New): filled | | Secondary / quiet | `btn-quiet` | Cancel, icon buttons, low-emphasis actions | | Destructive | `btn-danger` | revoke, delete | | State toggle | `btn-warn` / `btn-ok` | a reversible toggle that reads its state (Disable is a warning, Enable a success) | The **edit flow reads the same everywhere**: Edit is a filled `btn-action` with a pencil, Save is a filled `btn-action` with a disk icon, Cancel is a `btn-quiet` with an X. Create submits are `btn-action` with a plus. The icons come from the local `icons` set, so a control looks identical on every page. The intents are `@apply`-composed from daisyUI in `app.css`, so they inherit the theme's tokens: color lives in the tokens, not the markup. A `style-guard` test scans the source and fails the build on **any** raw daisyUI color/emphasis button class (`btn-primary`, `btn-ghost`, `btn-outline`, `btn-soft`, `btn-error`, `btn-success`, `btn-warning`, and the rest), so the vocabulary cannot drift back to one-off styling. The primary button (and every other primary-filled surface: solid badges, selected chips, avatars) is the **bright brand teal** with a dark ink foreground, driven by the theme's own `--color-primary` / `--color-primary-content`. daisyUI 5's `@apply btn-primary` drops the primary foreground and lets the filled button inherit the near-white base-content (1.8:1 on the teal, failing WCAG), so a single unlayered `.btn-action:not(:disabled)` rule restores the theme foreground. The dormant light theme also needs a darker teal for teal **text** on white (a `[data-theme=light] .text-primary` rule), since the bright teal is unreadable as small text on white (1.9:1) while it reads 8.3:1 as a fill with dark ink; that rule is inert while the console is dark-only. ## Status pills Status badges use `badge badge-sm` with a **soft hue** for a signalled state (`badge-soft badge-success` for up/enabled/responding, `badge-soft badge-error` for down, `badge-soft badge-warning` for stale). A **neutral** state (a node that has never checked in, a disabled task, an unknown verdict) does **not** use `badge-neutral` or `badge-ghost`: against this theme's dark `base-100` (`#080c16`), `badge-neutral` renders near-black and `badge-ghost` renders transparent, so both read as invisible. Use a soft grey fill tinted from the text color instead (`bg-base-content/10 text-base-content/70 border-transparent`), which reads as a visible pill in both themes at the same weight as the soft hues. The same reason keeps `type` values (interface/task `type`) as plain `font-data` text, not a `badge-neutral` chip. ## Primitives (the reuse target) `ListView`, `FilterBar`, `Drawer`, `PanelFooter`, `Donut`, `Badge`, `Fact`, `Page`, `DataTable`, `CommandPalette`, plus the `Sidebar` / `TopBar` shell. New inventory pages consume these; new surface *classes* (dashboards, alarms, explore, learn) add their own primitive rather than bending `ListView`. ## Build and embed The SPA builds with Vite (`npm run build`, into `internal/webui/dist`) and is embedded into the Go binary under the `web` build tag, served at `/web`. One artifact serves the API and the console. In dev, `npm run dev` serves the SPA on :5173 with `/api` proxied to a locally-running `omniglass server`, so the frontend loop needs no rebuild. ## Tests Component-level tests (Vitest + `@solidjs/testing-library`) cover the interactive widgets and the pure list/filter logic (`lib/predicate`, `lib/listmodel`, `FilterBar`, the data layers). The **browser-driven e2e tier** (drive the console as a user against the full stack) is the remaining gate per the [test-first doctrine](/contributing/test-driven/); until it lands, user-observable behavior is verified by hand/Playwright and is not yet a committed gate. ## How this relates to the UI architecture This page is the **build and dev guide** for the console: the stack, the generated client, the `ListView` shell and its primitives, and the build-and-embed pipeline. The **architecture** (the information architecture, the read-side BFF, the live-update model) is [UI](/architecture/ui/) on the architecture spine. Build mechanics live here; the model lives there. --- # Docs with everything URL: /contributing/docs-with-everything/ A feature is not done until the docs that teach it ship in the same PR. Omniglass ships its documentation *as part of the product*. The docs are not an afterthought in a separate wiki; they are Astro Starlight content under `docs/`, compiled to a static site and published at docs.omniglass.hyperscaleav.com (and, in time, embedded into the Go binary to serve at `/docs`). The architecture is published ahead of the code, so the design is visible (and reviewable) before, or alongside, the feature that implements it. ## The rule **A feature is not done until the docs that teach it ship in the same PR.** Concretely, a user-facing PR must do one of: - change `docs/` to add or update the page(s) that explain the new behavior, or - carry the `no-docs` label with a one-line justification (pure refactor, internal-only change, etc.). CI enforces the docs-touched gate. The justification path exists so the gate never blocks a genuine internal change, not as a routine escape hatch. ## What "the docs" means here - **Architecture pages** (`/architecture/`) hold the model: the spine plus leaf documents, and the current decisions. Each official term is defined once in the [glossary](/architecture/glossary/) and not redefined in the leaves. - **Guides** (`/guides/`) are how-to pages for someone *using* the product, split by audience: the **operator guide** (running the estate from the console and the CLI), the **admin guide** (managing accounts, access, audit, and config), and **deployment** (standing the platform up). **A slice that ships or changes a user-facing surface ships or updates its guide in the same PR**, not just the architecture page, filed under the section that matches who does the task. The architecture page says how the surface is built; the guide says how to use it. - **Concept and learning pages** teach a concept interactively (see [the learning-tool restriction](/contributing/learning-tool/)). When a feature introduces a concept an operator must understand, the teaching surface ships with it. - **Contributor pages** (`/contributing/`) are this doctrine set. So a feature that adds an operator surface usually touches two homes: the **architecture** page (the model) and a **guide** (the how-to). A purely internal change touches neither and takes the `no-docs` label. ## Status moves with the code The architecture pages are written in the present tense as the **target design**, so build status is carried *alongside* the prose, not woven into it, and keeping it current is part of docs-with-everything. A slice that advances a page updates three surfaces in the **same PR**: - the page's **status badge** moves to its new floor (`Design` to `Partial` to `Built`), which the live grid on [implementation status](/architecture/status/) reads directly, so the grid never lies; - the **build-progress note** on `status.mdx` gains the slice's entry; and - if the shipped code **diverges** from a page's design, the page carries an inline note and a [decision-log](/architecture/decisions/) entry (an ADR) lands in the same PR. Forward-looking intent that is not yet a slice lives in a GitHub epic and is indexed on the [roadmap](/architecture/roadmap/); it is not written into a page as if built. This is the contract that keeps the published design describing what exists: a built capability never sits behind a `Design` badge, and a divergence is never silent. ## Screenshots are generated, not pasted Screenshots embedded on a docs page are a **generated resource**, treated like the OpenAPI spec or the CLI reference, never a static image dropped in by hand. A page declares what it needs in `screenshots` frontmatter (the shot's `id`, the console `path`, its `alt`, and any interaction `steps`), and embeds it in the prose with a directive: ```markdown --- title: Secrets screenshots: - id: secrets path: /web/secrets alt: "The Secrets directory: type badges, owner scope, and masked field previews." --- ::screenshot{#secrets} ``` That frontmatter is the **single source**: `make docs-shots` reads it from every page, drives the real console (the same binary an operator runs, never a mock), and writes `public/screenshots/.png`; the directive renders the figure from the same entry. So the capture list and the embed cannot drift, a `#id` with no frontmatter entry (or no captured image) **fails the build**, and adding a screenshot is a frontmatter edit, not a code change. Because the images track the live UI, they are refreshed like any generated artifact: a change to an operator surface **re-runs `make docs-shots` and commits the new PNGs**, and `make docs-shots-check` recaptures against the real console and fails if a shot drifts beyond a small tolerance, the visual sibling of the `make gen` drift check. (Tolerance rather than an exact hash because the dev seed's random UUIDs move a fraction of a percent of pixels between captures; a real UI change moves far more.) ## Style - No em dashes. Use commas, colons, periods, or parentheses. - No AI/assistant attribution. - Write for someone learning the system, not someone who already built it. The same page serves the operator using the product and the contributor extending it. ## Publishing Docs build in CI on every PR (so a broken docs build fails the PR) and are embedded into the binary at release. The published site is docs.omniglass.hyperscaleav.com. --- # Labels URL: /contributing/labels/ The fixed label taxonomy: what is a native Issue Type, what is a Project field, and what stays a label. Labels are a small, fixed set. Two whole categories that would otherwise be labels are not labels here: they live in first-class GitHub fields instead, and labels are reserved for the axes those fields do not cover. ## What is not a label - **Kind of work** is a native **Issue Type** (`Task`, `Bug`, `Feature`, `Epic`), set on the issue itself, never a `type:*` label. It maps to the conventional-commit type the eventual PR carries: `Task` = ci/docs/chore/refactor/test/perf, `Bug` = fix, `Feature` = feat, `Epic` groups a body of work. One field, filterable, with no parallel label to drift out of sync. - **Priority** is a single-select **field on the Omniglass Project board** (`High`/`Medium`/`Low`), never a `prio:*` label. It sorts and groups on the board, where planning actually happens. Using a label for either would duplicate a field GitHub already models, and two systems for one fact drift apart. ## What is a label Everything left is one of two prefixed families. The prefix encodes the one distinction that matters: does the label **describe** the issue, or does it **trigger** automation? ### `area:` describes The subsystem a change touches, matching the architecture glossary: `area:foundation` `area:api` `area:storage` `area:auth` `area:collection` `area:expr` `area:ui` `area:node` Purely descriptive: it filters the issue list by subsystem. An issue can carry more than one. ### `run:` triggers A label that makes CI *do something* when it is applied. The `run:` prefix is a two-way promise: if a label runs automation it is named `run:`, and if a label is named `run:` then applying it runs that task. | Label | Applying it | Wired | | --- | --- | --- | | `run:preview` | spins up a live [PR preview environment](/guides/pr-previews/); removing it tears the preview down | yes | | `run:build` | rebuilds the release binaries and container image for the PR head on demand | reserved | | `run:test` | runs the full test suite on demand | reserved | `run:preview` is live today (the [PR previews guide](/guides/pr-previews/) covers it, and `preview-comment.yml` is the workflow behind it). `run:build` and `run:test` are reserved: the expensive build and test tiers run on every PR today, and [issue #78](https://github.com/hyperscaleav/omniglass/issues/78) tracks moving them behind these labels so they run only on demand. Wire the workflow in the same PR that first relies on a reserved label, rather than leaving a label that silently does nothing. ## The fixed set That is the whole taxonomy: a native Issue Type, the Project priority field, `area:*`, and `run:*`. There are no stock GitHub labels (`bug`, `enhancement`, `good first issue` and the rest were removed) and no bare `type:*` or `prio:*` labels. Adding a label means either a new subsystem (`area:*`) or a new automation task (`run:*`); anything else belongs in a field. --- # The learning-tool restriction URL: /contributing/learning-tool/ Every operator surface should also teach the concept it operates on, against real or simulated data. Omniglass is two things at once, by design: **a functional tool and a learning tool.** This is a standing design restriction, not a nice-to-have. It shapes what we build and how we judge it done. ## The restriction **Every operator surface should also teach the concept it operates on.** Where it makes sense, a page is not just a control panel over data; it is an interactive explanation of the concept and the data flow behind it, driven by real or simulated data. A feature that introduces a concept (a collection function, an edge parse step, a calc rollup, an alarm lifecycle) should ship a surface where a learner can *see the concept happen*: - the function or pipeline rendered, not just described, - real or simulated data moving through it, - the ability to poke it and watch the result change. ## What it teaches, and what it does not The audience is **AV and IT systems integrators and operators**, and the subject is **monitoring**: what it is, how to do it well, and how Omniglass models and monitors an estate, so an operator understands the data they get and the judgment behind it. It teaches the **AV Observability discipline** made concrete, the Align / Measure / Instrument / Practice layers as explorable artifacts rather than a PDF. It is **not** a software-engineering tutorial. It does not teach how to write software, how to architect a platform, or how Omniglass is built internally. The learner is operating an estate and learning **monitoring**, not reading source. "Teach the concept it operates on" means the *monitoring* concept (an edge parse, a calc rollup, an alarm lifecycle, a health rollup), never the code that implements it. ## Why The product is also the teaching artifact for the AV Observability discipline it implements. The Measure and Instrument layers should be concrete, explorable artifacts rather than blueprints in a PDF. A user who operates Omniglass should come away understanding *how* it models their estate, because the tool taught them while they used it. ## Real or simulated data Teaching surfaces must work without a live fleet. A simulated/lab data source (the emulated estate) backs the interactive pages so a learner, or a CI run, gets the same explorable behavior as a live deployment. "Works against the lab emulator" is part of done for a learning surface. ## How it interacts with the other doctrines - **Docs with everything:** the teaching surface is part of the docs that ship with a feature. A concept-introducing PR that has no learning surface should say why in its docs note. - **Test first:** the interactive surface is user-facing behavior, so it carries e2e coverage like any other surface. - **Functional and pedagogical:** the learning surface rides on the *real* implementation and real (or lab-simulated) data. It is not a mock diagram detached from the engine; it is the engine, made legible. --- # Primitive first URL: /contributing/primitive-first/ Build the reusable primitive, then consume it. The shared engines (expression, ViewResult, gateway, cascade, timer) the rest of the system is written against. Build the **reusable primitive first, then consume it.** A behavior that more than one feature needs is a primitive: define it once, test it once, and write every consumer against it. Do not inline a one-off where a primitive belongs, and do not grow a second variant of something that already exists. ## Why - **One tested thing, not N.** A primitive carries its own full test set, so every consumer inherits correctness. N inlined copies are N places to drift and N places a bug can hide. - **Consistency by construction.** When one engine backs every site, a contributor (and an operator) learns it once: the `filter` you write for a dynamic group is the `filter` you write for a list. - **The learning tool renders the real engine.** Operator surfaces teach a concept by running the primitive against real data ([learning tool](/contributing/learning-tool/)), so a primitive is the teaching artifact, not a diagram. ## The primitives the system is written against | Primitive | One model for | Doc | |---|---|---| | **Expression engine** | list `filter`, rule `scope`, dynamic-group membership, `fire_criteria`, calc `reduce` | [expressions](/architecture/expressions/) | | **`ViewResult` renderer contract** | every read beyond one resource (`{columns, rows}`, one renderer) | [views](/architecture/views/), [UI](/architecture/ui/) | | **Storage Gateway** | the only DB door: scope and in-transaction audit by construction | [storage](/architecture/storage/), [identity and access](/architecture/identity-access/) | | **Cascade** | resolving config, credentials, and variables down one tree | [cascade](/architecture/cascade/) | | **Admission consumer and the two lanes** | the one owner fence and the one data / record split on the bus | [messaging](/architecture/messaging/) | | **Timer and clock** | schedule, watchdog, for-duration, and runbook-wait, all one durable model | [time](/architecture/time/) | | **The `action` row** | every long-running operation's handle, rule-fired or API-called | [API](/architecture/api/), [alarms and actions](/architecture/alarms-actions/) | | **`property_type` registry** | one registry across metric, state, and log | [datapoints](/architecture/datapoints/) | ## How to apply - **Reach for the primitive before writing the one-off.** Need a filter, a read, a scheduled fire, a scoped query? It already exists, consume it. If you are about to hand-roll one, stop. - **Extract on the second use, not the third.** The moment a pattern is copied it is a primitive that has not been named yet. Pull it out, give it tests, point both callers at it. - **A primitive lands with its tests and its first consumer in the same slice** (vertical, not horizontal): build the primitive, prove it with one real consumer, ship both. The `/add-collection-primitive` and `/canonical-datapoint` skills are this doctrine made procedural. - **Do not fork an engine.** A second filter language, a second DB path, a second timer model is the anti-pattern this doctrine exists to prevent. This composes with the others: the [API](/contributing/api-first/) is generated from the primitives, [test-driven](/contributing/test-driven/) tests each primitive once, and the [learning tool](/contributing/learning-tool/) renders the real one. --- # Release and CI URL: /contributing/release-ci/ The CI gates on every PR and the manual semantic-release versioning cut from main. `make test` is the local gate (the [test-driven](/contributing/test-driven/) doctrine says validate locally, do not lean on CI). CI is the backstop and, on `main`, the release driver. Three workflows carry it. ## On every pull request - **`test.yml`** (the test gate) runs `go build ./...` and `go test ./...` on a runner with a Docker daemon, so the testcontainers-backed integration and e2e tiers actually execute. It also runs on `main` after merge. - **`pr-title.yml`** lints the PR title to the conventional-commit grammar. This matters because the repo squash-merges: the squash subject *is* the PR title, and semantic-release reads it to decide the next version. A malformed title would either mis-version or silently skip a release, so it is blocked at the PR. The other PR check, **`image.yml`**, builds the multi-arch container image (see [Container image](/guides/container-image/)). ## Cutting a release (manual) Releases are **not** cut automatically on merge to `main` (deliberately, for now). A release is a deliberate act, run from an up-to-date `main` with [semantic-release](https://semantic-release.gitbook.io/), which reads the conventional-commit subjects since the last tag, computes the next version, pushes a git tag, and creates a GitHub Release with generated notes. Two make targets: ```bash make release-plan # dry run: print the next version + notes, publish nothing make release-apply # tag + create the GitHub Release ``` The same thing can be dispatched in CI from the **release** workflow's "Run workflow" button (with a `dry_run` toggle), for a release cut from a clean checkout instead of a laptop. | Title prefix | Release | |--------------|---------| | `feat:` | minor | | `fix:`, `perf:` | patch | | `BREAKING CHANGE:` (footer) or `feat!:` | major | | `docs:`, `ci:`, `chore:`, `refactor:`, `test:` | none | No changelog is ever committed back to `main`, so the release never writes to the default branch. The generated notes live on the GitHub Release. To switch to release-on-merge later, change the release workflow's trigger to `push` on `main`; the make targets stay as the local preview path. ## Binaries on the release semantic-release cuts the tag and the Release; [GoReleaser](https://goreleaser.com/) then fills that Release with the cross-platform binaries. The two split cleanly: semantic-release owns the version and the notes, GoReleaser only builds artifacts and attaches them (its `release.mode: keep-existing` leaves the notes untouched). The matrix is `linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, and `windows/amd64`, plus a `checksums.txt` and an SBOM per archive. Because the binary is pure Go with CGO disabled, all of it cross-compiles from one runner; the SPA is built once by a `before` hook (`make web`) and embedded in every target via `-tags web`. The binaries are always built in CI, never on a laptop, but which workflow builds them depends on how the release was cut: - **`make release-apply`** pushes the tag with your token, which cascades, so the tag-triggered `goreleaser.yml` workflow builds the binaries. - **The CI-dispatch path** pushes the tag with `GITHUB_TOKEN`, which by design does not cascade, so `release.yml` builds the binaries inline in the same job. Both drive the same `.goreleaser.yaml`, so the artifacts are identical either way. Validate a config change locally with `make release-snapshot` (builds the whole matrix, no tag, no publish), and CI runs the same snapshot on any pull request that touches the release config, uploading the archives as downloadable workflow artifacts. A green PR snapshot is real evidence that the tagged release will build. ## Why the PR title, not the commits A squash merge collapses a branch's commits into one, and GitHub uses the PR title as that commit's subject. So the PR title is the single conventional-commit that lands on `main`, and it is the unit both the merge model and semantic-release reason about. That is why `pr-title.yml` is a required check, not advisory. --- # Slice workflow URL: /contributing/slice-workflow/ How a feature ships: one vertical slice per PR, through a fixed lifecycle of define, build test-first, document, validate, review, and a ship-review the architect approves. A feature is **one vertical slice**: a thin cut through the whole stack (schema to API to docs) that delivers a user-observable outcome, not a horizontal layer. Each slice is one PR, built through a fixed lifecycle so quality is a process, not a hope. ## The lifecycle | Stage | Practice | Gate | |---|---|---| | **Define** | a [feature issue](https://github.com/hyperscaleav/omniglass/issues/new/choose) under an epic: the outcome, the thin cut, the deferrals, the test plan, and the permission and scope it touches | **hard gate**: issue filed and scope approved before any branch | | **Design** | read the [architecture spine](/architecture/) (the docs are the spec); locate the seam; name the thin cut | the cut is explicit | | **Branch** | a git worktree off `origin/main` under `.claude/worktrees/`, never a commit on `main` | only after Define is approved | | **Build** | [test-first](/contributing/test-driven/): the failing test, then the feature, committing each increment. A slice cuts every entry point it touches, **API + CLI + UI**: the CLI command is generated from the OpenAPI (`make gen`); the UI view is built where the entity is live, or rendered as an honest stub where its backend does not exist yet | RED then GREEN; all three surfaces present (stub allowed) | | **Document** | the teaching [docs ship with it](/contributing/docs-with-everything/): the **architecture** page (the model) and, when the slice ships or changes an operator surface, its **operator guide** in `/guides/` (the how-to); plus a build-progress note on the status page, the page's **status badge advanced** to its new floor, and a [decision-log](/architecture/decisions/) entry for any divergence | docs in the same PR; badge advanced; divergence logged | | **Validate** | `make test` green (run fresh), `make gen` clean, no drift | green, fresh | | **Review** | a reviewer pass over the diff, findings addressed; a security lens when it touches authz, secrets, the edge, or an invariant | findings cleared | | **Ship** | the ship-review (below), then squash-merge | architect approves | | **Log** | record what shipped, the decisions, and the follow-ups | logged | The first six stages are the [five doctrines](/) in motion; the last two are how the work becomes externally visible and approvable. ## What "validated" means Not a vibe at each gate, a check: - **The ticket is the contract, and a hard gate.** The issue states the outcome, the thin cut, the deferred items (each its own issue), and the authorization surface (the permission checked and the scope injected). **No worktree or branch is created until the issue exists and the architect has approved its scope,** so the boundary is agreed before any code, not discovered at review. - **Tests are tiered and fresh.** Unit (pure, fast), integration (real Postgres via testcontainers, no mocking the database), and end-to-end (drive the entry point as the user). `make test` is the gate, run without a cache: a cached pass or a `-short` run hides the database-backed behavior, and a green claim is not evidence until the tier actually executed. - **Docs ship with the feature.** The page that teaches the concept lands in the same PR, the architecture-of-record stays consistent, and any divergence is stated, never silent. - **Status moves with the code.** A slice that advances a page flips its **status badge** to the new floor (`Design` to `Partial` to `Built`), adds the `status.mdx` build-progress note, and records any divergence from the design both inline on the page and in the [decision log](/architecture/decisions/). A built capability behind a `Design` badge is a drift bug. - **The API cannot drift.** `make gen` regenerates the OpenAPI and the clients (the cobra CLI and the typed SPA client) from the Go; a non-empty diff fails the slice until committed. - **Every entry point is covered.** A slice that adds or changes an operation surfaces it in all three entry points: the API route, the generated CLI command, and a UI view (live where the entity exists, an honest stub where its backend does not yet). Each is exercised as the user would drive it. - **Review verifies behavior to the outcome,** not just the call site. ## The thin-cut discipline A slice ships the smallest honest increment. A **thin cut** is a deliberate simplification (the first auth slice did bearer tokens only, and resolved the owner scope to all); a **deferral** is work moved to a later slice. Both are explicit: a thin cut is documented in the slice, a deferral is a filed issue. The opposite, a silent gap, is the failure this discipline prevents. ## The ship-review (the approval artifact) At PR-ready, the slice is presented as one **ship-review**, front-loaded so the architect approves in seconds or redirects. The `/ship-slice` skill runs the pre-ship checklist and emits it: ``` SHIP REVIEW - : (PR #N, closes #M) Outcome: Verdict: ready | ready-pending-your-call Scope: in / thin cut / deferred (#issues) Proof: make test green (fresh, N packages); the load-bearing behaviors; tiers; make gen clean Docs: what shipped; arch-of-record consistent or a divergence note Status: pages advanced (page: Design->Partial); status.mdx entry; ADR-#### if diverged Review: findings and how addressed; security note if relevant Decisions I made (your veto window): the judgment calls that bound the design Decisions I need from you: open forks, or none Diff / Risk: size, PR link; outward-facing? invariant-changing? reversible? ``` Approval means squash-merge (the conventional-commit PR title drives the release). A redirect adjusts the slice. The two lines that matter most are **Decisions I need from you** and **Risk**. ## Lessons held - **Commit per increment.** A slice is built as a sequence of green commits, not one batch at the end. Work that is not committed is work that can be lost. - **Verify fresh.** Re-run the database-backed tests before claiming green; do not trust a cache or a delegated agent's report. - **Approve at the boundary.** Scope is agreed at the ticket and again at the ship-review, so a surprise never lands in `main`. --- # Test-driven, always URL: /contributing/test-driven/ Build the failing test before the feature; each change carries the tier that proves it. The loop, in order, for every behavior change: 1. **Define the behavior.** State what the feature does and how it is observed, as an assertion, not a vibe. 2. **Write the failing test.** It must fail for the right reason before any machinery exists. A bug fix starts with a test that reproduces the bug. 3. **Build the minimal machinery** to make the test pass. Nothing more. 4. **Refactor** with the test green. A change that adds or alters behavior is incomplete without a test that failed before it and passes after. Each change carries the right tier(s): **unit** for logic, **integration** (real Postgres) for anything touching storage, **e2e** (API, CLI, UI) for user-facing behavior. Bug fixes start with a failing regression test that stays in the suite. `make test` is the gate: green before commit and before merge. Validate locally; do not lean on CI to find what a local run would. ## The spike carve-out A spike to learn whether something is *possible* may precede tests, but it must be labeled a spike and either deleted or stabilized with tests before it merges. "Spike" is not a standing excuse to skip the failing test. ## The capability-primitive carve-out When a unit wraps an environment-risky capability (raw sockets, ICMP, privileged syscalls, an external protocol), a fake-based unit test is necessary but not sufficient. - Commits may be incremental: a fake-green seam is a legitimate checkpoint commit. - The real-implementation integration test is required to **close the increment** and is an absolute gate before any merge. It is never dropped, only sequenced within the increment. The environment risk is the point of the primitive. A green fake with the real path unproven proves nothing about the capability. ## Tiers - **Unit:** pure logic, fast, no I/O. Expression compile/eval, decode, request shaping, mapping. - **Integration:** real Postgres, no mocking the database. `testcontainers-go` gives each run an ephemeral instance on a random port; never bind a fixed host port. - **End-to-end:** emulate the user at each entry point against the running stack: API (drive the contracts as a client), CLI (run the real commands), UI (browser-drive the SPA). Assert the user-observable outcome, not internals. No mocking the system under test. No tests-within-tests. ## The storage test harness Integration and end-to-end tests share one real-Postgres harness, `internal/storage/storagetest`. It starts a single container per test binary (lazily, via `sync.Once`) and hands each test a fresh, migrated, isolated database, so tests never share mutable state or collide on a host port. Cleanup is a hard contract, not a convenience. Every package that uses the harness **must** route its tests through `storagetest.Main` from a `TestMain`: ```go func TestMain(m *testing.M) { os.Exit(storagetest.Main(m)) } ``` `Main` terminates the shared container after `m.Run()`, in-process, on normal exit. This is the reason cleanup is reliable: it does not depend on the testcontainers reaper (ryuk), which is only a backstop for hard kills and cannot be relied on alone. In some environments (for example Docker Desktop on WSL2) ryuk is disabled or torn down before it can reap, so a container with no in-process teardown leaks and stays running indefinitely. A new harness-using package that omits its `TestMain` reintroduces that leak. For orphans left by a genuinely hard kill (a `SIGKILL` or a Docker restart before either mechanism fires), sweep them with `make clean-testcontainers`. It force-removes leftover Postgres test containers, scoped by the testcontainers label and the `postgres:18` image so it never touches the compose dev stack. --- # Admin guide URL: /guides/admin/ Administering an Omniglass platform: the people who can sign in, the access they carry, the audit trail, and the config and credentials that resolve down the estate. This is the how-to for **administering the platform**, the standing job of deciding who may sign in, what each account can see and do, and the config and credentials the estate runs on. It is a different job from **operating** the estate (running the inventory, reading the data), which is the [operator guide](/guides/operator/), and from **standing the platform up**, which is [deployment](/guides/deployment/). The dividing line is the [two authorization layers](/architecture/identity-access/), both enforced in the app on every request: a `:` **permission** checked on every route, and an ABAC **scope** injected by the Storage Gateway on every applicable query. An administrator is simply a principal whose grants carry the admin-tier permissions (`principal:*`, `role:read:admin`, `audit:read:admin`, `secret:*`, and so on); the surfaces below render and refuse exactly along those grants, so what an administrator sees is what they are allowed to change. ## Where the admin surfaces live Every task on these pages has two front doors, and they call the same API with the same checks: - **The console.** The **Admin** area of the [web console](/guides/operator/) holds Users, Roles, Groups, and Audit. Secrets and Variables have moved to **Inventory**, in the **Values** band. A tab you have no read grant for is hidden and its route refused, so the console never paints a page you cannot use. - **The CLI.** The same surfaces are generated commands on the [`omniglass` CLI](/guides/cli/) (`principal`, `principal-group`, `secret`, `variable`, and the rest, all in the [CLI reference](/reference/cli/)), plus the trusted direct-database lane (`bootstrap`, `token`, `set-password`) that mints the very first owner before any server is running. ## In this guide - **[Manage users](/guides/admin/users/)** is the principal directory: creating a human, editing and renaming, the disable-archive-purge lifecycle, admin password reset, and profile pictures. - **[Roles, groups, and grants](/guides/admin/access/)** is giving a user access: the built-in roles, user groups as shared grant anchors, and the grant builder that assigns a role at a scope. - **[The audit trail](/guides/admin/audit/)** is the read-only record of every privileged action and every sign-in, including who acted behind an impersonation. - **[Secrets](/guides/admin/secrets/)** and **[variables](/guides/admin/variables/)** are the config and credentials the estate resolves down the [cascade](/architecture/cascade/): encrypted secrets and plaintext variables, owned at a scope and resolved most-specific-wins onto a component. The model behind all of this is [identity and access](/architecture/identity-access/) and [config and credentials](/architecture/variables/); those pages say how it is built, these say how to run it. --- # Roles, groups, and grants URL: /guides/admin/access/ Giving a user access: the built-in roles, user groups as shared grant anchors, and the grant builder that assigns a role at a scope. Access is a **grant**: a role at a scope. You assign a grant to a [user](/guides/admin/users/) directly, or to a **group** the user belongs to; the scope decides which slice of the estate the grant reaches. This page is the three parts of that: the **roles** you can assign, the **groups** that share a grant across a team, and the **grant builder** that stages and commits both. The model underneath is [identity and access](/architecture/identity-access/). ## Roles :::note[Not a system role] A **role** here is an access role: a capability set you grant to a person. A **[system role](/guides/admin/standards/#roles-what-a-conforming-system-needs-filled)** is something else entirely, a slot in a room that a component fills (a table microphone, a main display). The two share the word and nothing else, and neither one can grant or deny the other. ::: **Admin > Roles** (with `role:read:admin`) is the catalog of the built-in roles on the same list surface as Users and Groups: a directory row per role (its id, whether it is **official**, what it inherits, and how many permissions it confers), ordered least to most powerful (viewer, operator, deploy, admin, owner). Open a row for its read-only **blade**, which shows the role's permissions as a **net** view against the full set of capabilities the platform enforces (the **permission universe**). A `Held / Missing / All` toggle switches between the permissions the role **holds**, the ones it is **missing**, and the whole universe with each lit or dimmed, listed one per line in alphabetical order. Held is resolved the same way the role acts at runtime (inheritance, wildcards, and the read floor applied), so `viewer` holds only the reads it can reach (no `secret:read`, no admin-tier reads), `admin` holds nearly everything, `owner` holds all of it, and an admin-sensitive permission like `audit:read:admin` is tinted for its `:admin` tier. It is a teaching surface: it renders the real seeded roles against the real routed capabilities, not a static table. Custom-role creation and editing are coming; today the built-in roles are read-only. ## Groups **Admin > Groups** (with `principal_group:read:admin`) is the admin surface for **user groups**: a group holds `role @ scope` grants, and every member **inherits** them, so you assign access to a team once instead of per user. Pick a row to open the group's **blade**: its **members** (add any principal, remove one, or open a member to stack that user's blade over the group) and its **grants**, built with the same grant builder the user detail uses. A grant added to the group takes effect for every member immediately, and is bounded by the same rule as a direct grant (you cannot grant a role above your own tier). **New group** creates one (name, display name, description); the **name** is a lowercase handle (the same rule as a username, validated inline), while the display name is free text. The new group then opens **directly in edit mode**, so you add its members and grants right away and one **Save** commits them (they are attached to the group once it exists, so creating and populating stay one flow). Deleting a group drops the memberships and the inherited grants, but members keep their own direct grants. On a **user's** detail, grants split into two: the ones you granted the user **directly** (editable in the grant builder) and the ones **inherited from a group** (shown read-only, tagged `from `), so it is always clear where a user's access comes from. To change an inherited grant, edit the group, not the user. From the CLI the surface is `omniglass principal-group list` / `get` / `create` / `update` / `delete`, plus its member and grant subcommands (see the [CLI reference](/reference/cli/)). ## The grant builder Users and Groups share one control for assigning access. With `principal_grant:create` / `:delete`, the detail panel's **grant builder** stages a set of changes and applies them only on **Save**, so there are no accidental edits. ::screenshot{#grant-builder} Type a role, then Tab or Enter to the scope kind, then (for a non-`all` scope) the specific location, system, or component from an indented tree, then the **operator** that says how that entity matches the tree: each commit becomes a `role @ operator scope` chip. The operator is one of **at or under** (`≥`, the entity and everything beneath it, the default), **under only** (`>`, the descendants but not the entity itself, for update and delete), or **just this** (`=`, exactly the one entity, no descendants and no adding children under it), so you can grant a field tech everything inside a room without letting them rename the room, or lock an operator to a single node. Removing an existing grant marks it (dimmed and struck, undoable), staging a new one shows it in green, and a pending-diff line ("+N to grant, -M to revoke") previews exactly what **Save** will do. A scope targets the entity by its internal id, so a grant survives a rename of that entity. One rule the server always holds: the **last owner grant cannot be revoked**, so the platform can never be locked out of administration. Hovering a role in the picker shows its description and the permissions it grants, so you can see what you are assigning before you stage it. From the CLI a direct grant is `omniglass principal principal grant create ` / `principal grant delete `. --- # The audit trail URL: /guides/admin/audit/ The read-only record of every privileged action and every sign-in, including who acted behind an impersonation. **Admin > Audit** (with `audit:read`, so **administrators and owners** only) is the read-only audit trail: every privileged action and every sign-in, newest first, each with when it happened, who did it, the action, and the resource. An action taken while impersonating shows the **real administrator** as the actor, with an `as ` tag naming the principal whose identity they assumed (for example `admin as bob`), so accountability lands on the human who acted and impersonation never hides them. A read-only user (a viewer) does not see this page: the audit trail is admin-level information, so a plain "read everything" grant does not open it. Failed sign-ins on a real account show as **login failed** (and a sign-in to a disabled account as **login denied**), so you can spot a brute-force attempt; attempts on usernames that do not exist are not recorded. ::screenshot{#audit} The page uses the same faceted search as the inventory lists: filter by **who**, **action**, **resource**, or **id** (type a term for a quick actor search, or `action:login` to pin a facet), and combine chips to narrow. Filtering runs over the rows already loaded; **Load older** pages further back in time, so a search that comes up short is a cue to load older and look deeper. The model behind the trail, what is recorded and the dual-actor rule for impersonation, is [identity and access](/architecture/identity-access/) and [audit](/architecture/audit/). --- # Capabilities URL: /guides/admin/capabilities/ The Capabilities catalog: the vocabulary of what a component can do, the product default and the component's own additions and suppressions over it, and how a system role requires them. **Catalog > Capabilities** (`/capabilities`, with `capability:read`, covered by every viewer's `*:read` floor) is the directory of **capabilities**: the flat vocabulary of what a component can do, on the same flat-registry pattern as [Types](/guides/admin/types/) and [Tags](/guides/admin/tags/). A capability is a plain name of a function (a microphone, a display, a camera), not a device and not a measurement. Each row shows the **id**, the **display name**, and its **origin** (**official**, seed-owned, or **custom**). A capability is the hinge between two halves of the estate model. On one side, a [product](/guides/admin/products/) declares the capabilities its instances provide, and a [component](/guides/operator/entities/) adds to or suppresses that set with its own. On the other, a [system role](/guides/admin/standards/#roles-what-a-conforming-system-needs-filled) **requires** a set of them, and a component may fill the role only if it provides every one. Naming a capability once is what makes those two sides line up. - **New capability** (with `capability:create`, an admin permission) opens a create drawer: name its **id** (a short identifier, unique tenant-wide, e.g. `microphone`) and give it a **display name**. - Pick a row to open its **detail blade**. The footer **Edit** pencil (with `capability:update`) edits the display name; the id is fixed. **Delete** (with `capability:delete`) removes the row, behind a confirm. - An **official** (seed-owned) row is always read-only: no Edit, no Delete, and the blade marks it "Seed-owned, read-only." Omniglass ships a starter set of official capabilities (Microphone, Speaker, Display, Flat Panel Display, Camera, Codec, Touch Panel), upserted idempotently at boot so the shared set cannot drift install to install; add a custom capability for anything else. - **Delete carries no in-use guard, and it reaches further than it used to.** Every link to a capability is `on delete cascade`, so deleting one drops it from every product's set, from every component's own declarations, **and from every role that requires it**. A role that loses its last requirement admits any component. Removing a custom row is unconditional (still refused for an official row, 422); the 409 delete-refused-while-referenced rule the [Types](/guides/admin/types/) registry enforces lives instead on `component.product_id` (a product with components cannot be deleted). The same operations are `omniglass capability list/get/create/update/delete` from the CLI (see the [CLI reference](/reference/cli/)). ## What a component actually provides A product is the **default** answer to "what can this component do", not the whole answer. Real estates diverge from the catalog: a unit has a mic pod nobody modeled, a room bar's camera is dead and the room should stop being offered as a camera, a component has **no product at all** because it is a one-off. So a component carries its **own** capability facts, layered over its product's. A component's detail carries a **Capabilities** panel showing the **resolved** set, which is: > the **product's** capabilities, **plus** the ones this component adds, **minus** the ones this > component suppresses. - **Add a capability** the product does not claim, and the component provides it from then on. This is also the only way a **productless** component provides anything, and it is why a component without a product is still fully staffable. - **Suppress a capability** the product does claim, and the component stops providing it. Use this for a removed or dead function, not for a temporary fault: suppression is a statement about what the unit **is**, and it will stop the component from filling any role that needs it. - **Clear a fact** to fall back to the product. Clearing is not the same as suppressing: cleared means "whatever the product says", suppressed means "not this one". - Both writes are the **component's own** (`component:update`), because a component's capabilities are its own data. A component outside your scope is **not found**, not forbidden. The resolved set is what the [role assignment](/guides/admin/standards/#staff-a-system-against-its-standard) guard checks, so this panel is where you go when an assignment was refused for a capability you know the device has: declare it here, then assign. From the CLI: `omniglass component capability list `, `omniglass component capability update --present true|false`, and `omniglass component capability delete `. --- # Drivers URL: /guides/admin/drivers/ The Drivers catalog: the implementations that get, emit, and set a product's signals (id, display name, version), seed-owned official rows read-only, admin-gated custom ones. **Catalog > Drivers** (`/drivers`, with `driver:read`, covered by every viewer's `*:read` floor) is the directory of **drivers**: the implementation that gets, emits, and sets a product's signals, on the same flat-registry pattern as [Types](/guides/admin/types/) and [Tags](/guides/admin/tags/). Where a [vendor](/guides/admin/vendors/) names who a device comes from, a driver names how it is talked to (for example `Generic SNMP` or `Cisco xAPI`). Each row shows the **id**, the **display name**, an optional **version**, and its **origin** (**official**, seed-owned, or **custom**). Today a driver stands alone: nothing in the estate points at one yet. It is a leaf catalog beside the vendor and [capability](/guides/admin/capabilities/) registries, the layer a future `product` will reference to say which driver reads it. See [core entities](/architecture/core-entities/) for where it sits in the estate model. - **New driver** (with `driver:create`, an admin permission) opens a create drawer: name its **id** (a short identifier, unique tenant-wide, e.g. `snmp-generic`), give it a **display name**, and, optionally, a **version**. - Pick a row to open its **detail blade**. The footer **Edit** pencil (with `driver:update`) edits the display name and version; the id is fixed. **Delete** (with `driver:delete`) removes the row, behind a confirm. - An **official** (seed-owned) row is always read-only: no Edit, no Delete, and the blade marks it "Seed-owned, read-only." Omniglass ships a starter set of official drivers (Generic SNMP, Cisco xAPI, Crestron CIP, HTTP JSON), upserted idempotently at boot so the shared set cannot drift install to install; add a custom driver for anything else. - **Delete** carries no in-use guard: a [product](/guides/admin/products/) references a `driver` through its optional `driver_id`, but that link is `on delete set null`, so deleting a driver detaches it from those products (their driver clears) rather than blocking. Removing a custom row is unconditional (still refused for an official row, 422). The 409 delete-refused-while-referenced rule the [Types](/guides/admin/types/) registry enforces lives instead on `component.product_id` (a product with components cannot be deleted), not on the driver. Minting a driver is admin-gated; the picker that consumes it, choosing a product's driver, does not exist yet, since it waits on `product`. The same operations are `omniglass driver list/get/create/update/delete` from the CLI (see the [CLI reference](/reference/cli/)). --- # Files URL: /guides/admin/files/ The Files directory under Values: upload, find, download, and delete the opaque bytes kept with an estate, deduplicated and content-addressed, with a sensitive tier for confidential ones. **Files** (under Values) is where you keep the **opaque bytes** that go with an estate, a firmware image, a device config dump, a runbook, a screenshot, a packet capture, each as a searchable **file** handle over a deduplicated, content-addressed store ([files and blobs](/architecture/files/)). It sits beside Secrets and Variables as operator-provided content, but unlike them it is not a cascaded value: it is flat and tenant-wide, not owned at a scope. ::screenshot{#files} It uses the same filter, column, and list controls as the estate directories, so browsing feels the same. Two things differ: - The list is **flat**, not a tree, and tenant-wide rather than scoped to a subtree, so there is no parent and no summary board. - You **upload** rather than fill a form. **New file** opens a drawer with a file picker; the name and content type default from the file you choose. Each file's detail offers **Download** and **Delete**. ## Dedup and the content hash A file's bytes are stored once, keyed by their **content hash** (the `sha256` shown on the detail). Upload the same bytes twice, as two handles, and they share one stored copy; the second upload adds no storage. Deleting a file frees its bytes when no other handle still references them, so removing files reclaims space rather than leaking it. ## Sensitive files A file can be marked **Sensitive** (shown by a badge in the list). A sensitive file, a competitive quote, or a config with internal detail, is visible only to the **admin tier**: it is hidden from an ordinary lister and cannot be opened or downloaded without that tier, the same rule a sensitive [secret](/guides/admin/secrets/) follows. Ordinary files are shared with anyone who can read files. Marking a file sensitive, and seeing one, both need the admin tier; you cannot create a sensitive file without it. ## Who can do what Reading and downloading ordinary files rides the viewer floor (`file:read`). Uploading is `file:create` and deleting is `file:delete`, the writes an operator holds. The sensitive tier (`file:read:admin` and friends), which admin and owner carry, is what gates the confidential ones. --- # Products URL: /guides/admin/products/ The Products catalog: a concrete SKU binding a vendor, a driver, a kind, and the capabilities it provides; a component points at the product it is; seed-owned official rows read-only, admin-gated custom ones. **Catalog > Products** (`/products`, with `product:read`, covered by every viewer's `*:read` floor) is the directory of **products**: the concrete SKUs the estate is built from, on the same flat-registry pattern as [Types](/guides/admin/types/) and [Tags](/guides/admin/tags/). A product is a specific model (a **Cisco Room Bar**, a **Samsung QM55**), not an organization and not an installed unit. It is where the three leaf catalogs converge: the [vendor](/guides/admin/vendors/) that makes it, the [driver](/guides/admin/drivers/) that speaks to it, and the [capabilities](/guides/admin/capabilities/) it provides, classified by a **kind**. Each row shows the **id**, the **display name**, its **vendor**, **driver**, **kind**, and its **origin** (**official**, seed-owned, or **custom**). A product is also what a **component** points at: `component.product_id` names the product a component **is**, and the product supplies that component's shape (its vendor, driver, and capability set). This replaces the old `component_type`-as-shape notion: a component's shape comes from its product now, not a separate genus. The system side has the same arrangement one level up: a system conforms to a [standard](/guides/admin/standards/), which is the blueprint's counterpart of a product. See [core entities](/architecture/core-entities/) for where the product registry sits in the estate model. - **Kind** classifies what the product is: a **device** (a physical unit), an **app** (software), a **service** (something hosted), or a **vm** (a virtual machine). It defaults to **device** and is a closed set; a value outside it is refused (422). - **Vendor**, **driver**, and **parent** are each optional pointers: the vendor that makes the product, the driver that talks to it, and a **parent product** it is a variant of (see below). Each must resolve against the vendor / driver / product catalogs; an unknown reference is refused (422). These three are nulled if their target is deleted, not blocked: removing a vendor clears the product's vendor pointer rather than blocking the vendor delete. - **Capabilities** is the set of things the product provides (a room bar provides **microphone**, **speaker**, **camera**, **codec**), each chosen from the [capability](/guides/admin/capabilities/) catalog. It is a many-to-many set: a product declares as many as it needs, and setting capabilities on an update **replaces** the whole set. An unknown capability id is refused (422). This set is the **default** for the product's components, not the last word: a component [adds or suppresses capabilities](/guides/admin/capabilities/#what-a-component-actually-provides) of its own over it, and the resolved set is what a [role assignment](/guides/admin/standards/#staff-a-system-against-its-standard) is checked against. - **Variants** use **parent product**: a specific SKU that inherits from a base product points at it with `parent_product_id` (a trim or regional variant of the same model). A product with no parent is a base product. - **New product** (with `product:create`, an admin permission) opens a create drawer: name its **id** (a short identifier, unique tenant-wide, e.g. `cisco-room-bar`), give it a **display name**, pick its **kind** (defaults to device), and, optionally, its **vendor**, **driver**, **parent product**, and **capabilities**. - Pick a row to open its **detail blade**. The footer **Edit** pencil (with `product:update`) edits the display name, vendor, driver, kind, parent, and capabilities; the id is fixed. **Delete** (with `product:delete`) removes the row, behind a confirm. - An **official** (seed-owned) row is always read-only: no Edit, no Delete, and the blade marks it "Seed-owned, read-only." Omniglass ships a starter set of official products (Cisco Room Bar, Samsung QM55, Shure MXA920, Crestron TSS-1070), upserted idempotently at boot so the shared set cannot drift install to install; add a custom product for anything else. - **Delete** enforces the referential guard the leaf catalogs deferred: a product still referenced by a **component** (`component.product_id`) cannot be deleted (409), the same delete-refused-while-referenced rule the [Types](/guides/admin/types/) registry enforces. Remove or repoint the component first. An official row is still refused (422) regardless. Minting a product is admin-gated, and the product form is where the vendor, driver, and capability catalogs are finally consumed, as the pickers that choose a product's vendor and driver and declare its capabilities. The same operations are `omniglass product list/get/create/update/delete` from the CLI (see the [CLI reference](/reference/cli/)). ## Declared properties: the product's contract A product's blade also carries a **Declared properties** panel, the product's **contract**: which [properties](/guides/admin/properties/) every instance of the product exposes, and what each one defaults to. It is the second half of "the product is the source of a component's shape": capabilities say what the product can **do**, the contract says what it **carries**. - **Declare a property** (with `product:update`) picks a name from the property catalog, optionally types a **default**, and optionally marks it **required**. The property must already exist in the catalog, since the contract only names it: mint it under [Catalog > Properties](/guides/admin/properties/) first. Declaring is **idempotent**, so declaring a property already on the contract revises that line in place rather than adding a second. - **The default is typed by the catalog, not here.** The panel labels the input with the property's data type, coerces what you type to it, and refuses a value that will not parse. Type and validation live on the property, so a product cannot redefine what `serial_number` means, only what a fresh instance of that product starts with. - **Required** means an instance must resolve the property to a value. A component of the product cannot save with a required property empty (see [set a property on an instance](/guides/admin/properties/#set-a-property-on-an-instance)). - **Withdraw** (with `product:delete`, behind a confirm) removes a line from the contract. Components **keep** any value they set for it; the value simply reads as **off contract** from then on, since nothing declares it any more. - An **official** (seed-owned) product's contract is read-only, like the rest of the row: the seeded Cisco Room Bar and Samsung QM55 ship declaring `serial_number`, `firmware_version`, and `model_number`, and those declarations come with the release. From the CLI the contract is `omniglass product property list `, `omniglass product property update `, and `omniglass product property delete `. --- # Properties URL: /guides/admin/properties/ The Properties catalog and the values behind it: the canonical typed names, the contract a product, standard, or location type declares, and the value a component, system, or location sets. **Catalog > Properties** (with `property_type:read`, covered by every viewer's `*:read` floor) is the estate's **signal catalog**: one directory of the typed names that identify what is tracked. A **property** is a name plus a data type (and an optional label, unit, and validation), identified by its **key** (its canonical name), and the same property is the same concept wherever it appears, whether a device **reports** it (an observed value) or an operator **types** it (a declared value). Registering `serial_number` once means it cannot drift into `serialNumber` in one place and `serial-number` in another. A property is used in three moves, and this page walks all three: the **catalog** names it, a **classifier declares** it (with a default, and whether it is required), and an **instance sets** it. There are three classifiers and they behave identically: a [product](/guides/admin/products/) declares for its components, a [standard](/guides/admin/standards/) for its systems, and a [location type](/guides/admin/types/) for its locations. The catalog is estate-wide reference data, not a scoped resource, so every property is visible to every reader; the write gates decide who may change it. - The directory lists every property sorted by name, each showing its **key** (the canonical name), **type** (`string`, `int`, `float`, `bool`, or `json`), **label**, **kind**, and **origin** (**official**, seed-owned, or **custom**). **name** filters by the key or its label; **type** and **official** narrow the list. - **Kind** marks a property that is **observed** as telemetry: `metric` (a continuous measure), `state` (a discrete condition), or `log` (an event). A property with no kind is a **declared** attribute, something an operator sets, like `serial_number`, that is never collected off a device. - **New property** (with `property_type:create`, granted to operators) opens a create **drawer**: name the **key** (lowercase, dot-hierarchied, for example `serial_number` or `interface.reachable`), choose its **data type**, and optionally add a **display name**, **description**, **unit**, and **kind**. Leave the kind as **declared** for an operator-set attribute. An invalid key (an uppercase letter, a hyphen, a leading digit, a stray dot) is refused with a message. - Pick a row to open its **detail blade**. The footer **Edit** pencil (with `property_type:update`) edits the label, description, and unit; the data type and kind are fixed at creation, since changing a property's type under the values that already use it is unsafe. **Delete** (with `property_type:delete`) removes a custom property, behind a confirm. - A property can carry a **validation** JSON Schema (for example a `pattern` on a MAC address, an `enum` on a state, or `minimum` / `maximum` on a number), shown read-only on the blade. Editing the schema in the console is a follow-up; set it through the API for now. - An **official** (seed-owned) property is always read-only: no Edit, no Delete, and the blade marks it "Seed-owned, read-only." The baseline ships the reachability properties (`icmp.reachable`, `interface.reachable`, and the round-trip and connect-time metrics) and a starter set of device attributes (`serial_number`, `mac_address`, `firmware_version`, `model_number`), so the shared vocabulary is the same from install to install. This catalog is the deliberate **exception** to the operator-owned rule that shipped [standards](/guides/admin/standards/) and location types follow: a property name is the vocabulary a driver maps onto, so a release has to be able to correct it ([the seed model](/architecture/core-entities/#the-seed-model-forked-templates-versus-canonical-catalogs)). - A **duplicate** name is refused (409), and an attempt to change an official property is refused too: the catalog has exactly one entry per name. The catalog is also the collection vocabulary: a telemetry datapoint lands only if its name is a registered property with a `metric`, `state`, or `log` kind (an unregistered name is dropped, not invented). ## Declare a property on a classifier The catalog says a property **exists**; a **classifier** says which properties its instances **have**. That declaration is the classifier's **contract**, edited in a **Declared properties** panel on the classifier's own detail blade. Each line names a catalog property, optionally gives it a **default**, and optionally marks it **required**. | Declare it on | For | Where | Gated by | |---|---|---|---| | a **product** | its components | **Catalog > [Products](/guides/admin/products/)** | `product:update` / `:delete` | | a **standard** | the systems that conform to it | **Catalog > [Standards](/guides/admin/standards/)** | `standard:update` / `:delete` | | a **location type** | the locations of that type | **Catalog > [Types](/guides/admin/types/)** | `type:update` / `:delete` | Declaring `serial_number`, `firmware_version`, and `model_number` on a Samsung QM55 is what makes every QM55 in the estate carry those three, with the same names and the same types, without touching a component. Declaring `room_capacity` on Huddle Room does the same for every huddle room. Type and validation are **not** repeated in the contract, they stay on the catalog entry, so a property means one thing everywhere. Inheritance is **live, not a copy**: revise a contract default and every instance that has not overridden that property picks up the new value. Withdrawing a line leaves any value an instance set, now reading as **off contract**. ## Set a property on an instance Open a **component**, a **system**, or a **location** from its inventory page. The detail carries a **Properties** panel: one row per property its classifier declares, resolved to the value that applies here, the **value set on the instance** or the **contract default** when nothing is set. An override reads with an accent **dot on its name** and its value in the accent colour, while an inherited (defaulted) property stays muted. The three panels are the same surface over the same resolver; only the classifier behind them differs. Property edits are **batched with the entity's edit**, not saved per property. Click **Edit** and each property becomes a **stacked cell**: a name row with a right-aligned **Override** switch, and a value row below it. The blade's **Save changes** commits every property you touched alongside the rest of the detail, and **Cancel** discards them. - **The Override switch is the choice.** With the switch **off** the property inherits the resolved value (the contract default), shown muted with no editable input. Flip it **on** and a type-aware input appears, seeded from that value, and the row now reads as your own. **Revert is the switch off**: there is no separate clear. Both directions are the **owning entity's** own write (`component:update`, `system:update`, or `location:update`, all **operator** permissions), and setting is an **idempotent upsert**, so overriding an already-set property patches it in place rather than failing on a second write. - **A bool reads as a word, overrides as a toggle.** Inherited, a bool shows the resolved word (`true` / `false`) muted, not a switch you appear to have set; override on gives a real editable toggle. - **A required property must be filled.** A property the contract marks **required** carries a red **`*`** by its name, stays overridden, and cannot be switched off until it holds a value. The red input box and a "This value is required" label appear only after a **Save** attempt leaves it empty, and **Save is blocked** while any required property is unfilled. - **Off contract is legal.** A property the contract does not declare can still be set directly on one instance (a one-off asset tag on a single unit). Those rows group under a dashed-bordered **Off contract** heading, so the shared shape and the local exception never blur together. Clearing an off-contract property removes it outright, since nothing declares it. - **An instance with no classifier is all off contract.** A **productless** component and a **one-off system** (one conforming to no standard) have no contract at all, so everything they carry is off contract. Nothing breaks; the panel simply shows one group. - An instance in a scope you cannot reach is **not found**, not forbidden: the panel resolves properties only within your read scope for that entity kind, mirroring secrets and variables. The same scope check runs on the **write**, so setting a property on a system or location outside your scope is a 404 too, never a silent success. From the CLI, the contract side is `omniglass product property list|set-property|delete-property`, `omniglass standard property list|set-property|delete-property`, and `omniglass location-type property list|set-property|delete-property`; the value side is `omniglass component|system|location property list|set-property|clear-property` (see the [CLI reference](/reference/cli/)). --- # Secrets URL: /guides/admin/secrets/ Encrypted-at-rest credentials owned at one scope and resolved down the cascade: the directory, create, reveal, and rotate. A **secret** is an encrypted-at-rest value (an SNMP community, a set of basic-auth credentials): typed, owned at one scope, and resolved down the [cascade](/architecture/cascade/) so the most-specific owner wins onto a component. Its plaintext sibling is a [variable](/guides/admin/variables/). The model underneath is [config and credentials](/architecture/variables/). **Values > Secrets** (with `secret:read`) is the directory of every [secret](/architecture/variables/) you may see: a typed, encrypted-at-rest value owned at one scope and resolved down the cascade. Each row shows its name, a **type badge**, a **scope** label (Platform, or the location / system / component it attaches to), and a **masked** field preview (`••••••`, never a value). The same chip filter as the inventory narrows the list. `secret` is a **sensitive resource** off the `*:read` floor, so a plain read-everything **viewer** does not see this page at all; an **operator**/**deploy** holds an explicit scoped `secret:read` and sees the secrets in its subtree. The directory is **scope-filtered** (you see your subtree, not the whole estate) and **hides admin-sensitive secrets** (platform credentials) from anyone below admin, so their existence and field names never appear to an operator. ::screenshot{#secrets} - **New secret** (with `secret:create`) opens a create **drawer**: pick a **type** (the shape, `snmp-community`, `basic-auth`, or `oauth2-client`), a **scope** (platform, location, system, or component), then the entity itself from the shared indented **tree picker**, and finally the type's operator fields (a password input for a secret field). A **platform** secret is install-wide, so it needs an all-scope grant plus `platform:create` on top of `secret:create`; without it the **Platform** scope is not offered at all, and the form says which capability that takes. Each type carries a **sensitivity default**: an integration type like `oauth2-client` creates an **admin-sensitive** secret (admin/owner-only), a device type an operational one. Creating an admin-sensitive secret requires the **admin tier**, so an operator can mint only operational secrets. - Pick a row to open its **detail blade** on the shared action rail. Fields render masked; **Reveal secret values** (with `secret:reveal`) runs the audited decrypt and shows the plaintext with a per-field **Copy**. An **operator** reveals the **operational** secrets in its scope; an **admin-sensitive** secret is admin/owner-only (it is hidden from the directory and a non-disclosing not-found on reveal for anyone below admin). Every reveal is written to the [audit trail](/guides/admin/audit/). - The footer **Edit** pencil (with `secret:update`) opens inline field edit; a **blank** secret field keeps its stored value, so you rotate one field without re-entering the rest. **Delete** (with `secret:delete`, admin and owner) sits in the footer behind a confirm. On a **Platform** row both also need the matching `platform:`; holding only the secret half leaves the blade read-only with a note naming what is missing. From the CLI the same surface is `omniglass secret list` / `create` / `update` / `reveal` / `delete` and `omniglass secret-type list` (see the [CLI reference](/reference/cli/)). --- # Standards URL: /guides/admin/standards/ The Standards catalog: the blueprint a system conforms to, its variants, the properties it declares, and the roles a conforming system must staff; shipped standards are yours to edit, not read-only seed content. **Catalog > Standards** (`/standards`, with `standard:read`, covered by every viewer's `*:read` floor) is the directory of **standards**: the blueprints your systems are built against. A **Huddle Room**, a **Classroom**, an **Auditorium** are standards; the meeting room on the third floor is a [system](/guides/operator/inventory/) that **conforms** to one. Each row shows the **id**, the **display name**, its **parent standard** if it is a variant, and its **origin**. A standard is the **system-side counterpart of a [product](/guides/admin/products/)**. A product says what a component *is* and what it carries; a standard says what a system is built to be and what it carries. This is the promotion of the old `system_type` registry, which was only a label: a standard declares a contract, so it earned its own catalog page and its own `standard:*` permission rather than sitting under the shared type registry. - **Conforming is optional.** A system points at a standard through its **Standard** field, and leaving it empty is legitimate: a **one-off system** that matches no blueprint carries only its own values, exactly as a component with no product does. - **Variants use parent standard.** A specific blueprint that refines a broader one points at it with **parent standard** (a Large Classroom under Classroom). A standard with no parent is a base standard. - **New standard** (with `standard:create`, an admin permission) opens a create drawer: name its **id** (a kebab identifier, unique tenant-wide, e.g. `lecture-hall`), give it a **display name**, and optionally pick a **parent standard**. An unknown parent is refused (422). - Pick a row to open its **detail blade**. The footer **Edit** pencil (with `standard:update`) edits the display name and parent; the id is fixed. **Delete** (with `standard:delete`) removes the row, behind a confirm. - **Delete is refused (409) while a system still conforms to it.** Repoint or remove those systems first. The same operations are `omniglass standard list/get/create/update/delete` from the CLI (see the [CLI reference](/reference/cli/)). ## The shipped standards are yours Omniglass ships a starter set (Meeting Room, Huddle Room, Classroom, Auditorium, Digital Signage, Control Room), and unlike a seeded [vendor](/guides/admin/vendors/) or [product](/guides/admin/products/) those rows are **not official and not read-only**. They arrive as ordinary **operator-owned** rows: rename them, re-parent them, add to their contract, delete the ones you do not run. This is deliberate. A standard is **forked from a template that lives in the code**, once, with **no inheritance**: nothing in your estate points back at that template afterwards. So Omniglass can improve its templates in any release without ever touching your rows, and your edits are never reverted by a restart, because the boot seed installs a standard **only if it is absent**. The thing the vendor updates and the thing you own are two different objects, which is the whole point. The exception is the **canonical vocabulary**: the [property catalog](/guides/admin/properties/) (and later commands and event types) is the shared namespace a driver maps onto, so it must read the same on every install. Those entries stay **official** and read-only, and a release can correct them. See [the seed model](/architecture/core-entities/#the-seed-model-forked-templates-versus-canonical-catalogs) for the full reasoning. ## Declared properties: the standard's contract A standard's blade carries a **Declared properties** panel, its **contract**: which [properties](/guides/admin/properties/) every system conforming to it exposes, and what each one defaults to. It is the same editor as a product's contract, on the system side. - **Declare a property** (with `standard:update`) picks a name from the property catalog, optionally types a **default**, and optionally marks it **required**. The property must already exist in the catalog, since the contract only names it: mint it under [Catalog > Properties](/guides/admin/properties/) first. Declaring is **idempotent**, so declaring a property already on the contract revises that line in place. - **The default is typed by the catalog, not here.** Type and validation live on the property, so a standard cannot redefine what `room_capacity` means, only what a system conforming to it starts with. - **Conformance is live, not a copy.** A system does **not** fork its standard. Change a contract default and every conforming system that has not overridden that property picks up the new value immediately. Only a system's own override survives the change. - **Required** means a conforming system must resolve the property to a value; the system's Properties panel blocks Save while a required property is empty. - **Withdraw** (with `standard:delete`, behind a confirm) removes a line. Conforming systems **keep** any value they set for it; it simply reads as **off contract** from then on. From the CLI the contract is `omniglass standard property list `, `omniglass standard property update `, and `omniglass standard property delete `. ## Roles: what a conforming system needs filled A contract says what a system **carries**. A **role** says what it **needs filled**: a room microphone, a main display, a confidence monitor. Declare a role on a standard and **every conforming system inherits it**, the same live inheritance the contract has, so a standard is not only a shape but a **checklist** that says which rooms are short a component. A role in this sense is a **slot in a room**, and it has nothing to do with the [roles under Admin](/guides/admin/access/) that grant people access. Different word, different namespace, no overlap. The standard's blade carries a **Roles** panel beside its contract. Each role has: - a **name** (its address within the standard, e.g. `room-mic`) and a **display name**; - a **quorum**, how many components should fill it. Two ceiling mics is **one role with quorum 2**, not two roles. The minimum is one, since a role no component need fill is not a role; - the **capabilities** it requires, picked from the [capability catalog](/guides/admin/capabilities/). The list is **all of them, not any of them**: a component must provide **every** capability listed to fill the role. Requiring `microphone` and `speaker` means a display cannot fill it no matter what else it does; - an **impact**, what the room loses when this slot is not being filled properly. **Impact is the health knob, and it belongs to the slot.** The same broken box matters differently depending on the job it was doing, so the judgement lives on the role rather than on the device: | impact | means | reach for it when | |---|---|---| | **outage** | the room is not working | the room cannot run without this slot | | **degraded** (the default) | the room works, worse | losing it costs quality, not the meeting | | **none** | nothing | you want the slot tracked, not depended on | A dead **confidence monitor** is not a dead **main display**, and this is where you say so. Set the main display to **outage** and the confidence monitor to **none**, and a failed confidence monitor stops paging anybody about a room that is running fine. What that impact does downstream is the [health rollup](/architecture/health/): a role short of its quorum contributes its impact, and the system takes the worst one. **Quorum is your redundancy setting**, and it pairs with impact. A role wanting **one** mic with **two** assigned survives losing either of them, because one working mic still meets the quorum. A role wanting **two** with two assigned is impaired the moment either one fails. Redundancy is not a separate switch, it is the gap between what you staffed and what you need. Declaring is **idempotent**, so saving a role that already exists revises it in place, and the capability list **replaces** the previous requirement wholesale (drop one by leaving it out). **Withdrawing** a role (with `standard:delete`) also removes every assignment conforming systems made to it, so withdraw only when the slot itself is gone, not when one room's component changed. Changing a quorum or an impact **re-evaluates every conforming system immediately**, so a retune shows up in the estate's health without waiting for anything to poll. Two shipped roles come with **Meeting Room**, and they are the worked example: **Room Microphone** (requires `microphone` and `speaker`, quorum **2**) and **Main Display** (requires `flat-panel-display`). Like the standards themselves they are **seeded only if absent**, so retuning quorum to what your rooms actually run survives the next restart. From the CLI: `omniglass standard role list `, `omniglass standard role update --display-name