# 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 } sample: "sample\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 -> sample: collect: parse at the edge sample -> event: evaluate: event_rule event -> alarm: fire opens / clear resolves alarm -> act: act config -- sample: "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. **[Properties](/architecture/properties/)** 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/). Unbuilt prose inside a page sits in a marked **design fence**, and the pages that are *entirely* unbuilt live in the sidebar's own **Design sketches** group, a working queue that empties as subsystems land, so a page's place in the nav tells you whether any of it exists. 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](/architecture/collection/)** (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](/architecture/templates/)**, 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 **sample**. ## The sample A **[sample](/architecture/properties/)** 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](/architecture/calculations/)** by Omniglass, or **intended** by a **[command](/architecture/commands/)** 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. The same cascade resolves **[tags](/architecture/tags/)** (the governed label vocabulary), encrypted secrets, and platform **[settings](/architecture/settings/)**, and **[files](/architecture/files/)** attach alongside them as searchable handles over a content-addressed blob store. 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 sample and fires when its condition, an **[expression](/architecture/expressions/)**, is met, recording an **[event](/architecture/events/)**: 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 sample. 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 sample, 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](/architecture/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 console is one client of the **[API](/architecture/api/)**, the same contract the generated CLI and the **[AI](/architecture/ai/)** seams (MCP included) drive. 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 } sample: "sample\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 -> sample: collect (node + edge parse) sample -> event: event_rule event -> alarm: fire / clear alarm -> health: degrades a capability alarm -> action: action_rule action -> gear: command { style.stroke-dash: 4 } config -- sample: drift { style.stroke-dash: 4 } sample -> 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. A grant can target a **[group](/architecture/groups/)** of principals as well as a single one. - the **[workers](/architecture/workers/)** are one machinery: durable JetStream consumers over the **[messaging](/architecture/messaging/)** subject contract. The built one is the telemetry consumer; the rule engine, the clock, and reconcile follow the same shape; 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. Samples 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 sample, event, and alarm names exactly one owner (component, system, location, or node), 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. - **The `official` boolean**: every registry (`property_type`, `event_type`, `command_type`, and the catalogs) and rule row carries an `official` boolean. `official` is the curated ship-with set, seeded at boot and authoritative; 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/), and the generated ERD over it in the [data model](/architecture/data-model/). --- # 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. :::note[Built today] An AI tool authenticates with a **bearer token or password as a `human` or `service` principal** (OAuth/OIDC is deferred, [ADR-0004](/architecture/decisions/#adr-0004-credentials-ship-bearer-only)) 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/)). Everything below this note is the target design. ::: :::design[Target design (ADR-0001): AI as a governed capability] 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. ## 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. ::: --- # 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 model (Huma's stock RFC 9457 shape, [ADR-0068](/architecture/decisions/#adr-0068-the-api-error-model-is-the-stock-rfc-9457-shape)), proven on `/auth`, `/roles`, `/locations`, `/systems`, `/components`, `/nodes`, `/interfaces`, `/tasks`, the first-party ingest write `POST /telemetry:push` (gated `telemetry:push`, owner declared in the body and fenced by the caller's scope), 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. 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: `/components/{name}/commands:issue`, `/auth/me/sessions/{id}:revoke`, `/nodes:claim`. The verb is also the **permission**: `:issue` is gated by `command:issue`, 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. - **Each typed registry is its own plural collection**: `/location-types`, `/property-types`, `/event-types`. Kind sub-segments under an umbrella path were retired ([ADR-0060](/architecture/decisions/#adr-0060-a-resource-is-one-kebab-case-noun-nesting-means-ownership)). - **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 The built lists take a small fixed set of query params (`kind`, `resource`, `verb`, `system`, `include_archived`, `include_cleared`), and the one paginated route, `GET /audit-log`, pages backward with `before` plus `limit`. **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. :::design[The AIP list parameters, tracked in #434] The target contract: 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/)). ::: ## Partial responses: field masks :::design[The fields read mask, tracked in #434] The `fields` read mask (a response subset, AIP-157) selects the fields a read returns; the default is the full resource. ::: Today the full resource is the only behavior. `PATCH` carries a **write mask implicitly**, and this part is built: 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`**, in Huma's stock shape: `title`, `status`, `detail`, and, for validation, an `errors` array of `{location, message, value}` details. One shape, so the generated client and the CLI render every failure uniformly. A custom envelope (a stable machine `code` plus a `violations` array) was sketched here and is retired: the stock model serves all 141 routes and no consumer drives a custom shape ([ADR-0068](/architecture/decisions/#adr-0068-the-api-error-model-is-the-stock-rfc-9457-shape)). 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. ::::design[Idempotency keys and optimistic concurrency, tracked in #434] ## 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. ::: :::: :::design[Long-running operations over the action row, tracked in #434] ## 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. The routes themselves live in the [generated API reference](/reference/api/), rendered from the OpenAPI document on every build, with each operation's description naming its permission gate verbatim (a guard test enforces exactly that, alongside the spec-contract test that every gated operation carries its `x-omniglass-permission` stamp and `POST /nodes:claim` stays the deliberate, justified public write). The collection surface is the standard family per resource (`/nodes`, `/interfaces`, `/tasks`, each with list/get and the mutating verbs its permission set gates) plus the `:verb` custom methods and the per-component reads (`reachability`, `reconciliation`, `events`), all `component:read`-gated. **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 write routes and no task write 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 telemetry 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 `event_type` `key` (e.g. `call.started`), the `origin` (caught/caused/derived/scheduled, how the occurrence arrived), 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. **The event_type registry is the occurrence keyspace, the twin of the property catalog.** `GET /event-types` lists the registered occurrence types (official and custom), and `GET/POST/PATCH/DELETE /event-types[/{name}]` are the custom-type CRUD, gated `event_type:read` / `:create` / `:update` / `:delete` on the same shape as `/property-types` (estate-wide reference data, no scope injection; official seed-owned types are read-only, a 409). A registered `event_type` name is what an ingested occurrence is typed by (the log-to-event promotion, ADR-0063); its optional `payload_schema` is a JSON Schema fragment for the occurrence payload. **The command_type registry is the do catalog, and `:issue` is the write.** `GET /command-types` + `GET/POST/PATCH/DELETE /command-types[/{name}]` are the driver-owned catalog of what a component can be told, gated `command_type:read` / `:create` / `:update` / `:delete` on the same shape as `/property-types` (official types read-only, a 409). Each carries a `settle_window_seconds` and an optional `target_property_type` (the property a settleable command sets). `POST /components/{name}/commands:issue` is the AIP custom method that issues a command: it records the invocation, writes a caused event, and (for a settleable command) opens an intended value, returning the computed settlement verdict (none/pending/settled/failed). It is gated by `command:issue` and scope-injected through the component (an out-of-scope component is a non-disclosing 404). **The reconciliation read pivots want/told/is over the property cache.** `GET /components/{name}/reconciliation` returns, per declared property, the **want** (the declared value, resolved live from the [cascade](/architecture/variables/), never a cache row), the **told** (the `intended` value a command set), and the **is** (the `observed` value from the [latest-value cache](/architecture/properties/)), with **drift** (the observed value present and disagreeing with the declared one) computed on read. 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. Like reachability and events, 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** carries the full set (create, list, get, update, delete, plus `:enroll` and `:claim`); its delete **cascades** its interfaces, their derived tasks, its node-owned tags and self-telemetry, and its enrollment credential. 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. Secret is a **sensitive resource** ([ADR-0025](/architecture/decisions/#adr-0025-secret-is-a-sensitive-resource-a-per-secret-admin_sensitive-flag-flips-a-secret-to-the-admin-tier)), so the viewer `*:read` floor does **not** reach it: the registry and the directory read need an explicit `secret:read` grant (seeded to operator, deploy, admin, and owner); the three writes gate on `secret:create` / `secret:update` / `secret:delete`; the plaintext decrypt gates on **`secret:reveal`**, held by operator and deploy (the device secrets in their scope) as well as admin (via `secret:>`, which alone reaches the admin-sensitive `:admin` tier) and owner (`>`). 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` lists the secrets the caller may see (`{secrets: [secret]}`), each row filtered by its owner's placement against the caller's read scope, with admin-sensitive secrets visible only to the admin tier (`secret:read`). - `POST /secrets` creates one from `{name, secret_type, owner_kind: platform|location|component (the system band is retired, ADR-0052), 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 `{name, 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. :::design[Views and the SSE live relay, tracked in #434] ## 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. :::design[The MCP surface, tracked in #434] ## 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/)). ::: :::design[The full node NATS contract, per ADR-0036] ## 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; the secret-decrypt audit (distinct `reveal` and `copy` verbs); the auth-event lane; and the `GET /audit-log` read, which returns the `old`/`new` images, with the console Admin > Audit page rendering each row's field-level before/after diff in a drawer. See [implementation status](/architecture/status/). ::: ## The model `audit_log` is **ground truth** (not derived): one row per mutation, carrying `actor`, `verb`, `resource`, `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. On the read side a **human** actor is additionally resolved to a username; a service or node actor surfaces as its principal id. - **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/)). :::design[The backtest and reconcile consumers, tracked in #434] - **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.** :::design[The read-audit toggle, tracked in #434] 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. ::: ## Reading the trail The read surface is `GET /audit-log`, gated by the admin-sensitive `audit:read:admin` (which a two-token wildcard cannot reach, so only admin and owner see the security trail). It returns rows newest first, filterable by `resource` and `verb`, and pages backward with a `before` timestamp plus a `limit` (default 100, capped at 500). Each event also carries the `old` and `new` row images the write recorded, completing the "and to what?" half of the question: a create has only `new`, a delete only `old`, an update both, and auth events neither. Redaction is owned by the write side (sealed secret material and credential hashes are never written into an image), so the read passes the images through verbatim. The console renders the trail as the Admin > Audit page, where a row opens into a drawer with the field-level before/after diff. Beside the in-transaction estate lane there is a **second write lane for auth events**. Login and logout run on read / no-transaction paths, so they emit through a standalone non-transactional seam (`WriteAuthEvent`), recorded under `resource = 'auth'` with the verbs `login`, `logout`, `login_failed` (a wrong password on a real account), `login_denied` (a correct password on a disabled account), `login_locked` (an attempt inside the lockout window), and `revoke_session` (an admin ending another principal's session). An impersonated action records **both** actors: `actor_principal_id` is the impersonated principal and `real_actor_principal_id` the admin behind it, and both usernames are **denormalized onto the row** (`actor_username`, `real_actor_username`) with the foreign keys going `ON DELETE SET NULL`, so the trail stays attributable even after its actor is purged ([ADR-0016](/architecture/decisions/#adr-0016-a-principal-can-be-purged-and-the-audit-trail-is-denormalized-to-survive-it)). ## Retention and integrity Audit carries the **longest retention** of any ground-truth log (compliance). It is append-only by construction. :::design[Retention partitioning, tracked in #434] Retention is enforced by time-partitioning `audit_log`: aging out a window drops a partition, never a row-by-row delete. ::: :::caution[Open question] Tamper-evidence (a hash-chain or signed audit) for high-assurance deployments. ::: :::design[The backtest, reconcile, and alarm-projection consumers, tracked in #434] ## 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. ::: --- # Build log URL: /architecture/build-log/ The slice-by-slice build history: one entry per merged vertical slice, oldest first. Each entry below is one merged vertical slice, oldest first, logged as it landed. The live map of what is built sits on [implementation status](/architecture/status/); this page is the history behind it. Entries are append-only and quote the vocabulary of their day, so retired terms appear here exactly as they shipped. The code is built one **vertical slice per PR**, each a thin cut through the whole stack rather than a horizontal layer. Slices are logged here as they land; a page's badge only flips once *all* of its capabilities ship, so an early slice can prove a seam without moving any page off `Design`. - **Slice 1: the walking skeleton.** The single binary boots in two run modes. `omniglass migrate` applies an embedded [dbmate](/architecture/storage/) migration (pure DDL, run-once, idempotent) against a BYO Postgres, and `omniglass server` serves `GET /api/v1/healthz`, which pings the database through the [Storage Gateway](/architecture/storage/) seam (the only DB path, an interface from day one) and reports `{status, db}`. The [HTTP API](/architecture/api/) is Huma over chi; the OpenAPI 3.1 document is generated from the Go structs (`make gen`), the source the typed clients are generated from in later slices. Proven by a testcontainer integration test (migrate applies clean, the gateway ping succeeds, healthz returns db ok) and an end-to-end test that builds and runs the real binary. Deferred to later slices: messaging (NATS / JetStream), the worker and outbox, [identity and access](/architecture/identity-access/) (Slice 2), the [collection](/architecture/collection/) engine, and the rest of the generation pipeline. - **Slice 2: the auth and gateway foundation.** The [identity and access](/architecture/identity-access/) schema (principal with per-kind human and service, credential, role, principal_grant, and audit_log; uuidv7 keys), the four official roles seeded idempotently at boot, and `omniglass bootstrap `, which creates the first owner (an `owner@all` grant plus a bearer credential) directly and idempotently. A bearer token resolves through the [Storage Gateway](/architecture/storage/) to its principal and grants, which flatten through the role index (inheritance, wildcards, the `:read` floor) into a capability set. Two seams every later route reuses: the **capability** fast-reject (401 unauthenticated, 403 missing capability) and `GET /api/v1/auth/me` (principal, permissions, grants). `GET /api/v1/roles` is gated by `role:read`. Proven by testcontainer integration and end-to-end tests (bootstrap idempotency, the 401 and 403 paths, the auth/me contract) plus pure unit tests of the capability logic. Deliberate thin cuts this slice: bearer-token auth only, and scope resolves `owner@all` to all (the per-action `visible_set` resolver lands when there are entities to scope). Deferred: password and OIDC auth, the first-class agent principal, and CDC-driven cache invalidation of the role index. - **Slice 3: locations and the per-action scope resolver.** The first scoped [core entity](/architecture/core-entities/), and where the [ABAC scope](/architecture/identity-access/) deferred from Slice 2 lands. The `location_type` registry (the only shape-definer, since a location has no template) seeds the four official types at boot, ranked (campus/building/floor/room, spaced by ten); `location` is a name-addressable, variable-depth tree (`parent_id`) whose type is a foreign key. `POST/GET/PATCH/DELETE/list /api/v1/locations` each declare a `location:` capability and resolve the caller's per-action `visible_set` from their grants and the role index, which the [Storage Gateway](/architecture/storage/) expands (a recursive subtree walk) into the row filter and applies on every query, writing the `audit_log` row in the same transaction. The over-permit fix is real: only the grants whose role carries the action contribute scope, so a read-anywhere principal with a narrow write grant cannot write outside it. The three-way status split holds: out of read scope is a non-disclosing 404, readable-but-out-of-action-scope is 403, and a delete is refused (409) while the location still has children. The `location_type` registry that classifies a location is itself readable at `GET /api/v1/types/location` (ranked, gated by `location:read`), which the operator console type picker lists. Proven by a pure resolver unit suite, a testcontainer integration test of the gateway split and audit, and an end-to-end HTTP test (scoped subtree listing, the 404, the capability 403, full owner CRUD). Deliberate thin cuts: scope kinds resolved are `all` and `location` only; the `location_type` registry seeds official rows only; `rank` orders and signals hierarchy but does not constrain nesting; update patches `display_name` and `location_type` (rename and reparent deferred); occupancy counts child locations only (placed systems and components join when they land). Deferred: system and component entities (Slice 4), group and dynamic-group scope (Slice 5), operator-defined location types via the namespace shadow, hard containment rules, and any `LocationTemplate`. - **Slice 3a: the generated CLI.** The second stage of the [generation pipeline](/contributing/api-first/) lands: `make gen` now runs `cmd/cligen`, which reads the committed `api/openapi.json` and emits the cobra command tree (`internal/cli/api_gen.go`, committed and reviewed like the spec). One command per operation, derived from the AIP path and method: `omniglass location list/get/create/update/delete`, `role list`, `auth me`, `healthz`; path parameters are positional args, the request body becomes `--flags`, and `--help` plus the example come from the operation's summary and description. A `:verb` custom method maps to ` ` generically, so future custom methods surface with no generator change. The generated tree composes with the hand-written commands (the `server`/`migrate` run modes and the trusted `bootstrap` lane) on one root through a stable seam (`internal/cli/api_hooks.go`: the JSON-over-HTTP client and the shared `--server`/`--token` flags), so regeneration never touches a hand-written command. The CLI is a client of the API like any other: it carries a bearer token and the server enforces the same capability and scope. Proven by an end-to-end test that builds the real binary, bootstraps an owner with the hand-written command, and drives the generated location commands against a live server (create, scoped list, get, a non-disclosing 404 as a non-zero exit, delete, and `--help`). Deliberate thin cuts: JSON output only (no table rendering), and a generic client rather than a per-type typed one. Deferred: the typed SPA client and UI (Slice 3b), shell completion, multi-server contexts, and the YAML JSONSchema target. - **Slice 3b: the operator console.** The last stage of the [generation pipeline](/contributing/api-first/) and the first browser surface: a SolidJS SPA (Vite, daisyUI 5 on Tailwind 4, `@solidjs/router`, `@tanstack/solid-query`, `openapi-fetch`) `go:embed`'d into the binary and served under `/web`. `make gen` now also emits the **typed SPA client** (`openapi-typescript` to `web/src/api/schema.gen.ts`), so the console cannot drift from the API. The embed uses the build-tag-with-fallback pattern (`internal/webui`): a bare `go build`/`go test` needs no `dist/` and serves a build-the-console placeholder, while `make build-web` runs the Vite build and compiles with `-tags web` to embed the real shell. The visual system is the "Omniglass Console" design ported faithfully (the `theme.css` tokens and component classes, the nav rail, top bar, density/theme tweaks, and Home). **Locations is the first live view**: list, detail, and create through the typed client, gated by a bearer-token login and the AuthGuard; the other nav sections render honest stubs until their backends land. Proven by `internal/webui` unit tests (the SPA-fallback routing against a fake FS, and the real embed under `-tags web`), an API mount test (`/web` serves the shell and the bare `/web` redirects), and a vitest suite over the locations data layer. Deliberate thin cuts: only locations is wired live; styling is daisyUI (two brand themes via the plugin) with Kobalte deferred until the first interactive widget needs it; auth is bearer-token paste, not a full login. Deferred: the mock-data screens (alarms, systems, components, dashboards) as their backends land, HTTP/2 (h2c) and the SSE live relay, and full auth UX. - **Slice 4a: the system tier.** The second scoped [core entity](/architecture/core-entities/): a `system_type` registry (seeded official, ranked, like `location_type`) and `system` as a name-addressable, variable-depth tree (`parent_id` subsystems), optionally located at a location (`location_id`) and classified by its type. Full scoped CRUD (`POST/GET/PATCH/DELETE/list /api/v1/systems`), gated by `system:`, with the same per-action `visible_set`, three-way 404/403 split, and in-transaction audit as locations. The scoped-tree recursive walk is now a **shared primitive** (`internal/storage/scopetree.go`): locations were refactored onto it and systems consume it, so the over-permit-safe subtree filter is written once. Proven by the pure resolver unit cases for the `system` kind, a testcontainer integration test (subtree scope, the 404/403 split, occupancy, FK faults, located-at, audit), and an end-to-end HTTP test plus the generated `omniglass system` CLI. Deliberate thin cuts: each entity is scoped by its **own** subtree (the cross-tier cascade, a location scope also covering its systems, is a later slice); reparent and located-at editing are deferred (create-time only). Deferred: components (Slice 4b), the cascade, templates, datapoints, and the systems UI (console section stays a stub for now). - **Slice 4b: the component tier, and a generic scoped-CRUD primitive.** The estate is complete: `component` (the leaf) joins locations and systems, with a `component_type` registry and a tree that belongs to a primary `system_id` and is located at a `location_id`. The per-entity boilerplate is now a **shared generic** (`internal/storage/scopedcrud.go`): the read, resolve, and delete paths (list/get/resolve-for-action/ delete-with-occupancy/by-name, all scope-aware and audited) are written once over a `scopedConfig[T]`, and locations and systems were refactored onto it; each entity keeps only its own create/update (which differ by the foreign keys they resolve). So a new scoped entity is a registry, a thin create/update, and a route file, not a mirrored copy. And the [authorization conformance suite](/architecture/identity-access/) made adding component **one registry line**: the full security matrix (capability 403, the over-permit scope 403, the non-disclosing 404, in-scope success) and the no-unguarded-route guard now cover all three entities automatically. Proven by the pure resolver `component` case, a testcontainer integration test (subtree scope, the split, occupancy, belongs-to + located-at FK resolution, faults, audit), the generic authz conformance over location + system + component, and the generated `omniglass component` CLI. Thin cuts as the other tiers: own-subtree scope (no cross-tier cascade), reparent/rebind/relocate deferred, components UI deferred. - **Identity slice 1: password auth and the session cookie.** Humans now sign in to the console with a username and password, the local-password method the [identity model](/architecture/identity-access/) designs: a `password` credential (argon2id, PHC-encoded, one per principal). `POST /api/v1/auth/login` verifies it and sets an httpOnly, `SameSite` session cookie, so the console no longer holds a token (services and the CLI still send a bearer header); `POST /api/v1/auth/logout` revokes the session and clears the cookie, and the authn middleware accepts either. `omniglass bootstrap --password` sets the first owner's password. Proven by argon2 unit tests, a storage round-trip, a real-binary login integration test, and a browser e2e that signs in through the form. Thin cuts: self profile edit and change-password (slice 2), admin user CRUD and role assignment (slice 3); email invite and forgot-password are later. - **Identity slice 2: the self-service profile.** A signed-in operator manages their own account, whatever their role: `PATCH /api/v1/auth/me` edits their own display name (email stays administrator-set), and `POST /api/v1/auth/me:changePassword` (the first AIP `:verb` custom method on the surface) verifies the current password and sets a new one, reusing the slice-1 argon2 primitive. Both are authn-only and self-scoped (they touch only the caller's own principal), so they join the small ungated allow-list alongside `GET /api/v1/auth/me` rather than carrying a capability. The console gains a **Your profile** page (profile form, change-password form, and a read-only Access panel that teaches the principal, grants, and permissions it operates on); the CLI gains `auth update-profile` and `auth change-password` from the generator. Proven by a storage round-trip, a real-binary integration test (wrong current password 403, short new 422, rotation, self-scope), and the client hook units. Thin cut: changing a password does not revoke existing sessions (a later hardening); admin user CRUD and role assignment are slice 3. - **Identity slice 3a: the admin principal directory.** The first surface to exercise the already-seeded `principal:*` capability: `GET /api/v1/principals` (with a `kind` filter) and `GET /api/v1/principals/{id}` list and read every principal with its grants, and `POST /api/v1/principals` creates a human with an optional initial password (argon2id, reusing the slice-1 credential; no new migration). All three are gated by a `principal` capability that resolves **only at all-scope**: a principal is not a scope-tree entity, so a location or system grant confers nothing and the gateway answers a non-all scope with 403, proven both ways. Responses carry credential-free profiles and grants, so no secret ever leaves the API. The console `/users` stub becomes a real **Users** directory (grid, detail panel, and a create form); the CLI gains `principal list` / `get` / `create`. Proven by a storage round-trip, a real-binary integration test (all-scope allow, scoped-deny 403, duplicate 409, short-password 422, secret redaction, created-human login), the route-gating guard, and the client data-layer units. Thin cuts: no `created_at` column in the directory yet, and create is human-only. Deferred: update / disable / delete a principal, role assignment, the owner-invariant trigger, and service-account and credential management. - **Identity slice 3b: admin update a principal.** `PATCH /api/v1/principals/{id}` edits a human's display name, email, and **username**, gated by `principal:update` (all-scope). Renaming is safe by construction: nothing keys on the username (credentials and grants reference the `principal.id` uuid), so a rename re-homes the login rather than breaking it. A non-human target is 422, a clash 409, an unknown id 404, a location-scoped admin 403. The console Users detail panel gains an **Edit** form; the CLI gains `principal update`. Proven by a storage round-trip (the rename re-homes authentication), a real-binary integration test (allow, scoped-deny 403, 409, 404, 422), and the client unit. Deferred: disable / delete, role assignment, and editing a service account's label. - **Identity slice 3c: role assignment and the owner invariant.** `POST /api/v1/principals/{id}/grants` assigns a role at a scope and `DELETE /api/v1/principals/{id}/grants/{grantId}` revokes one, gated by `principal_grant:create` / `:delete` (all-scope). This is how a fresh user gets permissions. The **owner-invariant trigger** (ADR-0006, now resolved) lands here: a `DEFERRABLE INITIALLY DEFERRED` Postgres constraint trigger refuses to leave zero `owner @ all` grants at `COMMIT`, so revoking the last owner is a 409 while a one-transaction owner swap still passes (mapped from the custom SQLSTATE `OG001` to `ErrLastOwner`). Bad inputs are caught: duplicate 409, unknown role 422, a scoped grant with no id 422, unknown grant 404. The console detail panel gains a grant chip **x** (revoke) and an **add-grant** form (role picker, scope kind, scope id); the CLI gains `grant create` / `grant delete`. Proven by a migration round-trip, a storage test (grant, revoke, the last-owner refusal, the swap), a real-binary integration test, and the client units. Deferred: disable / delete a principal, group grants, and editing a grant in place (revoke and re-grant instead). - **Identity slice 3d: disable and enable a principal.** `POST /api/v1/principals/{id}:disable` and `:enable` (AIP custom methods, gated `principal:update`, all-scope) soft-disable an account: a new `active` flag on `principal`, and `AuthenticateBearer` / `AuthenticatePassword` refuse a disabled principal, so it can neither sign in nor use a token. Its rows (and the audit trail that references it) are kept, which is why the model is disable, not delete: `audit_log` references the principal, so an actor that has acted cannot be removed. A **last-active-owner guard** refuses to disable the final active `owner @ all` (409), mirroring the grant trigger for the active flag. The console shows an **inactive** badge and a **Disable / Enable** toggle; the CLI gains `principal disable` / `enable`. Proven by a migration round-trip, a storage test (both auth paths refused, enable restores, last-owner refusal, the swap), and a real-binary integration test. Deferred: hard delete (would need an audit-actor `on delete set null`, a separate decision) and bulk operations. - **Identity slice 3e: the grant builder (console).** A UI-only refinement of slice 3c: the user card's three stacked selects become a **filter-bar-style scope editor**. A keyboard-staged combobox (type a role, Tab/Enter to the scope kind, then the entity as an indented tree) commits each grant as a chip, and the whole set of changes is staged locally and applied only on **Save** (stage, preview, save), so there are no accidental or unclear edits: removing an existing grant marks it (dimmed, undoable) instead of firing a live `DELETE`, and a pending-diff preview shows exactly what Save will grant and revoke. No new API: it drives the slice-3c `POST` / `DELETE` grant routes, applying adds before removes so an owner swap never trips the last-owner guard mid-batch. The staging core (draft, per-chip state, add/remove diff, stage validation) is a pure unit-tested module (`lib/grantdraft`); the `GrantBuilder` component takes its roles, entity trees, and save mutation as props, proven by a component test that drives the keyboard pipeline and asserts the saved diff. Deferred: previewing a grant's *effective access* across the scope subtree (#89) and group grants. - **Console: unified button vocabulary.** Buttons across the console moved from ad-hoc daisyUI classes (`btn-primary`, `btn-ghost text-error`, and an inconsistent disable/edit pairing) to a small set of **semantic intent classes** defined in `app.css` (`btn-action`, `btn-quiet`, `btn-danger`, `btn-warn`, `btn-ok`), `@apply`-composed from the daisyUI theme tokens so a future custom theme restyles every button from one place. A `style-guard` unit test scans the source and fails on a raw `btn-primary` / `btn-ghost`, pinning the vocabulary against drift. No behavior or API change: purely how buttons are styled. See the [design system](/contributing/design-system/#button-vocabulary). Follow-on: custom theme switching beyond the two brand themes. - **Identity: disabled-account sign-in message.** A disabled account that signs in with the **correct** password now gets an explicit "This account is disabled. Contact your administrator." (a distinct `403`), instead of the generic invalid-credentials message, so a locked-out operator knows to ask an admin rather than assuming a wrong password. The disclosure is **oracle-safe by construction**: `AuthenticatePassword` drops the `and pr.active` filter, always runs the argon2 verify (against the real hash, or a dummy hash of matching parameters on a miss, so there is no early-return timing leak), and only branches to the new `ErrAccountDisabled` **after** the password verifies. So a wrong password (or an unknown user) against a disabled account is indistinguishable from any other bad credential: same generic `401`, same work. The bearer/token path is unchanged. Proven by storage tests (correct password on a disabled account -> `ErrAccountDisabled`; wrong password -> `ErrBadCredentials`), a real-binary api test (disabled + correct -> `403`, disabled + wrong -> `401`), and a web unit. Deferred: **auditing** the disabled-login attempt (#97), which belongs at the api/outbox layer, not the gateway read, to avoid a write-on-read and an attacker-append vector. - **Identity: root-excluded grants and the deploy role.** A grant gains an optional `exclude_root` modifier that narrows its **modify** actions (update, delete) to the root's descendants: the holder can create under and edit within the subtree, but a `PATCH` / `DELETE` on the root itself is a **403** (readable, outside the write scope). Read and create-placement keep the root, so a `POST` under the root and a `PATCH` on a child still succeed. A new **deploy** official role (create + update on location / system / component, read via the viewer floor) is the integrator / field-tech grant: `deploy @ location:room-42 (exclude_root)` populates a room without touching its record. The modifier lives on `principal_grant`, resolved in one predicate (`inScopeTree`) shared by all three tree entities, not a new scope kind (ADR-0009); an inclusive grant on the same root wins. Proven by scope unit tests (per-action classification, inclusive-wins) and a real-binary API test (read root 200, create-under-root 201, update child 200, update root 403, delete 403). API and CLI carry `exclude_root`; the grant-builder toggle is a fast-follow (#99). - **Console: scope-aware per-row action gating.** The read side now annotates every inventory row with the scope-aware `actions` it permits (create-a-child / update / delete), computed from the **same** per-action `visible_set` the gateway enforces (a new batch `InScopeIDs` primitive answers a whole page in one query per action). The console's `ListView` gates each row's Add-child / Edit / Delete affordance on `row.actions` rather than the coarse `can(...)` capability, so a scoped operator (e.g. `viewer@all + writer@root`, or a `deploy` grant) sees only the buttons the server would actually allow, per row, with no client-side scope math. `can(...)` stays the coarse nav/section hint; the server remains the only authority. Proven by a storage test (batch membership across all / rooted / exclude-root scopes) and a real-binary API test (owner gets every action on every row, a viewer none, a `deploy @ root (exclude_root)` gets create-only on the root and create+update on descendants). Thin cut: the list carries `actions`; single-item GET annotation is a follow-on (the blades render from the list rows). - **Identity: impersonation (view-as and act-as).** An owner or all-scope admin holding `principal:impersonate` can `POST /principals/{id}:impersonate` to mint a bounded, revocable token to **view as** (read-only) or **act as** (full) another principal, and `POST /auth/me:stopImpersonation` to end it. Two guarantees: the **escalation guard** (`rbac.Set.Covers`) refuses impersonating a principal whose capabilities exceed the caller's, so no capability is ever gained; and **dual-actor audit**, a new nullable `audit_log.real_actor_principal_id` (threaded via a request-scoped context value, no gateway signature churn) records the true admin behind every impersonated mutation. The token is an `impersonation_session` (its own table, not a credential); `authn` resolves it on a bearer miss to the target, and `require()` refuses every non-read action in view-as. Self and nesting are refused; disabling either party kills the session on its next request (ADR-0008 / ADR-0010). Proven by a storage round-trip and a real-binary API test (act-as writes in the target scope with a dual-actor audit row, view-as GET 200 / PATCH 403, a lesser admin cannot impersonate the owner, self 422, a viewer capability-gated, stop kills the token, a split-grant admin cannot act-as into a disjoint scope). The console ships an **Impersonate** action (View as / Act as) on the user card and a persistent **acting-as banner** with Stop. Deferred: act-as within a scoped admin's own scope by intersection (#101; act-as currently requires all-scope over the target's write capabilities) and re-checking the guard per request (bounded by the TTL + revoke for now). - **Identity: grant scope operators (`scope_op`).** The `exclude_root` boolean generalizes into a `scope_op` operator on `principal_grant` (ADR-0011): **`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`** (exactly the root row for read/update/delete, no descendants and no create-placement, a leaf-lock) which is **net-new**, a grant on exactly one node. The pure `scope.Resolve` gains a `SelfIDs` set (matched by id equality, never subtree-expanded; a `self` grant re-adds a root a `subtree_excl_root` grant stripped), and the three gateway walks (`inScopeTree`, `InScopeIDs`, `scopedListSQL`) gain a self arm. The migration recreates the dedup index to include `scope_op` (fixing a latent collision where two grants differing only by operator clashed) and threads `scope_op` through `RevokeGrant`'s audit SELECT (previously dropped). The **grant builder** gains an operator stage (role -> kind -> entity -> operator, subtree pre-selected), and each scoped chip shows its operator glyph (= / ≥ / >), so #99 (setting the modifier from the console) ships here too. Proven by scope unit tests (self classification, self re-adds an excluded root, self-under-subtree is redundant), a storage round-trip (bad operator refused, a self grant persists alongside a subtree grant on the same root), a real-binary API test (a `self` grant reads and updates its node, descendants 404, list returns only the node), and web unit + component tests (operator in the grant identity, an operator change diffs as revoke + grant, staging a self grant sends `scope_op: self`). The act-as scope intersection (#101) is **not** subsumed: it is plumbing, orthogonal to how a scope is expressed. - **Identity: owner accounts are un-impersonatable.** A principal holding `owner @ all` cannot be impersonated by anyone, including another owner, in either mode (ADR-0012): a target-side check in the `:impersonate` handler before the mode branch (403), reusing the owner-invariant's `role='owner' and scope_kind='all'` lane. The escalation guard (`Covers`) already blocked a lesser admin from an owner, but `owner.Covers(owner)` was true, so owner-impersonates-owner was possible; this removes the highest-trust-account takeover vector explicitly. Impersonate stays gated by `principal:impersonate` **swept by `principal:*`** (holding it already lets a caller create and use its own principals, so impersonate confers no new reach there). Act-as scope intersection (#101) is **dropped**: act-as stays all-scope-only. Proven by the real-binary API test (an owner cannot view-as or act-as another owner, 403; admin-to-owner stays 403; a non-owner target still works). - **Identity: a grant cannot exceed the granter.** Grant creation is refused (403) when the granted role's capabilities are not covered by the granter's **all-scope** capabilities (`rbac.Set.Covers`, ADR-0013), so no caller can promote anyone (including itself) above its own tier: an **admin cannot grant `owner`** (`*:*`) and therefore cannot self-promote to superuser. `CreateGrant` previously checked only all-scope `principal_grant:create`, so admin could grant itself `owner@all` and log in as a superuser; the guard lives in the `create-grant` handler, mirroring the impersonation escalation guard, and only all-scope grants count (a narrowly-scoped capability cannot be conferred estate-wide). The deliberate stance: **admin is bounded on purpose**, the top management role, not the superuser; `owner` is the break-glass superuser and the owner-invariant anchor. Proven by a real-binary API test (admin cannot grant owner to a user or itself, 403; admin can grant viewer/operator it covers; owner can grant owner). Role editing will need the same rule. - **Identity: the Roles view and role metadata.** Roles gain a `display_name` and `description` (additive migration, seeded for the five official roles), and `GET /roles` now returns them plus each role's **effective permissions** (flattened through the role index: inheritance, wildcard, and the `:read` floor resolved). The console gains a read-only **Roles** page (`Settings > Roles`): a self-teaching catalog of each role's display name, description, inheritance, and effective permissions, ordered by tier, rendered from the real seeded roles. The **grant builder** role picker gains a hover tooltip showing a role's description and permissions, so an admin sees what a role grants while assigning it. No custom-role CRUD (a later slice; the ADR-0013 cover rule must extend to role editing then). Proven by a real-binary API test (owner effective set is `*:*`, admin's is broad but not `*:*`, viewer's includes `*:read`, operator inherits the read floor), storage round-trip, and web tests (the Roles page renders and tier-orders the seeded roles). - **Estate slice: per-type location icons.** `location_type` gains an `icon` column (a glyph key, seeded `landmark` / `building` / `layers` / `door-open` for the four official types, default `map-pin`), projected on `GET /types/location`. The console resolves the key to an SVG and renders it as the leading glyph on every location in the tree, tinted the same hue as the type badge, so a campus reads differently from a building at a glance. The glyph rides a new reusable `leadIcon` slot on the shared `ListView`, and `resolveIcon` falls back to `map-pin` for a key the console does not know, so the API can introduce a type icon without a coordinated release. Proven by a storage round-trip (the icon survives upsert and idempotent update), a seed test (the four shipped keys), a real-binary API test (the icon travels the wire), and a client unit (the resolver and its fallback). Deferred: per-location icon override, an operator icon picker (needs a `location_type` write surface), and server-side key validation. - **Dev experience: an example estate on `make dev`.** Distinct from the three production seeding concerns the [storage](/architecture/storage/) page names (schema migrations, boot seed, one-time backfills), a **dev-only example seed**, applied by `make dev` and never in production. `internal/devseed` embeds a fixture (a multi-site estate of three campuses, `hq` / `east` / `airport`, three sign-in-able users, and their grants spanning `operator@all`, `viewer@hq` subtree, and `deploy@east` subtree-excl-root) and installs it through the [Storage Gateway](/architecture/storage/) on the same trusted direct-DB lane as `bootstrap`, exposed as `omniglass seed-dev`. It is idempotent (a re-run of `make dev` changes nothing: locations are skipped by name, users by their taken-username error), so a fresh console comes up populated instead of empty and nobody hand-creates rows to demo a feature. Proven by a pure fixture-shape unit test and a testcontainer integration test (run twice: thirteen locations across three campuses, three users with password credentials, three grants of the expected scope, all stable across the second run), plus the `seed-dev` command driven end-to-end against a real Postgres. The `/ship-slice` gate now requires any slice that adds a new operator entity to seed an example of it here. - **Identity: the auth event log.** The `audit_log` already recorded every privileged mutation (with `actor` and, for impersonated actions, `real_actor`); this adds the read surface and captures auth events. `GET /audit-log` (newest first, filter by resource / verb, backward-paged by `before`) resolves each actor and real-actor to a username, and **login / logout are now captured** (`resource = 'auth'`, via a non-transactional emit seam, since login is a read/no-tx path), and so are **failed sign-ins**: a wrong password on a real account is `login_failed` (attributed to that principal) and a correct password against a disabled account is `login_denied` (#97), while an attempt on an unknown username is not written (so scanning cannot flood the log). The console gains a read-only **Audit** page (`Settings > Audit`): every action and sign-in, with an `as ` tag on anything done while impersonating. It is **admin/owner-only**: `audit` is a sensitive resource, so a `viewer`'s `*:read` does **not** confer `audit:read`, only an explicit grant or `*:*` (a small `rbac` sensitive-resource carve-out, ADR-0014). Proven by an rbac unit test (the carve-out: `*:read` denied, `*:*` and explicit allowed), a storage round-trip, a real-binary API test (a login appears, a real-account failed login is attributed while an unknown-user attempt is not, a viewer is 403, the resource filter narrows, and an impersonated action carries `real_actor`), and web tests (the Audit page renders and marks impersonated rows). Deferred: a failed-login rate-limit counter. - **Identity: permissions are topic patterns.** The capability matcher became a consistent NATS-style topic match (ADR-0015, superseding the ADR-0014 carve-out): `*` matches exactly one token and `>` matches the tail, so a two-token pattern (`*:read`, `*:*`, `principal:*`) structurally cannot reach a three-token `:admin` permission, and admin-sensitivity is a deeper token rather than a special case. `audit:read` became the admin-sensitive `audit:read:admin` (the audit route requires it), `owner` became `>`, and the `sensitiveResources` set and `grantsAll` helper were deleted. `Set.Allows` matches by token (with the unchanged `:read` floor); `Set.Covers` (the impersonation and grant-escalation guard) became pattern subsumption plus the floor, staying conservative. The one seed change is `owner`'s `*:*` -> `>`; every other permission keeps its meaning (`*` already meant a single token). As a free consequence, `principal:*` no longer sweeps a future admin-tier `principal::admin`. Proven by an rbac unit matrix (`*` one token, `>` tail, `*:read`/`*:*`/`principal:*` miss `:admin`, `>` and explicit hit it, Covers subsumption), the whole authz suite (the viewer is still 403 on the audit trail, now structurally), and web tests (the Roles view renders `>` as an "everything" chip and marks an `:admin` tier). The console Roles view and grant-builder render the new grammar. Foundational for a custom-role permission preview (a permission catalog). - **Identity: the audit page joins the list-view standard.** The Audit page swaps its bare table for the console's shared **FilterBar** faceted search (the same one the inventory lists use): filter by `who`, `action`, `resource`, or `id`, chips combining to narrow, matched client-side over the loaded rows via the `lib/predicate` engine. **Load older** pages backward through the server `before` cursor, so a filter that comes up short is a cue to load deeper. Proven by a predicate unit test (each facet, within-chip OR and cross-chip AND, the sorted value catalog) and a component test (the FilterBar narrows the rows, and load-older asks for events older than the oldest loaded). Deferred: a chip time-range facet (the shared `matchOp` compares `gt`/`lt` numerically, so an ISO-timestamp facet needs a date-aware operator in the predicate primitive first). - **Console: the list surface splits into a shell plus swappable bodies.** `ListShell` owns the chrome every list wears (the FilterBar chip state and client-side predicate, the card, the error banner, a trailing action slot) and hands its body the filtered rows; `FlatList` is the flat body (a sortable table, an optional row-to-Drawer detail, an optional create Drawer, an optional footer). The Audit page becomes a thin `FlatList` config, behaviour-preserving. This is the primitive-first factoring of the four list idioms that had grown up (tree ListView, the Users split-panel, the Roles catalog, the Audit table): the tree pages move onto `ListShell` + a `TreeList` body, and Users onto `FlatList`, in follow-up slices; Roles stays the read-only catalog until custom-role CRUD. Proven by the Audit component test (unchanged behaviour on the new primitives) and the existing web suite. - **Identity: principal groups (grant-by-group), backend.** A `principal_group` plus membership, and a grant that targets a group (a nullable `group_id` on the one `principal_grant` table, exactly-one-of principal/group). A member's effective grants are its direct grants unioned with its groups' grants in the grant loader, the single seam both the permission flatten and the gateway scope resolution already read, so an inherited grant scopes and flattens exactly like a direct one and no other code changes. Group CRUD, member add/remove, and group-grant assignment ship on the Gateway and the API (`/principal-groups`), gated by a new `principal_group` capability (admin gets `principal_group:*`); a group grant reuses `principal_grant:create` and the same escalation cover-check. Proven against real Postgres: a storage integration test (a viewer @ hq granted to a group resolves into a member's read scope and permissions; removal and group-deletion drop it; dedup / duplicate-name / idempotent-add hold) and an HTTP end-to-end test (a no-access user gains read once added to a group holding viewer @ all and loses it on removal; management is `principal_group`-gated; an admin cannot group-grant owner). - **Collection slice (checkpoints 1-2): the storage tier and the node runtime.** The first cut of the [collection](/architecture/collection/) engine. Checkpoint 1 landed the storage tier: the `node`, `interface_type`, `interface`, `task`, `datapoint_type`, and `metric_datapoint` tables (an idempotent dbmate migration), the reachability `datapoint_type` canon and the `icmp`/`tcp` `interface_type`s seeded at boot, a scope-safe `metric_datapoint` write path, and the pure reject-not-project registry. Checkpoint 2 adds the [node](/architecture/nodes/) runtime: `omniglass server` hosts an in-process `nats-server` (JetStream enabled) and `omniglass node` enrolls (create, `POST /nodes/{name}:enroll` mints the token, `POST /nodes:claim` exchanges it for the NATS credential), then over NATS pulls its worklist (`og.v1.worklist.` request-reply, its enabled tasks plus a `config_generation`) and heartbeats (`og.v1.heartbeat.`, the server stamps `last_heartbeat_at`). **Per-node subject isolation is real**: an in-process auth callback scopes each node's NATS credential to its own subjects, proven by a negative integration test (node A cannot publish or pull as node B) against a real embedded server on an ephemeral port, plus a fake-seam unit test and the full enroll -> pull -> heartbeat round trip. A node is a first-class `principal` of `kind='node'` with a 1:1 `node` detail table (name is the estate address the collection FKs reference) and a bearer `credential` row, reusing the human/service identity machinery. Deliberate thin cuts ([ADR-0017](/architecture/decisions/)): the credential is an interim shared secret stored as a bearer credential (the enrollment token doubles as the NATS password; nkey/JWT and the single-use bootstrap deferred), and the control plane is JSON over core NATS. Deferred to later checkpoints of this slice: the probes (tcp, then icmp), the protobuf telemetry `Event` over JetStream, the ingest consumer, and the API/CLI/UI surfaces (interface and task CRUD, the Nodes inventory and reachability panels). - **Collection slice (checkpoint 3): the reachability datapoint, end to end.** The capability-primitive heart: `omniglass node` runs a real **tcp reachability probe** (`collection.NewTCPDialer`, closed over the socket boundary and proven by a real-socket integration test, not just a fake seam), ships the result as a protobuf `Event` over JetStream (`og.v1.telemetry.`), and the `tcp.open` / `tcp.connect_time` datapoints land in `metric_datapoint` owned by the target component. Protobuf is **new** to omniglass: `proto/og/v1/event.proto` (`Event` + `Datapoint`, no gRPC service), generated by a `gen-proto` stage on `make gen`. The server hosts an `OG_TELEMETRY` JetStream stream and a single durable consumer (`og-telemetry-worker`) that derives inline: it binds the owner **server-side** from the task's interface (the node stamps no component identity), **confines** a node to its own tasks (an Event carrying another node's `task_id` is orphan-dropped, no row written), applies **reject-not-project** (an unregistered datapoint name is dropped), and writes through the checkpoint-1 `InsertMetricDatapoints` path (`provenance=observed`). Both invariants are negatively tested against real Postgres + a real embedded bus, and the whole path is driven end to end through the real `server` and `node` binaries. Deliberate thin cuts ([ADR-0018](/architecture/decisions/)): the hot-path/async split collapses (the durable consumer is the at-least-once worklist, no raw-telemetry table or Postgres queue), and owner binding is the interface-prebind path only. Deferred: raw-`Event` persistence + replay/backfill, label-based multi-owner routing, the remaining probes (snmp, http), and the API/CLI/UI surfaces. - **Collection slice (checkpoint 4): the icmp (ping) probe, the second capability primitive.** `omniglass node` now also runs a real **icmp reachability probe** (`collection.NewICMPPinger`, unprivileged SOCK_DGRAM ICMP via `pro-bing`), emitting `icmp.reachable` (always present, `1`/`0` with a `reason` label) and `icmp.rtt_avg` (ms, absent when unreachable). It rides the checkpoint-3 pipeline unchanged: the same protobuf `Event`, JetStream stream, and owner-confining reject-not-project consumer carry it (the consumer does not branch on probe type). The capability question is the point of the primitive: a once-cached loopback self-check tells "this node cannot do ICMP at all" (an error, inconclusive, no datapoint) from "this target did not answer" (DATA: `icmp.reachable=0` with a down reason), and per the capability-wrapping doctrine a fake-`Pinger` unit test does not close the increment, a real-socket integration test (loopback echo + a TEST-NET-1 unreachable address where `received==0` is data, not an error) is the closing gate. - **Collection slice (checkpoint 5a): the reachability verdict as a state.** The first honest substrate for availability history. The node now computes the per-interface verdict `interface.reachable` (`up`/`down`, the AND of the interface's probe results) and emits it as a built-in **state** datapoint (seeded `datapoint_type` at `kind=state`, domain `up`/`down`), riding the proto `string_value`. A new `state_datapoint` table mirrors `metric_datapoint` (same owner exclusive-arc, same lineage CHECK) with a categorical `value text` plus optional `value_json`, and the Gateway gains `InsertStateDatapoints` / `LatestState` / `StateTransitions`. The ingest consumer now **routes by the registry kind** (metric to `metric_datapoint`, state to `state_datapoint`) after the **unchanged** owner-confinement and reject-not-project, so a foreign or unregistered state is orphan-dropped identically to a metric (negatively tested for the state path). 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), proven by a negative test that repeated identical verdicts produce one row per transition, not per tick. Availability as `time_in_state` over this state, and the operator surfaces that render the transitions, are checkpoint 5b. See [ADR-0034](/architecture/decisions/#adr-0038-the-reachability-verdict-is-a-built-in-state). - **Collection slice (checkpoint 5b): the reachability surface, seen.** The first datapoint read surface. A per-component read BFF `GET /components/{name}/reachability` (permission `component:read`, scope-injected, so a viewer gets a 404 on a component outside its scope) composes, per interface, the latest verdict (`LatestState` on `interface.reachable`), the layer signals (`LatestMetric` on `tcp.open` / `icmp.reachable` and their rtt), and the verdict's transition history (`StateTransitions` over a window). The operator console gains a **Reachability panel** on the component detail: one row per interface with a verdict pill (`responding` / `down` / `stale` / `unknown`, staleness derived at read time), an **availability strip** drawn from the state transitions (up/down over time, not a latency trend), and an expandable L3/L4 gate breakdown (ping and port, the inline probes this slice ships; the L7 app-layer gate lands with the snmp/http/ssh interface types) with the reason a down interface is down. It renders only real API fields. The availability-percentage SLI (`time_in_state` over the verdict) still rides in with the health slice; the panel shows the transitions directly. Interface and task authoring (the "add check" affordance and the flat Nodes / Interfaces / Tasks pages) are checkpoints 5c and 5d. - **Collection slice (checkpoint 5c): the Nodes surface and enrollment.** The first authoring surface for the [node](/architecture/nodes/) tier. The console `/nodes` stub becomes a live **Nodes** inventory (the flat sibling of the tree lists, a config over the shared `FlatList`): a row per collection daemon with a liveness **status pill** derived client-side from `last_heartbeat_at` against the server's down window (`OMNIGLASS_NODE_DOWN_AFTER`, 90s: `up` within it, `down` once stale, `never` before first heartbeat), the relative last-heartbeat time, and a description, with name and status facets on the same FilterBar the other lists use. A row opens a detail Drawer (facts plus an Enroll / Re-enroll action), and **New node** creates a node then enrolls it (day-one: create, then `POST /nodes/{name}:enroll`). The minted **enrollment token is a secret shown once**: a modal reveals it in a monospace field with a copy-to-clipboard button and a clear "shown once, cannot be retrieved again" warning, and the token lives only for the modal's lifetime (cleared on close, never written to the query cache, `localStorage`, or a log). The nav tab is gated on `node:read`, create on `node:create`, and enroll on `node:enroll` via the same `can()` the sidebar and route guard read; the page renders only real `NodeBody` fields. No API or engine change (the node routes already exist), so `make gen` shows no drift. Proven by vitest: the data layer (list-envelope unwrap, create body, the `:enroll` custom-method POST, the pure status derivation and its window boundary, the status facet catalog) and the page (a row per node with the derived pill, the create affordance hidden without `node:create`, and the enrollment modal revealing the token, copying it to a faked clipboard, and clearing it on close). The Nodes page stays `Partial`: placement, assigned-task worklists, and node health beyond the heartbeat pill are still Design. Interfaces and Tasks authoring is checkpoint 5d. - **Collection slice (checkpoint 5d-api): the interface and task authoring backend.** The operator CRUD over the two collection primitives that the reachability read and the node worklist consume. The Storage Gateway gains scoped `Create/List/Get/Update/Delete` for both `interface` (name, type, owning component, node placement, params) and `task` (a content-addressed id over interface + mode + spec, so identical work dedupes), auditing every mutation in-transaction like the estate tiers; the Huma surface adds AIP CRUD at `/interfaces`, `/interfaces/{name}`, `/tasks`, and `/tasks/{id}`, regenerated into the OpenAPI document, the cobra CLI (`omniglass interface …` / `omniglass task …`), and the typed SPA client. Both authorization layers apply on every route: an `interface:` / `task:` **permission** (admin gains `interface:*` / `task:*`, operator keeps `create,update`, the `*:read` floor gives everyone read), and **scope** injected by the gateway. Neither primitive is a scope-tree entity of its own, so scope **cascades through the owning component**: an interface inherits its component's read/action scope, a task inherits its interface's component's, reusing the component tier with no new `scope_kind` (an operator scoped to a component subtree administers exactly that subtree's interfaces and tasks). A component-less (server-hosted) interface is reachable only under an all scope. An interface or task whose component is outside the caller's scope is a **non-disclosing 404** on read and update, and a create under an out-of-scope component is a 403, negatively tested at both the gateway and the API. No web surface yet: the flat Interfaces / Tasks pages and the "add check" affordance are checkpoint 5d-ui. - **Collection slice (checkpoint 5d-ui): the interface and task authoring surfaces.** The console gains the two authoring pages the 5d-api CRUD feeds, plus the component-scoped **Add reachability check** affordance. The `/interfaces` and `/tasks` stubs become live **Interfaces** and **Tasks** inventories (configs over the shared `FlatList`, the flat sibling of the tree lists): Interfaces shows a row per endpoint (name, type, owning component, node placement, probed target derived from `params`) with name / type / component facets; Tasks shows a row per unit of work (display name or content-addressed id, interface, mode, an enabled pill, node) with interface / mode / enabled facets. A row opens a detail Drawer (facts plus an inline edit of the mutable fields and a delete), and **New interface** / **New task** open the create Drawer. Because there is no `interface_type` list endpoint, the type picker offers the built transports (`icmp`, `tcp`, `ssh`, `http`) this slice ships; a future `GET /interface-types` registry route can replace the static options. The **Add reachability check** button on the Reachability panel (gated on `interface:create` *and* `task:create`) authors a check the way the node runs one: it creates the interface (`type` = the chosen transport, **named by its protocol**, owned by that component, `params.target` the `host[:port]` the probe dials) then a **poll** task over it (`mode=poll`, enabled), then invalidates the reachability, interfaces, and tasks queries so the panel and pages refresh. The two writes are handled honestly: if the task create fails after the interface exists, the affordance surfaces the partial state (the interface is created; the operator retries just the task) instead of swallowing it. Every nav tab and action gates on the real permission via the same `can()` the sidebar and route guard read (`interface:read` / `task:read` tabs and URL guard; create / update / delete actions), and the pages render only real `InterfaceBody` / `TaskBody` fields. No API or engine change (5d-api already ships the routes and typed client), so `make gen` shows no drift. Proven by vitest: the two data layers (envelope unwrap, create / patch bodies, filter keys, the target derivation), both pages (rows, the create gate hidden without the perm, the type / mode / interface pickers), and the Add-check affordance (the gate needs both perms, submit fires the interface then the task create with the right bodies, and the two-step error path surfaces). This closes the collection slice's authoring surfaces. (Superseded before the PR: checkpoint 5f removes the Add-check affordance, and 5g folds the standalone Interfaces and Tasks pages into the component and node details and **derives** the task, so these standalone surfaces and the `task:create` gate do not ship; the flat pages became detail panels.) - **Collection slice (checkpoint 5e): the interface model reframe.** Before its PR, the interface model was corrected: an **interface is an API we intend to call** (named by the protocol it speaks, `web` / `qrc`, unique per component), **not** a network interface; `interface_type` is the **transport** (the reach gate), not the protocol driver. Reachability is the first gate of a ladder (reach, auth, responds, collecting). The built transports grew from `tcp`/`icmp` to also cover `ssh` and `http` (all reach by opening the tcp port; icmp pings), the Add-check picker separates the transport **type** from the protocol **name**, and the dev seed now models a lab **polaris DSP** with two protocol-named APIs on one device (`web` over http, `qrc` over tcp) so `make dev` shows the "two APIs on one box" story. The driver layer that turns a device API into a normalized menu of datapoints and functions (OIDs, commands, parse) is a separate, later concern, decided in [ADR-0035](/architecture/decisions/#adr-0039-an-interface-is-a-device-api-the-interface-type-is-its-transport-not-its-driver); this slice ships only the transport / reach tier of it. - **Collection slice (checkpoint 5f): reachability lands read-only; check authoring deferred.** Before the PR, the component-scoped **Add check** affordance was **removed**: it was a stopgap that a proper **driver**-based authoring flow (attach a driver to a component, fill its inputs) replaces, and the driver/collect layer is under active design (template-centric vs driver-centric), decided deliberately rather than on momentum ([ADR-0020](/architecture/decisions/) status note). So the reachability panel is now **read-only display**; a reachability check is authored by adding an **interface** to its component (checkpoint 5g settles the interface as the only authored primitive, its poll task **derived**, and folds the interface and task surfaces into the component and node details). The slice lands as **remote node reachability**: enroll a node, it probes, the operator sees per-interface reachability. The collect layer (drivers, the normalized menu, SNMP) is its own slice after the driver design. out of `TreeList` into a standalone `BladeStack` primitive over a `createBladeController`, and both the inventory tree and the flat identity pages (`FlatList`) now consume it. A stack entry is a cross-entity `{ kind, id }` ref against a registry, so a user's group opens the group's blade over it and a group's member opens that user's blade, both stacking. Depth is bounded by construction: each page roots one kind and drills one direction (Users: user to group; Groups: group to user; Roles: role), so the drill graph is acyclic and the reverse relation on a terminal blade is read-only. A shared `DetailShell` (`Fact`, `RelatedList`, `DetailActions`) standardizes the chrome, so a group's members and a user's groups render through one list idiom and every footer places the destructive action left of the primary. Roles moves from the card catalog onto the same `FlatList` + read-only blade. Behaviour-preserving for the inventory pages (the Locations blade is identical); proven by the web suite (the `BladeStack`, `DetailShell`, and both-way identity drill tests) and tsc. - **Console: the read -> Edit -> Save contract on a blade footer action bar.** Every detail blade opens read-only; the header is chrome only (back, full-page, close), and a `createEditSlot` per blade drives a sticky **footer action bar** that `BladeStack` renders: a destructive action on the left (always available), secondary actions in a `⋯` kebab, and Edit on the right (which swaps to Save / Cancel in edit mode). The body reads `useBladeEdit().editing()` to switch its sections read-only vs live and registers the whole footer through `bind` (`editable`, `save`, `cancel`, `destructive`, `secondary`), so editability and the destructive label follow the caller's permission and a read-only blade (a role) registers nothing and shows no bar. In edit mode the profile becomes inputs, member add / remove stages locally, and the `GrantBuilder` gains a `bind` so its diff commits as part of the one blade Save; the destructive action (Delete for a group, Disable / Enable for a user, per the accounts-never-deleted invariant) confirms and is reachable straight from read mode. Save commits the staged session together and Cancel reverts. A single consolidated audit event and a config restore point (one backend action per Save) remain deferred to a batched endpoint; the user archive / purge lifecycle is its own slice. Proven by the web suite (the edit-slot, the `BladeStack` footer-bar chrome, and the Group / User read-vs-edit tests) and tsc. - **Identity: the principal lifecycle (archive + purge), backend.** Beyond the reversible disable, a principal can be **archived** (a soft delete, `archived_at`: hidden from the directory, cannot authenticate, reversible) and then **purged** (an irreversible hard delete, gated on prior archival and on the admin-sensitive `principal:purge:admin`, so admin and owner can purge but a two-token `principal:*` cannot reach it). The purge preserves the audit trail: every `audit_log` row now denormalizes the actor's label at write time, the audit foreign keys are `ON DELETE SET NULL`, and the read coalesces the live join to the snapshot, so a purged actor's history stays legible ([ADR-0016](/architecture/decisions/#adr-0016-a-principal-can-be-purged-and-the-audit-trail-is-denormalized-to-survive-it), retiring the "never hard-deleted" rule). The last-active-owner guard extends to archive. Gateway (`ArchivePrincipal` / `RestorePrincipal` / `PurgePrincipal`), the `:archive` / `:restore` / `:purge` API custom methods, and the generated CLI / client ship; proven against real Postgres by a storage integration test (lifecycle + audit-actor survival + owner guard) and an HTTP end-to-end test (the capability split, the archive-before-purge gate, soft-delete visibility). The console UI for the lifecycle is a follow-up ([#143](https://github.com/hyperscaleav/omniglass/issues/143) backend). - **Identity: the principal lifecycle in the console, UI.** The user blade's footer action bar presents the lifecycle by state: the left slot is the reversible toggle (Disable / Enable, or Restore for an archived account), and the kebab holds the escalating red steps (Archive when live, Purge when archived and the caller holds `principal:purge:admin`), each confirmed. The detail fetches the principal by id (not the directory list, which hides archived) so a just-soft-deleted user stays resolvable, and a **Show archived** toggle on the Users directory (a new `include_archived` list param) surfaces hidden accounts to re-find one. `BladeSecondary` gains a red tone and the destructive slot a restore tone; the web `can()` already honours the three-token `principal:purge:admin`. This slice also **renamed the soft-delete verb deactivate to archive** (restore, not reactivate) so the ladder reads pause (disable) to remove (archive) to destroy (purge), instead of two pause-synonyms; the column, endpoints, capability, and list param follow. Proven by the web suite (the live and archived footer states and the show-archived flow) and a storage test for the include-archived list; completes [#143](https://github.com/hyperscaleav/omniglass/issues/143) / [#146](https://github.com/hyperscaleav/omniglass/issues/146). - **Identity: IAM console form + blade hardening, UI.** A polish pass over the identity surfaces. Removing a principal now closes its blade cleanly: **archiving**, **purging** (and a group **delete**) closes the blade first, drops the dead detail query, then refreshes the list, so the blade never lingers on a stale or 404 view of the entity it just removed from the working set. **Impersonation** (start and stop) now reboots the console to Home (`window.location`), so the permission-driven sidebar and route guard rebuild from the new identity rather than showing the previous principal's navigation. The forms gain **inline validation** that mirrors the server's Huma constraints: a username and a group name are lowercase handles (pattern `^[a-z0-9][a-z0-9._-]*$`, no capitals or spaces) and an email must be well formed (`format: email`), each enforced on the create body server-side and checked in the form so an invalid field shows an inline error and disables the submit before the round-trip (a new `valid` gate on the blade edit contract disables the footer **Save**). The group create Drawer title now reads **New group** (it leaked the `principal_group` resource key), the group create form gained the same subtitle the user form has, and the form placeholders are consistent across create and edit. A newly created **user or group** opens **directly in edit mode** so its grants (and a group's members, child resources that need the parent id) are added in one flow rather than a second step. Proven by an API validation test (a malformed username, name, or email is a 422; a valid one is accepted) and web tests (the blade closes without an orphan refetch, and an invalid field blocks create and the footer Save). - **Identity: the Groups console + inherited-vs-direct grants, UI.** A **Groups** page (`Settings > Groups`, gated `principal_group:read`) as a config over the shared `FlatList`: list and create, and a row Drawer with member add/remove and the grant builder (reused, wired to the group grant endpoints, so a group's members inherit what it grants). On the **user** detail, grants now split into **direct** (editable) and **inherited from a group** (read-only, tagged `from `), so it is clear where a user's access comes from; the principal grant body gained `group_id` / `group_name` for the distinction. Proven by the web suite and tsc, with the route guard and sidebar both gating `/groups` on `principal_group`. This completes grant-by-group ([#90](https://github.com/hyperscaleav/omniglass/issues/90)); dynamic membership and SCIM group mapping remain deferred. - **Identity: password policy + generator.** A single pure validator (`auth.ValidatePassword`) enforces a password policy on the API password surfaces: **create a user** and **self-service change-password**. The direct-DB break-glass lanes (`bootstrap`, `set-password`) are exempt (fully trusted, DB-access-gated, the recovery path). The policy is **at least 12 characters, not on an embedded common-password denylist, and not containing the username** (NIST 800-63B style: length and a blocklist over composition rules). The API bodies raise their `minLength` to 12 and map a violation to 422 with a specific message; the console mirrors the length and username rules inline (a new `passwordError`) and gates the submit, while the denylist stays server-side (a 422 on a manually typed common password). A shared **`PasswordField`** component adds a show/hide toggle and, on the New user and change-password forms, a crypto-strong **Generate** action (`generatePassword`, a readable charset with no look-alike characters, kept masked and copied or revealed on demand) plus a **Copy** button. Proven by an `internal/auth` unit test (short / common / contains-username / valid), an API test (a weak, common, or username-containing password is a 422 on create, a strong one is accepted), and web tests (the generator's length / charset / policy-compliance, `passwordError`, and the Generate-fills-and-enables flow). Completes [#104](https://github.com/hyperscaleav/omniglass/issues/104) and [#151](https://github.com/hyperscaleav/omniglass/issues/151); an operator-configurable policy and a HIBP breached-password check remain deferred. - **Identity: admin password reset.** An administrator can reset another user's password without the user's current one: `POST /principals/{id}:resetPassword` gated by a new `principal:reset-password` capability (all-scope; admin holds it via `principal:*`, so a future help-desk role can be granted only the reset), policy-enforced (a 422 on a weak password), refused on self (change your own password from your profile, which verifies the current one), behind the same **takeover guard** as impersonation (an owner cannot be reset by anyone, and a caller cannot reset a principal whose capabilities exceed their own, a shared `checkTakeoverGuard`, plus the shared `allScopeCovers` all-scope-only cover that act-as uses so a reset cannot promote a narrow-scope capability estate-wide), and audited with the admin as the actor (the storage method `SetPrincipalPassword` sets by id, distinct from the self-service `SetPassword` that keys on username and audits the target). It also **forces logout**: a reset revokes every one of the target's bearer credentials (all sessions and tokens) in the same transaction, so a compromised or departing account is cut off at once; the self-service change-password revokes the caller's OTHER sessions (`RevokePrincipalBearers` keeping the current session's hash, stashed in the request context by authn). The console adds a **Reset password** kebab action on the user blade that opens an inline panel reusing the `PasswordField` (Generate, Copy, inline policy check); the set password stays copyable to hand over. This is the console counterpart to the CLI `set-password` (which, as a trusted direct-DB lane, stays policy-exempt). Proven by a storage test (the new secret authenticates and the old does not, the reset is audited as the admin, non-all scope and unknown id are refused), an API test (an operator lacks the capability 403, a weak / common / username-containing password is a 422, resetting yourself is a 422 and an owner is a 403, the admin reset lets the user sign in with the new password), and web tests (the kebab opens the panel and Generate + Set password calls the endpoint and confirms; the action is hidden on your own blade). - **Identity: bearer session expiry.** Bearer credentials gain a nullable `credential.expires_at` (a new additive migration), and `AuthenticateBearer` refuses a credential whose expiry has passed (an expired session authenticates nothing). A web login installs its session cookie with a fixed absolute lifetime (12h), setting both the credential's `expires_at` and the cookie `Max-Age`, so a stolen session cookie is no longer valid forever; the `IssueBearerCredential` gateway method gained an `expiresAt` argument. CLI API tokens (`omniglass token`) and the bootstrap token pass a null expiry and do not expire, so automation keeps working. Proven by a storage test (a past-expiry bearer is `ErrCredentialNotFound`, a future or null one authenticates). A sliding idle timeout and a background sweep of expired rows are deferred refinements; the security floor (expired is refused at auth) lands here. - **Identity: login brute-force lockout.** Failed password logins are now throttled. Two additive columns on `human` (`failed_login_count`, `locked_until`) count consecutive misses on a real account and, on the 5th, lock it for 15 minutes. Inside the window `AuthenticatePassword` refuses every attempt (even the correct password) via a new `ErrAccountLocked`, which the login handler maps to the **same generic 401** as a bad credential so the lock is not an enumeration oracle (only the `login_locked` audit records it). The lock is decided after the argon2 verify (a locked account is not a faster probe), and a correct password below the threshold clears the counter. The decision itself is a pure, unit-tested function; proven end to end by a storage integration test (5 misses lock, the correct password is refused while locked, an expired window lets it through and resets) and a real-endpoint test (the lockout as seen over `POST /auth/login`). Per-IP throttling and a configurable threshold are deferred. - **Identity: force a password change after an admin reset.** An admin reset now sets a new additive `human.must_change_password` flag; the user's own change-password clears it. While set, the `authn` choke point (the same place view-as read-only is enforced) refuses **every** route with a 403 except reading their own principal and the change itself, so the admin-known secret cannot be used to act. `GET /auth/me` carries `must_change_password`, and the console's `AuthGuard` swaps the whole app shell for a forced change-password screen until it clears (a `useChangePassword` that invalidates `/auth/me` releases the gate on success). Proven by a storage test (a reset sets the flag, a self-change clears it), a real-endpoint test (a flagged user reads `/auth/me` but every other route is a distinct "password change required" 403 until the change, then the gate releases), and a web test (the guard renders the forced screen only when flagged). Enforcement is a hard block on all routes; the CLI break-glass lanes do not set the flag. - **Identity: address a principal by username or uuid.** Every `/principals/{id}` route (read, update, grants, the lifecycle verbs, reset, impersonate) now accepts either the principal's uuid or a human's current username. A new gateway primitive `ResolvePrincipalRef` resolves it (a uuid passes through unchanged, so an unknown uuid keeps its existing not-found handling; otherwise a username lookup, and an unknown username is a 404), and each handler resolves at the top, before any self-check (so addressing yourself by username is caught for a reset or impersonation). The uuid stays the stable identity; a username is a convenience address resolved at call time. Service principals have no username and stay uuid-addressed. This makes the CLI usable without a uuid lookup first (`omniglass principal archive alice`). Proven by a storage test (username resolves, a uuid passes through, an unknown username is not found) and an API test (get by username matches get by uuid, a `:verb` method archives by username, an unknown username is a 404). - **Tag: value-domain enums and existing-value autocomplete.** A tag key can now **constrain its values to an enum**, answering the enum half of the value-domain open question. A new **`allowed_values`** column on `tag` (empty = free text) is the value set a bound value must belong to; `SetTagBinding` enforces it (a non-member is a dedicated 422, `ErrTagValueNotAllowed`), so `environment` can be declared as one of `prod` / `staging` / `dev`. The Tags directory create and edit forms gain a value-domain control (a checkbox that turns a 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 autocompletes the **distinct values already in use** for it, via a new `GET /tags/{name}:values` read (`select distinct value`), so an operator reaches for an existing value without the key declaring a set. Pure `web/src/lib/tagdraft` helpers (`isEnumKey`, `valueOptions`, `valueAllowed`) and a pure `internal/tag` (`ValidateAllowedValues`, `ValueAllowed`) hold the logic. Proven by unit suites on both pure cores, a storage integration test (the enum admits members and rejects non-members, a free key admits anything, narrowing the enum is enforced on the next bind, the distinct-values query dedupes and sorts) and an HTTP e2e (the enum 422, the values endpoint, the round-trip). A typed `value_type` and input normalization stay deferred ([ADR-0024](/architecture/decisions/#adr-0024-a-tag-key-may-constrain-its-values-to-an-enum)). - **Tag: apply and remove tags from the entity detail blade (TagAdder).** The capability that closes the set-side of the tag console: an operator now binds and unbinds tag values from the UI, not only the CLI. Each component, system, and location detail blade carries a **Tags** panel listing the tags bound **directly** on the entity as removable colored chips, plus a staged **key -> value** add row. The key stage autocompletes the registry filtered by `applies_to` for the entity kind and by what is already bound (a pure `web/src/lib/tagdraft` core: `keySuggestions`, `exactKey`, `canCoin`, `valueValid`); with `tag:create`, a **Create key** affordance opens the Tags directory's create form ([#192](https://github.com/hyperscaleav/omniglass/issues/192)'s `CreateTagForm`, now exported) in a drawer and returns with the minted key selected. Writes are immediate (each is the entity's own `:setTag` / `:removeTag` write, gated by its `:update`), so there is no separate Save; the affordances hide without the permission. The resolved cascade (inherited tags) stays in the directory [Tags column](/guides/operator/inventory/), not the panel. Proven by a `tagdraft` unit suite (applies_to filtering, already-bound exclusion, exact-match and coin eligibility, value validity) and a `TagAdder` render test (chips, the update-gated add row and per-chip remove, the read-only and empty states). The full winner-plus-shadowed cascade provenance in the blade, the dynamic tag search facets, and a stored per-key color override are later slices ([#189](https://github.com/hyperscaleav/omniglass/issues/189)). - **Tag: the colored effective-tags column on the directories.** The first VISIBLE tag surface, consuming the batch resolver: Components, Systems, and Locations each gain a **Tags** column rendering their **effective** tags (the resolved cascade winners, not just direct bindings) as `key = value` pills. Color is **client-derived** and needs no backend: `tagHue(key)` (a pure `web/src/lib/tagcolor` FNV-1a hash into a curated, yellow-green-pruned 12-hue ramp) maps a key to a stable hue, so the same key is the same color everywhere; a shared `TagPills` component crosses only that hue into a new unlayered `.tag-pill` CSS recipe (text and outline are the seed, the fill is the seed at 15% via `color-mix`), with lightness and chroma in per-theme tokens so one hue reads correctly and stays contrast-safe in both themes. The chips stay on one line, fading at the edge when they overflow, and a portaled hover tooltip reveals the full wrapped set (a `wrap` prop on `TagPills` is the seam for a future per-table wrap toggle). The column registers in each page descriptor, so the columns menu shows, hides, and reorders it, and it sorts by key set. A stored per-key color override is a later slice; the type-to-add editor and tag search follow ([#189](https://github.com/hyperscaleav/omniglass/issues/189)). Proven by a `tagHue` determinism unit test (stable, in-ramp, spread) and a `TagPills` render test (sorted chips, the `--tag-h` per key, the empty dash), plus the page-descriptor conformance matrix. - **Tag: batch effective-tags on the directory rows.** The first slice of the tag-apply UI, backend only: the directory list routes (`GET /components`, `/systems`, `/locations`) now carry an **`effective_tags`** map (key to winning value, winners only) on each row, resolved for the whole page in one batched query per kind (`Gateway.EffectiveTags`), feeding the coming Tags column with zero per-row fetch. Effective resolution extends past the component: a **location** resolves the install-wide tier + its own location tree, and a **system** resolves it + its system tree + **the location it is placed at** ([ADR-0022](/architecture/decisions/#adr-0022-effective-tags-resolve-onto-systems-and-locations-a-placed-system-inherits-its-location)), so a system in a PCI building surfaces `compliance: pci`. Three per-kind recursive-CTE resolvers thread a target id through the ancestor chains and rank per (target, key); the resolver is scopeless by contract (the list already filtered the ids to the read scope, the rowActions batch pattern). Proven by a storage integration test (the four-band component cascade, the placed-system-inherits-location case, the non-propagating flat case, a batched shared-ancestor pair with no false cycle, and the empty/unknown edges) and an HTTP e2e (`effective_tags` on the component, system, and location list bodies). The Tags column, the type-to-add editor, and tag search follow in later slices ([#189](https://github.com/hyperscaleav/omniglass/issues/189)). - **Tag: the console key directory.** The [tag](/architecture/tags/) vocabulary gets its operator surface: a **Tags** directory under Catalog on the shared FlatList shell, where an admin mints a key (`tag:create`), edits its governance fields (applies_to and the cascade-versus-flat `propagates` toggle, `tag:update`), and deletes it (`tag:delete`, cascading its bindings). Rows address by the key **name** (the write paths key on the name), and the create drawer plus the edit blade reuse the estate's drawer and blade primitives, no fork. Binding a value onto an entity and the effective-tags cascade panel are the next console slice ([#189](https://github.com/hyperscaleav/omniglass/issues/189)). Proven by a data-layer unit test (the envelope unwrap, the create/update/delete request shapes, the error throw) and the full web suite, and live-verified against `make dev` (the directory, the create drawer, the edit blade). - **Tag: the governed label vocabulary and its cascaded bindings.** The [tag](/architecture/tags/) primitive lands as its own page: a governed **`tag`** key registry (a normalized lowercase-identifier vocabulary, minting gated by all-scope `tag:create`, broadened to `tag:*` for admin), a per-entity **`tag_binding`** cell owned on the same exclusive arc as a secret and a variable (`platform | location | system | component`), and a resolver that **unions keys and overrides values** most-specific-wins down the [cascade](/architecture/cascade/). The governance split is the point: minting a key is admin-curated, but **setting a value is the entity's own write** (`component:update` and friends), so an operator tags what it may already edit with no new grant; a platform-tier binding is `tag:update` plus `platform:update`. A key carries **`applies_to`** (an entity-kind allow-list, checked on bind) and **`propagates`** (cascade inheritance versus a flat per-entity set, the shape a [file](/architecture/files/) reuses). `GET|POST /tags`, `PATCH|DELETE /tags/{name}`, `POST /tags/{name}:setPlatform|:clearPlatform` (the platform-tier value), and per-entity custom methods `GET /{components,systems,locations}/{name}:listTags` and `POST .../{name}:setTag|:removeTag` (bindings are entity custom methods, like the principal lifecycle, so the generated CLI stays collision-free), plus `GET /components/{name}/effective-tags` (the per-component cascade). Deferred (ADR-0021): the console surface (a Tags directory and per-entity editor, [#189](https://github.com/hyperscaleav/omniglass/issues/189)), binding via [groups](/architecture/groups/) and a `template`-scoped binding ([#184](https://github.com/hyperscaleav/omniglass/issues/184)), value-domain governance ([#190](https://github.com/hyperscaleav/omniglass/issues/190)), and binding onto a file ([#191](https://github.com/hyperscaleav/omniglass/issues/191)). Proven by a pure `internal/tag` validation unit test (key normalization, value bounds, the applies_to allow-list), a storage integration test (the registry CRUD and all-scope gate, the applies_to and value gates, the binding upsert and the owner-update scope split, the full union-on-key / override-on-value cascade incl. the non-propagating flat case, and the delete-key cascade to bindings), and an HTTP e2e (the cascade over the wire and the mint-versus-bind authz split: an operator binds on its component but cannot mint a key nor bind on a system it cannot write, a viewer reads but cannot mint or bind). - **Variable: the cascade-resolved free value.** The second member of the [config, secrets, and variables](/architecture/variables/) trio lands, after the secret. A **variable** is a typed **plaintext** value (a macro) owned on the same exclusive arc as a secret (`platform | location | system | component`) and resolved most-specific-wins down the [cascade](/architecture/cascade/), but shown in the clear (no crypto, no masking, no reveal). Typing is **inline**: a `value_type` of `string` / `int` / `float` / `bool` / `json` plus a jsonb `value`, validated against the type in a pure `internal/variable` package (no shape registry, unlike the secret). `GET|POST /variables`, `PATCH|DELETE /variables/{id}`, and `GET /components/{name}/effective-variables` (the per-component cascade view, **since retired**, [#281](https://github.com/hyperscaleav/omniglass/issues/281)). Reads ride the viewer floor; **create and update are granted to operators** (`variable:create,update`), delete stays `variable:delete` (admin, owner), the same split the secret got. Scoping a variable required wiring `variable` into the ABAC resolver's owner-arc tiers, exactly as the secret did. The console adds a **Variables** directory (type in its own column) with a type-aware value editor; a per-component **effective-variables** cascade panel also shipped and was **later retired** ([#281](https://github.com/hyperscaleav/omniglass/issues/281), the panel-retirement note below). Deferred (ADR-0020): the **`template`** owner scope and cascade groups ([#184](https://github.com/hyperscaleav/omniglass/issues/184)), a `variable_type` registry (types are inline), the **`$var:`** interpolation consumer, and the secret-flagged variable. Proven by a `value_type` validation unit test (each scalar's valid and invalid forms), a storage integration test (jsonb round-trip per type, full cascade precedence, the owner-scope split), an HTTP e2e (the cascade, the authz split incl. the scoped-operator create / update and the viewer 403s), a scope-resolver unit test for the owner-arc kinds, and the web data-layer suite. - **Secret: the cascade-resolved encrypted value.** The first of the [config, credentials, and variables](/architecture/variables/) trio lands, and the prerequisite for the [collection](/architecture/collection/) driver's interface inputs ([#155](https://github.com/hyperscaleav/omniglass/issues/155)). A **secret** is a typed, encrypted-at-rest value owned on the exclusive arc (`platform | location | system | component`) and resolved most-specific-wins down the [cascade](/architecture/cascade/). Its shape is a `secret_type` registry (per-field secrecy and origin; `snmp-community` and `basic-auth` seeded); crypto is **envelope AES-256-GCM** behind a pluggable KEK provider (env / file / fallback), with `(owner, name, field)` bound as AAD so a ciphertext cannot be lifted between rows. The load-bearing new piece is the **cascade resolver**: it walks the three owner trees up from a component, tags each owner with a band and depth, and ranks per name (highest tier, then deepest), returning the winner and the shadowed candidates. `GET /types/secret`, `/secrets` (all-scope list, create, update, delete), `GET /components/{name}/effective-secrets` (the masked cascade view, **since retired**, [#281](https://github.com/hyperscaleav/omniglass/issues/281)), and the audited decrypts `POST /secrets/{id}:reveal` and `:copy` (a clipboard copy, recorded under a distinct `copy` verb). Masked reads ride the viewer floor; **create and update** are gated by `secret:create` / `secret:update` and granted to **operators** in their scope; **delete** stays `secret:delete` (admin, owner); **reveal** and **copy** by the sensitive `secret:reveal`, which the `*:read` floor does not carry, so only admin (`secret:*`) and owner may decrypt. Scoping a secret required wiring `secret` into the ABAC resolver's owner-arc tiers (location / system / component), so a scoped grant now confers secret scope over its subtree (before, only an all-scope owner could). The console adds a **Secrets** directory (type in its own column) with per-field in-place reveal + copy adornments; a per-component **effective-secrets** list also shipped (each resolved secret opening a blade that decrypts the value and shows the full cascade top-to-bottom) and was **later retired** ([#281](https://github.com/hyperscaleav/omniglass/issues/281), the panel-retirement note below). Renamed **credential to secret** (ADR-0017). Proven by a real-crypto testcontainer test (seal, jsonb round-trip, unseal; encrypted-at-rest; full cascade precedence), an HTTP e2e (the cascade, the authz split incl. the scoped-operator create / update, and the reveal / copy round-trips plus their 403s), a scope-resolver unit test for the owner-arc kinds, and the web data-layer suite. Deferred: the **variable** and **config** members of the page, the interpolation consumer, secret **lifecycle** (oauth2 refresh, rotation), and groups / weighted precedence in the cascade. - **Identity: clear the login lockout on a password rotation.** Rotating a password now clears the brute-force lockout (`failed_login_count = 0`, `locked_until = null`) in the same transaction as the new secret. Before this the lock (from the login-throttle slice) only cleared lazily at the next login, so an admin reset left the account locked for the rest of the 15-minute window even with the new password. Both rotation lanes clear it: the admin reset (`SetPrincipalPassword`, the reachable intervention while an account is locked) and the self-service change / CLI set-password (`SetPassword`). No new endpoint or capability: rotating the secret is the intervention (auto-unlock over a dedicated `:unlock` action). Proven by a storage test that locks the account with five misses, confirms it is locked, rotates via each lane, and asserts the new secret authenticates immediately with no wait. No migration (the columns exist); no operator-facing surface change. - **Identity: profile pictures for human principals.** A human principal can carry a **profile picture**, managed on two lanes: **self** (any signed-in user sets or removes their own via `POST /auth/me:setAvatar` / `:removeAvatar`, authn-only and self-scoped, no capability, on the ungated self-service lane) and **admin** (a new `principal:set-avatar` all-scope capability sets or removes any principal's via `POST /principals/{id}:setAvatar` / `:removeAvatar`, audited with the admin as actor; `admin` holds it through `principal:*` and `owner` through `>`, so no `roles.yaml` change). A pure `internal/avatar.Normalize` primitive is the server-authoritative pipeline: accept JPEG/PNG/WebP (GIF and all else rejected), reject a payload over 8 MiB or any dimension over 8000px (decompression-bomb guards), center-crop to a square, resize 256x256, re-encode JPEG q82; a bad or oversize image is a 422. Two additive columns on `human` (`avatar` base64, `avatar_updated_at`) store the one normalized size; the bytes never load on the `loadPrincipal` hot path (which selects only `avatar is not null`), so a cheap `has_avatar` bool rides the read models (`GET /principals/{id}`, the Users list, `GET /auth/me`). The **read endpoint is JSON** (`GET /principals/{id}/avatar` gated `principal:read`, `GET /auth/me/avatar` self), returning `{ image_base64 }` the console renders as a `data:` URL, deliberately not a raw `image/jpeg` handler so every route stays under the Huma authz middleware ([ADR-0018](/architecture/decisions/#adr-0018-the-avatar-read-endpoint-is-json-not-raw-image-bytes)); a principal without a picture is a 404. The console **Profile** page gains an image-backed avatar with upload/remove (initials fallback), and the **Users** directory renders per-row thumbnails plus an admin upload/remove panel gated on `principal:set-avatar`. The CLI and typed client fall out of the generator (`omniglass principal setAvatar` / `removeAvatar`). Proven by the `internal/avatar` golden-fixture unit suite (a square JPEG out, non-image / GIF / oversize rejected), a storage round-trip (set / get / clear, `has_avatar` and `avatar_updated_at`, the all-scope gate on the admin write), and a real-binary API test (self set + read + remove, garbage 422, the admin permission 403 without `principal:set-avatar` and success with), plus web tests for both surfaces. Deferred: the top-nav avatar, non-human avatars, multiple sizes / srcset, and external (Gravatar) sources. - **Identity: view and revoke your own sessions (slice 1 of 2).** A signed-in user can now see and end their own sign-ins and API tokens. Two gateway primitives land on the existing `credential` table (no migration; `created_at` and `expires_at` already exist): `ListBearerCredentials` returns each of a principal's bearer credentials with only non-secret metadata (id, `ogp_` prefix, created, expiry) and never selects the raw `secret_hash` into a returned field (the request's own `sha256(token)` is compared in SQL to flag the `current` one, so the hash never leaves the database), and `RevokeBearerByID` deletes one **scoped to the owning principal** so a caller can only revoke their own. Over the API, `GET /auth/me/sessions` lists them (labelled `session` or `token` by the credential's `purpose`, the current one flagged) and `POST /auth/me/sessions/{id}:revoke` ends one (204); both are authn-only and self-scoped, so a credential id belonging to another principal is a non-disclosing 404, and revoking the current credential signs it out. The console's **Your profile** gains a **Sessions** card listing each credential with a **Revoke** action (**Sign out** on the current one), through the generated typed client and TanStack Query. The generated CLI gains `omniglass session list` / `session revoke `. Proven by a storage test (list returns both with metadata and no secret and the current flag, revoke by id scoped to the principal, a cross-principal or malformed id is a no-op) and a real-binary API test (the current session is flagged, a second session is revoked and stops authenticating, another principal's id is a 404, and revoking the current one signs out). The **admin** surface (an operator revoking another principal's sessions) is slice 2. - **Identity: every credential is time-bounded, and sessions split from API tokens.** Building on the slice above, the tokens-never-expire behavior is reversed ([ADR-0019](/architecture/decisions/#adr-0019-every-credential-is-time-bounded-token-purpose-not-expiry-shape)): a web-login **session** keeps its 12h absolute lifetime, while a CLI/API **token** (`omniglass token`) and the **bootstrap token** (`omniglass bootstrap`) now get a **90-day default** expiry with a `--ttl` override hard-capped at **365 days** (a `--ttl` above the cap is a clean error, before any DB work), so no eternal secret sits in the field. Because both kinds now carry an expiry, a new **`credential.purpose`** column (`session` / `token`), not the nullable `expires_at`, is the discriminator; a migration adds it and backfills existing bearers (expiry set to `session`, else `token`). `IssueBearerCredential` and `BootstrapOwner` take the purpose and expiry; `ListBearerCredentials` returns the purpose and now filters to **live** rows only (`expires_at is null or expires_at > now()`, mirroring `AuthenticateBearer`), so a dead credential is never listed. The console's **Your profile** splits the one list into a **Sessions** section and an **API tokens** section, both rendering a shared `SessionsList` primitive, and a token now shows its expiry. Enforcement stays **lazy** (an expired row is refused at auth, no background sweep). Proven by a storage test (the list carries the right purpose and excludes an expired row), an updated real-binary API test (session vs token asserted via purpose, the minted token carries a future expiry), and a CLI unit test (a `--ttl` above the 365-day cap errors for both `token` and `bootstrap`). Deferred: a sliding idle timeout, a housekeeping sweep of long-expired rows, and nearing-expiry notifications. - **Identity: an admin can view and revoke a user's sessions (slice 2 of 2).** An administrator can now see and end **another** principal's sign-ins and API tokens, so a lost laptop or a leaked token is cut off without resetting the account. No new storage: it reuses slice 1's `ListBearerCredentials` (passing a nil `currentHash`, so no row is ever flagged `current` when viewing someone else) and the principal-scoped `RevokeBearerByID`. A new **`principal:revoke-session`** capability (a normal two-token permission, held by `admin` and `owner` through their `principal:*` / `>` wildcards, kept separable for a future help-desk role) gates both `GET /principals/{id}/sessions` and `POST /principals/{id}/sessions/{sid}:revoke`. The revoke is bounded to the target (a `sid` that is not theirs is a non-disclosing 404), sits behind the same **takeover guard** as impersonation and the password reset (an owner's sessions cannot be revoked by a lesser admin, nor a principal whose capabilities exceed the caller's), and is **audited with the acting admin as the actor**. The console's **Users** blade gains **Sessions** and **API tokens** sections (a shared `SessionsList` now backs both them and the self-service Profile card), hidden unless the caller holds the capability, with a **Revoke** per row. The blade's action-rail kebab also offers **Revoke all sessions** and **Revoke all tokens** (each confirmed), bulk-ending one purpose at a time through a new `POST /principals/{id}/sessions:revokeAll` backed by a purpose-filtered `RevokeBearersByPurpose` (revoking sessions never touches tokens), returning the count and under the same gate, takeover guard, and audit as the single revoke. The generated CLI gains `omniglass principal sessions ` / `principal revoke-session ` / `principal revoke-all-sessions ` (the cligen `nameOverride` seam groups them under `principal` so they do not collide with the self-service `session` commands, which share the leaf noun). Proven by real-binary API tests: an admin lists a target's sessions and revokes one so it stops authenticating, bulk-revokes all sessions (both cookies stop authenticating, the tokens survive) then all tokens, an operator is 403, revoking an **owner's** session or bulk-revoking its tokens as a lesser admin is 403 (the takeover guard), a `sid` that is not the target's (or an unknown principal) is a 404, a bad purpose is a 422, the secret never appears, and the revoke is audited with the admin as actor. - **Identity: a password change keeps API tokens, and self-service bulk revoke.** Two refinements to the session/token model. First, a password change now revokes **sessions only, never tokens** ([#194](https://github.com/hyperscaleav/omniglass/issues/194)): a token is its own bearer secret, not tied to the password, and has its own revoke surface, so both the admin reset (`SetPrincipalPassword`, now `purpose = 'session'`) and the self-service change (`RevokeBearersByPurposeExcept(pr, "session", keep)`, keeping the current session) leave the target's tokens intact. Second, the self-service **Profile** gains **Revoke all** on each of its Sessions and API tokens sections ([#195](https://github.com/hyperscaleav/omniglass/issues/195)), through a new authn-only, self-scoped `POST /auth/me/sessions:revokeAll` (a `{ purpose }` body) that ends all of one kind at once but **always keeps the credential making the request**, so a user is never signed out of the one they are on; the generated CLI gains `omniglass session revoke-all`. Both are built on one shared primitive, `RevokeBearersByPurposeExcept` (the plain `RevokeBearersByPurpose` becomes its nil-keep case, and the now-unused `RevokePrincipalBearers` is retired). The admin blade also stops offering revoke on an **owner** target (whom the takeover guard makes un-revocable): its session/token list renders read-only, with a line explaining an owner can be seen but not ended. Proven by a storage test (the keep-current filter revokes the others and keeps the current + the tokens), an updated real-binary API test (a password change and an admin reset both leave the token authenticating while the sessions die), a new self-service bulk-revoke API test (all sessions except current, then all tokens, a bad purpose 422), and web tests (the Profile Revoke all posts the purpose, and an owner target hides every revoke). - **Identity: break-glass `set-password` locks out live sessions.** The direct-DB `omniglass set-password ` (the trusted recovery lane, and the **only** way to touch a compromised **owner**, whose credentials the API guards leave un-revocable) previously rotated only the password, so a stolen session cookie or API token kept working after the reset ([#198](https://github.com/hyperscaleav/omniglass/issues/198)). It now also revokes the target's live **sessions** (a break-glass is a lockout), and revokes its API **tokens** too with `--revoke-tokens` (kept by default, matching the password-change-keeps-tokens rule; a full compromise wants the flag). It reuses the `RevokeBearersByPurpose` primitive after `SetPassword`, resolving the principal by username; no API or console change (break-glass stays behind Postgres access). Proven by a real-Postgres CLI test: a live session stops authenticating after the reset, the token survives without the flag and is revoked with it, and the new password authenticates while the old one does not. - **Identity: self-service tokens, token descriptions, and session identification.** Three coupled additions to the bearer-credential model. A signed-in user can **mint its own** API token from the console (a **Create token** action on the Profile API tokens section) or the API (`POST /auth/me/tokens`, authn-only and self-scoped): a **required description** and an optional `ttl_days` (default 90, capped at 365), returning the secret **once** ([#204](https://github.com/hyperscaleav/omniglass/issues/204), [#205](https://github.com/hyperscaleav/omniglass/issues/205)). A token now **must** carry a description (the CLI `omniglass token` gains a required `--description`, the bootstrap token gets a default, a session leaves it empty), so a user can tell tokens apart. And every credential records the **device and address** that created it: three additive `credential` columns (`description`, `user_agent`, `client_ip`), captured by a middleware before Huma so both login and the token mint stamp them, plus the existing `last_used_at` now **bumped on authentication** (throttled to the minute). The session and token lists (self and admin) show a **device label** (a coarse User-Agent parse, `Chrome on macOS`; `CLI / API` for a token), the creating **IP**, and a **last active** time; `IssueBearerCredential` is struct-ified to a `BearerIssue` to carry the metadata. Location from the IP (a GeoIP lookup, [#193](https://github.com/hyperscaleav/omniglass/issues/193)) is deferred: v1 is IP and User-Agent only. Proven by a storage test (the identity fields round-trip and `last_used_at` bumps on auth), a real-binary API test (self-mint returns the token once, it authenticates and lists as a described token, a blank description or over-cap ttl is a 422), a `deviceLabel` unit test, and a Profile web test. - **Identity: the Users, Roles, and Groups directories move to the admin tier.** The directory reads of `principal` (list, get, and the profile-picture route), `role`, and `principal_group` are promoted 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 pages, on the console or the API. This supersedes the earlier `role:read` / `principal_group:read` / `principal:read` gates named in the slices above. `admin` carries an explicit `:read:admin` alongside its wildcards, the same shape as `principal:purge:admin`; `owner`'s `>` is unaffected, and create, update, and the lifecycle verbs stay two-token. Proven by a real-binary API test (a viewer@all 403s on `/principals`, `/roles`, `/principal-groups`; admin and owner 200), the nav filter's web test (the three tabs hidden from `*:read`, shown to admin), and rbac matcher cases ([ADR-0023](/architecture/decisions/#adr-0023-the-iam-directory-reads-principal-role-principal_group-are-admin-tier)). Secrets, which an operator legitimately reads in scope, are handled by a later slice. - **Secret: admin-sensitivity and a scoped, sensitive-off-the-floor directory.** The second half of the visibility rework, for secrets. Two axes now decide who reaches a secret ([#210](https://github.com/hyperscaleav/omniglass/issues/210)): **placement scope** (unchanged) gives locality, and a new per-secret **`admin_sensitive`** column flips a secret to the **`:admin` tier**, so a platform credential (a Zoom or Microsoft client secret) stays admin/owner-only even at the same scope as an operational device secret. The type carries a **`default_admin_sensitive`** that seeds the create form (a new `oauth2-client` type defaults sensitive, `snmp-community`/`basic-auth` operational). `secret` also joins a **sensitive-resource set** a bare `*` does not reach, in both the direct match and the `:read` floor (Go rbac and the client `can()`), so a `viewer` (only `*:read`) reads no secrets while `operator`/`deploy` gain a scoped `secret:read,reveal,create,update`; `admin`'s `secret:*` becomes `secret:>`. Enforcement is a `canAdmin` capability computed at the API and passed to the Storage Gateway: the `/secrets` directory is now **scope-filtered** and hides admin-sensitive rows, and reveal/update/delete of a hidden secret is a **non-disclosing 404**; creating an admin-sensitive secret needs the admin tier. Proven by a real-binary API test (an operator sees and reveals its in-scope device secret but a non-disclosing 404 on the admin-sensitive one and an out-of-scope one, and is 403 creating a sensitive one; a same-scope admin and the owner see and reveal it), storage integration tests (the scoped list, the seeded type defaults), rbac matcher and covers cases, and the nav/`can()` web tests (Secrets hidden from `*:read`, shown to an explicit `secret:read`). The move of Secrets, Variables, and Config into Catalog is a separate branch ([ADR-0025](/architecture/decisions/#adr-0025-secret-is-a-sensitive-resource-a-per-secret-admin_sensitive-flag-flips-a-secret-to-the-admin-tier)). - **Tag: filter the directories by their effective tags.** The last major tag piece, closing the set-and-see loop's read half: the Components, Systems, and Locations chip filters gain a single **tag** field that discloses one facet per tag key in use, so an operator narrows a directory by any tag through one guided step (**tag**, then the key, then the value) rather than a top-level field per key. A tag facet reads a row's **effective** value (a component matches on a tag it inherits from its system or location, not only a direct binding), autocompletes the values already in use for that key, and offers two new **value-less** operators, **is set** (`?`) and **is absent** (`!?`), that test only whether the tag is present. These land in the shared [`lib/predicate`](/guides/operator/inventory/#filter) engine (an `exists` / `absent` `OpKey` carrying a `valueless` flag, threaded through `opsFor`, `matchOp`, `buildPredicate`, and `tokenToChip`) so every FilterBar inherits them, plus a `tagFilterKeys` helper that projects one `FilterKey` per tag key present on the loaded rows; the FilterBar keeps those presence facets out of the top-level field list and groups them under the `tag` entry (a direct `key:` still works as a fast path). `ListConfig.filterKeys` becomes **accessor-reactive** (a `FilterKeys` is an array **or** an accessor, resolved in a reactive scope), so the facet set appears the moment the effective tags resolve and tracks whatever the estate is tagged with; each page merges its static facets with a `tagFacets` memo derived from the rows. Deferred: server-side tag filtering at pagination scale (today the match is client-side over the loaded rows) and a stored per-key color override ([#226](https://github.com/hyperscaleav/omniglass/issues/226)). Proven by a `lib/predicate` unit suite (the presence operators, the per-key facet projection and its dedup against the static keys, the value-less tokenizing) and `FilterBar` render tests (the `tag` group discloses its keys, and a `?` / `!?` token commits a value-less chip that renders with no value button). - **Estate: the `type` capability resource gates the location/system/component type registries.** The three classifier registries (`location_type`, `system_type`, `component_type`) gain full CRUD behind one new, capability-only, unscoped **`type`** resource, replacing the borrowed `location:read` gate on `list-location-types`. `type:read` needs no new grant (already covered by `viewer`'s `*:read` floor); `type:create,update,delete` is granted to `admin`. A shared storage primitive (`typeregistry.go`) carries the sentinels and the delete guard: an `official` (seed-owned) row is read-only (422 on update/delete), and a row still referenced by a location, system, or component is refused on delete (409, a Gateway pre-count backstopped by the parent foreign key's `NO ACTION`). `system_type` and `component_type` also gain their first `list` route (previously list-only for `location_type`, absent for the other two), retiring the console's fake-from-existing- rows type picker workaround. `secret_type` is untouched (list-only; its fields-schema editor is a separate follow-up). Proven by a storage round-trip per registry (create, duplicate id 409, update, official read-only, in-use delete refused) and a real-binary API test per registry (the CRUD lifecycle, 422 official, 409 in-use, auto-discovered 403 without `type:*` by the route-gating guard). The generated CLI and web client ship in the same slice (`make gen`). Deferred: the Types catalog console page (segmented tab per kind, CRUD drawer over the three writable kinds plus a read-only view of `secret_type`) is a follow-up slice. - **Console: nav IA rework, estate values get their own group and Settings becomes Admin.** The sidebar's `nav.ts` gains a new **Values** top-level group (Variables, Secrets, Config), the estate-attached values that used to sit under Settings, standing beside **Inventory** (Components, Systems, Locations, and **Nodes**, the collection daemons) rather than nested inside it. Interfaces and Tasks are dropped from the nav: an interface is a panel on a component and a task is a panel on a node, both future work, not directories of their own. The Settings group is renamed **Admin** (Users, Roles, Groups, Audit) and gains an ungated **soon** Settings leaf reserving the platform-preferences page (severity scales, schedules, retention, defaults) ([#222](https://github.com/hyperscaleav/omniglass/issues/222)). Routes stay flat and unchanged (`/secrets`, `/variables`, `/config`, `/nodes`, the new `/settings` stub), so only the grouping and labels move; this supersedes the earlier same-day plan to move those values into Catalog ([decision log](/architecture/decisions/)). Proven by `nav.test.ts` (the Inventory and Values group order and labels, Admin's renamed label and its soon-stub surviving for both an owner and a bare `*:read` viewer, the moved entries keeping their existing gates) and a `Sidebar.test.tsx` render test (the Inventory and Values groups actually render in the expanded submenu). - **Console: the Types catalog page.** The UI follow-up to the type registry CRUD API above: **[Catalog > Types](/guides/admin/types/)** (`/types`) is a **segmented tab control** over all four classifier registries (`location_type`, `system_type`, `component_type`, `secret_type`), the first catalog page to span several registries in one page rather than a directory per primitive. Each tab (Location, System, Component, Secret) renders its own `FlatList` directory scoped to that kind's rows, so the tab is the facet: no cross-kind table and no kind filter to type in. Name and official/custom still narrow the active tab, and rows are addressed `:` on the shared blade stack since an id is unique only within its own kind. The create drawer opens scoped to the active tab when it is a writable kind (location, system, component); an **official** (seed-owned) row and every row on the Secret tab render with neither Edit nor Delete, the Secret tab instead showing each row's declared fields (name, scalar type, secrecy, origin) read-only, with a note that the fields-schema editor is a follow-up. The page reuses the shell built for the identity surfaces (`FlatList`, `BladeStack`, the blade edit contract) rather than a bespoke layout, so a fifth classifier registry is a `TYPE_KINDS` entry and a route, not a new page shape. Proven by a web data-layer unit suite (`lib/types.ts`): the four-registry aggregation tags each row's kind, a location row carries its icon and a secret row its fields, and create/update/delete each refuse the `secret` kind without a network call. Deferred: the `secret_type` fields-schema editor (noted above) and a page-level component test of the blade CRUD flow. - **Inventory: create-as-route and the read-only view invariant.** Creating a component, system, or location no longer returns you to the list. `New` navigates to `//create`, a **draft accordion** (Identity and Placement writable, the Tags section locked until the entity exists); **Save** commits the row and hands off to `//` in **edit mode** via a one-shot pending-edit flag, so you tag and finish configuring in place. The detail is one accordion, **read-only in view and the sole writer in edit**: the own-field inputs and the `TagAdder` write controls appear only in edit (the footer Edit/Delete and the read-only effective-secrets/variables panels, both since retired ([#281](https://github.com/hyperscaleav/omniglass/issues/281)), were exempt), which retires the old drawer-reopen edit and the mutation-in-view. This is the Users inline-blade-edit model generalised to inventory, on both the docked blade and the full page ([ADR-0027](/architecture/decisions/#adr-0027-create-is-a-route-inventory-create-and-edit-unify-on-the-detail-accordion), [#231](https://github.com/hyperscaleav/omniglass/issues/231)). The shared `TreeList` gains a per-surface edit slot on `ListCtx` (the full page makes its own, since the shared `renderDetail` must not call `useBladeEdit` outside a blade provider), plus `renderCreate` / `onNew` / `onEdit` hooks and an optional `FormBody`. Proven by the live console (draft to create to edit hand-off, then a read-only fresh visit) and per-page web tests for the three entities (the draft route renders, the view has no tag-add control). Deferred: a shared cross-page form shell (slice 2) and moving the Users page onto it (slice 3). - **Estate: `rank` retired from the type registries; alphabetical sort.** `rank` was sort-only (no nesting enforcement, per its own seed comment) on `location_type`, `system_type`, and `component_type`; a new dbmate migration (`ALTER TABLE ... DROP COLUMN IF EXISTS rank`, idempotent) drops it from all three tables, and `ListLocationTypes`/`ListSystemTypes`/ `ListComponentTypes` now `ORDER BY display_name, id`. The field is gone from the three type bodies, the boot-seed YAMLs, the generated client and CLI, and the [Types catalog](/guides/admin/types/) page (no Rank column, no Rank field on create or edit). This is the mechanical precursor to `allowed_parent_types` (placement constraints on `location_type`), which lands as its own slice ([#239](https://github.com/hyperscaleav/omniglass/issues/239)). Proven by the storage round-trip per registry (alphabetical order, not insertion or id order), the boot-seed idempotency test (the lowest-alphabetical official row per registry), and a web test suite update (no Rank fixtures, no Rank assertions). - **Estate: technical-name rename with an inline name check.** A component, system, or location's technical name (its `name`, the URL address) is now editable from the detail accordion in edit mode rather than fixed at creation. Because `id` (uuidv7) is the identity and every foreign key, tag/variable/secret binding, and placement references that UUID, a rename is a one-column update with no cascade. A shared `ValidateEntityName` slug rule (`^[a-z0-9][a-z0-9-]*$`) is the server source of truth, mirrored client-side, and now gates create as well as rename. An inline **Check** button calls a collection-level `POST /:checkName` that reports `{valid, available, reason}`; availability is deliberately **scope-blind** to match the global unique constraint (a scope-aware check would false-positive on a name held outside the caller's scope) and is gated by `:update`. The check is advisory: Save stays enabled and the unique constraint (409) is the real gate. `make gen` carries the new field and method through to the typed client and CLI. Proven by the rename round-trip per entity (a child or binding's UUID foreign key still resolves after the parent renames, and the old name frees for reuse), dup-name 409, bad-slug 422 at both create and rename, the three checkName states, and a scope-blind test (a `deploy` principal sees an out-of-scope name as taken, not available), plus per-page web tests for the editable field and the read-only view ([#245](https://github.com/hyperscaleav/omniglass/issues/245)). Deferred: an old-name to id redirect for bookmarked links (a soft 404 today), and wiring the check into the create draft's live validation. - **Files: the content-addressed blob store and the file handle.** The first [files](/architecture/files/) slice. A **`blob`** store lands as a Storage Gateway **primitive**: a `blob.Store` seam with a default **pgblobs** backend (bytes held inline in Postgres), keyed by the **sha256** of the bytes so identical bytes **dedup** to one row (`on conflict do nothing`), integrity-verified on read. On top of it, the **`file`** handle, searchable metadata (name, content_type, size, sha256, sensitive) that points at a blob by hash, gets CRUD over the API: **create-from-upload** hashes and dedups server-side, plus get, list, **download** (bytes read back and hash-verified), and delete (which **frees the blob** in the same transaction when no other handle still references it, dedup-aware, so storage is reclaimed; async mark-sweep GC of aged/event-referenced blobs is a later slice). Access is two layers with **no new machinery**: the `file:` permission, and a per-file **`sensitive`** flag reusing the secret `:admin`-tier rule (a flagged file is hidden from a non-admin lister and a non-disclosing 404 to a non-admin reader, admin-only to create), defaulting **off** and leaving `file` off the sensitive-resource set so the viewer floor reads ordinary files. Files carry **no placement scope** (a file is tenant-wide; its 1:many locality is a future attachment, not an owner arc). The generated CLI (a `file` command group) and typed client follow from the Huma structs, and the **Files directory** ships under Values (upload, download, delete, a sensitive badge). Proven by the pure blob and file-validation unit tests, the **pgblobs testcontainer** round-trip and dedup gate (the capability-wrapping close), the gateway integration tests (round-trip, dedup, delete-leaves-blob, the sensitive gate), and a real-binary API e2e (upload to download identical bytes, the viewer/operator/admin permission and sensitive gates) ([ADR-0029](/architecture/decisions/#adr-0029-files-slice-1-a-content-addressed-blob-store-and-a-tenant-wide-file-handle), [#242](https://github.com/hyperscaleav/omniglass/issues/242), [#244](https://github.com/hyperscaleav/omniglass/issues/244)). Deferred: attach-to-entities-and-types (slice 2), tags-on-files ([#191](https://github.com/hyperscaleav/omniglass/issues/191)), async mark-sweep GC of aged/event-referenced blobs, the S3 and disk backends, and the classification lattice ([#243](https://github.com/hyperscaleav/omniglass/issues/243)). - **Estate: `allowed_parent_types` constrains where a location may be placed.** `location_type` gains `allowed_parent_types` (`text[]`, default `{}`, a new idempotent migration): a set of `location_type` ids and/or the reserved `root` sentinel a location of that type may sit under. Empty is unconstrained (every existing custom type, unchanged); non-empty is enforced on `CreateLocation` and a new move primitive on `UpdateLocation` (a `parent` patch field, cycle-guarded). A violation is a distinct `storage.PlacementError` (wraps `storage.ErrPlacementNotAllowed`, carries the offending child and parent type names), mapped to a 422 that names both. `CreateLocationType` refuses the id `root`, so a real type can never collide with the sentinel. The four seeded types ship their sets: `campus={root}`, `building={root,campus}`, `floor={building,campus}`, `room={floor,building,campus}`. The [Types catalog](/guides/admin/types/) gains an allowed-parents editor (a checkbox list over the location types plus a Root option) on the location tab's create and edit forms, and the **location edit form's Placement section makes Parent editable**: a picker (riding #240's inventory edit model, the same `Show when={editing()}` split as every other editable field) narrowed to the type's allowed parents and excluding the location's own subtree, wired to the move primitive; moving back to root is not offered ([ADR-0030](/architecture/decisions/#adr-0030-allowed_parent_types-constrains-where-a-location-may-be-placed), [#239](https://github.com/hyperscaleav/omniglass/issues/239)). Proven by a storage integration suite (unconstrained allows all, a listed parent and root both succeed, an out-of-order placement is refused on create and on move with the type names in the error, a cycle move is refused distinctly, and an existing noncompliant placement is untouched by an unrelated update until something tries to move it), a boot-seed idempotency assertion, and API/web tests (including the reparent picker's candidate filtering and self-exclusion). Deferred: `allowed_parent_types` for `system_type`/`component_type`, the same read-only-in-edit Parent gap on Systems and Components, and a drag-to-reparent affordance (this slice's picker is a select, not a drag surface). - **Collection slice (checkpoint 5g): the derived interface/task model.** The reachability primitives were reframed to their honest shape ([ADR-0040](/architecture/decisions/#adr-0040-the-task-is-derived-read-only-plumbing-projected-from-its-interface)): the **interface** is the only authored primitive and the **task** is **derived**. An interface is now **named by its protocol** (the name derives from its `interface_type`, unique per component), so the create surface takes a type, not a free-text name. Creating an interface **derives its one poll task**, so the task surface is **read-only**: the `POST` / `PATCH` / `DELETE /tasks` routes and the `task:create` / `task:update` grants are gone, leaving `GET /tasks` and `GET /tasks/{id}`. `task.node_name` is dropped and **projected** from `interface.node_name` (the worklist and the telemetry owner-confinement now join the interface for placement), and a **node purge cascades** its interfaces and their tasks (`interface.node_name` and `task.interface_id` are `ON DELETE CASCADE`). The console follows the entity model, folding the collection children onto their parents: the standalone Tasks page is removed and a node's derived tasks read as a **panel on the node detail**, and the standalone Interfaces tab is removed and a component's interfaces (with their reachability, add, and edit/delete) read as a **panel on the component detail** (an interface belongs to its component). The interface create form drops the name input (name derives from the protocol), the node detail moves onto the shared read-edit-save blade, and the collection pages route through the shared Button primitive. Storage, the API, the CLI, and the typed client regenerate from the reduced surface, so `make gen` is clean. Proven by the reduced storage, API, and web suites over the derived model (an interface derives its task, the task surface is read-only, placement projects, a node purge cascades). This keeps the [collection](/architecture/collection/) and [nodes](/architecture/nodes/) pages `Partial`: the driver / collect layer (the normalized menu, SNMP, the `$sec:` / `$var:` interpolation consumer, templates) is still Design. - **Catalog: the `component_make` manufacturer registry.** The first landed slice of a larger make/model catalog: a flat, seed-and-custom registry (`id`, `official`, `display_name`, `icon`, `support_phone`, `website`) naming who makes a component, on the same official-row-read-only pattern as the `*_type` registries. Full CRUD (`POST/GET/PATCH/DELETE/list /api/v1/component-makes`) gated by a new, capability-only, unscoped **`make`** resource (`make:read` sits in the viewer `*:read` floor, `make:create,update,delete` at the admin tier, mirroring `type:*`); eight official makes (Crestron, Biamp, QSC, Shure, Cisco, Extron, Sony, Samsung) are upserted idempotently at boot. The `website` field is validated to an `http`/`https` URL on create and update, both client and server side (a non-browser caller is refused with a 422), closing a stored-XSS path a `javascript:`/`data:` value would otherwise open when the console rendered it as a live link; a value that still fails the check renders as plain text, never a dead or unsafe anchor. The console ships a **[Makes](/guides/admin/vendors/)** catalog page (`FlatList` + blade, an official row read-only, same shape as [Types](/guides/admin/types/)) and the generated CLI (`omniglass component-make list/get/create/update/delete`). Proven by a storage round-trip (official read-only, duplicate id, alphabetical list), a seed idempotency test, a real-binary API test (the CRUD lifecycle, the website-scheme 422, the capability gate), and web unit and component tests covering the safe-URL render guard. Deliberate thin cuts: no in-use delete guard (nothing references a make yet, so a custom row deletes unconditionally; the referential 409 lands with `component_model`), no `component_type` genus tree, no `component_model`, and no picker wiring a component to a make. Deferred: the rest of the make/model catalog ([epic #254](https://github.com/hyperscaleav/omniglass/issues/254), [#255](https://github.com/hyperscaleav/omniglass/issues/255)). - **Field: an operator-defined typed attribute on a type (slice 0).** The first cut of the [field](/architecture/variables/#property-one-typed-name-a-product-contract-a-stored-value) primitive: an operator declares a typed field on a `component_type` (a `field_definition`: a `name`, a `data_type` of `string`/`int`/`float`/`bool`/`json`, and an optional type-level default validated against the `data_type`, unique per `(component_type, name)`), a component sets a **literal** for a field defined on its type (a `field_value`), and the component's **effective** value resolves to the set literal or the type default (an `is_set` flag marks the override). The whole vertical shipped: storage (transactional, audited), the API (the definition catalog flat and `field:`-gated, the value routes ABAC-scoped to the owning component), the generated CLI and typed client, and the UI (define on the component-type blade under [Types](/guides/admin/types/), plus an **Effective fields** panel on the component detail that sets a literal and shows override-versus-default). Owner is the **component only**: macro interpolation (`$var:`/`$sec:`/`$datapoint:` in a value), the cross-type cascade (`product → location → system → component`, deepest-wins), the `sources` model, typed `file` fields, and definitions on the non-`component_type` owners are deferred to later slices; the UI cannot yet clear a set value back to the type default (the clear route exists on the API and CLI but is not wired into the panel). The [config, secrets, and variables](/architecture/variables/) page moves to `Partial` for the field member. Proven by a storage integration test (definition CRUD and the default type-match guard on create and update, the effective set-or-default read and the not-applicable guard, and value update/delete reverting the component to the default), an HTTP e2e (the effective read, the override set path, and the viewer-reads / operator-sets authz split at the `field:create` gate), and a scope-resolver unit test for the field arc kinds (a component-scoped operator resolves a subtree scope, a viewer resolves empty). - **Platform: the settings engine (install-wide level).** The [settings](/architecture/settings/) subsystem ships its first slice: a pure `settings` package that resolves an effective configuration document by deep-merging ordered layers (embedded declared defaults, an optional operator `file`, and the install-wide DB override) most-specific-wins in JSON map-space, tracking per-key **provenance** and enforcing a top-down **lock** (broader level wins). The single **`setting_override`** table holds only the override level, since the base layers are recomputed in memory each boot and restore is therefore a delete ([ADR-0033](/architecture/decisions/#adr-0033-settings-persist-only-the-override-level-base-layers-are-recomputed-in-memory)), and its Gateway methods are **unscoped**, gated by the `settings:` permission alone ([ADR-0034](/architecture/decisions/#adr-0034-the-settings-gateway-is-unscoped-only-the-permission-gates-it)), reusing the [cascade](/architecture/cascade/) primitive on the principal axis ([ADR-0035](/architecture/decisions/#adr-0035-settings-resolve-as-a-cascade-over-principals-with-a-broader-wins-lock)). The API exposes an admin **`GET /settings`** with provenance, a client-safe authn-only **`GET /settings/me`**, and **`PATCH`** / **`DELETE`** / **`POST /settings:restoreDefaults`** writes under `settings:read` / `settings:update`; the generated CLI and typed client follow. Two `profile`-domain namespaces seed (**`ui`** and **`keybindings`**), and **`ui.theme` is wired end to end**: the console reads its theme from `/settings/me`, so an admin setting the org theme default re-themes the SPA on next load. The Admin **Settings** page (namespace sections, provenance badges, lock chips, restore-to-default) replaces the `/settings` nav stub. Proven by the pure engine unit suite (deep merge, RFC 7386 merge-patch, cascade resolution, lock enforcement, provenance), a testcontainer override round-trip (upsert, audit-in-transaction, delete-restores), an HTTP e2e (admin read with provenance, patch-then-read reflects the override, `/settings/me` readable by a non-admin, the admin read forbidden to a viewer), and web tests (the page renders a provenance badge; the theme mapper). 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`), a GitOps read-only mode, and live file reload (SIGHUP). - **Platform: typed settings (slice 1).** A setting is now declared once as a field on a canonical **`Settings`** Go struct: reflection over its `default`, `enum`, `pattern`, and `settings:","` tags builds the code-defaults layer and the namespace registry (the embedded `defaults.yaml` and the hand-kept `Namespaces()` list are retired), so adding a setting is one tagged field with no second place to drift ([ADR-0041](/architecture/decisions/#adr-0041-settings-are-a-reflected-typed-struct-with-generated-client-and-server-validation)). The cascade still merges partial maps, but the effective read unmarshals into the typed struct: the API `values` is now the typed **`Settings`** (the generated client gets `values.ui.theme` as a union), and Go code reads a setting off `settingsSvc.EffectiveTyped(ctx)`. Writes validate against the reflected schema (Huma `SchemaFromType` plus `Validate`): an unknown namespace is a **404**, an unknown key or a value failing its `enum`/`pattern`/type is a **422**. A `make gen` step slices those field constraints out of the OpenAPI into **`web/src/api/settings.schema.gen.ts`**, and the settings form validates each field **inline** against it (an enum renders as a select of the generated options, a bad value shows an inline message and blocks Save), with the server 422 as the backstop, so the console and the server enforce the same rules from one source. `default_landing` gains a `^/` pattern to make the inline validation demonstrable. Proven by the reflection unit suite (defaults coercion, registry from tags), the validator suite (unknown-namespace, unknown-key, enum and pattern violations, null-delete skipped), HTTP e2e (a bad `PATCH` is 422 and leaves no override, an unknown namespace is 404), and web tests (the inline validator plus the form rendering the generated enum). Deferred: the declarative operator-file machinery (a generated schema for the operator file, file-layer validation, and letting the file layer outrank the database at the platform level). - **Field: an optional `display_name` on a definition.** A `field_definition` gains a nullable **`display_name`**, a human label threaded schema through UI: the console shows the label wherever it is set (the Fields editor on the component-type blade and the **Effective fields** panel), while the raw `name` stays the unique key and the interpolation handle, so an unset label falls back to the key. The column is additive and idempotent, presentation only. Proven by the storage round-trip (the definition create and update carry the label, an unset label stores NULL), the HTTP e2e (the create body accepts `display_name` and it round-trips through the catalog list), and the dev seed (the seeded display fields carry labels without changing row counts). The [config, secrets, and variables](/architecture/variables/) page keeps its `Partial` field badge. - **Field: batched edit, upsert, and inherited-versus-set clarity.** The Fields panel's edit flow is reworked. Setting a value is now an **idempotent upsert** (`SetFieldValue`): the first set creates, a later set patches in place (no more `409 field already exists` on a second set), and a set to the unchanged value is a no-op, so the `set-field-value` route returns `200` and stays `field:create`-gated. In the UI the per-field save is gone: a field's edit **stages a draft** and the blade's **Save changes** flushes every touched field (an upsert, or a delete for a cleared override) alongside the component core, through a new `onSave` contributor on the blade edit slot. Edit mode now tells **inherited** from **set**: an inherited field is an empty input with a greyed `unset` placeholder, a set field shows its value with a **clear (×)** that stages a revert to the type default (replacing the revert arrow). Proven by a storage integration test (a second set patches in place, a same-value set writes no audit row), the updated HTTP e2e (the second POST upserts to `200` keeping its `value_id`), and a blade-slot unit test (contributors flush before the primary save, and a throwing contributor aborts and keeps the blade in edit). The [config, secrets, and variables](/architecture/variables/) page keeps its `Partial` field badge. - **Config, secrets, and variables: the per-component effective-secrets and effective-variables panels retire.** The standalone **Effective secrets** and **Effective variables** panels on the component detail, and their `GET /components/{name}/effective-secrets` / `GET /components/{name}/effective-variables` routes (with the generated `omniglass effective-secret list` / `effective-variable list` commands and the matching typed-client methods), are **removed** ([#281](https://github.com/hyperscaleav/omniglass/issues/281), under the [field](/architecture/variables/#property-one-typed-name-a-product-contract-a-stored-value) epic [#266](https://github.com/hyperscaleav/omniglass/issues/266)). The panels listed **every** cascade-resolving cell that reached a component, mostly inherited noise; the **field** primitive is the schema-over-cells consumer, so a component's values are now its **fields** (override versus type-default, the **Effective fields** panel), and a secret or 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:** 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. The [config, secrets, and variables](/architecture/variables/) page drops the retired-panel claims and the [decision log](/architecture/decisions/) records the call. No schema change: the value cells, the resolvers, and the directories are all unchanged. - **Node identity and edit (N1).** A node reaches parity with the component/system/location primitives: it gains a nullable `display_name` and an optional `location` (a descriptive placement referencing `location(name)`, `ON DELETE SET NULL`, not a scope; a node stays estate-wide), added by an additive migration. `name` stays the immutable key and estate address. `UpdateNode` and `PATCH /nodes/{name}` (gated a new `node:update`, covered by the owner `node:*`) patch display_name, description, and location, with an unknown location a 422 through the FK. The console blade becomes read-edit-save: the title is the display_name (falling back to the name), **Edit** is the primary action and Enroll / Re-enroll moves to the secondary kebab, the list row labels by display_name with the key and location as its subtitle, and the create Drawer carries the two identity fields (its enroll flow is unchanged). Proven by the storage round-trip (each patched field, name immutable, location set / clear, the `ON DELETE SET NULL`), the real-binary API test (the PATCH round-trip, the `node:update` 403, the unknown-location 422, create mints no token in the body), and the web suite (the Edit action gated on `node:update`, the editable fields, Re-enroll from the kebab, the labelled rows). Deferred to their own issues: the token-after-create flow (#287), node tags (#285), decommission (#286), and inline create for flat lists (a platform list-primitive concern). The [nodes](/architecture/nodes/) page stays `Partial`. - **Node tags (N2).** A node becomes a taggable owner kind, alongside the install-wide tier / component / system / location. The `tag_binding` owner arc gains a `node_id` leg (an additive migration re-adding the two CHECK constraints; `ON DELETE CASCADE` so a node purge drops its bindings), and the governed `applies_to` set can now include `node`. A node is estate-wide, not a scope tree, so tagging it needs an all scope (`node:update`, reusing the identity gate) and its effective tags are the install-wide layer plus its own direct bindings, no inheritance. The generic per-entity tag routes grow the `/nodes/{name}:listTags` / `:setTag` / `:removeTag` methods, and the node body carries `effective_tags`. The console node blade gains a **Tags** panel (the shared `TagAdder`: read pills, add / remove in edit), and the node list a **Tags** column plus a per-key filter facet, the same shape as the component list. Proven by a storage integration test (the applies_to gate, the all-scope requirement, effective = install-wide + direct, unbind), a real-binary API test (bind / list / effective / unbind and the `node:update` 403), and the web suite. Follows [node identity](/architecture/nodes/) (N1); the [tags](/architecture/tags/) page stays `Partial`. Deferred: setting tags **during** node create (an inline-create platform concern). - **Node decommission (N3).** The node blade gets its destructive action: `DeleteNode` + `DELETE /nodes/{name}` (gated a new `node:delete`, covered by the owner `node:*`). It is a hard delete of the node's `kind='node'` principal, which cascades the node detail and, through it, its interfaces and their derived tasks, its node-owned tags and self-telemetry, and its enrollment credential, every referencing FK is already `ON DELETE CASCADE`, so no migration. A node is estate-wide, so it needs an all scope; the delete is audited, and the component telemetry the node collected (owner arc = component, `node_id` null) survives untouched. The console blade adds a **Delete** action (confirm, then close and refresh), completing its cred-action set (Edit primary, Enroll / Re-enroll kebab, Delete destructive). Proven by a storage test (the cascade of interfaces / tasks / tags, the component datapoint surviving, the all-scope gate) and a real-binary API test (the DELETE and the `node:delete` 403). Closes the node-lifecycle arc (N1 identity, N2 tags, N3 delete). The [nodes](/architecture/nodes/) page stays `Partial`. - **Field: the override model.** Field rendering converges on one generic primitive, **`FieldControl`** (the field-facing sibling of the `KVRow` / `KVStacked` key:value pair), consumed everywhere a resolved-value-with-override appears. It renders in two modes. **Read** is a slim one-line row (label left, value right) where an **override** reads with an accent **dot on its key** and its value in the **accent colour**, while inherited stays muted. **Edit** is a stacked cell whose key row carries a right-aligned **Override** switch: switch **off** inherits the resolved value (the type default this slice) with no editable input; switch **on** reveals a type-aware input seeded from that value, and **revert is the switch off** (no separate clear). This fixes the **bool** case the old renderer got wrong: inherited, a bool shows the resolved word (`true` / `false`) muted rather than a toggle you appear to have set, and override on gives a real editable toggle. A new **`field_definition.required`** boolean (additive migration, default false, carried through the effective read) marks a required field with a red **`*`**, forces its override on, and gates the blade **Save**: the red input box and a "This value is required" label appear only after a submit attempt leaves the field empty, and Save is blocked while any required field is unfilled. **`EffectiveFields`** is the first consumer, now rendering every field through `FieldControl` and still batching each touched field onto the blade Save. The **`$` source picker** (variable / secret / file) and the symbol-plus-name display of a sourced value are **drawn in the control but wired in a later slice**; this slice sets literals only. Proven by `FieldControl` unit tests (read dot-and-colour and inherited-muted, edit switch-off-value versus switch-on-input and revert, the bool word-versus-toggle split, and the required marker plus the submit-only red box and Save gate), the `EffectiveFields` blade-batch test, and a storage/HTTP round-trip carrying `required`. The [config, secrets, and variables](/architecture/variables/) page keeps its `Partial` field badge. - **The property catalog.** The `datapoint_type` catalog is generalized into the primitive-agnostic **`property`** catalog (the physical table; the concept, the `/properties` API, the Go `Property`, and the console all read `property`, while a property's identifier stays a `key`): the typed set of signals a datapoint **observes** and a field **declares**. The `(scope, name)` ladder collapses to a `name` primary key plus an **`official`** boolean (seed-owned properties read-only); `value_type` becomes **`data_type`** over `{string, int, float, bool, json}` (`text` backfills to `string`, `bool` added); **`kind`** is nullable (a declared-only attribute property has none); and **`validation`** is a **JSON Schema** enforced by Huma's own validator with **no new dependency**. Value and source tables keep keying by the **name string** (no FK), so the rename is behavior-preserving: the ingest registry, the reachability BFF, and the metric/state sinks are unchanged. A new **`internal/key`** primitive holds the one canonical name-format rule (lowercase, dot-hierarchied) and the typed value validator; tag's key rule folds onto it. The Storage Gateway gains custom-property CRUD (gated `property:create` / `:update` / `:delete`, official properties read-only, audited in the same transaction), exposed at **`/properties`** with a generated CLI and client, and a new **Catalog > Properties** console page (list, blade, create form). An official seed ships the reachability properties plus a starter attribute set (`serial_number`, `mac_address`, `firmware_version`, `model_number`). Proven by `internal/key` unit tests, the `property` CRUD integration test, the `/properties` HTTP e2e (the `property:create` 403 and official read-only), and the console suite. The type-schema (`field_definition.key`, PR-B) and reconciliation are deferred ([ADR-0043](/architecture/decisions/#adr-0043-the-property-catalog), [#297](https://github.com/hyperscaleav/omniglass/issues/297)). The [config, secrets, and variables](/architecture/variables/) page stays `Partial`. - **The component classification catalogs.** The `component_make` registry is generalized into a **`vendor`** catalog with a **`kind`** (`manufacturer` / `integrator` / `developer`), and two new leaf catalogs join it as the rest of the component-classification reference data: a **`driver`** (id, display_name, version) and a **`capability`** (id, display_name). Each of the three reuses the `official`-boolean chassis the type and property registries prove: seed-owned official rows are read-only (update / delete refused 422), a custom row is full CRUD gated by the resource's `:create` / `:update` / `:delete` permission (admin gains `:*`, the `*:read` floor gives everyone read) and audited in the same transaction, with official rows seeded at boot. All three ship end to end: the Storage Gateway CRUD, the Huma surface (`/vendors`, `/drivers`, `/capabilities`) regenerated into the OpenAPI document, the cobra CLI, and the typed SPA client, plus a gated **Catalog** console page each (`/vendors`, `/drivers`, `/capabilities`) with a list, detail blade, and create form, official rows read-only in the UI. Proven by the per-catalog CRUD integration tests (official read-only, the scoped permission 403), the HTTP e2e, and the console suites. This is **PR2** of the estate-model shift toward property / event / command + vendor / product / driver / capability / standard / role / health; `product`, `product_capability`, and `component.product` are the next slice ([ADR-0044](/architecture/decisions/#adr-0044-the-component-classification-catalogs)). The [core entities](/architecture/core-entities/) page stays `Partial`. - **The product catalog.** **`product`** lands as the concrete **SKU** that ties the [ADR-0044](/architecture/decisions/#adr-0044-the-component-classification-catalogs) leaf catalogs together: a **`kind`** (`device` / `app` / `service` / `vm`), an optional `vendor_id` (who makes it) and `driver_id` (what talks to it), an optional `parent_product_id` (a variant points at its base product), the `official` boolean, and the capabilities it provides through the **`product_capability`** join (a video bar provides microphone, speaker, camera, codec; a replace-the-whole-set update). It reuses the `official`-boolean chassis: seed-owned official rows read-only (update / delete 422), custom rows full CRUD gated by `product:create` / `:update` / `:delete` (the `*:read` floor gives everyone read) and audited in the same transaction, official rows seeded at boot. The slice also adds **`component.product_id`** (`on delete restrict`), the pointer from a component to the product it **is**, making the product the source of a component's shape and retiring the `component_type`-as-shape notion; a product still referenced by a component cannot be deleted (409). It ships end to end: the Storage Gateway CRUD, the Huma surface (`/products`) regenerated into the OpenAPI document, the cobra CLI (`omniglass product list/get/create/update/delete`), the typed SPA client, and a gated **Catalog** console page (`/products`) with a list, detail blade, and create form (vendor, driver, parent, and capability pickers), official rows read-only in the UI. Proven by the product CRUD integration test (official read-only, the `product:create` 403, an unknown vendor / driver / capability reference 422, and the in-use 409 when a component points at it), the `/products` HTTP e2e, and the console suite. This is **PR3** of the estate-model shift toward property / event / command + vendor / product / driver / capability / standard / role / health ([ADR-0045](/architecture/decisions/#adr-0045-the-product-catalog)). The [core entities](/architecture/core-entities/) page stays `Partial`. - **The `event` log-kind sink.** The collection pipeline gains its **third sink**. A new **`event`** table is the **log-kind sink** (a past occurrence) beside `metric_datapoint` / `state_datapoint` (a sampled present value), carrying the **same datapoint owner exclusive-arc** (`owner_kind` plus `component_id` / `system_id` / `location_id` / `node_id`, one-set CHECK) and the **same provenance** (`observed` / `calculated` / `intended` / `declared`, default `observed`), plus a **`message`** (text) and structured **`attributes`** (jsonb). A **log**-kind datapoint that the ingest consumer used to **drop** (it had no sink) now routes to `event`: `deriveDatapoints` returns metrics, states, **and** events, and the consumer calls **`InsertEvents`**, so a log rides `string_value` (its message) or `json_value` (its attributes) under the **same** owner-confinement and reject-not-project gates as the other two sinks. A boot-seed property **`log.line`** (kind `log`) is the canonical 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) and `ListComponentEvents` (newest first); the read route **`GET /components/{name}/events`** (`list-component-events`, gated `component:read`, non-disclosing 404 out of scope) returns the last 24 hours capped at 200, regenerated into the OpenAPI document, the cobra CLI, and the typed SPA client, and the component detail page gains an **Events** panel over it. Proven by the consumer unit tests (a log routes to the event slice, a metric/state still land in their sinks), the `InsertEvents` / `ListComponentEvents` storage integration test, and the `/components/{name}/events` HTTP e2e (the newest-first window and the out-of-scope 404). With metric, state, **and** log all flowing, this is the **P1 follow-up** of the estate-model roadmap ([ADR-0046](/architecture/decisions/#adr-0046-the-event-log-kind-sink)); the [datapoints](/architecture/properties/) and [data collection](/architecture/collection/) pages stay `Partial` (the `log_datapoint` table, the `event_type` registry, and log-to-event promotion are still `Design`). - **The fields fold: the product contract and the property value store.** The standalone **fields** feature **retires**: a field was never a primitive, it was a **property with `declared` provenance**. Two tables take its place. **`product_property`** is the product's declared-property **contract** (`product_id`, `property_name`, an optional `default_value`, a `required` flag, unique per pair), replacing `field_definition` and its per-`component_type` catalog; `data_type` and `validation` are **not** duplicated, they stay on the [property catalog](/guides/admin/properties/). **`property_value`** is the value store, carrying the **same owner exclusive-arc** as `metric_datapoint` and `event` (`owner_kind` plus `component_id` / `system_id` / `location_id` / `node_id`, one-set CHECK) plus 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 the default NULLS DISTINCT would let duplicates through. The resolver **`EffectiveProperties`** is one SQL UNION: the **contract arm** (every `product_property` of the component's product, valued `coalesce(the component's declared value, the contract default)`, `from_contract` true) plus the **off-contract arm** (declared values the contract does not declare), so a **productless** component still resolves, to its off-contract set alone. Six routes ship, regenerated into the OpenAPI document, the cobra CLI, and the typed SPA client: `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 in the same transaction). The CLI reads `omniglass product properties|set-property|delete-property` and `omniglass component properties|set-property|clear-property`. The console renames the operator word from **Fields** to **Properties**: the component detail gains a **Properties** panel (contract rows, a dashed-bordered **off contract** group for the ad-hoc ones, an override toggle with an accent dot on an override, a required property blocking Save), and the product detail gains a **Declared properties** contract editor (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 with its `/types/component` routes, its console registry section, and its seed. A component's shape now comes from its **product** (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. Proven by the `product_property` and `property_value` storage integration tests (the in-place contract upsert, a nil default round-tripping as SQL NULL, both resolver arms, the idempotent re-set on one series row, the clear-to-default, and the productless component), the `/products/{id}/properties` and `/components/{name}/properties` HTTP e2e (the official-product 422, the unknown-property 422, the out-of-scope non-disclosing 404, the withdraw-twice 404, and the clear back to the contract default), and the console suites for both new surfaces. This is **PR5** of the estate-model shift ([ADR-0047](/architecture/decisions/#adr-0047-the-fields-fold-product_property-and-property_value)). No page badge moves: [core entities](/architecture/core-entities/) and [config, secrets, and variables](/architecture/variables/) both stay `Partial` (the cross-owner cascade, the non-`declared` provenance producers, and the `standard` / `location_type` contracts are still ahead). - **The `standard` blueprint, the owner-generic resolver, and the template-fork seed model.** `system_type` is **promoted to `standard`**: the blueprint a system conforms to, the system-side counterpart of `product`. The renamed table gains **`parent_standard_id`** (variants, mirroring `product.parent_product_id`) and a declared property contract, and `system.system_type` becomes **`system.standard_id`**, now **optional**, so a **one-off system that conforms to no standard** is first-class exactly like a productless component. The seeded rows carry over. Since a standard now owns a contract it is a **Catalog entity, not a bare type registry**: it leaves the shared `type:*` permission for its own **`standard:read` / `:create` / `:update` / `:delete`** (read on the viewer `*:read` floor, the writes admin-tier, exactly like `product:*`) and its routes move from `/types/system` to **`/standards`**. Two contract tables join `product_property` on the identical shape: **`standard_property`** and **`location_type_property`** (`_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](/guides/admin/properties/). The resolver then generalizes to **`EffectiveProperties(ctx, ownerKind, ownerID, read)`**, resolving **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 through `component.product_id`, system through `system.standard_id`, location through `location.location_type`, and a node, having no classifier, resolves ad-hoc values only. The two-arm shape is unchanged (contract arm `coalesce(the instance's value, the contract default)` with `from_contract` true, UNION the ad-hoc arm), so three classifier/instance pairs cannot drift apart. `guardOwnerScope` now scope-checks **every** owner arc on a value write, not just the component one. The routes ship regenerated into the OpenAPI document, the cobra CLI, and the typed SPA client: the `/standards` CRUD; `GET /standards/{id}/properties` plus `PUT` / `DELETE .../{property}` (gated `standard:read|update|delete`); `GET /location-types/{id}/properties` plus `PUT` / `DELETE .../{property}` (gated `type:*`, the registry CRUD staying at `/types/location`); and the value sides `GET /systems/{name}/properties` and `GET /locations/{name}/properties` plus their `PUT` / `DELETE .../{property}` (gated `system:*` / `location:*`, ABAC-scoped with a non-disclosing 404 out of scope, audited in the same transaction). The CLI reads `omniglass standard properties|set-property|delete-property`, `omniglass location-type properties|set-property|delete-property`, and `omniglass system|location properties|set-property|clear-property`. The console gains a **Standards** catalog page with a **Declared properties** contract editor (the Products pattern), a contract editor on the location type blade, and a **Properties** panel on the system and location details (the component pattern: contract rows, a dashed-bordered **off contract** group, an override toggle, a required property blocking Save); the **Types** page drops its System tab. The **seed model** is the conceptual half of the slice: a standard and a location type are created by **forking an in-code template**, a **one-time fork with no inheritance**, so a template can be improved in any release because nothing in any tenant points at it. What lands is therefore **operator-owned**: shipped standards and the four shipped location types seed **`official: false`** through **seed-if-absent** paths (`SeedStandard` / `SeedLocationType`, `ON CONFLICT DO NOTHING`), never the authoritative `Upsert*`, whose `ON CONFLICT DO UPDATE` would silently revert an operator's edit on the next boot. Forking applies **template -> standard**, never **standard -> system**: a system **conforms** with **live** inheritance. The **canonical catalogs are the exception** and keep the authoritative upsert with `official: true`, `property` above all, since it is the shared vocabulary a driver maps onto and a release must be able to correct it. Proven by the `standard`, `standard_property`, and `location_type_property` storage integration tests, the owner-generic resolver tests across all four owner kinds (including the classifier-less node and one-off system), the seed regression test that **edits a seeded standard, re-runs the seed, and asserts the edit survived**, the `/standards` and four property-route HTTP e2e suites (the official read-only 422, the unknown-property 422, the out-of-scope non-disclosing 404 on both the read and the write, the withdraw-twice 404, and the clear back to the contract default), and the console suites for the new surfaces. This is **PR6** of the estate-model shift ([ADR-0048](/architecture/decisions/#adr-0048-the-standard-blueprint-and-the-template-fork-seed-model)). No page badge moves: [core entities](/architecture/core-entities/) stays `Partial` (the cross-owner cascade, the non-`declared` provenance producers, `system_member` composition, and template pinning are still ahead), and [API](/architecture/api/) stays `Partial`. - **System roles, required capabilities, and the assignment guard.** A system now declares **what it needs filled**. **`system_role`** is the slot (a table microphone, a main display), 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). Both owners ride the **same exclusive arc `property_value` uses** (`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, since the arc leaves one owner column NULL). A role carries a **`quorum`** (how many components should fill it, floored at one) and requires a **conjunctive** set of **`role_capability`** rows: a component must provide **every** listed capability. Two more tables complete it: **`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), and **`role_assignment`** records who fills the role in this system, with the component FK **`on delete restrict`** so a component staffing a role cannot be deleted out from under it. Two resolvers ship with them. **`EffectiveCapabilities(component)`** is the product's capabilities UNION the component's additions MINUS its suppressions, so a **productless component resolves to just its own declarations**; it is the single definition of "what this component can do" for the whole platform. **`EffectiveRoles(system)`** merges the inherited arm (`from_standard` true) with the ad-hoc arm, each role carrying its required capabilities, its quorum, its assignments, and the served **`assigned`** and **`understaffed`** counts, so no surface does the arithmetic itself. The slice's decision is the **guard**: **`AssignRole` refuses (422) when the component's resolved capabilities do not cover the role's requirement, and the refusal 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), a refusal on **modeled** grounds in the same class as the location placement constraint, and named for the same reason. Capabilities became a **resolved** set precisely to make that strictness survivable: `product` is optional on a component, so a guard over a product-only fact would have locked every productless component out of every role. Eight routes ship, regenerated into the OpenAPI document, the cobra CLI, and the typed SPA 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 on the read and the write alike, and every write is audited in the same transaction. The CLI reads `omniglass standard roles|set-role|delete-role`, `omniglass system roles|set-role|delete-role|assign-role|unassign-role`, and `omniglass component capabilities|set-capability|clear-capability`. The console follows the property pattern one tier up: a **Roles** editor on the standard blade (declare a role, set its quorum, pick the capabilities it requires), a **Roles** panel on the system detail (each role with its source, its staffing, an **understaffed** marker, and assign / unassign), and a **Capabilities** panel on the component detail (the resolved set, with the component's own additions and suppressions marked against its product's). 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. Proven by the `EffectiveCapabilities` storage integration test (the product's set, an addition, a suppression, and the productless component resolving to exactly its own declarations), the `EffectiveRoles` and assignment test (both arms resolving, quorum 2 reading understaffed 2 then 1 after one assignment, the idempotent re-assign, the **named** shortfall on a display that provides neither microphone nor speaker, a productless component that declares what the role needs being staffed successfully, the unassign-twice miss, the unknown-role not-found, and the one-off system seeing only its own roles), the `/systems/{name}/roles` HTTP e2e (the inherited and ad-hoc rows, the 422 that names the gap, the out-of-scope non-disclosing 404) plus the seeded-standard e2e, and the seed regression that **retunes a seeded role's quorum, re-runs the seed, and asserts the retune survived**. This is **PR7** of the estate-model shift ([ADR-0049](/architecture/decisions/#adr-0049-the-system-role-capability-gated-staffing-and-the-resolved-capability-set)). No page badge moves: [core entities](/architecture/core-entities/) stays `Partial` (a role's **impact** on health, `system_member` composition, template pinning, the cross-owner cascade, and operational mode are still ahead) and [API](/architecture/api/) stays `Partial`. - **Health: the alarm-capability-role chain, and a verdict recorded as a transition.** A system and a location now answer "is this working right now?" and "since when?". An **`alarm`** is **component-local** (a `severity` of `info` / `warning` / `critical`, a `message`, a `raised_at`, and a **nullable `cleared_at`**, so clearing **keeps the row** and the record of what was wrong outlives the fix), and **`alarm_capability`** names the capabilities it **degrades**. That naming is the only route out of the component: 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 the new **`system_role.impact`** (`outage` / `degraded` / `none`, defaulting to `degraded`, landing here because this is the slice that reads it); a system takes the **worst** contribution among its roles and a location the worst among the systems placed anywhere beneath it. The verdict domain is **`healthy` < `degraded` < `outage`**, and the judgement is a **pure package** (`internal/health`) unit-tested with **no database**: `Satisfies`, the quorum boundary, worst-wins, and two deliberate safety defaults in opposite directions (an unrecognized **impact** reads `degraded`, so a bad value never makes an impaired role silently harmless; an unrecognized **recorded value** reads `healthy`, so one stray row cannot paint an estate broken). The conceptual heart is **how health is recorded**. It is written **transition-only** onto **`state_datapoint`**, which is already exactly that primitive (a row only when the value differs from the last one stored, and `StateTransitions` reads the ordered flips the reachability strip draws), on the owner arc with `provenance='calculated'` and `source_rule='health-rollup'`; there is **no new history table**. Two alternatives were rejected for failing the requirement that the **edges be accurate weeks later**: **compute-on-read** keeps no history at all, and **compute-and-write-through-on-read** keeps a history **sampled by whoever opens a page**, so the recorded edge is stamped when somebody looked rather than when the estate changed. Instead the verdict is **recomputed at the writes that can change it, in the same transaction**: alarm raise and clear, assign and unassign, role declare and withdraw, a quorum or impact change, a component capability or **product** change, system create (which gives a system's history a defined beginning), a **standard** change (which moves every conforming system), and a **relocation** (recomputing **both** the location arrived at and the one left, since that one may have just improved). **A read never writes**, and it computes the verdict it serves from the **same resolved rows it displays**, a correctness fix for a report that could otherwise say `healthy` beside an impaired `outage` role; recorded transitions stay the source for history. The slice also forced a **latent bug** into the open: recording an opening verdict at system creation gave every system a `state_datapoint` row from birth, and **every rename then 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 same gap on `metric_datapoint`, `event`, `property_value`, `alarm`, and the role tables is tracked in [#314](https://github.com/hyperscaleav/omniglass/issues/314). Five routes ship, regenerated into the OpenAPI document, the cobra CLI, and the typed SPA client: `GET` / `POST /components/{name}/alarms` and `DELETE /components/{name}/alarms/{id}` (gated `component:read` / `:update`, an unknown capability or bad severity a 422), plus `GET /systems/{name}/health` and `GET /locations/{name}/health` (gated `system:read` / `location:read`, scope-injected with a non-disclosing 404), each returning the verdict, the contributing roles with their degraded capabilities and the causing alarms (or the systems beneath, for a location), 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`, and the seed adds a **`health`** state-kind property. The console carries a **health badge** on the system and location details and in the systems list, a **Health** panel that names the causal chain role by role (alarm -> degraded capability -> role below quorum -> verdict), a **History** strip reusing the reachability availability-strip shape, and an **Alarms** panel on the component (raise, clear, and a recently-cleared group). Proven by the `internal/health` unit suite (the quorum boundary, a degraded assignee dropping a role below it, worst-wins at both levels, the impact mapping, and the verdict round-trip) and the storage and HTTP integration suites (the transitions written through the whole chain, an irrelevant degraded capability changing nothing, the report naming the cause, impact and quorum, the moves on unassign / standard change / product change / relocation, the fresh system's report, **health surviving a rename**, the alarm listing and its refusals, and the out-of-scope non-disclosing 404). This is **PR8** of the estate-model shift ([ADR-0050](/architecture/decisions/#adr-0050-health-is-a-recorded-transition-computed-from-the-alarm-capability-role-chain)), and it **closes epic [#266](https://github.com/hyperscaleav/omniglass/issues/266)**: the slice that consumes what the previous seven built. [Health](/architecture/health/) advances from `Design` to **`Partial`** (alarms raised by an `event_rule`, system- and location-owned alarms, the `unknown` verdict, the `global` estate top, and the SLI / SLO / SLA and KPI tier are still `Design`); [core entities](/architecture/core-entities/) and [API](/architecture/api/) stay `Partial`. - **System membership** (`system_member`): a component's binding to a system is now a row rather than a write-once pointer nobody read, and a role attaches to it. Membership is **many-valued**, so a rack DSP serving three rooms is a member of all three, which the old single pointer could not say at all, and **`is_primary`** keeps one answer for a question asked with no system in hand (a default for context-free callers, not a resolution rule; a partial unique index makes a second primary impossible). **Staffing a role creates the membership**, so the two can never disagree, while giving up a role leaves it, because a member carrying no role (a power conditioner, a spare) is ordinary. A one-time backfill reads **both** of the places membership used to be implied, the role table and the old pointer, since each holds components the other drops. The API adds `GET /systems/{name}/members`, `GET /components/{name}/memberships`, `PUT`/`DELETE /systems/{name}/members/{component}` and `POST /systems/{name}/members/{component}:setPrimary`; the CLI adds `omniglass system members|add-member|remove-member|set-primary-member` and `omniglass component systems`. Proven by the storage integration suite (the shared device in two systems, the first membership taking the default without being asked, a later one not stealing it, the default moving rather than duplicating, assignment creating the binding, removal refused while a role is still filled, the cascade from both ends, and the out-of-scope non-disclosing 404) and by a backfill test that executes the **shipped migration file** rather than a copy, covering the shared device, the pointer-only component, and both-at-once, and asserting a second run changes nothing. Resolution behaviour is a **deliberate thin cut**: `component.system_id` stays and keeps feeding the four cascade resolvers unchanged, so this slice ships and is verified on its own. Slice 1 of epic [#324](https://github.com/hyperscaleav/omniglass/issues/324) ([ADR-0051](/architecture/decisions/#adr-0051-membership-is-the-attachment-and-a-role-is-what-it-does)); [core entities](/architecture/core-entities/) and [API](/architecture/api/) stay `Partial`. - **Membership-scoped resolution** and the end of the component's system pointer: the tag and variable cascades seed their system band from `system_member` rather than `component.system_id`, and tag resolution takes the system to resolve against (`GET /components/{name}/effective-tags?system=`), falling back to the component's **primary** membership when none is given. A shared device therefore answers **differently for each system it serves**, which the single pointer could not express at all. **Secrets carry no system band**, on ownership grounds: a credential belongs to the device, not to the room it serves. `component.system_id` is **dropped**; the component body now reports `system` (the primary, by name) and `system_count`, and the console reads both instead of joining uuids client-side. Proven by an integration suite written before the change, since a mis-seeded chain is valid SQL that silently returns fewer rows: the same component resolving `prod` for one system and `lab` for another, the context-free read following the primary and moving when the primary moves, a component in no system resolving the other bands without error, a system the component is not a member of lending it nothing, and a system-owned secret never reaching a component. Slice 2 of epic [#324](https://github.com/hyperscaleav/omniglass/issues/324) ([ADR-0052](/architecture/decisions/#adr-0052-the-cascade-resolves-through-membership-and-secrets-carry-no-system-band)); [cascade](/architecture/cascade/) and [core entities](/architecture/core-entities/) stay `Partial`. - **The effective-values resolution view**, resolved per membership: the component detail gains an **Effective tags** panel showing, per key, the **winning value**, the tier it won from (global, location, system, or the component itself) and the owner's name, with the **shadowed candidates** struck through beneath it. `GET /components/{name}/effective-tags` had existed for some time with no console consumer, so the provenance it returns was being thrown away and the list's flat pills were the only view of a resolved value. A **system selector** appears only when the component belongs to more than one system, and switching it re-resolves against that membership (`?system=`), so a shared device visibly answers `prod` for one room and `lab` for another. A component in a single system sees no selector and reads exactly as before. This closes epic [#324](https://github.com/hyperscaleav/omniglass/issues/324): the engine became per-membership in slice 2, and this is the surface that teaches it. [Cascade](/architecture/cascade/) and [core entities](/architecture/core-entities/) stay `Partial`. - **A name is the address** ([ADR-0053](/architecture/decisions/#adr-0053-a-name-is-the-address-a-uuid-is-identity)): the API accepted names on write and returned uuids on read, so a component created with `{"parent": "rack"}` read back as `{"parent_id": "0198f..."}` and no response body could be replayed as the request that produced it. Every client had to fetch a second collection and join by uuid to render one label. Normalized on component, system, and location (`parent`, `location`) and on the tag, variable, and secret bodies, which already carried `owner_name` beside the redundant id. Enforced by `TestResponsesAddressEntitiesByName`, a guard over the generated OpenAPI that fails on any field naming another entity by uuid, and proven by an HTTP round-trip test on all three entities. **Breaking** at v0.0.0, deliberately. The schema stragglers (`secret`, `variable`, `tag_binding` owner arcs) keep their uuid keys for now and are called out in the ADR. - **The owner arcs key by name** ([ADR-0055](/architecture/decisions/#adr-0055-the-tag-variable-and-secret-owner-arcs-key-by-name)): the nine arc columns on `tag_binding`, `variable`, and `secret` convert from `uuid references (id)` to `text references (name) on update cascade`, matching every table from the collection era onward and completing what ADR-0053 deliberately left. The two conventions no longer meet inside a query: the cascade resolvers projected uuid chains purely to match these three tables, and each chain now projects the **name** while still recursing on `parent_id`. Proven by `TestOwnerArcsSurviveARename`, which binds a tag, a variable, and a secret at a location, renames it, and asserts all three still resolve; **mutation-checked**, since removing `on update cascade` makes the rename fail outright rather than merely orphan the rows. `tag_binding.node_id` keeps its uuid (a node is addressed by its enrollment identity) and `tag_binding.tag_id` is tracked separately in [#340](https://github.com/hyperscaleav/omniglass/issues/340). - **Every foreign key stores a primary key** ([ADR-0056](/architecture/decisions/#adr-0056-every-foreign-key-stores-a-primary-key)): all 30 name-keyed foreign keys convert to the target's primary key (a uuid, `principal_id` for a node), reversing the direction ADR-0053 set and ADR-0055 completed. `on update cascade` is gone with them: it was write amplification across every referencing row to protect a key that did not need to be a name. The conversion also fixed a refusal, not just an inefficiency: `interface.component` referenced `component (name)` with **no** `on update` clause, so renaming a component that owned any interface failed outright with a foreign-key violation. Verified against the restored pre-conversion schema rather than argued. In exchange the API accepts **either** form wherever a reference is written (uuid tried first) and every response carries both the name and the id. Shipped in five slices grouped by subsystem so each file changed once; the last converts the collection tier (`metric_datapoint`'s four arcs, `interface`'s component and node arcs, `node.location_name`) and every remaining node reference. Proven by `TestCollectionReferencesSurviveARename`, which renames a component and a location under a live interface, task, and datapoint and asserts the node's resolved worklist and the component's interface list still come back; **mutation-checked** in both directions. Health keeps names internally on purpose, since its advisory lock hashes `health//` and a mixed currency would silently stop serializing. - **The cascade's least-specific tier is `platform`.** One word, `global`, named two unrelated things: the install-wide **binding tier** and the singleton estate **owner** where health and KPIs roll up. The tier is renamed **`platform`** on both axes, at exactly the rung it already occupied, and `default` is documented off the axis entirely ([ADR-0057](/architecture/decisions/#adr-0057-the-cascades-least-specific-tier-is-platform-and-a-default-is-not-a-tier)). An idempotent migration rewrites `owner_kind` on `variable`, `secret`, and `tag_binding` plus `setting_override.scope`, with the check constraints and the partial unique indexes keyed on the value; **no precedence changes, no rows are added, and every existing deployment resolves byte-identically after upgrade**. The settings engine's `code` level becomes **`default`** and its `global` level **`platform`**, the enum rides `make gen` into the OpenAPI, the typed client, and the CLI (`tag setPlatform` / `clearPlatform`, `--owner-kind platform`), and the console renders the tier and a declared default as what they are ("Declared default" carries no origin badge, because nobody set it). The new capability is authorization: a write at the tier now needs **`platform:`** on top of the resource permission, published per route as `x-omniglass-platform-permission` and seeded to `admin` only, so an all-scoped operator runs every site without being able to move the install-wide value under them. `platform` joins the **sensitive-resource set** (with `secret` and `settings`) so a bare `*:update` cannot reach it: install-wide authority is never implied by estate reach, in the rbac core and in the console's mirror of it. The console gates every tier control on **both** halves, on all three surfaces that write at the tier: the Settings page, and the Secrets and Variables pages, where a caller without the tier half is not offered the Platform scope on the create form nor Edit / Delete on a tier row, and reads which capability is missing instead of meeting a 403. **Breaking:** a secret sealed at the old tier cannot be decrypted after upgrade, since the AAD binds `ownerKind|ownerID|name|field` (accepted deliberately, see the ADR). Proven by a testcontainer upgrade test (pre-rename rows resolve identically after the migration, the constraints and unique indexes still hold one row per identity), the settings resolver unit suite on the renamed levels, and an HTTP end-to-end authz test (an all-scoped role without `platform:*` is refused at the tier and permitted below it, and the seeded roles carry the capability exactly where they should). The dev seed carries the no-root-location rule into `make dev`: the example estate is a forest of three unparented tops with devices under two of them, so the location binding at HQ visibly misses the East campus and only the `platform` value reaches both (proven by a pure fixture test and a seeded effective-tags assertion). A closing migration puts `setting_override.scope` under its own CHECK. It was the one tier column with no constraint behind it, which is how the rename passed a green suite while the Go layer still wrote `global` and orphaned every override in silence. The legal set is the levels that are persisted as rows, today `platform` alone: `default` is reflected off the `Settings` struct and `file` is read from disk at boot, so neither can ever be a row, and the group and user rungs will widen the CHECK when they land. The [cascade](/architecture/cascade/) page moves to `Partial`: its binding chain ships for tags, secrets, and variables, while the template bands, group placement by weight, rule accumulation, and the resolve view stay `Design`. - **The node API commands are reachable, and a guard keeps the CLI namespace honest** ([ADR-0058](/architecture/decisions/#adr-0058-a-run-mode-is-a-verb-under-its-noun-and-no-command-may-be-shadowed)): the hand-written edge run mode and the generated `node` group both registered as `node`, so cobra resolved `omniglass node list` to the daemon and it failed asking for `--token`. Every generated node command was unreachable while the CLI guide documented them as working. The run mode is now `omniglass node run`. The durable half is `TestNoCommandNameCollisions`, which walks the assembled tree and fails on any duplicate name: written first, it found the known `node` and `type list` cases plus **two nobody had reported**, `grant create` and `grant delete`, where the principal-group variants shadow the principal ones and granting a role to a principal has no CLI path at all ([#357](https://github.com/hyperscaleav/omniglass/issues/357)). Those four are carried as an explicit list that may only shrink, since the guard also fails on an entry that stops colliding. - **The CLI naming rule is derived from the whole route, not the leaf noun** ([ADR-0059](/architecture/decisions/#adr-0059-every-collection-segment-is-a-command-level)): every collection segment is a level, so a subresource is addressed under its owner (`component property list`, `principal grant create`). The old leaf-noun rule produced **24 collisions across 195 operations** (`property list` seven ways), each silently unreachable and each patched by hand afterwards: `nameOverride` had reached 53 entries whose comments all described the same defect. It is now 14, all genuinely non-AIP `/auth` routes. The generator's grouping also became a real N-level tree, since bucketing by the first word and naming by the last collapsed three-word paths back into collisions. 67 of 202 commands renamed, 135 unchanged, zero collisions. `omniglass statu` (the depluralizer eating `status`) is gone. Two guards keep it: the collision test, whose known-collision list is now empty, and `TestDocsOnlyNameRealCommands`, which fails when a guide teaches a command that does not resolve and immediately found one that had never existed in any build plus two with no API route ([#359](https://github.com/hyperscaleav/omniglass/issues/359)). - **The `/types` umbrella is retired** ([ADR-0060](/architecture/decisions/#adr-0060-a-resource-is-one-kebab-case-noun-nesting-means-ownership)): the location type registry was addressed two ways, `/types/location` for its CRUD and `/location-types/{id}/properties` for its contract, so one entity had two command groups and an operator had to know both spellings. The registry CRUD moves to `GET/POST /location-types`, `PATCH/DELETE /location-types/{id}`, and the secret shape registry to `GET /secret-types`. The rule is now stateable in one line: a resource is one kebab-case noun, and nesting means ownership. The `type` command group is gone; `location-type list` is the registry and `location-type property list` its contract. Addressing only: same handlers, same permission gates, same scope injection, no schema change. - **"Latest" in a calculated series means the highest id** ([ADR-0061](/architecture/decisions/#adr-0061-a-calculated-series-is-current-at-its-highest-id-not-its-newest-timestamp)): a health row's `ts` is `clock_timestamp()` evaluated before its identity id is assigned, so two concurrent inserts can land with the two orderings disagreeing about which row is current. Every production reader already ordered by id; the health test helper ordered by `ts`, which is what made `make test` intermittently red with a verdict the engine never produced. `LatestState`, which backs the ingest transition guard, ordered by `ts` alone and now tie-breaks on id, so a poll cycle stamping several rows in one instant can no longer resolve to an arbitrary one. Reproduced deliberately (six concurrent copies of the storage package; never once in nine idle full-suite runs) and verified over 24 runs under the same load. - **The variable and secret cascades get their routes.** `GET /components/{name}/effective-variables` and `/effective-secrets` resolve the same cascade the tag route resolves, returning the winner **and** the shadowed candidates with the tier each came from. The gateway resolvers had been written and tested since the cascade slice and were **unreachable**: no route, so no CLI command and no console read, while the CLI guide taught `omniglass effective-secret list` as though it worked ([#359](https://github.com/hyperscaleav/omniglass/issues/359), found by the docs-command guard). The two are gated differently on purpose: a variable read rides `variable:read`, a secret read rides **`secret:read`**, which the viewer floor deliberately does not carry, and returns **masked** fields. The effective read answers which secret applies and where it comes from, never what it contains; plaintext stays behind the audited reveal, proven by a test that fails if the seeded plaintext ever appears. The CLI commands generated with no generator change (`component effective-variable list`, `component effective-secret list`), which is the parent-qualified naming rule paying off. **Thin cut:** the console's resolution panel still shows tags only ([#365](https://github.com/hyperscaleav/omniglass/issues/365)). - **A list row carries both identities.** Every estate entity has a **key** (`name`, the kebab identifier the API and CLI address it by) and an optional **display name**. The console showed one or the other and never both, so the string an operator needs for `omniglass component get ` was invisible on every list but Nodes. The row now shows the display name with the key beneath it, muted and in the data face, always visible rather than on hover: hover does not exist on touch, is not discoverable, and cannot be selected to copy, and copying it is the point. When an entity has no display name the label **is** the key, so it renders once, in the data face, marking it as an identifier. **Nothing is derived:** sentence-casing `hq-boardroom-dsp` gives "Hq boardroom dsp", and this domain is acronyms (DSP, HDMI, NVX, PTZ, UC, AVoIP), so mechanical casing mangles them and makes an absent display name look like a typo rather than an absence. The rule now lives in one place (`entityLabel`), replacing **nine** copies of `display_name || name` across the node helper, three page mappers, two local `label` helpers, and the owner and parent pickers. - **Create forms lead with the display name and derive the key as you type.** An operator types "Conf Room 301" and the key fills in as `conf-room-301`, editable underneath, instead of having to invent a kebab identifier and get the character class right. `deriveKey` folds diacritics (so "Café" is `cafe`, not `caf`), collapses punctuation runs, trims leading and trailing separators, and respects the 100 character ceiling without leaving a trailing `-`; it only ever produces the empty string or a key matching the `^[a-z0-9][a-z0-9-]*$` the API enforces, asserted against that pattern directly. **The moment the operator edits the key it becomes theirs and stops following**, which is the rule that makes the pattern usable rather than infuriating, and the field says which state it is in. An existing entity's key is owned from the start, so relabelling can never rename: the API takes a rename explicitly and it stays a deliberate act. The coupling is one primitive (`createIdentity`) rather than three copies of a signal pair, on Components, Systems, and Locations. - **The resolution panel reads all three cascades, in one list.** It explained the tag cascade only; the variable and secret cascades had routes since the effective-values slice and no console read. All three are the same engine, so they share one table with a **kind** column rather than tabs or stacked sections: their bands genuinely differ (a variable seeds its system band from the **primary** membership, a secret has **no** system band at all), and one list makes that visible instead of hiding it behind a control. A secret shows its provenance and the word `masked`, never a value: which credential a device resolves and from where is the operationally useful fact, and plaintext stays behind the audited reveal. The system selector appears only when a tag is in play, since it is the only kind that answers per-system. A caller without `secret:read` simply sees the other two kinds. Fixed on the way: `bandLabel` still said **global** for the install-wide tier, months after [ADR-0057](/architecture/decisions/#adr-0057-the-cascades-least-specific-tier-is-platform-and-a-default-is-not-a-tier) renamed it to `platform`. Its test asserted `from global` against a fixture whose `owner_kind` was `global`, a value the API stopped producing at that rename, so the label and its test were stale together and the suite stayed green. - **`product` and `vendor` take uuid primary keys** ([ADR-0062](/architecture/decisions/#adr-0062-a-registry-takes-a-uuid-primary-key-and-a-renameable-handle), slice 1 of [#262](https://github.com/hyperscaleav/omniglass/issues/262)): their kebab id becomes `name`, a unique **renameable** handle, and five inbound foreign keys move to the uuid (`product.vendor_id`, `product.parent_product_id`, `product_capability`, `product_property`, and `component.product_id`). The registries were the last place a foreign key pointed at a mutable string, so a product id could not be corrected after a typo or a rebrand. The API now carries **both** forms on every body and accepts either wherever a product or vendor is named. Proven by `TestRegistryHandleRenameKeepsReferences`, which renames both handles under a live sub-product, contract, capability set, and component instance, and asserts every reference resolves and reads the new handle; **mutation-checked**. The remaining seven registries follow, and five of those are a **rename** of `id` to `name` rather than an addition, since the registries did not agree with each other on the column name to begin with. - **`capability` and `standard` take uuid primary keys** (slice 2 of [#262](https://github.com/hyperscaleav/omniglass/issues/262), [ADR-0062](/architecture/decisions/#adr-0062-a-registry-takes-a-uuid-primary-key-and-a-renameable-handle)): their kebab id becomes `name`, and eight inbound foreign keys move to the uuid, `capability` from `product_capability`, `role_capability`, `component_capability`, and `alarm_capability`; `standard` from `standard_property`, `system_role`, its own `parent_standard_id`, and `system.standard_id`. Every read that returned a capability set (the effective-capabilities resolver, the role requirement lists, an alarm's degraded set, the health rollup) now projects the capability's **name**, so the wire surface is unchanged while the storage key is a uuid. Two migration facts from slice 1 recurred and are worth the note: a renamed table keeps its old primary-key constraint name (here `standard` still answered to `system_type_pkey`), so the drop looks the name up from the catalog rather than guessing; and recreating a column drops the NOT NULL and the unique constraints it carried, so an unknown capability that resolves to null on a not-null arc is mapped back to the ref-not-found sentinel rather than surfacing as a 500. The rename test now renames a capability and a standard under a live product, role, and contract and asserts each still resolves and reads the new handle. - **`property` takes a uuid primary key, and telemetry keys become real foreign keys** (slice 3 of [#262](https://github.com/hyperscaleav/omniglass/issues/262), [ADR-0062](/architecture/decisions/#adr-0062-a-registry-takes-a-uuid-primary-key-and-a-renameable-handle)): the largest registry conversion. `property`'s kebab id becomes `name`, and its **seven** references move to the uuid: four contract/value tables (`product_property`, `standard_property`, `location_type_property`, `property_value`) that already referenced it, plus the `key` column on `metric_datapoint`, `state_datapoint`, and `event`, which had been a loose property name with **no** foreign key. Those three become real `property_id` foreign keys, so a rename now follows an observation series, not only a contract. Every telemetry key was already a registered property (reject-not-project drops an unregistered name at collection), so the constraint only makes the invariant the database's as well. Every read still projects the property's **name** as `key` and `property_name`, so the wire surface is unchanged. Proven by `TestRegistryHandleRenameKeepsReferences`, extended to rename a property under a live contract, a declared value, and a metric datapoint, and assert the datapoint reads the new key. The remaining four leaf registries are slice 4. - **`location_type`, `secret_type`, `driver`, and `interface_type` take uuid primary keys** (slice 4 of [#262](https://github.com/hyperscaleav/omniglass/issues/262), [ADR-0062](/architecture/decisions/#adr-0062-a-registry-takes-a-uuid-primary-key-and-a-renameable-handle)): the last of the epic, closing the invariant that no foreign key anywhere points at a mutable string. `location_type`, `secret_type`, and `driver` are a **rename** of their kebab `id` to `name`; `interface_type` already called its slug `name` and only gains the uuid, as `property` did. Five inbound foreign keys move to the uuid: `location.location_type` and `location_type_property.location_type_id`, `secret.secret_type`, `product.driver_id`, and `interface.type`. Every read still projects the type's **name** (a scalar subquery aliased to the wire field), so the wire surface is unchanged while the storage key is a uuid, and the product body now carries a `driver` handle beside `driver_id` as it carries `vendor` beside `vendor_id`. `allowed_parent_types` stays a text array of type **names**, not a foreign key, since it is a self-reference the placement validator resolves by name. Proven by `TestRegistryHandleRenameKeepsReferences`, extended to rename all four under a live location, a location-type property contract, a secret, a product, and an interface, and assert every reference resolves and reads the new handle; **mutation-checked**. With this slice every registry is uuid-keyed; retiring the slug-key carve-out in the doctrine and the guard allow-lists is the epic's closing slice. - **The slug-keyed exception is retired** (slice 5 of [#262](https://github.com/hyperscaleav/omniglass/issues/262), [ADR-0062](/architecture/decisions/#adr-0062-a-registry-takes-a-uuid-primary-key-and-a-renameable-handle), the epic's close): a docs-and-guard slice with one real fix. The [api-first](/contributing/api-first/) rule now reads "every foreign key stores a uuid, no exception"; [ADR-0056](/architecture/decisions/#adr-0056-every-foreign-key-stores-a-primary-key)'s slug-keyed carve-out is marked retired. The `TestReferencesCarryBothForms` guard drops its slug-keyed allow-list: the registry references (`product_id`, `vendor_id`, `driver_id`, `standard_id`, and the two parents) move into the both-forms rule, and emptying the allow-list **surfaced a real gap**, the component response carried `product_id` without the product's name, now fixed by adding the `product` handle to the component body (the guard's whole purpose, catching a uuid-only reference). The storage helper collapses in step: the per-registry `productRefCol` / `vendorRefCol` and the `registryHandles` set fold into one `registryRefCol(ref)`, since every registry behaves identically now. **Epic #262 is complete**: no foreign key anywhere points at a mutable string. - **`role` (IAM) takes a uuid primary key**, the last named entity still keyed by its slug. It gains a uuid `id` and demotes the slug to a unique, renameable `name`, and its one inbound reference, `principal_grant.role_id`, moves to the uuid. The RBAC engine is unchanged: it keys roles by name and expands `inherits` by name, so the well-known handles (`owner` / `viewer` / `operator` / `deploy` / `admin`) stay stable and the storage layer resolves `role_id` to a name on read and a name (or uuid) to the uuid on write. The owner invariant, previously holding the literal slug `'owner'` in its trigger, now resolves owner by name (`(select id from role where name = 'owner')`), and the roles view and grant bodies carry the role's name beside the uuid. With this every named entity in the schema is uuid-keyed; the only non-uuid identities left are deliberate (`node.principal_id`, the polymorphic `scope_id` / `resource_id`, content-addressed `blob` / `task`). - **The type-registry references carry both forms**: the `interface`, `location`, and `secret` bodies gained `type_id` / `location_type_id` / `secret_type_id` beside their name (`type` / `location_type` / `secret_type`), which were previously name-only, an inconsistency with the both-forms rule the epic enforces everywhere else. `TestReferencesCarryBothForms` now guards **both directions**: the forward scan already caught a `*_id` with no name; a reverse pass over a curated set of unambiguous registry name-fields (`location_type`, `secret_type`) now catches a registry handle with no id, closing the gap that let those name-only references slip past (the forward scan has no `*_id` field to trip on). `type` stays forward-covered on the primary interface body alone, since it also names an RFC-9457 error type and a secret field's data type. The `property_name` contract and value bodies are a follow-up. - **The interface's registry field is renamed `type` to `interface_type`** (with `interface_type_id`), matching `location_type` and `secret_type` so the interface reference reads uniformly and can join the guard's reverse set (a bare `type` could not, since it also names an RFC-9457 error type and a secret field's data type). The create flag becomes `--interface-type`. The diagnostic reachability row keeps its `interface_type` name-only (a display value never fed back to a write), recorded as an explicit reverse-check exemption. - **The declared-property contract and value bodies carry `property_id`** beside `property_name`, the last name-only registry references: the product / standard / location_type contract lines, the component / system / location value bodies, and the effective-property resolution. `property_name` joins the guard's reverse set, so a contract or value body can no longer name a property without its uuid. The **event body carries `property_id` beside its `key`** too (`key` is the datapoint-key vocabulary for the property name on the telemetry bodies): the event row already stored `property_id` exactly as metric and state do, so exposing it is just a projection. The guard accepts `key` as property_id's name pair on the telemetry body; `key` stays forward-covered rather than joining the reverse set, since it is also a tag-binding field and so ambiguous. Every registry reference in the API now carries both forms. - **The migration chain is collapsed to a single init.** The 69 per-slice migrations that carried the schema from the first commit through the uuid-key epic are squashed into one `db/migrations` file that reproduces the current schema exactly. Verified by round trip: a fresh apply of the full chain and a fresh apply of the lone init produce a **byte-identical** `pg_dump`. It is pure DDL (no seed rows, which a squash drops), reuses the original initial migration's version so an existing database treats it as already applied, and carries a real `down` that drops every table. The workflow is unchanged going forward: the next schema change is still a new migration on top, never an edit to this one. One migration-history test that rolled the database back to a now-absent version was removed; the resolution behaviour it checked is covered by the cascade suite. (The stale constraint names the renames left, `component_make_*` on `vendor` and so on, are preserved faithfully by the squash; renaming them is a separate cosmetic pass.) - **The observation tables lose the `_datapoint` genus**: `metric_datapoint` becomes `metric` and `state_datapoint` becomes `state`, matching their bare-noun sibling `event` on the principle that the noun is the entity and classification takes a `_type` / `_kind` suffix (the `datapoint_type` classifier already became `property`). Each table name now equals the `property.kind` value it stores (`kind='metric'` to `metric`), and the freed name `datapoint` stays available for the future UNION view over the kind-tables. Verified collision-free (not Postgres keywords, no existing table or column named `metric`/`state`/`log`). Dependent object names (pkey, sequence, indexes, FK and CHECK constraints) keep their `_datapoint` identifiers for now, as the `#262` renames left `vendor` and `standard`; the pending migration collapse cleans every stale identifier in one pass. Table-only rename, no data or wire change. - **The stale schema identifier names are cleaned, and two role tables disambiguated.** The collapse preserved every dependent object name verbatim, so the single init carried constraints, indexes, and sequences whose prefixes no longer matched their tables: `metric_datapoint_*` and `state_datapoint_*` on the renamed `metric` / `state`, `component_make_*` on `vendor`, `system_type_*` on `standard`, `datapoint_type_*` on `property`. Each is brought back in line with its table, and the `#262` artifact is fixed too: every registry had a `*_uuid_id_not_null` on its uuid `id` column while the text handle kept the old `*_id_not_null`, now `*_id_not_null` on `id` and `*_name_not_null` on `name`. Separately, `role_capability` and `role_assignment` become `system_role_capability` and `system_role_assignment`: both belong to the AV [system role](/architecture/core-entities/) (a slot a component fills in a system), not the IAM `role` table, and the bare `role_` prefix invited exactly that confusion. Identifiers only, no behaviour change: the init applies clean and the full suite is green. - **The name foundation of [ADR-0063](/architecture/decisions/#adr-0063-the-telemetry-model-is-typed-registries-over-bare-noun-data-tables) lands: `property_type`, `property`, `property_type_id`.** The signal-definition registry (long carried in the docs as `datapoint_type`, and built as `property`) becomes **`property_type`**; the latest-value store `property_value` takes the freed bare noun **`property`**; and every data table's FK to the registry (`metric`, `state`, `event`, `product_property`, `standard_property`, `location_type_property`, and `property` itself) is repointed `property_id` to **`property_type_id`**, on the wire as well as in the column. A guarded dbmate migration does the table and column renames (the stale dependent object names are left for a cosmetic pass, as the collapse did); the storage structs (`PropertyType`, `Property`), the API bodies, the generated OpenAPI, typed client, CLI, and JSONSchema, and the console all follow. This closes the `datapoint_type`-to-`property` divergence the docs carried: the registry is `property_type` everywhere. The rename is **complete, not a thin cut**: the public resource surface moves too (the registry is `GET/POST /property-types` under the `property_type:*` permission, while the owner-scoped `/{owner}/properties` value routes stay), the both-forms name pair reads `(property_type_id, property_type_name)`, the audit resources are `property_type` / `property`, and a second migration block renames every dependent constraint and index in line with its table. The fuller ADR-0063 model (the provenance-keyed cache, the `event_type` family, the `command` pillar) is still staged. - **A component's product, location, and parent, and a system's location and parent, become patchable ([ADR-0064](/architecture/decisions/#adr-0064-placement-and-classification-are-mutable-after-create)).** These fields were accepted on create but had no update path, so a mis-classified or physically moved entity could only be deleted and recreated, losing its telemetry history. `PATCH /components/{name}` and `PATCH /systems/{name}` now take them on the house three-state (omitted unchanged, empty string clears, name sets); a reparent is cycle-guarded and scope-injected like a location move, and a product swap keeps hand-set property values while the new contract's defaults follow. No new UI: this opens the API and CLI and unblocks the CRUD form primitive's generated edit form. Closes [#342](https://github.com/hyperscaleav/omniglass/issues/342). - **The `property` latest-value cache and the reconciliation read land ([ADR-0063](/architecture/decisions/#adr-0063-the-telemetry-model-is-typed-registries-over-bare-noun-data-tables), first feature slice).** `property` becomes the single-lookup latest-value cache for the producer provenances: the telemetry ingest sink, after appending the `metric`/`state` sample, derives the newest value per `(owner, property_type, instance, provenance)` series through a new `UpsertProperties` gateway, where an out-of-order guard keeps a late older sample from displacing a newer latest. The derive is **non-gating** (a failed upsert is logged, never fails the ack, and the cache is rebuildable from the append-only samples) and idempotent, so a redelivery does not double-write. `LatestValue` returns the current value in one lookup instead of an `order by ts` scan, and a **reconciliation** read (`GET /components/{name}/reconciliation`) pivots **want** (declared, resolved live from the cascade via `EffectiveProperties`, never a cache row), **told** (intended), and **is** (observed), with config-drift computed on read; a read-only Reconciliation panel on the component detail teaches the pivot. Only `observed` has a live producer today; `intended` (the command pillar) and `calculated` (the calc engine) are tested cache slots. One additive migration adds the value's own `ts` to `property`. The [datapoints](/architecture/properties/) and [config, secrets, and variables](/architecture/variables/) pages stay `Partial` (the cache and reconciliation are built; the intended/calculated producers, log-to-event, calc, and fusion remain). Part of [#394](https://github.com/hyperscaleav/omniglass/issues/394). - **The event_type family lands: events get their own registry ([ADR-0063](/architecture/decisions/#adr-0063-the-telemetry-model-is-typed-registries-over-bare-noun-data-tables), second feature slice).** Events move out of `property_type` into their own **`event_type`** registry, the occurrence keyspace and the twin of `property_type`. A guarded migration creates `event_type`, repoints `event` off `property_type` (kind=log) onto `event_type`, adds the **`origin`** column (caught/caused/derived/ scheduled) plus the `caused_by_event_id` and `correlation_id` causation columns, and drops the `log` kind from `property_type` (now `{metric, state}`). Event rows are ephemeral telemetry logs, so the repoint clears the occurrence log rather than backfilling a type. The registry ships full CRUD (`/event-types`, gated `event_type:*`, seeded official `call.started`, official rows read-only), a generated CLI and typed client, and a console **Event Types** catalog page mirroring Properties. The **native caught path** is built: a component that publishes an event (an xAPI event, a trap) has its registered `event_type` name route it to the `event` sink with `origin=caught`, under the same owner-confinement and reject-not-project as a metric or state, and the events panel shows the origin. Raw log lines are a **separate ingest lane**, not events ([ADR-0066](/architecture/decisions/#adr-0066-logs-are-a-raw-ingest-lane-not-events)): the `log_line` table and the derivation rules that turn a log line into an event are a later slice. Only the caught path has a producer today; `caused` is the command pillar (#396) and `derived`/`scheduled` need the rule engine and the clock (the action layer). [events](/architecture/events/) advances `Design` to **`Partial`**; [datapoints](/architecture/properties/) notes the `log` kind's graduation. Part of [#395](https://github.com/hyperscaleav/omniglass/issues/395). - **The command pillar lands: the do primitive and computed settlement ([ADR-0063](/architecture/decisions/#adr-0063-the-telemetry-model-is-typed-registries-over-bare-noun-data-tables), third and final slice).** Events know, datapoints happen, and now a **command** records what a component was told, the third registry (`command_type`) alongside `property_type` and `event_type`. A migration adds `command_type` (with a driver-owned `settle_window_seconds` and an optional `target_property_type`) and the `command` invocation table over the owner arc. **Issuing** a command is one transaction that composes the whole model: it writes the `command`, a **caused** event (`origin=caused`, typed `command.issued`, #395), and, for a settleable command, an **intended** value in the property cache (#394) that the observed value settles against. **Settlement is computed, never stored:** within the settle window a difference is pending, past it a match is settled and a mismatch is failed, the windowed form of the reconciliation read's want/told/is. The registry ships full CRUD (`/command-types`, gated `command_type:*`, seeded `set_input`/`reboot`, plus a `video.input` state and a `command.issued` event_type), the `POST /components/{name}/commands:issue` custom method (gated `command:issue`), a generated CLI and client, and a console **Command Types** catalog page mirroring the property and event catalogs. Deferred (the actuation half): the outbound push to a driver or node, the protobuf `Command` on the wire, and the device acknowledgement. A new [commands](/architecture/commands/) page lands at `Partial`. Part of [#396](https://github.com/hyperscaleav/omniglass/issues/396). - **The datapoint concept retires for property, sample, and current value ([ADR-0065](/architecture/decisions/#adr-0065-property-sample-and-current-value-replace-the-datapoint)).** The one word "datapoint" split the signal from the observation: a **property** is the signal (registry `property_type`), a **sample** is one timestamped observation of it (a `metric` / `state` row, the kind), and the **current value** is the latest sample per series (the `property` cache). A behavior-preserving rename, no tables touched: the proto `Datapoint` message becomes `Sample`, the metric and state datapoint Go types become `*Sample`, `deriveDatapoints` becomes `deriveSamples`, and the value-model page is now [properties](/architecture/properties/) (redirect from the old slug), which carries the current-value cache and want/told/is reconciliation explainer. The ADR also settles that the built `property` cache, a table upserted from the sink rather than the once-sketched metric view, is the architecture-of-record for current values. Part of [#405](https://github.com/hyperscaleav/omniglass/issues/405). - **The platform ERD is generated from the schema ([data model](/architecture/data-model/)).** A new `make gen` step (`cmd/erdgen`) applies the embedded migrations to a throwaway Postgres, introspects `information_schema` (tables, primary and foreign keys), and renders a subsystem-clustered D2 entity-relationship diagram onto the new **Data model** page, so the diagram can never drift from the tables. The `internal/erd` render core is pure and golden-tested; the introspection and a drift gate (regenerate, compare to the committed D2) run against the real schema. Tables land in hand-mapped subsystem containers (identity, estate, catalog, telemetry, collection, config, content, audit); an unmapped table renders in a visible `unclustered` box that trips the drift gate. The sql_table shapes are themed from the brand tokens so the ERD tracks the light/dark toggle. Part of [#406](https://github.com/hyperscaleav/omniglass/issues/406). - **The raw-log ingest lane is built for the read path ([ADR-0066](/architecture/decisions/#adr-0066-logs-are-a-raw-ingest-lane-not-events)).** `log_line` is the raw-arrival table: untyped device text owned through the exclusive arc, with `severity` and `facility` promoted to indexed columns for retention and routing, and freeform `attributes`/`labels`. A log line is not an event, so the write has no registry gate. The read is `GET /components/{name}/logs` (gated `component:read`, scope-injected), with a generated CLI and typed client, and a console **Logs** panel beside the events panel (severity badges, the structured `attributes` and `labels` columns on demand). The dev seed installs the lobby display's device logs (link, CEC, EDID, input, thermal) as log lines, their model-correct home. The generated ERD picks up `log_line` in the telemetry cluster. Still directional: the ingest producer, the derivation engine that turns log lines into events, and the lineage columns. Part of [#410](https://github.com/hyperscaleav/omniglass/issues/410). - **The event lineage columns land ([ADR-0066](/architecture/decisions/#adr-0066-logs-are-a-raw-ingest-lane-not-events)).** `event.caused_by_event_id` is renamed to **`source_event_id`** (the source is the cause), and **`source_log_line_id`** (an FK to `log_line`, the raw line a rule derived the event from) and **`derived_by_rule_id`** (which rule) are added, all nullable, so a natively-caught event carries none. This is the schema the derivation engine writes into: a rule that turns a `log_line` into an event will stamp the two new columns. No producer fills them yet (the derivation engine is the next slice); the generated ERD carries the new shape and the `event -> log_line` edge. Part of [#410](https://github.com/hyperscaleav/omniglass/issues/410). - **The raw-log lane gets its first producer: node self-logs ([ADR-0066](/architecture/decisions/#adr-0066-logs-are-a-raw-ingest-lane-not-events)).** The telemetry `Event` gains a `LogLine` message and a `repeated logs` field: raw log lines are untyped arrival, not registry-resolved samples, so they need their own wire slot. The node now runs an slog handler that writes to its console and buffers every record, **installed as the process default so it is a node-wide emitter**: any node code ships a self-log with a plain `slog.Info(...)` (carrying a `facility`, and optionally a `source`, which the mapping lifts into the `log_line` columns), with no logger threaded down to it, and the buffer is bounded (oldest dropped, count reported) so a chatty stretch between ticks cannot grow the edge process without bound. The run loop drains it each tick and publishes the records as a logs-only Event, which the ingest consumer routes to `log_line` **owner-bound to the node**, ahead of the sample owner-confinement and independent of any task (a logs-only Event lands without a `task_id`). The node narrates its operational story back to the platform (connected to the bus, worklist pulled, a task skipped) instead of swallowing it, and a skipped task becomes a node self-log, not a false-down component state. The read is `GET /nodes/{name}/logs` (gated `node:read`, scope-injected), with a generated CLI (`node log list`) and typed client; the console reuses the `LogsPanel` primitive as a **Self-logs** panel on the node blade. That panel's disclosure is now named for the columns it reveals (`attributes`, `labels`, or `attributes + labels`, each block captioned once open) rather than the overloaded "fields", which collided with a component's property fields and said nothing about which of the two a line carries. The dev seed installs the edge node's example self-logs, and one seeded device log line is **classified with a label**, the ADR-0066 way an untyped line gets sorted into a class without a registry, which nothing in dev exercised before. The end-to-end producer loop is closed by a real node publishing a `worklist pulled` self-log that lands on its `log_line` lane. Still directional: a device-log source (a syslog listener or a poll) and the derivation engine that turns log lines into events. Part of [#410](https://github.com/hyperscaleav/omniglass/issues/410). - **The telemetry wire message is renamed: `Event` becomes `TelemetryBatch` ([#424](https://github.com/hyperscaleav/omniglass/issues/424)).** The protobuf message a node ships was called `Event`, but an event in this platform is a semantic occurrence (`event_type`-registered, carrying an `origin`, per [ADR-0066](/architecture/decisions/#adr-0066-logs-are-a-raw-ingest-lane-not-events)), while the message is the **envelope** that carries samples and, since the log lane landed, raw log lines. Naming the envelope after one of its passengers had put four different meanings on the word "event", three of them in a single function signature. So `proto/og/v1/event.proto` becomes `telemetry.proto`, the message becomes **`TelemetryBatch`** (head-noun-last, matching the subject it travels on, `og.v1.telemetry.`), and the storage write structs align with the `LogLineWrite` convention the log lane established: `MetricSampleEvent` and `StateSampleEvent` become **`MetricSampleWrite`** and **`StateSampleWrite`** (they are insert structs, not events), and `EventOccurrence` becomes **`EventWrite`**. Wire-safe by construction: protobuf encodes field numbers, never message names, so the bytes are identical before and after and a mixed-vintage node and server still interoperate. No behavior change, no schema change, no API surface touched. - **Push ingest: the API telemetry lane ([#423](https://github.com/hyperscaleav/omniglass/issues/423)).** A second ingest lane beside the node's, so data can arrive without a driver, a node, or a protocol client. **`POST /telemetry:push`** takes telemetry in our own schema (a *webhook*, by contrast, is the case where we do not control the payload and something must normalize it, which is driver work) and is gated by **`telemetry:push`** with the caller's **scope as the fence**: the owner is declared in the body and read back through the gateway, so an out-of-scope owner is a non-disclosing 404. That is the inverse of the node lane, where the server must derive the owner because a node cannot be trusted to assert one. The payload is **two arrays, split typed versus untyped**: `samples[]`, where the **registry** decides the destination (metric, state, or a caught `event`), and `logs[]`, which are untyped and ungated. The caller supplies a name, never a table, so reject-not-project stays the only authority over where a value lands. Rejection is **synchronous** (validated at the route against the in-memory registry snapshot) while acceptance stays asynchronous, because a 202 that reveals nothing is useless to a human who mistyped a name. A push **publishes onto the data lane** (`og.v1.api.telemetry`) rather than writing Postgres directly, since the rule engine is designed as a stream consumer and anything skipping the stream would be invisible to every rule, alarm, and derivation. Trust is decided by **which subject a batch arrived on**: the lane sits outside the single-token `og.v1.telemetry.*` wildcard (a node named for any literal reserved there would otherwise be granted it), only the server's credential can publish to it, and a node-lane batch that asserts an owner is **dropped, not ignored**. Both lanes land through one extracted **`land()`**, so a new lane cannot drift from the node lane's semantics. Three proto fields earn their place: `owner`, `source` (the node lane fills provenance from `interface_type`, which a push has none of), and `Sample.instance` (a first-class dimension the wire could never express, since the node lane smuggles it via the interface name). Both fall back to the interface-derived value, so the node lane is unchanged. Still directional: owner kinds beyond `component` ([#422](https://github.com/hyperscaleav/omniglass/issues/422)) and webhook normalization. - **Templates are redefined, not deferred ([ADR-0071](/architecture/decisions/#adr-0071-a-template-is-a-clonable-example-not-a-versioned-shape-an-instance-pins)).** The decision log and the code had drifted into disagreement and neither said so. ADR-0045 deferred "a product's own template or field-schema binding" and ADR-0049 stated "Templates and their frozen BOM stay `Design`", so the log asserted the pinned model was merely *deferred*, while the shipped schema pointed a component at `product_id` and a system at `standard_id` and ADR-0047 and ADR-0048 had retired `component_type` and `system_type` outright. A template is now an **example configuration an operator clones**: forking is one-time, with no inheritance and no back-pointer, so **a template is upgrade-safe precisely because nothing stays connected to it**. The versioned-shape model retires with the pin (`component_template`, `system_template`, their `*_version` rows, the `stable` / `beta` channels, the frozen BOM, "an instance pins a version"). A component's shape is its **`product`**; a system's is the **`standard`** it **conforms** to with live inheritance. Forking applies template to row, conformance applies row to instance, and only the second is live. The word survives with its purpose inverted: the pin existed so a template could not change under an instance, the fork exists so it can change freely. `templates.md` is rewritten to the fork model (its frontmatter description carried the retired nouns into search results), the cascade loses two rungs (a template cannot be a resolution tier when nothing points at it), and the glossary's two shape rows become supersession entries. Two denylist entries land with it, scoped to the snake_case identifiers on purpose: the *word* template is not retired, so prose like "start from a template" stays correct. Still `Design`: the template loader, its catalog, and the create-from-template flow ([#317](https://github.com/hyperscaleav/omniglass/issues/317)), which now also covers instantiating a whole system. - **The collect layer is authorized: a driver consumes transports, and a transport is code ([ADR-0073](/architecture/decisions/#adr-0073-a-driver-consumes-transports-a-transport-is-code-not-a-row)).** [ADR-0039](/architecture/decisions/#adr-0039-an-interface-is-a-device-api-the-interface-type-is-its-transport-not-its-driver) recorded the driver-centric split as "the current-best direction, **not a locked gate**" and named its own successor: driver-centric versus template-centric had to be re-examined "in a later ADR **before the collect layer is built**". That ADR was never written, so every collect slice sat behind the scope gate. This is it, and it confirms rather than reverses: `interface_type` is the **transport**, decided to be a **code** registry (one package per transport) rather than an operator-editable table, which retires the table, its FK from `interface.type`, and the hand-written dispatch switch in `internal/node/probe.go` in a later slice. A **driver** declares the transports it can run over and is never one of them. The `interface_type` **is** a driver clause in ADR-0067 is **retracted**: a calendar integration is an `https` transport plus a `graph-calendar` driver, which is the drift a table invites, since any string fits where a transport belongs. Two denylist entries land with it, matching the **claim** rather than the two nouns, so ordinary prose naming both a transport and a driver stays correct. Nothing here is built: `driver` still carries identity only (name, version, official), and the catalog, the normalized menu, discovery, and the device pack are the [collect layer epic](https://github.com/hyperscaleav/omniglass/issues/489). Deliberately left open, each with a home: whether a product may bind more than one driver, whether a product is versioned or stays a live classifier ([#491](https://github.com/hyperscaleav/omniglass/issues/491)), and where cadence lives. - **The feature-loop framework: work defined once, approved once, shipped as one rollup PR ([ADR-0074](/architecture/decisions/#adr-0074-an-approved-definition-rolls-up-to-one-pr-slices-cascade-on-an-integration-branch)).** The [feature loops](/contributing/feature-loops/) contract page defines how a body of work executes through an agent loop with exactly two human touchpoints: the architect approves a prose definition at the front and merges one rollup PR at the end, while sub-issue slices cascade through per-slice gates (test-first, the docs lints, an adversarial review) on an integration branch between them. Four skills operationalize it: `/define-work` (the nine-section approvable shape), `/run-feature-loop` (the executor run-book), `/add-api-route` (the middle-of-slice procedure, built from a code study so every named symbol resolves: `gated(...)`, the scoped-CRUD primitive, `writeAuditRes` in-transaction, the ADR-0062 addressing rule, the gen ripple), and `/adversarial-review` (refute-not-confirm, a concrete failure scenario per finding, sweeping a twelve-entry anti-pattern catalog seeded from the 2026-07-30 audit's failure classes). Generate-first is codified as a house rule with its `/ship-slice` gate, and the harness gains unattended-run plumbing (a permission allowlist and a SessionStart environment check pinned to the gen-drift versions). Built as the [AI-driven feature loops epic](https://github.com/hyperscaleav/omniglass/issues/488); the pilot loop ([#502](https://github.com/hyperscaleav/omniglass/issues/502)) runs one approved drift batch through the whole frame before any feature loop launches. - **The generated-facts rails: schema, seed, and environment claims can no longer drift ([#433](https://github.com/hyperscaleav/omniglass/issues/433)).** Three artifacts now render the fact classes the 2026-07-30 audit found drifting worst, each committed by `make gen`, gated by `gen-drift.yml`, and pinned by its own drift test. `schema.json` (erdgen's introspection, extended to nullability, defaults, CHECK constraints, and unique indexes) feeds the `SchemaTable` component, and eleven architecture pages plus [tags](/architecture/tags/) now render their storage-table mechanics from it, hand-written narrative kept as notes; the sweep verifies no remaining hand-written row claims a live table's columns. `seed.json` (seedgen, no database: the twelve embedded seed YAMLs, with role rows carrying **effective** permissions through the same rbac path the authorizer uses) feeds `SeededSet`, and five admin guides render their shipped-set claims from it, retiring the enumerations that undercounted the property catalog. `config.json` (configgen) renders the new **declarative environment registry**: every variable the binary reads is declared with its doc and scope, `config.Get` refuses an undeclared key, and the container and Helm guides render their env tables from it (the Helm guide previously documented none). Two API guards land with it: every gated operation's description must name its `x-omniglass-permission` stamp verbatim (one shipped deviation corrected: `principal:purge:admin`), and api.md's last hand-written route table is replaced by the generated `/reference/api/`. Built as the [#502](https://github.com/hyperscaleav/omniglass/issues/502) pilot of the feature-loop framework; the run's lessons landed on [feature loops](/contributing/feature-loops/) in the same PR. - **Scope and secrets integrity: the schema admits only what the model allows ([#431](https://github.com/hyperscaleav/omniglass/issues/431)).** The unifying idea, a value the code refuses must not be a value the schema, the API, or the console offers, closed across eight slices. The secret directory now **injects scope into its query** through the new arc-scope primitive (`arcScopeCTEs` / `arcScopePredicate`, the exclusive-arc counterpart of the scoped-CRUD walk; one query regardless of row count), retiring the audit's `unscoped-gateway-list` anti-pattern; the secret **system band** is gone from the CHECK, the enum, the console picker, and the docs (finishing ADR-0052); the sample provenance domains lost their impossible `declared` arm; `scope_kind='group'` stopped being advertised by the schema and both grant enums (group-as-scope returns with its slice); the decoy `platform_setting` table and the eight unrouted seed grants are gone, the ship-ahead allowlist now deliberately empty under a reversed policy (a grant lands with its first route); the Helm chart defaults session cookies to `Secure`; and `alarm` gained its **condition identity** ([ADR-0075](/architecture/decisions/#adr-0075-an-alarms-condition-identity-is-a-raiser-supplied-dedup-key)): a `dedup_key` plus the `alarm_open_condition_key` partial unique index make one-open-per-condition a database fact, with `RaiseAlarm` a guarded conditional insert returning the existing incident instead of a duplicate. Run as the second feature loop on the ADR-0074 machinery. - **Console address honesty and the audit diff ([#432](https://github.com/hyperscaleav/omniglass/issues/432)).** The console now addresses everything the way [ADR-0062](/architecture/decisions/) promised: the uuid is identity, the kebab `name` is what an operator reads, types, and posts. Nine slices: the location_type and secret_type pickers post the handle the write paths resolve (a fresh install could not add a location from the console before); every registry write accepts either form through `registryRefCol`; the six catalog pages (Drivers, Vendors, Products, Capabilities, Standards, Types) lead with a Name column, filter and address rows by the handle, and keep the uuid as a dim blade fact; Products renders vendor/driver handles and its capability picker joins on the names the wire carries, so removal works; a successful create now lands the operator on the new row's blade across all twelve catalog pages; the 52 blank CLI-reference flag descriptions are filled at their Huma `doc:` tags and a docslint check keeps the class from recurring; and the audit read finally answers "and to what?": `old`/`new` ride the wire and the Audit page's first detail surface renders a field-level before/after diff (a pure `diffFields` projection), with redaction swept and pinned by an e2e (a secret's audit body carries metadata only). The prevention half is a fixture guard: console test fixtures must mint uuid-shaped registry ids (`uuidFor`), and converting 25 files flushed out four more live uuid-vs-name joins (the Systems standard picker, RoleEditor, AlarmsPanel, CapabilitiesPanel), fixed in the same sweep. The `uuid-as-address` anti-pattern is retired in the adversarial catalog. Run as the third feature loop on the ADR-0074 machinery. - **The docs corpus restructures around the design fence ([#434](https://github.com/hyperscaleav/omniglass/issues/434)).** A `:::design` container marks unbuilt prose structurally: it renders as a dashed target-design aside, must name the issue or ADR that tracks its gap (an unowned fence fails the build), and is machine-readable (`internal/docslint`'s `Regions`). The thirty hand-written "Still Design" summaries across fourteen pages migrated onto fences at the sections they described; the cascade and the two-lane data plane each consolidated to one home (cascade.md, messaging.md); the six entirely-unbuilt pages moved to a Design sketches sidebar group with page-spanning fences; this build log split off status.mdx (which dropped from 2,300 lines to 80); the glossary reconciled term-by-term against the generated schema; and a badge-fence lint now fails `make test` when any architecture page's badge disagrees with its fence census, so the badge is derivable, not asserted. - **The drift-prevention gates land: a documented fact must exist ([#429](https://github.com/hyperscaleav/omniglass/issues/429)).** Seven slices close the loop the 2026-07-30 audit opened. `make test` now fails when current-tense prose names a route absent from the OpenAPI document (methods checked, fence-aware, with an illustrative-marker escape), a permission that gates no route (both directions: doc mentions and seeded grants, matched with the real rbac rules minus the read floor, handler-enforced admin tiers registered against their call sites), a make target, env var, or repo path that does not exist (with the generated config registry counting as documentation by construction), or a `table.column` the generated schema facts do not carry (seeded dotted registry keys exempt). The CLI docs guard now validates documented flags against the cobra tree and walks the architecture and contributing trees too. The semantic drift no lint can see gets the periodic `/docs-audit` skill, encoding the audit's fan-out, drift definition, and issues-not-files landing rule. --- # Calculations URL: /architecture/calculations/ The rule families that run server-side over typed samples, and calc_rule in detail: cross-key and system-level derivation. :::design[Target design: the calc rule family, tracked in #434] Parsing a raw payload into samples is the **edge function** ([collection](/architecture/collection/)), not a server-side rule: a function extracts, keys, and normalizes on the node and emits resolved samples. The rules that run server-side over the typed samples 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 samples arrive on the NATS **trusted** `samples` 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 samples and **publishes** its derived samples 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 samples 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 [samples](/architecture/properties/) for the data lane and [events](/architecture/events/) for the record lane. ## Rules: calc, event, action - **calc_rule**: samples to sample (calculated). The subject of this page (below). - **event_rule**: sample 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 samples from the data lane (NATS) and publishes a sample 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 sample. 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/properties/#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/properties/#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/properties/#the-instance-dimension-many-values-of-one-key-on-one-owner) for the full instance model. Calc is one half of [fusion](/architecture/properties/#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 [samples](/architecture/properties/#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: [samples](/architecture/properties/) (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` (the tag arc has a fifth kind, `node`) 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/). The operator-facing **resolve view** is also built: the three `/components/{name}/effective-*` routes return the winner plus the ordered shadowed bindings, rendered by the console ResolutionPanel. Still `Design`: the two **template** bands, [group](/architecture/groups/) placement by weight, additive **rule** accumulation with suppression, and the fall-through-to-declaration provenance in the resolve view. ::: 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. *(the system's standard beats the component's product)* - **"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 product the leaf component's product bindings standard the owning system's standard 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_value)). | | [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. :::design[Group placement by weight, tracked in #10] **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. For the additive rule combinator, the same weight also resolves an add-versus-suppress conflict between layers. 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 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. ## The registry is outside the cascade `property_type` defines **identity** (kind, unit, validation, fusion_policy) for every property, 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 standard: Std Huddle Room - - - 100 product: 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 model URL: /architecture/data-model/ The platform entity-relationship diagram, generated from the live schema by make gen: every table, its keys, and its foreign keys, grouped by subsystem. This is the whole platform's relational shape in one diagram: every table, its primary and foreign keys, and the references between them. It is **generated from the live schema**, not drawn by hand, so it cannot fall out of step with the tables. When a migration adds or changes a table, the next `make gen` redraws this page, and a schema change that forgets to regenerate is caught by the ERD drift test, the same way a stale generated client is. ## How to read it The diagram groups tables into **subsystem containers**. Each table is a node showing only its **primary-key and foreign-key columns**, the relational skeleton, so a forty-plus-table platform stays legible; the full column list of any table lives in its migration and its Storage Gateway type, not here. Each edge is a **foreign key**, drawn from the referencing column to the row it points at. Edges that cross container borders are the seams between subsystems: `estate.component.product_id` pointing at `catalog.product.id`, or `catalog.product_property.property_type_id` pointing at `telemetry.property_type.id`. The generator would render a table not yet assigned to a subsystem in an `unclustered` container, but a committed diagram never shows one: the introspection test hard-fails on any unmapped table, so the failure is the prompt to add a one-line entry for the new table to the cluster map in `internal/erd/cluster.go` (`cmd/erdgen` only calls it). :::note[Generated: do not edit the diagram] The D2 between the markers below is written by `cmd/erdgen` (it applies the embedded migrations to a throwaway Postgres, introspects the catalog, and renders the diagram). Edit the generator or the subsystem map, never the region; even the marker comment text itself is emitted by `cmd/erdgen`, so it is fixed there when the generator changes. After a schema change: `make gen` to redraw the D2, then `pnpm diagrams` to re-render the committed SVG. ::: ## The platform ERD ```d2 direction: right identity: { capability: { shape: sql_table id: uuid {constraint: primary_key} } human: { shape: sql_table principal_id: uuid {constraint: primary_key} } impersonation_session: { shape: sql_table id: uuid {constraint: primary_key} real_actor_principal_id: uuid {constraint: foreign_key} target_principal_id: uuid {constraint: foreign_key} } principal: { shape: sql_table id: uuid {constraint: primary_key} } principal_grant: { shape: sql_table id: uuid {constraint: primary_key} group_id: uuid {constraint: foreign_key} principal_id: uuid {constraint: foreign_key} role_id: uuid {constraint: foreign_key} } principal_group: { shape: sql_table id: uuid {constraint: primary_key} } principal_group_member: { shape: sql_table group_id: uuid {constraint: primary_key} principal_id: uuid {constraint: primary_key} } role: { shape: sql_table id: uuid {constraint: primary_key} } service: { shape: sql_table principal_id: uuid {constraint: primary_key} } system_role: { shape: sql_table id: uuid {constraint: primary_key} standard_id: uuid {constraint: foreign_key} system_id: uuid {constraint: foreign_key} } system_role_assignment: { shape: sql_table id: uuid {constraint: primary_key} component_id: uuid {constraint: foreign_key} role_id: uuid {constraint: foreign_key} system_id: uuid {constraint: foreign_key} } system_role_capability: { shape: sql_table id: uuid {constraint: primary_key} capability_id: uuid {constraint: foreign_key} role_id: uuid {constraint: foreign_key} } } estate: { component: { shape: sql_table id: uuid {constraint: primary_key} location_id: uuid {constraint: foreign_key} parent_id: uuid {constraint: foreign_key} product_id: uuid {constraint: foreign_key} } component_capability: { shape: sql_table id: uuid {constraint: primary_key} capability_id: uuid {constraint: foreign_key} component_id: uuid {constraint: foreign_key} } interface: { shape: sql_table id: uuid {constraint: primary_key} component: uuid {constraint: foreign_key} node_name: uuid {constraint: foreign_key} type: uuid {constraint: foreign_key} } interface_type: { shape: sql_table id: uuid {constraint: primary_key} } location: { shape: sql_table id: uuid {constraint: primary_key} location_type: uuid {constraint: foreign_key} parent_id: uuid {constraint: foreign_key} } location_type: { shape: sql_table id: uuid {constraint: primary_key} } location_type_property: { shape: sql_table id: uuid {constraint: primary_key} location_type_id: uuid {constraint: foreign_key} property_type_id: uuid {constraint: foreign_key} } system: { shape: sql_table id: uuid {constraint: primary_key} location_id: uuid {constraint: foreign_key} parent_id: uuid {constraint: foreign_key} standard_id: uuid {constraint: foreign_key} } system_member: { shape: sql_table id: uuid {constraint: primary_key} component_id: uuid {constraint: foreign_key} system_id: uuid {constraint: foreign_key} } } catalog: { driver: { shape: sql_table id: uuid {constraint: primary_key} } product: { shape: sql_table id: uuid {constraint: primary_key} driver_id: uuid {constraint: foreign_key} parent_product_id: uuid {constraint: foreign_key} vendor_id: uuid {constraint: foreign_key} } product_capability: { shape: sql_table id: uuid {constraint: primary_key} capability_id: uuid {constraint: foreign_key} product_id: uuid {constraint: foreign_key} } product_property: { shape: sql_table id: uuid {constraint: primary_key} product_id: uuid {constraint: foreign_key} property_type_id: uuid {constraint: foreign_key} } standard: { shape: sql_table id: uuid {constraint: primary_key} parent_standard_id: uuid {constraint: foreign_key} } standard_property: { shape: sql_table id: uuid {constraint: primary_key} property_type_id: uuid {constraint: foreign_key} standard_id: uuid {constraint: foreign_key} } vendor: { shape: sql_table id: uuid {constraint: primary_key} } } telemetry: { alarm: { shape: sql_table id: uuid {constraint: primary_key} component_id: uuid {constraint: foreign_key} } alarm_capability: { shape: sql_table id: uuid {constraint: primary_key} alarm_id: uuid {constraint: foreign_key} capability_id: uuid {constraint: foreign_key} } command: { shape: sql_table id: bigint {constraint: primary_key} caused_event_id: bigint {constraint: foreign_key} command_type_id: uuid {constraint: foreign_key} component_id: uuid {constraint: foreign_key} location_id: uuid {constraint: foreign_key} node_id: uuid {constraint: foreign_key} system_id: uuid {constraint: foreign_key} } command_type: { shape: sql_table id: uuid {constraint: primary_key} target_property_type_id: uuid {constraint: foreign_key} } event: { shape: sql_table id: bigint {constraint: primary_key} component_id: uuid {constraint: foreign_key} event_type_id: uuid {constraint: foreign_key} location_id: uuid {constraint: foreign_key} node_id: uuid {constraint: foreign_key} source_event_id: bigint {constraint: foreign_key} source_log_line_id: bigint {constraint: foreign_key} system_id: uuid {constraint: foreign_key} } event_type: { shape: sql_table id: uuid {constraint: primary_key} } log_line: { shape: sql_table id: bigint {constraint: primary_key} component_id: uuid {constraint: foreign_key} location_id: uuid {constraint: foreign_key} node_id: uuid {constraint: foreign_key} system_id: uuid {constraint: foreign_key} } metric: { shape: sql_table id: bigint {constraint: primary_key} component_id: uuid {constraint: foreign_key} event_id: bigint {constraint: foreign_key} location_id: uuid {constraint: foreign_key} node_id: uuid {constraint: foreign_key} property_type_id: uuid {constraint: foreign_key} system_id: uuid {constraint: foreign_key} } property: { shape: sql_table id: uuid {constraint: primary_key} component_id: uuid {constraint: foreign_key} location_id: uuid {constraint: foreign_key} node_id: uuid {constraint: foreign_key} property_type_id: uuid {constraint: foreign_key} system_id: uuid {constraint: foreign_key} } property_type: { shape: sql_table id: uuid {constraint: primary_key} } state: { shape: sql_table id: bigint {constraint: primary_key} component_id: uuid {constraint: foreign_key} event_id: bigint {constraint: foreign_key} location_id: uuid {constraint: foreign_key} node_id: uuid {constraint: foreign_key} property_type_id: uuid {constraint: foreign_key} system_id: uuid {constraint: foreign_key} } } collection: { node: { shape: sql_table principal_id: uuid {constraint: primary_key} location_id: uuid {constraint: foreign_key} } task: { shape: sql_table id: text {constraint: primary_key} interface_id: uuid {constraint: foreign_key} } } config: { credential: { shape: sql_table id: uuid {constraint: primary_key} principal_id: uuid {constraint: foreign_key} } secret: { shape: sql_table id: uuid {constraint: primary_key} component_id: uuid {constraint: foreign_key} location_id: uuid {constraint: foreign_key} secret_type: uuid {constraint: foreign_key} system_id: uuid {constraint: foreign_key} } secret_type: { shape: sql_table id: uuid {constraint: primary_key} } setting_override: { shape: sql_table id: uuid {constraint: primary_key} } variable: { shape: sql_table id: uuid {constraint: primary_key} component_id: uuid {constraint: foreign_key} location_id: uuid {constraint: foreign_key} system_id: uuid {constraint: foreign_key} } } content: { blob: { shape: sql_table sha256: text {constraint: primary_key} } file: { shape: sql_table id: uuid {constraint: primary_key} sha256: text {constraint: foreign_key} } tag: { shape: sql_table id: uuid {constraint: primary_key} } tag_binding: { shape: sql_table id: uuid {constraint: primary_key} component_id: uuid {constraint: foreign_key} location_id: uuid {constraint: foreign_key} node_id: uuid {constraint: foreign_key} system_id: uuid {constraint: foreign_key} tag_id: uuid {constraint: foreign_key} } } audit: { audit_log: { shape: sql_table id: uuid {constraint: primary_key} actor_principal_id: uuid {constraint: foreign_key} real_actor_principal_id: uuid {constraint: foreign_key} } } audit.audit_log.actor_principal_id -> identity.principal.id audit.audit_log.real_actor_principal_id -> identity.principal.id catalog.product.driver_id -> catalog.driver.id catalog.product.parent_product_id -> catalog.product.id catalog.product.vendor_id -> catalog.vendor.id catalog.product_capability.capability_id -> identity.capability.id catalog.product_capability.product_id -> catalog.product.id catalog.product_property.product_id -> catalog.product.id catalog.product_property.property_type_id -> telemetry.property_type.id catalog.standard.parent_standard_id -> catalog.standard.id catalog.standard_property.property_type_id -> telemetry.property_type.id catalog.standard_property.standard_id -> catalog.standard.id collection.node.location_id -> estate.location.id collection.node.principal_id -> identity.principal.id collection.task.interface_id -> estate.interface.id config.credential.principal_id -> identity.principal.id config.secret.component_id -> estate.component.id config.secret.location_id -> estate.location.id config.secret.secret_type -> config.secret_type.id config.secret.system_id -> estate.system.id config.variable.component_id -> estate.component.id config.variable.location_id -> estate.location.id config.variable.system_id -> estate.system.id content.file.sha256 -> content.blob.sha256 content.tag_binding.component_id -> estate.component.id content.tag_binding.location_id -> estate.location.id content.tag_binding.node_id -> collection.node.principal_id content.tag_binding.system_id -> estate.system.id content.tag_binding.tag_id -> content.tag.id estate.component.location_id -> estate.location.id estate.component.parent_id -> estate.component.id estate.component.product_id -> catalog.product.id estate.component_capability.capability_id -> identity.capability.id estate.component_capability.component_id -> estate.component.id estate.interface.component -> estate.component.id estate.interface.node_name -> collection.node.principal_id estate.interface.type -> estate.interface_type.id estate.location.location_type -> estate.location_type.id estate.location.parent_id -> estate.location.id estate.location_type_property.location_type_id -> estate.location_type.id estate.location_type_property.property_type_id -> telemetry.property_type.id estate.system.location_id -> estate.location.id estate.system.parent_id -> estate.system.id estate.system.standard_id -> catalog.standard.id estate.system_member.component_id -> estate.component.id estate.system_member.system_id -> estate.system.id identity.human.principal_id -> identity.principal.id identity.impersonation_session.real_actor_principal_id -> identity.principal.id identity.impersonation_session.target_principal_id -> identity.principal.id identity.principal_grant.group_id -> identity.principal_group.id identity.principal_grant.principal_id -> identity.principal.id identity.principal_grant.role_id -> identity.role.id identity.principal_group_member.group_id -> identity.principal_group.id identity.principal_group_member.principal_id -> identity.principal.id identity.service.principal_id -> identity.principal.id identity.system_role.standard_id -> catalog.standard.id identity.system_role.system_id -> estate.system.id identity.system_role_assignment.component_id -> estate.component.id identity.system_role_assignment.role_id -> identity.system_role.id identity.system_role_assignment.system_id -> estate.system.id identity.system_role_capability.capability_id -> identity.capability.id identity.system_role_capability.role_id -> identity.system_role.id telemetry.alarm.component_id -> estate.component.id telemetry.alarm_capability.alarm_id -> telemetry.alarm.id telemetry.alarm_capability.capability_id -> identity.capability.id telemetry.command.caused_event_id -> telemetry.event.id telemetry.command.command_type_id -> telemetry.command_type.id telemetry.command.component_id -> estate.component.id telemetry.command.location_id -> estate.location.id telemetry.command.node_id -> collection.node.principal_id telemetry.command.system_id -> estate.system.id telemetry.command_type.target_property_type_id -> telemetry.property_type.id telemetry.event.component_id -> estate.component.id telemetry.event.event_type_id -> telemetry.event_type.id telemetry.event.location_id -> estate.location.id telemetry.event.node_id -> collection.node.principal_id telemetry.event.source_event_id -> telemetry.event.id telemetry.event.source_log_line_id -> telemetry.log_line.id telemetry.event.system_id -> estate.system.id telemetry.log_line.component_id -> estate.component.id telemetry.log_line.location_id -> estate.location.id telemetry.log_line.node_id -> collection.node.principal_id telemetry.log_line.system_id -> estate.system.id telemetry.metric.component_id -> estate.component.id telemetry.metric.event_id -> telemetry.event.id telemetry.metric.location_id -> estate.location.id telemetry.metric.node_id -> collection.node.principal_id telemetry.metric.property_type_id -> telemetry.property_type.id telemetry.metric.system_id -> estate.system.id telemetry.property.component_id -> estate.component.id telemetry.property.location_id -> estate.location.id telemetry.property.node_id -> collection.node.principal_id telemetry.property.property_type_id -> telemetry.property_type.id telemetry.property.system_id -> estate.system.id telemetry.state.component_id -> estate.component.id telemetry.state.event_id -> telemetry.event.id telemetry.state.location_id -> estate.location.id telemetry.state.node_id -> collection.node.principal_id telemetry.state.property_type_id -> telemetry.property_type.id telemetry.state.system_id -> estate.system.id ``` ## The subsystems - **identity** - who can act and what they may do: principals (human and service), groups, grants, roles, capabilities, and the impersonation trail. - **estate** - what is being monitored: locations, systems, components, and the interfaces a component exposes. - **catalog** - the shared reference library: vendors, products, drivers, and standards, plus the capabilities and properties they define. - **telemetry** - the observability model of [ADR-0063](/architecture/decisions/#adr-0063-the-telemetry-model-is-typed-registries-over-bare-noun-data-tables): the typed registries (`property_type`, `event_type`, `command_type`) over the bare-noun data tables (`property`, `metric`, `state`, `event`, `log_line`, `command`) and the alarms raised off them (`alarm`, `alarm_capability`). - **collection** - where and how telemetry is gathered: the nodes that run probes and the tasks they execute. - **config** - the settings, variables, secrets, and credentials that parameterize everything above. - **content** - the cross-cutting attachments: blobs, files, and the tag bindings that label any entity. - **audit** - the immutable record of every privileged mutation and auth event. --- # 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`, `Resolved`, 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-0030](#adr-0030-allowed_parent_types-constrains-where-a-location-may-be-placed) | 2026-07-14 | Accepted | `allowed_parent_types` constrains where a location may be placed | | [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; superseded in part by [ADR-0066](#adr-0066-logs-are-a-raw-ingest-lane-not-events) | 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-0051](#adr-0051-membership-is-the-attachment-and-a-role-is-what-it-does) | 2026-07-21 | Accepted | Membership is the attachment, and a role is what it does | | [ADR-0052](#adr-0052-the-cascade-resolves-through-membership-and-secrets-carry-no-system-band) | 2026-07-21 | Accepted | The cascade resolves through membership, and secrets carry no system band | | [ADR-0053](#adr-0053-a-name-is-the-address-a-uuid-is-identity) | 2026-07-21 | Superseded in part by [ADR-0056](#adr-0056-every-foreign-key-stores-a-primary-key) | A name is the address, a uuid is identity | | [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-0055](#adr-0055-the-tag-variable-and-secret-owner-arcs-key-by-name) | 2026-07-21 | Superseded by [ADR-0056](#adr-0056-every-foreign-key-stores-a-primary-key) | The tag, variable, and secret owner arcs key by name | | [ADR-0056](#adr-0056-every-foreign-key-stores-a-primary-key) | 2026-07-22 | Accepted; the slug-keyed carve-out is retired by [ADR-0062](#adr-0062-a-registry-takes-a-uuid-primary-key-and-a-renameable-handle) | Every foreign key stores a primary key | | [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) | | [ADR-0058](#adr-0058-a-run-mode-is-a-verb-under-its-noun-and-no-command-may-be-shadowed) | 2026-07-22 | Accepted | A run mode is a verb under its noun, and no command may be shadowed | | [ADR-0059](#adr-0059-every-collection-segment-is-a-command-level) | 2026-07-22 | Accepted | Every collection segment is a command level | | [ADR-0060](#adr-0060-a-resource-is-one-kebab-case-noun-nesting-means-ownership) | 2026-07-22 | Accepted | A resource is one kebab-case noun; nesting means ownership | | [ADR-0061](#adr-0061-a-calculated-series-is-current-at-its-highest-id-not-its-newest-timestamp) | 2026-07-22 | Accepted | A calculated series is current at its highest id, not its newest timestamp | | [ADR-0062](#adr-0062-a-registry-takes-a-uuid-primary-key-and-a-renameable-handle) | 2026-07-22 | Accepted | A registry takes a uuid primary key and a renameable handle | | [ADR-0063](#adr-0063-the-telemetry-model-is-typed-registries-over-bare-noun-data-tables) | 2026-07-23 | Accepted | The telemetry model is typed registries over bare-noun data tables | | [ADR-0064](#adr-0064-placement-and-classification-are-mutable-after-create) | 2026-07-23 | Accepted | Placement and classification are mutable after create | | [ADR-0065](#adr-0065-property-sample-and-current-value-replace-the-datapoint) | 2026-07-28 | Accepted | Property, sample, and current value replace the datapoint | | [ADR-0066](#adr-0066-logs-are-a-raw-ingest-lane-not-events) | 2026-07-28 | Accepted | Logs are a raw ingest lane, not events | | [ADR-0067](#adr-0067-bookings-are-exclusive-arc-owned-schedules-reconciled-against-observed-usage) | 2026-07-28 | Accepted | Bookings are exclusive-arc-owned schedules, reconciled against observed usage | | [ADR-0068](#adr-0068-the-api-error-model-is-the-stock-rfc-9457-shape) | 2026-07-30 | Accepted | The API error model is Huma's stock RFC 9457 problem+json (`ErrorModel` with `ErrorDetail` `{location, message, value}`); the custom `code` plus `violations` envelope sketched on the API page is retired | | [ADR-0069](#adr-0069-cycle-safety-is-provenance-based-not-topology-based) | 2026-07-30 | Accepted | Cycle safety is provenance-based: consequence writes carry `provenance='calculated'` with a `source_rule` naming the producer, and rules never route on their own consequences; supersedes the "alarms are terminal upstream and never write samples" premise | | [ADR-0070](#adr-0070-retire-the-standalone-effective-secrets-and-effective-variables-per-component-panels-fields-become-the-component-value-surface) | 2026-07-16 | Accepted | retire the standalone effective-secrets and effective-variables per-component panels; fields become the component value surface | | [ADR-0071](#adr-0071-a-template-is-a-clonable-example-not-a-versioned-shape-an-instance-pins) | 2026-07-31 | Accepted | A template is an example configuration an operator **clones**: creating from one is a one-time fork with no inheritance and no back-pointer, so templates stay **upgrade-safe because nothing remains connected to them**. The versioned-shape model retires (`component_template`, `system_template`, `*_version`, channels, the frozen BOM, instance pinning); a component's shape is its `product`, a system's is its `standard`, and a system **conforms** to that standard with live inheritance. Reverses ADR-0045's deferral and ADR-0049's "templates stay `Design`" | | [ADR-0072](#adr-0072-an-envelope-is-not-named-after-its-passengers-and-an-insert-struct-takes-the-write-suffix) | 2026-07-31 | Accepted | Two naming rules: a carrier is named for what it carries, never a passenger (the telemetry wire message is `TelemetryBatch`, since it carries samples, log lines, and later events), and a storage insert struct takes the **`Write`** suffix paired with the bare read struct (`MetricSampleWrite` / `MetricSample`), the pattern `LogLineWrite` set. Retires `MetricSampleEvent`, `StateSampleEvent`, `EventOccurrence`, and the proto `Event` | | [ADR-0073](#adr-0073-a-driver-consumes-transports-a-transport-is-code-not-a-row) | 2026-07-31 | Accepted | a driver consumes transports; a transport is code, not a row | | [ADR-0074](#adr-0074-an-approved-definition-rolls-up-to-one-pr-slices-cascade-on-an-integration-branch) | 2026-08-01 | Accepted | loop-executed work rolls up to one PR per approved definition; slices cascade through per-slice gates on an integration branch | | [ADR-0075](#adr-0075-an-alarms-condition-identity-is-a-raiser-supplied-dedup-key) | 2026-08-01 | Accepted | alarm gains dedup_key and the one-open-per-condition partial unique index; RaiseAlarm becomes a guarded conditional insert | ## 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-the-owner-invariant-is-enforced-by-bootstrap-for-now)'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-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-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-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-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-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: 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/properties/) - **Decision:** A node ships each collected batch as a protobuf `Event` (proto3, `proto/og/v1/event.proto`, since renamed to `TelemetryBatch` in `proto/og/v1/telemetry.proto`, #424, `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/properties/), [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-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; superseded in part by [ADR-0066](#adr-0066-logs-are-a-raw-ingest-lane-not-events) (the log-to-event ingest promotion and the seeded `log.line` type were removed) | **Pages:** [core entities](/architecture/core-entities/), [datapoints](/architecture/properties/), [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 **`log.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/properties/#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/properties/):** 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 - **Date:** 2026-07-21 | **Status:** Accepted | **Pages:** [core entities](/architecture/core-entities/) - **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 - **Date:** 2026-07-21 | **Status:** Accepted | **Pages:** [cascade](/architecture/cascade/) - **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 - **Date:** 2026-07-21 | **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 - **Date:** 2026-07-21 | **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/properties/) - **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/properties/), [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/properties/) 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. ### ADR-0064: Placement and classification are mutable after create - **Date:** 2026-07-23 | **Status:** Accepted | **Pages:** [core entities](/architecture/core-entities/), [api](/architecture/api/) - **Decision:** a component's **product**, **location**, and **parent**, and a system's **location** and **parent**, are patchable after create, not fixed at creation. Each follows the house **three-state** convention on `PATCH`: an omitted field is unchanged, an explicit empty string **clears** (a productless one-off, an unplaced entity, a root), and a name **sets**. A re-parent is **cycle-guarded** (refused when the new parent is the entity itself or one of its own descendants, the same recursive walk `location` already uses) and **scope-injected** (the new parent must sit inside the caller's update scope). The existing per-transaction audit and health recompute rules are unchanged. - **Context:** the create body accepted these fields, the update body did not, so a component classified wrong on import or a display that physically moved rooms had **no path back except delete and recreate**, which destroys its telemetry history. The gap was invisible from any single body; it only showed up as the set difference between the create and update schemas, which is what surfaced it ([#342](https://github.com/hyperscaleav/omniglass/issues/342)). - **Why product is the sharp one:** [ADR-0047](#adr-0047-the-fields-fold-product_property-and-property_value) made `product` the carrier of the property contract, so a wrong product resolves the wrong property defaults. A swap therefore keeps every explicitly-set value (they key by component and property_type, independent of product) and lets only the unset defaults follow the new product, so re-classifying is never a silent data loss. - **What is deliberately NOT carried over from the location pattern:** location's reparent also runs an **allowed-parent-type** placement check, because a `location_type` constrains its parents. Products and standards are not placement-typed, so a component and a system carry the **cycle guard and scope injection only**, no placement-type validation. A component and a system reparent also, unlike a location this slice, support clearing to a root, since a root component and a root system are ordinary. - **Health does not move on a placement change.** The rollup runs component to systems-it-staffs to locations-over-those-systems, so a component's own location and parent, and a system's parent, sit outside the chain; only a product swap (capabilities) and a system relocate (which rung it rolls into) recompute, both already wired. - **Storage was already half-built:** `ComponentPatch.ProductName` and `SystemPatch.LocationName` were wired but unexposed; this slice adds the two reparent paths and the component relocate, and opens all of it on the API. It unblocks the [CRUD form primitive](/contributing/design-system/), whose generated edit form reads mutability off the create-minus-update schema difference and would otherwise render these fields read-only. ### ADR-0065: Property, sample, and current value replace the datapoint - **Date:** 2026-07-28 | **Status:** Accepted | **Pages:** [properties](/architecture/properties/), [storage](/architecture/storage/) The one word "datapoint" conflated two things: the signal (what is measured) and the observation (a single reading of it). Splitting them removes the ambiguity that let "the datapoint's current value" and "a datapoint arrives" name different nouns. - A **property** is the canonical signal on one owning entity (the key a sample observes and config declares); its registry is `property_type`. - A **sample** is one timestamped observation of a property, a row in `metric` / `state` (the kind). - The **current value** is the latest sample per series, held in the `property` cache. No tables were renamed (`metric`, `state`, `event`, `property`, `property_type` stay); the shift is vocabulary across the code (the proto `Sample` message, the `*Sample` Go types, `deriveSamples`) and the docs (the value-model page is now [properties](/architecture/properties/), with a redirect from the old slug). It also settles the built shape the earlier design left open: the **`property` cache is the architecture-of-record for current values**, a table upserted from the persistence sink with an out-of-order guard, not the "view over metric" once sketched on [storage](/architecture/storage/). The `intended` value a command opens is written in the command's Postgres transaction; the data-lane re-entry of intended and the adaptive-poll reconciliation described under [intended](/architecture/properties/#intended-the-declared-effect-of-a-command) are the deferred **actuation** evolution, not the built path. Refines [ADR-0063](#adr-0063-the-telemetry-model-is-typed-registries-over-bare-noun-data-tables). ### ADR-0066: Logs are a raw ingest lane, not events - **Date:** 2026-07-28 | **Status:** Accepted Pulling `log` out of `property_type` into `event_type` (#395) also promoted every raw log line, at ingest, into a caught `log.line` event. Reviewing the seeded event types showed that conflates two different things: a log line that **arrived** versus an occurrence that **happened**. It is the same category slip as naming a type after its transport (the `syslog.line` to `log.line` fix): arrival is not a happening. **Three ingest shapes, not two.** A **property** (metric or state) is a sampled value with a current value. An **event** is a semantic, typed happening. A **log line** is raw arrival: untyped text off a firehose. A log line is neither of the other two, it is the substrate some events are derived from. **Two lanes meet at `event`.** (1) The raw firehose lands in a `log_line` ingest table; derivation rules read it and emit semantic `event` rows. (2) A component with a native event model (an xAPI xEvent, an SNMP trap, a webhook) publishes straight to `event`. Neither lane is a subset of the other: most log lines never become events (they are searched, retained, and aged out having produced nothing), and native events never touch a log. That asymmetry is why `log_line` is its own table, not a flag on `event`. **`origin` names the producer.** `derived` (a rule produced it, from a log line or another event), `caught` (a component published it natively), `scheduled`. The retired model's "a log line is a caught event" collapses into: the log line is a `log_line` row, and if it matters a rule **derives** an event from it. **Lineage lives on the event row.** A derived event carries `source_event_id` (unified: the source is the cause, so this replaces the separate `caused_by_event_id`), `source_log_line_id`, and `derived_by_rule_id`, all null for a natively-caught event. The derivation engine keeps its own execution history (which rule version fired, when, over what inputs), but that is a separate observability lane and lineage must not depend on it: rotating the execution log cannot be allowed to orphan an event's provenance. `derived_by_rule_id` is the bridge into that history when the full story is wanted, without being the only home for the basic fact. **A log line is untyped but classifiable by labels, not a registry.** There is deliberately no `log_type` registry: the log lane's job is to swallow the firehose **without** pre-declaration, the exact opposite of the reject-not-project contract that justifies the `property_type` and `event_type` registries, and log classes (system, app, firmware, kernel) are device- and OS-specific and open-ended. Classification is descriptive: a `source` channel plus freeform labels and `attributes`, which an operator can extend without a schema change. The one exception is `severity` (and a coarse `facility`), promoted to indexed columns because retention and routing policy keys on them (keep firmware and error lines longer, drop debug faster). The shape is `log_line { ts, owner, source, severity?, facility?, message, attributes, labels, correlation_id }`. **Build gate.** The `log_line` table, retention, the derivation engine, the lineage columns, and native-event producers are their own slice ([#410](https://github.com/hyperscaleav/omniglass/issues/410)), worth building when logs are a real firehose to search and age out separately. This ADR records the target; it is not built all at once. **Consequence for #395 (the event_type family slice).** The bits that encode the retired model come out now: the ingest log-to-event promotion and the seeded `log.line` event type. The slice keeps the `event_type` registry, the richer `event` (origin, causation, correlation), `log` leaving `property_type`, and a native caught example (`call.started`). The `caused_by_event_id` to `source_event_id` rename and the new lineage columns land together in the log-lane slice, not piecemeal here. ### ADR-0067: Bookings are exclusive-arc-owned schedules, reconciled against observed usage - **Date:** 2026-07-28 | **Status:** Accepted Tracking how systems and spaces are used needs the **scheduled** side, not only the observed telemetry. A booking (a room reservation, an equipment checkout, a virtual-room reservation) is what a calendar system holds, and it is a different shape from a telemetry event: an **interval** (a start and an end) with a lifecycle (created, moved, cancelled), and it is **declarative**, someone reserved something. **A `booking` is its own entity, owned through the exclusive arc.** It uses the same `owner_kind` plus one-of (`component_id` / `system_id` / `location_id` / `node_id`) plus the CHECK that exactly one is set, the ownership model the rest of the estate uses. The arc **is** the binding, so no separate resource layer is needed: a room booking owns to a **location**, a mobile-equipment checkout owns to a **component**, a virtual meeting room owns to a **system** (the bridge modeled as a virtual conferencing system), and a bookable space with no AV owns to a **location** that has no systems. The heterogeneity flagged during design review (not every reservation is a physical room) is exactly what the arc already expresses. **A booking is an observed intended schedule.** Its provenance is **observed**: the platform did not declare it, it caught it from an external calendar. Its meaning is **intended**: a declaration of how the owner is meant to be used. A booking therefore carries observed provenance of intended use, and it is the **intended-schedule** side of usage. Shape: `booking { id, owner arc, source, external_id, subject, organizer, start, end, status, attendee_count, ... }`, deduplicated and updated on `(source, external_id)`. **Sourcing is a calendar driver on one platform-level integration, not a per-room mailbox fanout.** A per-room-mailbox model fails operationally at scale (one subscription and one credential per room), and a calendar is a **source**, not a device. Instead there is one platform-scoped integration component per calendar tenant, holding the tenant application credential, exposing a `calendar` **interface whose `interface_type` is a driver** (`graph-calendar`, `google-calendar`), consistent with the interface-is-a-driver model. A server-side worker runs the driver, enumerates the tenant's bookable resources, and fans out **internally**, upserting each booking with its owner bound through the arc. The operator's only seam is mapping a resource to its owner (auto-matched by resource address, operator-confirmed). **Bookings drive usage reconciliation and utilization SLIs.** The booking is the intended schedule; telemetry (`call.started`, occupancy, an active input) is the observed actual. Reconciled per owner and window: booked and observed is real utilization, booked and not observed is a no-show, observed and not booked is ad-hoc use. These are **SLI slices** in the health layer (utilization percent, no-show rate, ad-hoc rate, per location / system / component / window). An owner with no observability (a booked space with no systems) degrades gracefully to a booked-only SLI. **Deferred, each its own slice.** The resource-to-owner binding (auto-match versus operator-set); **privacy**, since booking subjects and organizers are more sensitive than device telemetry, so store the minimal busy/free plus booked-by by default and make the full subject opt-in; the platform order (Microsoft 365 Graph first, Google Workspace second, Teams and Zoom Rooms scheduling as a later layer); and recurrence (expand a series to instances versus store the series) with the sync window. Nothing here is built. This ADR records the target so the booking slice ([#412](https://github.com/hyperscaleav/omniglass/issues/412)) inherits a decided model. ### ADR-0068: The API error model is the stock RFC 9457 shape - **Date:** 2026-07-30 | **Status:** Accepted | **Pages:** [API](/architecture/api/) - **Decision:** the API's error model is Huma's stock RFC 9457 `application/problem+json` shape: the `ErrorModel` (`title`, `status`, `detail`), carrying for validation an `errors` array of `ErrorDetail` entries, each `{location, message, value}`. The custom envelope sketched on the API page (a stable machine `code` plus a `violations` array of `{field, message}`) is retired. - **Context:** the custom envelope was designed before any route existed. Today 141 routes serve the stock model, and the generated SPA client and the CLI already render it uniformly. A bespoke envelope would be cost without a driving consumer: no caller keys on an error `code`, and Huma's `ErrorDetail` already names the failing field through `location`. - **Reversible:** additive. If a consumer ever needs a stable machine code, it can be added as an extension field on the stock model without breaking the shape. ### ADR-0069: Cycle safety is provenance-based, not topology-based - **Date:** 2026-07-30 | **Status:** Accepted | **Pages:** [alarms and actions](/architecture/alarms-actions/), [health](/architecture/health/) - **Decision:** the guarantee that automation cannot feed back into itself rests on **provenance**, not on a topological "alarms are terminal upstream and never write samples" rule. A consequence write carries `provenance='calculated'` with a `source_rule` naming its producer (today: the health rollup's `state` sample, `source_rule='health-rollup'`, written on alarm raise and clear), and rules must never route on their own consequences: the routing layer refuses to re-trigger a rule off a sample whose `source_rule` is that rule. - **Context:** the alarms page argued cycle safety from the premise that alarms never write samples. The build falsified it: raising or clearing an alarm recomputes health in the same transaction (`internal/storage/alarms.go`), and the rollup records the verdict transition as a calculated-provenance `state` row (`internal/storage/health.go`). That write is correct, it is the recorded-transition model of [ADR-0050](#adr-0050-health-is-a-recorded-transition-computed-from-the-alarm-capability-role-chain); the premise was too strong. The real invariant is lineage-based and survives future consequence writers (calculations, action side effects) that a topology rule would forbid. - **Supersedes** the terminal-upstream cycle-safety argument on [alarms and actions](/architecture/alarms-actions/); the page now derives safety from provenance. ### ADR-0070: 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-0071: a template is a clonable example, not a versioned shape an instance pins - **Date:** 2026-07-31 | **Status:** Accepted | **Pages:** [templates](/architecture/templates/), [core entities](/architecture/core-entities/), [cascade](/architecture/cascade/), [glossary](/architecture/glossary/), [collection](/architecture/collection/) - **Decision:** A **template is an example configuration an operator clones**. Creating from one is a **one-time fork with no inheritance and no back-pointer**, so a template can be improved in any release without migrating anyone: **it is upgrade-safe precisely because nothing stays connected to it**. What an operator instantiates from a template is an ordinary row they then own, whether that is a `location_type`, a `standard`, or a whole system. The **versioned-shape model is retired**: `component_template`, `system_template`, their `*_version` rows, the `stable` / `beta` channels, the frozen BOM, and "an instance pins a version" or "tracks latest" are gone. A component's shape is its **`product`** (with the `product_property` contract), and a system's shape is its **`standard`**, to which a system **conforms with live inheritance**. Forking applies template to row; conformance applies row to instance. The word *template* survives; its meaning is inverted. - **Context:** The decision log and the code had drifted into disagreement, and neither said so. ADR-0045 deferred "a product's own template or field-schema binding" and ADR-0049 stated "Templates and their frozen BOM stay `Design`; the two models are reconciled when template pinning is built", so the log asserted the pinned model was merely *deferred*. Meanwhile ADR-0047 retired `component_type`, ADR-0048 promoted `system_type` to `standard`, and the shipped schema pointed a component at `product_id` and a system at `standard_id`. A 2026-07-30 vocabulary audit found `templates.md` still teaching version pinning across 29 lines, with the retired nouns reaching search results through the page's own frontmatter description, and no denylist entry anywhere because the estate-model ADRs had each introduced a new noun without retiring the old one. The two models are not variants of one idea, they are opposites. The pin existed **so a template could not change under an instance**; the fork exists **so a template can change freely because no instance is watching**. Holding both was what made the docs unresolvable: every page had to hedge. This generalizes the fork-seed model ADR-0048 already shipped for standards and location types (tracked for build in [#317](https://github.com/hyperscaleav/omniglass/issues/317)) and extends it to instantiating a **system** from a template, which is the operator-facing half. It also removes two rungs from the structural cascade: a template cannot be a resolution tier when nothing points at it. Rejected: keeping pinning for components while forking systems (two mental models for one word), and retiring the word *template* entirely (it is the right word for a clonable example, and the fork model is what an operator already expects from "start from a template"). ### ADR-0072: an envelope is not named after its passengers, and an insert struct takes the Write suffix - **Date:** 2026-07-31 | **Status:** Accepted | **Pages:** [storage](/architecture/storage/), [glossary](/architecture/glossary/) - **Decision:** Two naming rules, both general. **A carrier is named for what it carries, never for one of its passengers.** The telemetry wire message is a **`TelemetryBatch`** (`proto/og/v1/telemetry.proto`): it carries samples and raw log lines and will carry natively caught events, so naming it `Event` named it after one passenger. **And a storage insert struct takes the `Write` suffix**, paired with the bare read struct: `MetricSampleWrite` / `MetricSample`, `StateSampleWrite` / `StateSample`, `EventWrite` / `Event`, `LogLineWrite` / `LogLine`. A `Write` is the shape a caller hands the gateway; the bare noun is the row that comes back. - **Context:** "Event" had come to mean four different things: the wire batch, the two sample insert structs (`MetricSampleEvent`, `StateSampleEvent`), the event insert struct (`EventOccurrence`), and the event read struct. Three of the four collided in a single signature, `deriveSamples(ev *ogv1.Event, ...) ([]MetricSampleEvent, []StateSampleEvent, []EventOccurrence)`, where only the last had anything to do with an event as the platform defines one (an `event_type`-registered occurrence carrying an `origin`, ADR-0066). The batch had already outgrown its name once when the log lane added raw log lines to it, and the push route ([#423](https://github.com/hyperscaleav/omniglass/issues/423)) would have made the collision visible on the wire as `Event.events[]`. The rename was **free**, which is why it happened before the payload grew rather than after: protobuf encodes field **numbers** and never message names, so the same payload marshals byte-identically before and after (verified by encoding one on each side and diffing the hex), and a node and a server of different vintages still interoperate. No migration, no dual-write, no deploy ordering. The `Write` half is not a new idea, only a newly stated one: the log lane set the pattern with `LogLineWrite` in [#414](https://github.com/hyperscaleav/omniglass/issues/414) and the older types simply predated it, so the codebase carried two spellings of the same concept. Recorded here because it is a rule for **every future insert struct**, and a convention that lives only in a commit message is one nobody applies. ADR-0037's title still reads "a protobuf Event", correctly: that is what was decided on 2026-07-07, the log is append-only, and its heading generates the anchor other entries cite. It carries a forward pointer to the rename instead. ### ADR-0073: A driver consumes transports; a transport is code, not a row - **Date:** 2026-07-31 | **Status:** Accepted | **Pages:** [collection](/architecture/collection/), [glossary](/architecture/glossary/) - **Decision:** The collect layer is **driver-centric**, closing the question [ADR-0039](#adr-0039-an-interface-is-a-device-api-the-interface-type-is-its-transport-not-its-driver) left open. Three things follow. **A transport is code, not an operator-editable row.** `interface_type` is a low-level primitive (`icmp`, `tcp`, `ssh`, `http`, `snmp`, ...), one per package, discovered through a code registry the way collection primitives already are; the `interface_type` **table**, its FK from `interface.type`, and the hand-written dispatch switch in `internal/node/probe.go` all retire together in a later slice. An operator cannot author a transport, because a transport is a wire implementation, not configuration. **A driver consumes transports and is never one.** A driver declares which transports it can run over and the instance picks one; the driver owns the normalized catalog (what to fetch, how to parse), the transport owns only how bytes move. **The `interface_type` is a driver clause in [ADR-0067](#adr-0067-bookings-are-exclusive-arc-owned-schedules-reconciled-against-observed-usage) is retracted**: a calendar integration is an `https` **transport** plus a `graph-calendar` or `google-calendar` **driver**, the same shape as every other device, not an `interface_type` named after a vendor API. - **Context:** ADR-0039 recorded the driver-centric split as *"the current-best direction, not a locked gate"* and named its own successor: *"driver-centric vs template-centric is re-examined, and this ADR revised or superseded, in a later ADR before the collect layer is built."* This is that ADR, and it confirms rather than reverses the direction, because the alternative has been tried in this tree and lost twice. Protocol handling in the template makes every operator a programmer, which the product cannot ask for; and a transport as a **row** produced exactly the drift it invites, an ADR sourcing calendars through an `interface_type` named for a vendor API, which reads as reasonable precisely because a table accepts any string. A code registry cannot be extended by an operator, so it cannot silently absorb the driver's job. The estate model above this line is built (a component points at a product, a product carries a capability set and a property contract, roles staff a standard), while below it `driver` holds a name and a version and nothing a node could act on, so nothing is being unbuilt here: the decision authorizes work that the scope gate has been blocking. - **Deliberately not decided here**, each with a home so none of it rides on this entry: whether a product may bind **more than one** driver (`product.driver_id` is a single nullable uuid today, and role-addressed driver output is the recommended alternative to a join table, but it is a recommendation, not a ruling); whether a product is a **versioned artifact instances pin** or stays the live classifier it is today ([#491](https://github.com/hyperscaleav/omniglass/issues/491)); where **cadence** lives; and how a component resolves its effective driver set (the `EffectiveCapabilities` plus-minus pattern in `internal/storage/roles.go` is the obvious candidate to reuse rather than invent a second mechanism). - **Supersedes:** the status note on [ADR-0039](#adr-0039-an-interface-is-a-device-api-the-interface-type-is-its-transport-not-its-driver) (the driver-centric split is no longer provisional) and, in part, [ADR-0067](#adr-0067-bookings-are-exclusive-arc-owned-schedules-reconciled-against-observed-usage) (its calendar sourcing clause only; the booking entity and its arc ownership stand). - **Enables:** the [collect layer epic](https://github.com/hyperscaleav/omniglass/issues/489), whose slices were unauthorized without it. Nothing in this entry is built; it is a decision, and the code lands in that epic. ### ADR-0074: An approved definition rolls up to one PR; slices cascade on an integration branch - **Date:** 2026-08-01 | **Status:** Accepted | **Pages:** [slice workflow](/contributing/slice-workflow/), [feature loops](/contributing/feature-loops/) - **Decision:** For loop-executed work (a body of slices defined and approved as one Epic or Feature issue), the PR granularity moves from one-slice-per-PR to **one-PR-per-approved-definition**: sub-issue slices are built test-first on cascade branches (or serial commits) merged into one integration branch, each passing the per-slice gates before merging inward, and the definition ships as a single rollup PR whose ship-review covers the whole diff. The slice lifecycle inside each sub-issue is unchanged, and merge to `main` remains the architect's call. Hand-driven single slices keep the original one-slice-per-PR shape. - **Context:** Long agent loops ship several related slices per body of work. Per-slice PRs would put the architect back in the approval loop once per slice (the touchpoint cost the loop exists to remove), while letting unreviewed slices land on `main` would gut the ship gate. The cascade keeps every per-slice gate and gives the architect one reviewable boundary: approve the prose definition at the front, merge one rollup PR at the end. The [feature loops](/contributing/feature-loops/) page is the contract; built for the [AI-driven feature loops epic](https://github.com/hyperscaleav/omniglass/issues/488). ### ADR-0075: An alarm's condition identity is a raiser-supplied dedup key - **Date:** 2026-08-01 | **Status:** Accepted | **Pages:** [workers](/architecture/workers/), [alarms and actions](/architecture/alarms-actions/) - **Decision:** `alarm` gains a **`dedup_key`** column (the condition identity; it defaults to the message when the raiser supplies none) and the partial unique index `alarm_open_condition_key` on `(component_id, dedup_key) WHERE cleared_at IS NULL`, so **one open alarm per condition per component** is a database fact. `RaiseAlarm` is a guarded conditional insert: a losing raise returns the existing open incident instead of a duplicate, writing no audit row and recomputing nothing. The rule engine's `event_rule_id` keying joins alongside the dedup key with its own slice; a per-component unique index was refused (it would forbid two unrelated conditions on one component, contradicting the capability-degradation model). - **Context:** workers.md reasoned from a "one-open index" that did not exist: `RaiseAlarm` was an unguarded insert, `alarm_active_idx` is non-unique, and the liveness sweep was idempotent only because it happens to run as a singleton. The table had no column naming WHICH condition was open, so the documented `(event_rule, owner)` key was unrepresentable before the rule table exists; the raiser-supplied key works today and does not block on the engine. Shipped by [#465](https://github.com/hyperscaleav/omniglass/issues/465) in the #431 loop. --- # Expressions URL: /architecture/expressions/ Omniglass expressions: one engine built on Expr and extended with Omniglass functions, behind every operator-authored expression leaf. :::design[Target design: the expression engine, tracked in #434] 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 property'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/properties/#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 sample 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 sample 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), `$prop.` (properties, emitted and readable for branching; this designed namespace was `$dp.`, renamed with the ADR-0065 vocabulary before any engine exists to bind it), `$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 property, the resource row). ## Divergence and `disagree(A, B)` **`disagree(A, B)`** is the condition operator that compares two provenances or sources of one key: config drift, reconcile conflict, and cross-source disagreement are all the one operator. Reading both sides as inputs (rather than writing a verdict back into either) is what keeps the pipeline's DAG acyclic. What it surfaces is **divergence**: any two provenances or sources of one key that disagree, the universal anomaly signal. ## 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 property label. ::: --- # 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. A term the code does not implement yet is not a plain row here: design-only terms live inside the `:::design` fences at the bottom of this page, or on the sketch page that owns their domain ([templates](/architecture/templates/), [calculations](/architecture/calculations/), [expressions](/architecture/expressions/), [time](/architecture/time/), [views](/architecture/views/), [AI](/architecture/ai/)). | Term | Definition | |---|---| | **node** | Edge process (`omniglass node run`); 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/). | | **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** | The **transport**: the wire an interface speaks over (ssh, http, snmp, tcp, icmp...), carrying its connection-param schema and its default reachability probe. Not the protocol handler, which is the **driver**. Decided to be a code registry rather than an operator-editable table ([ADR-0073](/architecture/decisions/#adr-0073-a-driver-consumes-transports-a-transport-is-code-not-a-row)); today it is still a table. | | **driver** | The **collect** layer: a protocol handler plus the transports it can run over plus the normalized catalog (what to fetch, how to parse) that turns a device family's API into a pick-from menu. Consumes transports, never is one. Today the `driver` table carries identity only (name, version, official); the catalog is unbuilt ([#489](https://github.com/hyperscaleav/omniglass/issues/489)). | | **property** | A canonical signal on one owning entity: the key a sample observes and config declares, and the stored latest value for a `property_type` on one arc owner (the latest-value store, was `property_value`), tagged with its provenance (observed / calculated / intended / declared). History is the sample tables; the latest is the `property` cache row. | | **sample** | One observation of a property: a key's value on one owning entity at one time, with provenance + source + on-row lineage. Kinds: metric, state. A row in `metric` / `state`. | | **current value** | The latest sample per series `(owner, property_type, instance, provenance)`, held in the `property` cache so a read is one lookup, not a scan over samples. Producer provenances (observed/calculated/intended) are cached; declared resolves live from the cascade. | | **metric** | Numeric (float8) sample. Continuous, aggregatable. The firehose. | | **state** | Categorical/text/object sample. Discrete, dwell-measurable. [Config](/architecture/variables/) is keyed to one as its observed side. | | **log_line** | A component's or node's own log lines, untyped raw arrival on its own ingest lane (not a sample, no property name, no registry gate). A rule may derive an `event` from one. | | **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 sample/event/alarm's subject, the exclusive-arc: `owner_kind` + the matching typed FK (`component_id`/`system_id`/`location_id`/`node_id`), + CHECK. (The singleton `global` arm is design, fenced below.) | | **property_type** | Registry for property keys: name, kind, data_type, unit, precision, fusion_policy, validation, and the `official` boolean. Every sample is typed by one (the FK is non-null). (The template / org / official scope ladder and the promotion re-scope are design, fenced below.) | | **canonical unit** | The one `unit` a `property_type` stores in (its `unit` column): 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, display unit a presentation fact; neither is stored, and the conversion machinery is the unit registry (design, fenced below) with `convert()` on [expressions](/architecture/expressions/). See [samples](/architecture/properties/). | | **event_type** | Registry for event keys: name, display_name, payload_schema, and the `official` boolean; seeded at boot ([ADR-0063](/architecture/decisions/)). (The template / org / official scope ladder it would share with `property_type` is design, fenced below.) | | **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 samples 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; the `source` column on every sample row, distinct from provenance; enables multi-source rows and the perspectives they carry. (A `source` registry with default weights is design, fenced below.) | | **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. | | **config** | The declared side of a canonical signal: an operator-set value keyed to a `property_type`, compared against the observed sample (drift) under 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 sample, on one signal key. | | **reconcile** | Closing config drift on one signal key. Built: the read side (the `/components/{name}/reconciliation` want / told / is pivot). The per-item policy modes (`observe` / `warn` / `enforce`) and the one-shot adopt-observed import are design; see [config and credentials](/architecture/variables/). | | **cascade** | Resolves the effective config / variable value by folding **bindings** (things somebody decided): platform, the component's product, the system's standard, 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. | | **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. 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). | | **event** | A discrete semantic occurrence the action layer reacts to. Keyed, point-in-time, owned via the arc. Not a sample. | | **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. | | **severity** | An alarm's alert importance: the `severity` column, a fixed three-value domain today (`info` / `warning` / `critical`, a CHECK the storage validation mirrors); 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. (The operator-editable severity-level registry is design, fenced below.) | | **command** | A recorded invocation: a component was told to do something. A `command` row carries the same owner arc as a sample, the `command_type` it invokes (the seeded, driver-owned catalog of what a component can be told), its params, and the caused `command.issued` event. A settleable command_type targets a property and opens an **intended** value that settles against the observed value; settlement is computed, not stored. Issued via `:issue` on the owner. | | **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 sample kind or a stored span subsystem. | | **component** | A deployed instance (device/app/service); owns samples; a variable-depth tree; 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. | | **TelemetryBatch** | The protobuf message a producer ships (`proto/og/v1/telemetry.proto`): one batch of samples and raw log lines for one owner, carried on `og.v1.telemetry.` (a node) or `og.v1.api.telemetry` (an authorized push). Named for what it carries, not for a passenger ([ADR-0072](/architecture/decisions/#adr-0072-an-envelope-is-not-named-after-its-passengers-and-an-insert-struct-takes-the-write-suffix)). | | **component_template / _version** | Retired by [ADR-0071](/architecture/decisions/#adr-0071-a-template-is-a-clonable-example-not-a-versioned-shape-an-instance-pins). The device shape is the **product** (with its `product_property` contract), which a component points at; nothing is pinned or versioned. A *template* now means a clonable example, not a shape an instance holds. | | **system** | A composition of components/subsystems (the service tree); located at a location; **conforms to** an optional `standard`, with live inheritance. | | **system_template / _version** | Retired by [ADR-0071](/architecture/decisions/#adr-0071-a-template-is-a-clonable-example-not-a-versioned-shape-an-instance-pins). The composition shape is the **standard** a system conforms to, and the slots are **system roles** (a capability set, a quorum, an impact). The frozen BOM is gone with the pin. | | **role requirement** | Folded into **system role**. A slot requires a **capability** set (conjunctive) declared on a standard or a system; a component qualifies when its resolved capabilities cover it, and `AssignRole` refuses and names what is missing. No allow-list: declare what you need, any qualifying component fills it. See [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. | | **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 of principals used as a grant subject: the `principal_group`, the built case. (The general **entity group**, a weighted cascade overlay and access scope over components / systems / locations, is design, fenced below.) | | **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 slot; 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). | | **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. | | **ground truth** | Immutable append-only records. Built: `log_line` and `audit_log`. (`session_log` and `internal_log` are design, fenced below.) | | **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. | ## Design-only terms Every term below is committed architecture that nothing in the code implements yet: no live table, column, route, or seeded registry row carries it. Each fence names the tracker that owns the gap. Design terms whose domain has its own sketch page are defined there instead: the calc rule family on [calculations](/architecture/calculations/) (`calc_rule`, with `event_rule` / `action_rule` summarized below because their home pages are not sketches), `convert()` and `disagree()` on [expressions](/architecture/expressions/), `schedule` and `timer` on [time](/architecture/time/), the `view` and `ViewResult` contract on [views](/architecture/views/), and the template catalog, signature, and capability manifest on [templates](/architecture/templates/). :::design[Data-plane terms, tracked in #430] | Term | Definition | |---|---| | **session** | A stateful interface's live held-open connection; a current-state view over `session_log`. | | **session_log** | Connection-lifecycle transitions (node-reported, diagnostic). | | **internal_log** | Platform self-narration (startup, reconcile, migration, node-reg, config-sync). | | **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/). | | **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). Today the ingest consumer terminates a rejected batch without emitting an event. | | **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 sample sharing its key. Today only the task's own interface component binds an owner; a shared interface's rows are orphans. 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/). | | **source registry** | A `source` registry carrying per-source default weights for fusion. Today `source` is a bare text column on each sample row (that column is live; see **source** above). | | **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 [samples](/architecture/properties/) and `convert()` on [expressions](/architecture/expressions/). | | **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 fusion is a `calc_rule` ([calculations](/architecture/calculations/)). Perspectives are always preserved. The `fusion_policy` column is live (see above); the read-time reduce is not. | | **correlation_id (sample) / source_event_id** | Nullable trace columns on the sample tables, orthogonal to the exclusive-lineage CHECK (not lineage pointers), letting the cycle-guard walk cross a command -> device -> observed round trip. Today the trace pair (`correlation_id`, `source_event_id`) lives on the `event` table only; a sample carries `event_id` lineage but no trace columns. See [samples](/architecture/properties/). | ::: :::design[The edge function, tracked in #434] | Term | Definition | |---|---| | **function** | A trigger plus a DAG of steps, declared by a collection template; the unit of edge collection. Triggered by a schedule (poll), incoming data (listen), or a command. See [collection](/architecture/collection/). | | **edge parse** | A function parses a raw payload into samples on the node, the edge half of [collection](/architecture/collection/). There is no server-side transform rule. | ::: :::design[Alarm-chain, action, and health-tier terms (ADR-0050)] | Term | Definition | |---|---| | **event_rule** | sample change to event: fire_criteria + optional clear_criteria (clear makes events alarm-paired). No separate alarm or condition rule; today an alarm 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 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. | | **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**. | | **flow** | A multi-step **action** (branching, parallel steps, waits); an escalation is the canonical case. See [alarms and actions](/architecture/alarms-actions/). | | **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. Today severity is the fixed three-value column domain (see **severity** above). | | **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/). | | **health coverage / uncovered** | 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** | 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** | 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/). | | **KPI** | A shipped derived sample (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 sample 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/). | ::: :::design[The registry scope ladder, tracked in #317] | Term | Definition | |---|---| | **scope (registry)** | A key's uniqueness-and-trust axis on `property_type` and `event_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` as the top scope would fold in the `official` boolean, which is the only piece built today. Distinct from the ABAC scope the Storage Gateway injects (see **Storage Gateway** above). | | **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. | ::: :::design[The `global` estate owner (ADR-0057)] | Term | Definition | |---|---| | **global** | The singleton estate **owner**: the top owner above every location where estate-wide health and KPIs roll up. One per deployment, no FK (an all-null owner arm). Ownership only, **not** a cascade tier (that is `platform`) and not a location (the location tree has N unparented tops and no root). | ::: :::design[Operational mode and the delete cascade, tracked in #434] | Term | Definition | |---|---| | **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/). | ::: :::design[Entity groups and cascade weight, tracked in #10] | Term | Definition | |---|---| | **entity group** | The general **group**: a named set (component/system/location), static or dynamic, weighted; a cascade overlay + access scope. The built case is the principal-subject `principal_group` (see **group** above). | | **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/). | ::: --- # 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 sample 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. The console surface shipped too: **HealthPanel**, **HealthBadge**, and **HealthHistory**, plus the **AlarmsPanel**, on the component, system, and location details. 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 the same transition-only primitive with its own owner-arc read (`healthTransitions`, the same ordered flip sequence on the **[owner arc](/architecture/core-entities/#ownership-the-exclusive-arc)** rather than the component-and-instance one): 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. ::::design[Target design (ADR-0050)] ## 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 samples, 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 property 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 sample 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 properties, [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. :::: ::::design[The SLI / SLO / SLA and KPI tier (ADR-0050)] ## 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 property (the temporal reducer, [expressions](/architecture/expressions/)): ```yaml # availability = fraction of the last 30 days the system was healthy source: { property: 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 property, 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"' property: 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 property (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 samples, 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 sample pipeline, so the **system tree is the service tree**: the verdict is a `state` sample, 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 properties 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), [properties](/architecture/properties/) (the `state` sample and the owner arc), and the [Standards](/guides/admin/standards/) and [Work with an entity](/guides/operator/entities/) guides for the operator loop. --- # 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. :::note[Implementation status] Built today: the embedded nats-server with JetStream (`internal/bus/server.go`), per-node subject isolation with the auth callback, one `OG_TELEMETRY` stream on the node subjects (`og.v1.telemetry.*`) and the API push subject (`og.v1.api.telemetry`), with the single durable `og-telemetry-worker` consumer writing straight to Postgres, and the worklist and heartbeat request-reply lanes. The two-lane split below is target design; today there is **one stream and one serial consumer doing admission (owner confinement) and persistence inline**. The design fences below mark the parts of this page that are target design, each naming its tracker. ::: This page is the **one home of the two-lane data plane**: the admission / trusted / persistence split, the CDC publisher, and the ingest-path enumeration are described here and only named, with a link, everywhere else. 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/). :::design[Target design, tracked in #430] ## Two lanes, one bus Internal traffic splits by what is moving: - **Data lane (NATS-native): samples.** Untrusted publishers (a node, an external webhook sender) publish to a **raw ingress subject** (the wire unit is a `Sample` in a `TelemetryBatch`); an **admission consumer** at the head of the lane owner-confines each sample and re-publishes only confined ones to the **trusted** samples 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 `source_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 the Postgres `metric`, `state`, `event`, and `log_line` tables as an async sink, idempotent on `(series, ts)` so a redelivery lands the same row. The sink never gates the rule engine: a slow or paused persistence consumer holds up only the durable record, never the live signal, so rules never wait on Postgres (Postgres is the durable record, NATS is the live signal). 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 sample is logged as a discovery candidate, [identity and access](/architecture/identity-access/)). Samples do not go through CDC, they are already on the bus. - **Record / state lane (Postgres-first, CDC-out): events, alarms, actions, operator mutations** (config, ack, snooze, settings, manual commands). Born in a Postgres transaction: a firing `event_rule` writes the event plus the alarm transition atomically (the alarm transition is serialized per `(event_rule, owner)`), and the API writes config, ack, and settings the same way. A **leader-elected CDC publisher** (exactly one active, failing over on the NATS KV lock, [workers](/architecture/workers/)) reads committed changes from a replication slot by **logical decoding of the WAL** and publishes each to JetStream, where `action_rule`, reconcile, and projection consumers react. Delivery is at-least-once with an **idempotency key per change**, so a consumer that sees a change twice is a no-op and outcomes stay exactly-once downstream. **No dual-write**, no row-lock single-fire worklist, and no `LISTEN`/`NOTIFY` fan-out: the change is committed once and the bridge fans it out. **Postgres is never a message bus**; it only emits its changes. The replication **slot and publication are ensured in the idempotent boot phase** (the same phase that upserts ship-with reference data; boot creates them if absent and leaves them untouched if present), never a run-once dbmate migration, so a fresh database and an existing one converge to the same state. ::: ## The three ingest paths Three ingest paths exist today, and this list is the enumeration's one home: - **The node bus path.** A node publishes a `TelemetryBatch` on its own subject (`og.v1.telemetry.`), and the server binds each sample's owner from the task's interface ([collection](/architecture/collection/)). - **The API push path.** `POST /telemetry:push` is the first-party HTTP ingest write: a scoped caller declares the owner and the route's scope check is the fence. The API publishes the batch onto the bus (`og.v1.api.telemetry`, trusted by subject, below) rather than writing Postgres directly, so pushed records are visible to the same stream consumers and land in history the same way ([API](/architecture/api/)). - **The raw log path.** A raw log line rides either transport in the same batch but lands on its own untyped lane, `log_line`: no property name, no registry gate ([ADR-0066](/architecture/decisions/#adr-0066-logs-are-a-raw-ingest-lane-not-events)). The two transports meet at one `land()` write path, so neither can drift from the other's semantics; the mechanics of `land()` (reject-not-project, the transition-only state guard, the current-value derive) belong to [samples](/architecture/properties/). ## Streams and consumers As built today there is **one stream**, `OG_TELEMETRY`, bound to the node subjects (`og.v1.telemetry.*`) and the API push subject (`og.v1.api.telemetry`), consumed by the single durable `og-telemetry-worker`; `og.v1.telemetry.` is the sample firehose itself, carrying each node's samples and its raw self-logs, not a separate control-plane lane. The set below is the target topology, not the current one. :::design[Target design, tracked in #430] - **samples** (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** samples 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/)). 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 sample 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. Today's delivery contract is weaker than the target: node publishes are fire-and-forget core NATS (no publish ack, no `Nats-Msg-Id`) and the consumer acks once after multi-transaction writes, so delivery is at-least-once with a known duplicate risk until #430 lands (see #430 and #311). :::design[Target design, tracked in #430] ## 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 samples 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\nsample 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 } ``` The two lanes, drawn end to end. On the data lane, the edge parses payloads into observed samples and publishes them to raw ingress; the admission consumer republishes confined points to the trusted stream; the `event_rule` consumer evaluates live off the trusted stream while the persistence consumer sinks the durable copy; calc output and a command's intended write enter the trusted stream directly. On the record lane, an `event_rule` fire writes the event and alarm transition to PG in one transaction and the CDC publisher fans the commit onto JetStream, where `action_rule` consumers react; a command's intended sample then re-enters the data lane on the device round trip. The teal node is `audit_log`, the ground-truth record of operator writes (including config changes); observed and calculated samples carry `source_rule` on the row, and 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 sample as its observed side. ::: ## Subjects, accounts, and scope Subjects are hierarchical and **scope is expressed in them**, not bolted on: :::design[Target design, tracked in #434] - **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/)). ::: - **The API telemetry lane (`og.v1.api.telemetry`) is trusted by subject.** A first-party push (`POST /telemetry:push`) is authorized at the route, so the API publishes as a **trusted server producer** with no admission pass, and the ingest consumer believes the owner the batch carries **because of the subject it arrived on**, never because the field is populated. A batch on `og.v1.telemetry.*` that asserts an owner is dropped. Only the server's own credential can reach this subject: a node's grant is an explicit allow-list of its own three subjects, and the lane deliberately sits **outside** the single-token `og.v1.telemetry.*` wildcard, so a node named for whatever literal we might reserve there cannot be handed it. - **Subject permissions gate the subject string.** 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. **Operators never connect to the bus**, so there is no operator subject-permission model to keep in sync (see the live UI relay below). :::design[Target design, tracked in #430] A sample'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/). ::: ## 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. :::design[Target design, tracked in #434] ## 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. ::: :::design[Target design, tracked in #434] ## 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 samples 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 run` 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. Also built: running tasks (the probes, `internal/node/probe.go`), shipping telemetry (the JetStream `TelemetryBatch` ingested by `internal/bus/consumer.go`), and the raw self-log lane (ADR-0066: `internal/node/logs.go`, `GET /nodes/{name}/logs`, the console Self-logs panel). Still `Design`: commands and the durable command queue, sessions and inbound demux, the layered `_check` reachability gate, config-generation-driven cache invalidation, node self-telemetry, the node-down sweep, the JetStream publish-ack delivery contract (today the telemetry publish is fire-and-forget, #430), the tick scheduling and concurrency knobs, and config apply (the `:apply` flow). 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 node run`), 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. :::design[Target design: the cascade-resolved worklist, tracked in #489] 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) :::design[Target design: config propagation to a running node (`:apply` and the generation-driven cache), tracked in #489] 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 sample-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: :::design[The full node NATS contract, per ADR-0036; delivery guarantees are #430] - **Telemetry up** (node to server): the node **publishes** `TelemetryBatch` batches (`{samples, labels}` plus the `(task, ts)` envelope, [below](#shipping-samples)) 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: - **samples** 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 samples, or an `event` a rule derived from a `log_line`, [events](/architecture/events/)). The edge produces samples (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 :::design[Target design: the edge buffer, tracked in #430; retention is #417] 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` sample (depth, drops), so shedding is visible, never silent. ::: ### Credentials at the edge :::design[Target design: credentials at the edge, tracked in #489] 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/)). :::design[Fleet auto-enrollment over a `discovery_rule`, tracked in #489] 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) :::design[Target design: cascaded placement, tracked in #489] Collection follows **ETL**: extract **and transform** (including the extractor's Expr transform) default to the **edge**, then the shaped samples 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 :::design[Target design: edge normalization (locate + Expr) and the listen mode, tracked in #489] 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 samples 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 samples 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 ::::design[Target design: sessions and the session pool, tracked in #489] 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 sample so it is alertable. A command timeout can touch both. :::: ## Inbound handling on a shared connection :::design[Target design: the ordered matcher set, tracked in #489] 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 ::::design[Target design: the component task queue, tracked in #489; the durable command queue is ADR-0036] The node's work is the **component task queue** (distinct from the central **rule engine** that consumes samples off NATS and does derivation; see [workers](/architecture/workers/)). It holds **poll tasks** (produce samples) 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 sample 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). :::design[Target design: the layered availability gate, tracked in #489] The results come back as `reachable` / `port_open` **samples** 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 sample, 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 | Sample | 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 samples 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 samples The node ships a native `TelemetryBatch`: `{ samples, 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). :::design[Target design: the edge retry buffer, raw on failure, and the OTLP adapter, tracked in #430] The publish is **buffered with retry/backoff**. On a parse or validation failure the node 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: samples + 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 samples 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 samples; only calc and event rules derive. The node's job ends at the ship. ::: ## Tick scheduling, concurrency, and self-observability ::::design[Target design: the tick scheduler, `node.self`, and the node-down sweep, tracked in #489 and #430; the seeded node rules are the `event_rule` (ADR-0050)] 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 samples (the seeded `node.*` types) that ride the **same raw-ingress -> admission -> trusted** path as any other node sample, and the rule engine derives node-health from them like any other sample. A node carries no operator-authored template; its self shape is **built into the binary** (the seeded `node.*` sample 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 samples to its component. So node samples 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 sample 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 samples: "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 sample, 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 sample'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 platform half is well past its midpoint. The single binary runs in `server` / `node` / `migrate` modes over a BYO Postgres, and the tiers below are shipped, tested, and live in the console; the per-slice detail (136 entries at this update) is on [implementation status](/architecture/status/). - **Identity and access, end to end.** Password, session-cookie, and API-token auth with expiry, lockout, and password policy; the full principal lifecycle (disable, archive, purge); impersonation with dual-actor audit; principal groups with grant-by-group; grants with scope operators; and the append-only [audit](/architecture/audit/) trail with its admin read surface. - **The estate and its catalogs.** The `location` / `system` / `component` trees on the shared scoped-CRUD primitive, `system_member` multi-membership, system roles with the assignment guard, the standard / product / vendor / driver / capability registries, and location types with `allowed_parent_types` placement rules. - **The values cascade.** [Tags](/architecture/tags/), [variables](/architecture/variables/), envelope-encrypted secrets, [files](/architecture/files/) over the content-addressed blob store, the [settings](/architecture/settings/) engine (platform rung), and the unified resolution panel that explains which value won and why. - **The collection vertical.** The [node](/architecture/nodes/) runtime with enrollment and claim, embedded NATS/JetStream with per-node subject isolation, the icmp and tcp probes, the transition-only reachability verdict and its panel, interface and task authoring, and the raw-log lane with node self-logs ([ADR-0066](/architecture/decisions/#adr-0066-logs-are-a-raw-ingest-lane-not-events)). - **The telemetry ontology.** The `property_type` / `event_type` / `command_type` registries, the sample sinks (`metric`, `state`, `event`, `log_line`), the `property` latest-value cache, and the [command](/architecture/commands/) pillar with computed settlement ([ADR-0063](/architecture/decisions/#adr-0063-the-telemetry-model-is-typed-registries-over-bare-noun-data-tables)). - **Delivery and the pipeline.** `make gen` emits the OpenAPI document, the cobra CLI, the typed SPA client, the CLI reference, and the generated ERD; goreleaser builds the release matrix and the multi-arch image; the Helm chart deploys production and powers per-PR preview environments. ## 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) | **Shipped.** Password login over an httpOnly cookie session, self-service profile and password change, and admin user / grant management, then well beyond the original scope (lifecycle, impersonation, groups, tokens, lockout). Its exit condition is met: 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)) are closed. The identity slices on [implementation status](/architecture/status/) carry the detail. | [identity and access](/architecture/identity-access/) | | [Deploy spine: PR previews (#41)](https://github.com/hyperscaleav/omniglass/issues/41) | **Live.** 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). See [PR previews](/guides/pr-previews/). | [scaling and deployment](/architecture/scaling/) | | [Estate model: groups + dynamic scope (#10)](https://github.com/hyperscaleav/omniglass/issues/10) | Ahead: entity-groups as scope anchors and dynamic-membership scope, plus the cross-tier cascade (a location scope reaching its systems and components). Principal groups as grant subjects shipped; group-as-scope has not. | [identity and access](/architecture/identity-access/), [groups](/architecture/groups/), [cascade](/architecture/cascade/) | | [Public releases (#57)](https://github.com/hyperscaleav/omniglass/issues/57) | Ahead: 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) | Ahead: 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 current workstream is the drift-correction program from the 2026-07-30 doc-versus-code audit, tracked as epics [#428](https://github.com/hyperscaleav/omniglass/issues/428) (documentation drift correction), [#429](https://github.com/hyperscaleav/omniglass/issues/429) (drift prevention tooling), [#430](https://github.com/hyperscaleav/omniglass/issues/430) (telemetry integrity), [#431](https://github.com/hyperscaleav/omniglass/issues/431) (scope and secrets integrity), [#432](https://github.com/hyperscaleav/omniglass/issues/432) (console address honesty and the audit diff), [#433](https://github.com/hyperscaleav/omniglass/issues/433) (generated docs facts), and [#434](https://github.com/hyperscaleav/omniglass/issues/434) (docs corpus restructure). ## The architectural arc ahead The spine has converged over most of its lower half; what remains `Design` is concentrated in the automation and read tier. The broad order the remaining work follows, each band pointing at the page that describes the target: 1. **Collection at the edge.** Substantially landed: the [node](/architecture/nodes/) runtime, the embedded bus, the icmp/tcp probes, edge parsing, and [interface](/architecture/collection/) and task authoring all shipped. Still ahead: [templates](/architecture/templates/) (the reusable device shape) and the richer transports (real snmp / http / ssh drivers, webhook listeners; today `ssh` and `http` probe as tcp-connect only). 2. **The data model.** Landed except one piece: [properties](/architecture/properties/) (the canonical-signal registry and the exclusive-arc owner columns), the sample sinks, [config and variables](/architecture/variables/) resolved down the [cascade](/architecture/cascade/) all shipped. Still ahead: the [expression engine](/architecture/expressions/) the rules and filters share. 3. **Detection and verdict.** [Events](/architecture/events/) shipped (their registry, sink, and lineage columns), as did [commands](/architecture/commands/) with settlement, and 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 is what **produces** an alarm (the `event_rule` tier), [calculations](/architecture/calculations/), and what **acts** on one ([alarms and actions](/architecture/alarms-actions/), the automation tier). 4. **The machinery underneath.** The [messaging](/architecture/messaging/) subject contract, per-node subject isolation, and the durable JetStream telemetry consumer ([workers](/architecture/workers/)) shipped. Still ahead: the raw/trusted two-lane data plane, [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 (hand-written commands: `server`, `migrate`, `bootstrap`, `token`, `set-password`, `seed-dev`, and `node run`), the embedded NATS server (in-process `nats-server` with JetStream, `internal/cli/server.go`), the `node` run mode, the multi-arch container image, and the Helm chart. Today one stream and one serial consumer do confinement and persistence inline; the design fences below mark the parts of this page that are target design, each naming its tracker. 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. :::design[Target design, tracked in #57] - **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. :::design[Embedded Postgres, tracked in #19] - **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, samples, 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. :::design[Target design, tracked in #434] JetStream **KV** (config, locks, leader-election) and an **object store** for internal artifacts ride the same substrate; user files stay on the content-addressed blob store (built: `internal/blob`, Postgres-backed), not the object store. ::: :::design[Target design, tracked in #430] The two meet through **change data capture**: Postgres tells us *what changed*, and NATS carries the queue. A single **leader-elected CDC publisher** bridges committed Postgres changes onto JetStream; **Postgres is never a message bus**, it only emits its changes. The mechanism (logical decoding of the WAL, the per-change idempotency key, the boot-phase replication slot) is described once, on [messaging](/architecture/messaging/#two-lanes-one-bus). ::: :::design[Target design, tracked in #430] ### Inter-service communication Service-to-service traffic rides **two lanes on the one JetStream bus**, by what is moving: the NATS-native **data lane** for samples, and the Postgres-first, CDC-out **record and state lane** for events, alarms, actions, and operator mutations. The lane model (the admission consumer, the trusted stream, the persistence consumer, the CDC publisher, and the ingest paths) is described once, on [messaging](/architecture/messaging/#two-lanes-one-bus); here is how the pieces deploy: ```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 } ``` ::: :::design[Target design, tracked in #57] ## 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. ::: :::design[Target design, tracked in #57] ## 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 lock-permission 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.** :::design[The group and user override rungs, tracked in #270] 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. :::design[The fast-follow rungs and platform-domain namespaces, tracked in #270] **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. :::design[The declarative operator-file machinery and the cascade rungs, tracked in #270] **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. Scope arrives at the gateway as an explicit per-call `scope.Set` argument; seed and system callers pass an all-scope set. Today `metric` / `state` carry no `series` column and no uniqueness key, so redelivery can duplicate ([#430](https://github.com/hyperscaleav/omniglass/issues/430) stage 3). The design fences below mark the parts of this page that are target design, each naming its tracker. The `property` current-value cache is built, a table upserted from the sink, not the metric-view once sketched here ([properties](/architecture/properties/#the-current-value-cache)). 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) are written synchronously through the Storage Gateway. :::design[Target design, tracked in #430] **The sample tables are an async SINK**: the NATS **persistence consumer** batch-writes samples off the data lane into `metric` / `state`, idempotent on `(series, ts)` ([#430](https://github.com/hyperscaleav/omniglass/issues/430) stage 3), so a redelivery lands the same row, the firehose never blocks on the database, and the rule engine never waits on a sample reaching Postgres. Raw log lines ride the same ingest path into `log_line` but are keyed on nothing (an untyped arrival has no series), so their at-least-once story is the lane's own, not this one's. Every other row (the `event` and `alarm` rows an `event_rule` consumer commits in one transaction, and every operator mutation) is born in a Postgres transaction and fanned outward by the **CDC publisher**. The two-lane model, both consumers, and the CDC mechanism are described once, on [messaging](/architecture/messaging/#two-lanes-one-bus). ::: The column schemas live with each owning feature: [samples](/architecture/properties/#the-sample-tables) (the three kind-tables), [events](/architecture/events/#storage) (the `event` row), [alarms and actions](/architecture/alarms-actions/#storage) (`alarm` / `action`), [commands](/architecture/commands/) (the command pillar: the `command_type` registry, the `command` invocation row, and its computed settlement), [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 registries and catalogs (`property_type`, `event_type`, `interface_type`, `location_type`, `secret_type`, `vendor`, `driver`, `capability`, `product`, `standard`) carry an **`official` boolean** (a richer per-registry `scope` ladder, template / org / official, remains future design; see [key scope](/architecture/properties/#key-scope-template-org-official)): `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_line` (the raw log lane, not a sample), `audit_log` (operator actions), and the standing `*_log` ground-truth logs (`session_log`, `internal_log`, plus the `collection_log` / `node_log` companions; [ground-truth records](#ground-truth-records) below). There is **no `telemetry` table**: samples 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 sample, and raw appears only on a `collection.failed` event or a dev raw-mode tap ([samples](/architecture/properties/)). 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. **Samples** (`metric` / `state`) are the typed observation firehose, and `log_line` is the untyped raw arrival beside them ([ADR-0066](/architecture/decisions/#adr-0066-logs-are-a-raw-ingest-lane-not-events)): a log line is not a sample kind, so it carries no property name and passes no registry gate. **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 sample**: `provenance` (observed / calculated / intended / declared), `source` (which sensor or path, for observed), and a lineage pointer. **calculated** requires `source_rule` (+ version), the rule that produced the row; **intended** requires `event_id` (the command) and no `source_rule`; **observed** requires only that `event_id` is null (it may carry a `source_rule` when an edge function produced it, but the CHECK does not demand one); **declared** requires neither pointer. A CHECK enforces the pointer per provenance, and **observed vs calculated is the `provenance` value itself**, not a column-presence trick. `declared` exists as a sample provenance in the schema ([ADR-0047](/architecture/decisions/#adr-0047-the-fields-fold-product_property-and-property_value)), although the model going forward keeps declared config in [config](/architecture/variables/), keyed to the same signal. - **Ownership is the exclusive-arc**, though not one uniform arc. The sample tables (`metric` / `state`) and `event` carry `owner_kind` (`component` / `system` / `location` / `node`) plus the matching typed FK and a CHECK that exactly the matching column is set; there is no platform or global arm on a sample. `variable`'s arc is `platform` / `component` / `system` / `location` (no node arm; the `platform` kind sets all three FKs null). `alarm` carries **no arc**: a single NOT NULL `component_id`, component-local by design today. The full pattern is on [core entities](/architecture/core-entities/#ownership-the-exclusive-arc). - **A write struct takes the `Write` suffix; the bare noun is the row.** `MetricSampleWrite` is what a caller hands the gateway, `MetricSample` is what comes back, and the same holds for `StateSampleWrite`, `EventWrite`, and `LogLineWrite`. A carrier is likewise named for what it carries rather than for one of its passengers, which is why the telemetry wire message is a `TelemetryBatch` ([ADR-0072](/architecture/decisions/#adr-0072-an-envelope-is-not-named-after-its-passengers-and-an-insert-struct-takes-the-write-suffix)). - **Keys**: samples and events use a surrogate id plus `ts`; the key registry `property_type` is name-unique with the **`official` boolean** deciding authority (the template / org / official `scope` ladder is future design); structural entities are name-keyed; a `task` is **content-addressed** (`sha256` over `(interface_id, mode, spec)`); 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 } property: property { 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 -> property: current value state -> property: current value state -> variable: linked_state (observed side) ``` The structural and template entities (`component` / `system` / `location` and the `*_template` / `product` / `standard` / `system_role` / `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). ## 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_line`** (a component's or node's own words, the untyped raw ingest lane, not a sample, [ADR-0066](/architecture/decisions/#adr-0066-logs-are-a-raw-ingest-lane-not-events)); - **`audit_log`** (operator actions: actor, verb, resource, `old -> new`; the lineage target for operator writes; secret decrypts always recorded, [audit](/architecture/audit/)). :::design[Target design, tracked in #430] - **`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: **calculated** requires `source_rule` (+ version), the rule that produced the row; **intended** requires the command `event_id` and no `source_rule`; **observed** requires only that `event_id` is null (it may carry a `source_rule`, but the CHECK does not demand one); **declared** requires neither pointer. The pointer per provenance is enforced so e.g. "intended with no command event" is impossible at the storage layer. The real four-branch CHECK on the sample tables: ```sql CHECK ( (provenance = 'observed' AND event_id IS NULL) OR (provenance = '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) OR (provenance = 'declared' AND source_rule IS NULL AND event_id IS NULL) ) ``` Observed and calculated 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 **trace columns live beside the lineage pointers, but not on the sample tables**: `event` carries `correlation_id` and `source_event_id` (the causation parent, plus `source_log_line_id` and `derived_by_rule_id`), and `log_line` carries `correlation_id`; `metric` and `state` carry no trace columns today. These are orthogonal to the lineage CHECK. :::design[Target design, tracked in #430] The designed carriage: causation rides **NATS message headers** across the command -> device -> observed-sample round trip and lands on the sample row, so the cycle guard walks a real id ([samples](/architecture/properties/), [alarms and actions](/architecture/alarms-actions/)). ::: ## 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. **The schema holds zero SQL views today**; the shipped current-value read is the `property` cache table (the status note above). ::::design[Target design, tracked in #430] | Read model | Of | Shape | Notes | |---|---|---|---| | `current_value` | latest sample 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 sample 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)? ::: :::: ::::design[Target design: partitioning is tracked in #420, retention in #417, and the `raw_sample` buffer in #430] ## 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` longer, `audit_log` longest (compliance), `internal_log` short, and `log_line` keyed on its own axes (`severity` / `facility` are indexed columns precisely so retention and routing can discriminate: a debug line ages out long before a warning). On-row lineage ages out with its sample, and a `log_line` aged out from under an event it was derived from leaves `event.source_log_line_id` null rather than deleting the event (`on delete set null`). 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 sample 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, [samples](/architecture/properties/)), so there is no tenant context to set. Every read and write lands here. Scope arrives as an **explicit per-call `scope.Set` argument**: the request path passes the caller's action-scoped set, and seed and system callers pass an all-scope set. The named three-mode contract (scoped / node / system) that identity and access describes is the Design formalization of that convention, not a built mode switch. The [CDC publisher](/architecture/messaging/#two-lanes-one-bus) reads committed changes from 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 (samples, ground-truth records, views, registries). Postgres is **BYO today**. - **blobs**: opaque bytes (a firmware image, a config dump, a capture, and later a large `log_line` 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, and a row references a blob by its `sha256`, never inline bytes. :::design[Embedded Postgres, tracked in #19] Embedded Postgres in the single binary (the same code path either way) replaces BYO for the all-in-one run mode; the data lane's persistence consumer and the record lane's CDC publisher (#430) target this one backend either way. ::: ::::design[The cold tier, tracked in #434] **tiering**: the firehose does not stay in hot Postgres forever. Aged `metric` / `log_line` 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`. :::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. ::: :::: :::design[Alternate blob backends, tracked in #248] An S3-compatible or disk blob backend swaps in beneath the same `blob.Store` seam with no model change, since rows reference blobs by `sha256`. ::: :::design[Target design, tracked in #434] ## 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 sample 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. ::: --- # Templates URL: /architecture/templates/ Example configurations an operator clones. Forking is one-time and leaves no link back, which is exactly why a template can be improved in any release without migrating anyone. ::::design[Target design, tracked in #317] A template is an **example configuration an operator clones**. It ships inside the binary as a menu, never as data: nothing is inserted until somebody picks one, and what they get is an ordinary row they then own outright. The property that makes this worth having is what happens next: **nothing points back at the template**. Forking is one-time, with no inheritance and no back-pointer, so a template can be rewritten in any release without migrating a single install. Improving the examples we ship costs nothing, because no instance is watching. :::caution[Design] Nothing on this page is built. The interim state is that standards and location types **seed as ordinary rows** (`official: false`, inserted only if absent, so an operator's edit is never reverted on the next boot). The template loader, the catalog it exposes, and the create-from-template flow are tracked by [#317](https://github.com/hyperscaleav/omniglass/issues/317). ::: ## Fork, not pin This page used to describe the opposite model, and the contrast is the fastest way to understand the current one. The old model was **immutable versioned shapes that instances pin**: a component pinned a versioned device shape, a system pinned a versioned composition shape with a frozen bill of materials, editing one minted a new version, and an instance tracked `latest` or followed a `stable` / `beta` channel. The whole point of the pin was that **the shape could not change under an instance**. The current model inverts that. A template is not a shape an instance holds on to; it is a starting point an instance is copied from and then forgets. The point of the fork is that **a template can change freely, because no instance is watching**. Same word, opposite purpose, which is why the two were never reconcilable and why holding both made every page hedge ([ADR-0071](/architecture/decisions/#adr-0071-a-template-is-a-clonable-example-not-a-versioned-shape-an-instance-pins)). What replaced the pinned shapes is not a template at all: | The shape of a... | is its... | and the relationship is | |---|---|---| | component | [`product`](/architecture/core-entities/), with its `product_property` contract | a pointer, `component.product_id` | | system | [`standard`](/architecture/core-entities/) | **conformance**, with live inheritance | **Forking applies template to row. Conformance applies row to instance.** They are different mechanisms and only the second is live: a standard's contract default resolves for every conforming system until that system overrides it, and that link is real and permanent. A template's link is severed at creation. ## What a template can create Anything an operator would otherwise build from nothing, at three sizes: - **A [location type](/architecture/core-entities/)**, so a new install can start from Campus, Building, Floor rather than inventing a hierarchy vocabulary on day one. - **A [standard](/architecture/core-entities/)**, the composition shape a system conforms to: a huddle room, an auditorium, a training room. - **A system**, instantiated whole. This is the operator-facing one: "start from: huddle room" should produce a working system, not merely the standard it conforms to. The first two generalize the fork-seed model already recorded in [ADR-0048](/architecture/decisions/#adr-0048-the-standard-blueprint-and-the-template-fork-seed-model); the third is the reason the model is worth building rather than only worth writing down. ## How a template ships A template is **authored as YAML in the repo and `go:embed`ed into the binary**, exactly like today's seed files. Same authoring format, same git-diffable review. The difference is not the format, it is what the loader does with it: - a **seed** inserts rows whether or not anyone wanted them; - a **template** inserts nothing. It sits in the binary as a catalog the create-from-template flow reads, and writes exactly one row when an operator forks it. Offered, not applied. A template must never be addressable as a row, and must never be inserted wholesale, or it stops being a template and becomes shipped data carrying every update-versus-local-edit problem the model exists to avoid. ## Why this dissolves the shipped-defaults problem The thing the vendor updates and the thing the operator owns are **never the same object**. That is the whole trick, and it is why a template change never needs a migration. Both alternatives fail on the same point. Shipping standards as authoritative rows means a release either overwrites an operator's edits or gives up on improving the defaults. Shipping them as pinned versions means every install carries a version pointer, and improving the example means persuading people to re-point at something that may not be compatible. Forking has neither problem, because there is nothing to reconcile. ## What a template carries Open, and the answer decides how much a fork scaffolds in one write. A template that carries only the row's own attributes is trivial to build. A template that also carries a **property contract**, and later **[system roles](/architecture/health/)**, would let "start from: huddle room" scaffold the slots a room needs filled as well as the row itself, which is most of the operator value. Tracked as the open question on [#317](https://github.com/hyperscaleav/omniglass/issues/317). ## Trust: the signature and the capability manifest Two glossary terms cover the trust surface of a template catalog that accepts templates from outside the binary (the hosted / marketplace path): - **template signature / attestation**: an optional author signature on a template, verified on import. It answers authenticity (who authored it), distinct from the content-hash integrity check (that it is unaltered). The hosted / marketplace path verifies signatures regardless of the self-host runtime stance. - **capability manifest**: a declaration on a template of which write-commands and credential shapes it exercises, shown and approved when an operator forks it, so a device-mutating template never gains powers the operator did not see at the moment of the fork. :::: --- # 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. ::::design[Target design: the time primitive, tracked in #419] 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 sample); 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 sample, 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 property goes stale | `metric` / `state` (a derived staleness sample) | 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 sample 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 sample (`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. The built current-value shape is the `property` cache row, `CachedValue{Value, TS, Provenance}`. :::design[The `freshness` quality on the current value, tracked in #430] The cache row carries a `freshness (fresh | stale)` quality: staleness marked on the current value 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-property-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 property is the `stale` reason, and a covered-but-never-reported property 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 console now has roughly 22 live pages spanning inventory, catalog, values, and admin, plus the shell. These ship as **config-driven `ListShell` pages (with `FlatList` / `TreeList` bodies) 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, sample 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 :::design[Target design: the ViewResult contract, tracked in #434] 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 `ListShell` (with `FlatList` / `TreeList` bodies, `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 ::::design[Target design: the renderer library and composable dashboards, tracked in #434] 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 :::design[Target design: default views behind coded pages, tracked in #434] 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). ::::design[Views and the SSE live relay, tracked in #434; the unit registry is #430] 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 sample value resolves the same way: on read the UI converts canonical to the operator's preferred display unit, looked up from a future unit registry by the [property_type](/architecture/properties/)'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 and secret; the system and component kinds moved to Standards and Products), each tab its own directory over that registry, CRUD on the location kind, and a read-only view of secret ([build log](/architecture/build-log/)). Editing a setting is editing **[config](/architecture/variables/)**, an audited mutation, not a separate prop store ([audit](/architecture/audit/)). :::design[The rule-authoring surface (ADR-0050); the expression editor is #434, the AI seam ADR-0001] 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 samples 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 :::design[Target design: the exploration surfaces over views, tracked in #434] 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. - **Sample 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 sample 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, files), Catalog (templates, types, standards, properties, event types, command types, tags, vendors, drivers, capabilities, products, rules), Explore, Learn, Admin (users, roles, groups, audit, and the 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 samples. A node is a monitored, scope-controlled entity like any other estate member (live, gated on `node:read` plus ABAC scope), 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 live Settings leaf, the platform-preferences page. :::design[The Home situation room and the Dashboards tier, tracked in #434] **Home is distinct from Dashboards.** Dashboards monitor the *fleet* (property 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, samples 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. --- # 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. ::::design[Target design: the ViewResult contract, tracked in #434] :::caution[Design: the ViewResult contract is entirely unbuilt] Nothing on this page is built: there is no `view` table, no `ViewResult` shape, no `/views/{id}:run` route, and no renderer library. Today's read side is typed CRUD `GET`s plus five hand-written composed reads (reachability, events, reconciliation, and the component and node log reads) that stand in until this framework lands, which the [API](/architecture/api/) page's views-exception note covers. ::: Writes go through typed resource CRUD; in the target model **everything read goes through a view** (today's reality is the typed CRUD `GET`s plus the five composed reads named above). 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 would query: the designed example is an Alarms page reading a `firing-now` view rather than a bespoke route, so the read contract stays uniform and the same view backs a dashboard widget unchanged. (None of that trio exists yet: there is no `firing-now` view, no `GET /alarms` route, and the console's Alarms page is a stub.) - **Private views** are operator-saved **structured** queries (filter + order + fields + params), **never raw SQL**. They follow the official / private [namespace shadow](/architecture/properties/#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 the designed [API](/architecture/api/) list convention (`page_token`), over the already-scoped result. No built route takes `page_token` today; the API page fences the list conventions as Design. - **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** would render a view through the renderer library ([UI](/architecture/ui/)): coded pages and dashboard widgets both binding `view ref + renderer + field-mapping + params`. The built console instead binds hand-written page configs over `ListShell` / `FlatList` / `TreeList` to the generated typed client; the renderer library arrives with this contract. - **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. :::note[Implementation status] Built today: one durable JetStream work-queue consumer, `og-telemetry-worker` (`internal/bus/consumer.go`: explicit ack, MaxDeliver 5, bounded nak backoff, serial dispatch), which does owner confinement and persistence inline. The design fences below mark the parts of this page that are target design, each naming its tracker. ::: Workers are how Omniglass does the steady background work, deriving samples, 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). That triple is the target contract, not the current one: today there is no `Nats-Msg-Id` dedup and the sink is not idempotent (#311, #430), and the one built consumer dispatches serially on purpose because the state transition guard depends on it (`internal/bus/consumer.go:246-253`), so the configurable concurrency pool and exactly-once outcomes are Design. It is instantiated over several consumers rather than separate loops: :::design[Target design, tracked in #430; the clock is #419] - **the admission consumer**: the owner-confinement gate at the head of the data lane, a worker running in system mode; the mechanism is described once, on [messaging](/architecture/messaging/#two-lanes-one-bus); - **the rule engine** (sample consumers): consume arriving samples from the **trusted** JetStream samples stream, apply `calc_rule`s and `event_rule`s, publish derived samples 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**: the data lane's batch sink into the Postgres sample tables, async so rules never wait on PG ([messaging](/architecture/messaging/#two-lanes-one-bus)); - **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. ::: :::design[The node-liveness sweep, tracked in #419] 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 over the one-open dedup primitive ([ADR-0075](/architecture/decisions/#adr-0075-an-alarms-condition-identity-is-a-raiser-supplied-dedup-key), which is built; nodes already publish the heartbeat the sweep will read, and the sweep itself is not). ::: There is no separate projector: current values live in the `property` latest-value cache table (ADR-0065; see [storage](/architecture/storage/)), and `alarm` / `action` hold their state directly. :::design[Target design, tracked in #430 (the CDC publisher) and #419 (the clock)] ## Consumer groups versus singletons Most of the machinery is competing consumers, but two pieces must run as exactly one active instance: the **CDC publisher** (the record lane's bridge from Postgres commits onto JetStream, described once on [messaging](/architecture/messaging/#two-lanes-one-bus)) 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. ::: :::design[Target design, tracked in #430] ## Re-entry, not one mega-pass The pipeline `sample -> alarm -> action` is **not one transaction**. A sample arrives on the samples stream; `event_rule`s evaluate it (the stateless then stateful stages below); two edges re-enter: **calc** (a `calc_rule` produces *new* samples) re-enters by publishing the derived samples 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 samples 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 serializes per condition: today the condition identity is the raiser-supplied `dedup_key` ([ADR-0075](/architecture/decisions/#adr-0075-an-alarms-condition-identity-is-a-raiser-supplied-dedup-key)); the `event_rule` keying joins alongside when the rule engine lands, and its ordered write will land 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 (built for alarms: `alarm_open_condition_key`, one open row per `(component, dedup_key)`). - **Backtest is harder**: it must process each entity's series in order. :::design[Target design, tracked in #430] ## Lineage the engine stamps Every derived sample carries its lineage **on the row** (a `provenance`, `source_rule` plus version, and the one provenance pointer; see [storage](/architecture/storage/), [samples](/architecture/properties/)). 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 samples) stamps the same `source_rule` on each. The rule version is the hinge for backtest. ::: :::design[Target design, tracked in #434] ## Backtest: re-run a changed rule over retained samples The model is **not event-sourced**: current state lives in the sample 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 samples** 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** samples 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 sample 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. ::: :::design[Target design, tracked in #434] ## 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.** The `gen-drift` workflow (`.github/workflows/gen-drift.yml`) enforces it in CI: it runs `make gen` and fails the PR on any diff in the committed generated artifacts. ## The generation pipeline | Generator | Input | Output | Consumer | |---|---|---|---| | `cmd/openapigen` | Huma Go structs | `api/openapi.json` (+ `.yaml`) | everything below | | `cd web && npm run 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/docsgen` | the live cobra tree | `docs/src/content/docs/reference/cli/index.md` | the published CLI reference | | `cmd/erdgen` | the embedded migrations, applied to a throwaway Postgres and introspected | the D2 region of `docs/src/content/docs/architecture/data-model.md` and the schema facts `docs/src/generated/schema.json` | the published ERD and every docs storage table | | `cmd/seedgen` | the twelve embedded seed YAMLs, parsed in-process (no database) | `docs/src/generated/seed.json`, roles carrying effective permissions | the docs' shipped-set claims | | `cmd/configgen` | the declarative env registry in `internal/config` | `docs/src/generated/config.json` | the deployment guides' env tables | | `gen-proto` | `proto/og/v1/*.proto` | committed `*.pb.go` | the NATS `TelemetryBatch` telemetry message | Two more stages are planned but have **no generator yet**: an MCP tool catalog for AI agents over the [API contract](/architecture/api/), and a JSONSchema for YAML editor validation. Neither exists today; they land with their consuming slices. One command runs them all (`make gen`); two focused targets regenerate a subset (`make gen-proto` for the protobuf wire, `make gen-web` for the spec plus the typed SPA client). The committed `*.pb.go` lets 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**: `/components/{name}/commands:issue`, `/auth/me:changePassword`, `/auth/me/sessions/{id}:revoke`, `/nodes:claim`, `/principals/{id}:disable`. - **Singular kind sub-segments**: `/rules/calc`, `/property-types`, `/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). :::design[Target design: the ViewResult contract, tracked in #434] **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`, add the per-route test, keep the coverage tests green. --- # UI and the design system URL: /contributing/design-system/ The SolidJS and daisyUI console, config-driven ListShell pages (with FlatList / TreeList bodies) 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: `ListShell` with `FlatList` / `TreeList` bodies.** 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), the tree and flattened body 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; `ListShell` 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) `ListShell` (with its `FlatList` / `TreeList` bodies), `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 `ListShell`. ## 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** is `make test-e2e` (`web/e2e/run.sh`): it brings up the dev Postgres, builds the binary with the console embedded, serves it, and runs Playwright against the real login form and console, driving the surfaces as a user would per the [test-first doctrine](/contributing/test-driven/). ## How this relates to the UI architecture This page is the **build and dev guide** for the console: the stack, the generated client, the `ListShell` 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 - state a one-line justification in the PR body (pure refactor, internal-only change, etc.). The docs-touched gate is enforced by the PR template checklist and the `/ship-slice` pre-ship pass, not by a label-driven CI check (the [label taxonomy](/contributing/labels/) is closed, and no workflow reads a docs label). The justification path exists so the gate never blocks a genuine internal change, not as a routine escape hatch. A mechanical layer backs the human gate: the **docs lint suite** (`internal/docslint`, run by `go test` and so by `make test` and CI) checks the hand-written docs against the code. The suite covers vocabulary (retired identifiers must not appear in current-tense prose, in the docs, the operator strings, and the generated OpenAPI descriptions), the decision log's format, routes and permissions (both checked against the generated spec, permissions in both directions), make targets, env vars, file paths, and table.column references (against the generated schema facts), plus the badge-fence coverage check and the docs-command-and-flag guard in `internal/cli`. The semantic drift no lint can see gets the periodic `/docs-audit` pass (#429). ## 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 states its one-line justification in the PR body. ## 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 log](/architecture/build-log/) 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. ## The design fence A badge is page-granular, and a page is not one thing: most architecture pages mix built behavior with target design. The **design fence** marks the unbuilt part structurally, at the section where it lives, instead of a hand-written "still design" sentence at the top that rots when a slice ships: ```markdown :::design[Target design, tracked in #434] The prose in here describes something that does not exist yet. ::: ``` The fence renders as a visually distinct aside (dashed, purple, labeled), and its label **must name the issue or ADR that tracks the gap**; a fence with neither fails the docs build, so unbuilt prose always has an owner. To nest a normal aside inside a fence, give the fence more colons (`::::design` around a `:::note`), which is remark-directive's nesting rule. The fence is also the boundary the [docs lint suite](#the-rule) keys on (`internal/docslint`, the `Regions` split): the **vocabulary lint runs everywhere**, fenced or not, because future design must still be written in current nouns, while the **existence lints** (routes, permissions, tables, make targets, env vars, paths) run only outside a fence, where prose claims to describe something that exists. Building a fenced section means **deleting the fence in the same PR**, exactly like moving the badge. ## 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 The docs site builds in CI on every PR that touches `docs/**` (the `docs-build` workflow, `.github/workflows/docs-build.yml`, path-filtered), so a broken `.mdx` page, a dead import, or invalid frontmatter fails the PR that introduces it. The published site is docs.omniglass.hyperscaleav.com; in time, the same content is embedded into the Go binary to serve at `/docs`. --- # Feature loops URL: /contributing/feature-loops/ How a body of work ships through an agent loop: a prose definition approved once, slices cascading on an integration branch, and one rollup PR the architect merges. A **feature loop** is how a multi-slice body of work ships when an agent executes it end to end: the architect approves a prose definition once at the start and makes a merge decision once at the end, and everything between runs against gates that are mechanical, not conversational. The [slice workflow](/contributing/slice-workflow/) is unchanged *inside* each slice; what a loop changes is the PR granularity (one PR per approved definition, not per slice) and who drives the lifecycle between the two human touchpoints ([ADR-0074](/architecture/decisions/#adr-0074-an-approved-definition-rolls-up-to-one-pr-slices-cascade-on-an-integration-branch)). ## The lifecycle **Define.** Work is defined in prose on an `Epic` (multi-slice) or `Feature` (single-slice) issue, in the standard approvable shape the `/define-work` skill drafts: outcome, scope, thin cuts, out of scope, surfaces touched, acceptance behaviors, proposed sub-issues, planned PR count (default one), and the decisions the definition leaves open. No sub-issues exist yet and no branch exists yet. **Approve.** The architect reads the definition and approves or redirects it, as a comment on the definition issue. This is the existing hard gate (no branch until the issue exists and its scope is approved), paid once per body of work instead of once per conversation. Approval covers everything the definition states; it does not cover what the definition leaves open, which stays on the issue as a question. An in-session approval (the architect directing a live session) is valid; the loop records it as a comment on the definition issue the same day, so the paper trail never depends on a chat transcript. A loop may also **adopt a pre-existing sub-issue breakdown** (one built by another session) instead of creating its own; adoption carries a verification step, the breakdown must add up to the approved definition and no more, and any mismatch goes back to the architect. **Loop.** On approval the loop creates the sub-issues under the parent and opens one **integration branch** for the definition. Each sub-issue is one slice, built test-first through the normal lifecycle, and merged **inward** to the integration branch only when its gates are green. Progress is readable from the sub-issue list itself: a sub-issue is closed when its slice merges inward, with the commit named in the closing comment. The sub-issue list is the status; there is no parallel checklist to drift. **Roll up.** One PR from the integration branch to `main`, carrying the full [`/ship-slice`](/contributing/slice-workflow/#the-ship-review-the-approval-artifact) ship-review for the whole diff: fresh `make test` output, `make gen` clean, live screenshots for any operator surface, and the docs that teach it. The loop never merges to `main`; merge is the architect's call, always. ## Branch topology ``` main └── / the integration branch, from origin/main ├── /-- cascade branch, merges inward ├── /-- cascade branch, merges inward └── ... one rollup PR to main at the end ``` - The integration branch follows the normal naming rule (`/`, the conventional-commit type of the eventual rollup PR title). - A **cascade branch** per sub-issue is the default for parallel work or a slice large enough to want its own local history. A serial loop working slices one at a time may commit a slice directly to the integration branch instead; the per-slice gates apply either way, and each commit references its sub-issue. - Per-slice gates, before a slice merges inward: the failing test first, `make test-short` green while iterating, the docs lint suite green, and an `/adversarial-review` pass over the slice diff with findings addressed. - The rollup gate is `/ship-slice` over the whole integration diff, which re-runs the full fresh `make test`. A red rollup gate is fixed on the integration branch before the PR is proposed. ## Decision authority The loop decides alone, within the approved scope: - implementation choices a reviewer would accept either way (naming within conventions, file placement, test structure); - thin cuts, documented in the ship-review's Scope block as always; - the sub-issue breakdown itself, provided it adds up to the approved definition and no more. The loop **stops and asks** instead of proceeding when: - a genuine fork appears that the definition left open (it goes to the ship-review's "Decisions I need from you" line if it can wait, or stops the loop if later slices depend on it); - a gate stays red after honest attempts to fix it (a red gate is never bypassed; `--no-verify` still requires explicit approval); - the work grows beyond the approved definition (new scope means a new or amended definition, not silent expansion); - a change would touch an invariant (the authorization layers, the migration rules, the audit contract) in a way the definition did not name. ## Limits and the exception - One integration branch and at most one open rollup PR per definition. - Sub-issues are worked serially by default; parallel cascade branches are for genuinely independent slices. - The default is **one** rollup PR. A definition too large for one reviewable diff names its planned PR count in the Define stage, so the granularity is approved with the scope, never improvised mid-loop. - After the rollup PR opens, the loop may push review-response fixes within the approved scope without a fresh approval. Anything beyond that scope goes back through Define. --- # 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, named to match the architecture glossary. The set grows as subsystems land; today: `area:foundation` `area:api` `area:storage` `area:auth` `area:collection` `area:expr` `area:ui` `area:node` `area:health` `area:settings` `area:tags` `area:files` 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 | [properties](/architecture/properties/) | ## 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. Procedural skills make this doctrine repeatable, ported as their subsystems land: `/add-inventory-view` exists today; a collection-primitive skill and a canonical-property skill follow with the collection-engine and registry slices. - **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. Nine workflows carry it: `test`, `pr-title`, `image`, `docs-shots`, `gen-drift`, `docs-build`, `preview-comment`, `release`, and `goreleaser`. ## 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 (after widening `net.ipv4.ping_group_range` so the collection tier's unprivileged ICMP probe can open its socket). 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. - **`gen-drift.yml`** runs `make gen` and fails the PR on any diff in the committed generated artifacts (the OpenAPI, the cobra tree, the CLI reference, the ERD, the typed SPA client, the protobuf wire), the drift gate [API first](/contributing/api-first/) promises. - **`docs-build.yml`** (path-filtered to `docs/**`) builds the Astro docs site, so a broken page fails the PR that introduces it instead of the next deploy. - **`docs-shots.yml`** (path-filtered to `docs/**` and `web/**`) gates the generated docs screenshots: every declared shot has a committed PNG, and a recapture against the real console fails if a shot drifted beyond the tolerance. Two more PR checks: **`image.yml`** builds the multi-arch container image (see [Container image](/guides/container-image/)), and **`preview-comment.yml`** reacts to the `run:preview` label (see [PR previews](/guides/pr-previews/)). ## 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. When several related slices ship as one approved body of work through an agent loop, the PR granularity changes (one rollup PR per approved definition) while this lifecycle holds inside each slice: see [feature loops](/contributing/feature-loops/) and [ADR-0074](/architecture/decisions/#adr-0074-an-approved-definition-rolls-up-to-one-pr-slices-cascade-on-an-integration-branch). ## 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 seven stages (Define through Review) are the [five doctrines](/) in motion; the last two (Ship and Log) 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 [build log](/architecture/build-log/) entry, 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 Visual: live screenshots for any operator-facing surface (the PR template's Visual confirmation section) | n/a (no UI surface) Docs: what shipped; arch-of-record consistent or a divergence note Status: pages advanced (page: Design->Partial); build-log 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, Settings, 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. - **[Files](/guides/admin/files/)** is the content kept with the estate: uploads in the blob store, owned at a scope like the other values. - **[Tags](/guides/admin/tags/)** is the governed key vocabulary behind the tag chips, and **[properties](/guides/admin/properties/)** is the signal catalog of canonical typed names. - The classifier catalogs shape the estate's entities: **[types](/guides/admin/types/)** for locations, **[standards](/guides/admin/standards/)** for systems, and **[products](/guides/admin/products/)** for components, with **[vendors](/guides/admin/vendors/)**, **[drivers](/guides/admin/drivers/)**, and **[capabilities](/guides/admin/capabilities/)** the reference registries a product ties together. 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. --- # 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:admin`, 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 **Id** column shows the resource's friendly handle, resolved from the row's own change images; hovering it reveals the stable uuid the trail actually stores, which is what keeps a row attributable after a rename. A row whose images carry no handle (an older registry delete) falls back to the raw id. Pick a row to open its **detail drawer**: who acted (and, for an impersonated action, who was behind it), when, and the **field-level before/after diff** of the change itself. Changed fields highlight both sides, an added field only its After, a removed one only its Before, and unchanged fields dim. A create shows only After values and a delete only Before. Registry mutations record the full row on both sides (an update carries the row as it stood and as it became, a delete the whole row it removed); rows written before that shipped carried only an id, so an old row can still read one-sided. Auth events (sign-ins, password changes) record no row images at all, and the drawer says so. Sealed material never appears: a secret's audit row carries its metadata only, and password changes record that the credential changed, never a value. 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 **name** (the operator-facing kebab handle, for example `microphone`), the **display name**, and its **origin** (**official**, seed-owned, or **custom**). A capability also carries an `id`, a uuid minted by the database, the internal address the handle resolves to ([ADR-0062](/architecture/decisions/)); the handle is what you type and read. 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: give it a **name** (the kebab handle, unique tenant-wide, e.g. `microphone`) and a **display name**. - Pick a row to open its **detail blade**. The footer **Edit** pencil (with `capability:update`) edits the display name; the uuid `id` is database-minted and never edited. **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 **name** (the operator-facing kebab handle, for example `snmp-generic`), the **display name**, an optional **version**, and its **origin** (**official**, seed-owned, or **custom**). A driver also carries an `id`, a uuid minted by the database, the internal address the handle resolves to ([ADR-0062](/architecture/decisions/)); the handle is what you type and read. A driver is consumed by the [product](/guides/admin/products/) catalog: a `product` references its driver through an optional `driver_id` to say which driver reads it, chosen from a driver picker on the product's create and edit forms, and three of the shipped official products bind a driver this way. It is a leaf catalog beside the vendor and [capability](/guides/admin/capabilities/) registries. 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: give it a **name** (the kebab handle, unique tenant-wide, e.g. `snmp-generic`), 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 uuid `id` is database-minted and never edited. **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 lives on the [product](/guides/admin/products/) create and edit forms. 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 **name** (the operator-facing kebab handle, for example `cisco-room-bar`), the **display name**, its **vendor**, **driver**, **kind**, and its **origin** (**official**, seed-owned, or **custom**). A product also carries an `id`, a uuid minted by the database, the internal address the handle resolves to ([ADR-0062](/architecture/decisions/)); the handle is what you type and read. 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: give it a **name** (the kebab handle, unique tenant-wide, e.g. `cisco-room-bar`) and 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 uuid `id` is database-minted and never edited. **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 `. --- # Tags URL: /guides/admin/tags/ The Tags directory: mint the governed key vocabulary, set what each key applies to and whether it cascades, and edit or delete a key. **Catalog > Tags** (with `tag:read`) is the directory of the governed [tag](/architecture/tags/) key vocabulary: the tenant-wide set of `key: value` label names an operator binds onto the estate. Each row shows the **key**, its **Applies to** (the entity kinds it may bind to, or **Any**), and its **Binding** (**cascades** to descendants, or **flat**, a per-entity label). - **New tag key** (with `tag:create`, an admin permission) opens a create **drawer**: name the key (a normalized lowercase identifier, unique tenant-wide), check the entity kinds it **applies to** (leave all unchecked for any), toggle whether its bindings **cascade**, and set its **value domain**, either free text or **constrained to a fixed set** (an enum, like `environment` being one of `prod`, `staging`, `dev`). An enum is enforced on every bind and shown as a strict dropdown; a free key autocompletes the values already in use. Minting the vocabulary is deliberately admin-gated; *setting a value* on a key is the ordinary entity write, done on that entity's own page. - Pick a row to open its **detail blade**. The footer **Edit** pencil (with `tag:update`) edits the governance fields (applies_to, propagates); the key name is fixed. **Delete** (with `tag:delete`) removes the key and, with it, every binding across the estate, behind a confirm. Minting a **key** is admin-gated here; **setting a value** on a key is the ordinary entity write. Open a component, system, or location, and its detail blade carries a **Tags** panel: type a key (the picker offers the registry keys that apply to that entity kind, and with `tag:create` a **Create key** shortcut opens this same create form), give it a value, and it binds on **Add**; the **x** on a chip removes it. Each write is gated by that entity's own `:update`, so an operator tags what it may already edit. The estate directories then **show** each row's effective tags in a colored [Tags column](/guides/operator/inventory/) (the resolved cascade, keys unioning and values overriding most-specific-wins). The same operations are `omniglass component setTag` / `system setTag` / `location setTag` and `omniglass component effective-tag list ` from the CLI. --- # Manage users URL: /guides/admin/users/ The principal directory: create a human, edit and rename, the disable-archive-purge lifecycle, admin password reset, and profile pictures. **Admin > Users** is the admin directory of every principal, humans and service accounts, each with the roles granted to it. This page is the account lifecycle: creating, editing, disabling, and removing a user. Giving a user access, a role at a scope, is the [grant builder](/guides/admin/access/#the-grant-builder) on the next page. The model underneath is [identity and access](/architecture/identity-access/). Every row leads with the principal's avatar, its uploaded picture when it has one and its initials otherwise. You see the directory only if you hold `principal:read:admin`, the admin-tier read that a plain `*:read` cannot reach, so a viewer or field tech cannot enumerate users. Because a principal is not part of any location or system tree, that grant is inherently **all-scope**: a location-scoped admin cannot list users. ::screenshot{#users} - Pick a row to open its **blade**: a principal's profile, the **groups** it belongs to (open one to stack that group's blade over the user), and its **role grants** (each a role at a scope). - With `principal:create`, **New user** creates a human with a username and an optional initial password (which must meet the **password policy**: at least 12 characters, not a common password, and not containing the username). **Generate** fills a strong random password, kept masked, with a **Copy** button to hand it over (or reveal it with the show/hide toggle). The new user can sign in right away and change that password themselves; a fresh account holds no grants (so it can sign in but do nothing) until you assign a role. The form validates as you type: a **username** is a lowercase handle (letters, digits, and `. _ -`, no capitals or spaces) and an **email** must be well formed, so an invalid field shows an inline error and blocks the submit before the round-trip (the same rules the server enforces). The same handle rule and inline check apply when you rename a user in edit mode. The new user's blade opens **directly in edit mode**, so you assign its roles right away and one **Save** commits them. - With `principal:update`, the footer **Edit** opens edit mode, where you change a user's display name, email, and **username**, or adjust its grants, and **Save** commits the lot; **Disable / Enable** sits in the footer's left slot, available without entering edit. Renaming is safe: their credentials and grants follow the account (they key on an internal id, not the username), so a renamed user keeps their password and access. Only an administrator can change a username; the user cannot change their own. - A user has a **lifecycle** in the blade footer, escalating from reversible to permanent, and reads pause to remove to destroy. The left slot is the reversible toggle: **Disable** (`principal:update`) suspends sign-in (the row reads **inactive**), **Enable** restores it. The kebab holds the stronger, red steps: **Archive** (`principal:archive`) soft-deletes a user (hidden from the directory, cannot sign in, reversibly), and **Purge** (`principal:purge:admin`, admin-sensitive so admin and owner only) permanently deletes an archived user and its grants and memberships, with a confirm. The audit trail is kept through a purge. An archived user shows **Restore** in the left slot; the **Show archived** toggle above the directory surfaces hidden accounts so you can re-find one to restore or purge. The **last active owner** cannot be disabled or archived, the same invariant that protects the last owner grant. - With `principal:reset-password`, the kebab on **another user's** blade holds **Reset password**: it opens an inline panel with a password field (the same **Generate** and inline policy check as the New user form) and sets a new password for that user without their current one. The reset **immediately signs the user out of every session** (its API tokens are kept, a token not being tied to the password), so it cuts off a compromised or departing account's logins at once. The set password stays copyable so you can hand it over; the user changes it after signing in. The reset is audited with **you** as the actor. It is refused on your **own** account (change your own password from **Your profile**, which verifies your current one) and on an **owner** (owners cannot be reset by anyone). This is a console path for what the CLI does with `omniglass set-password`; unlike that trusted direct-DB lane, the console reset enforces the password policy and the takeover guard. - With `principal:set-avatar` (an all-scope capability), a user's **Edit** blade gains an **Upload / Remove** picture panel: **Upload** sets that user's profile picture from an image file (JPEG, PNG, or WebP, normalized server-side to a small square), **Remove** clears it, and the change is audited with **you** as the actor. Without the capability the panel does not render, though the user's picture still shows in the blade header and the directory. This is a console path for `omniglass principal setAvatar ` / `removeAvatar ` on the CLI. - With `principal:revoke-session`, another user's blade gains **Sessions** and **API tokens** sections: every credential the account holds, listed with its `ogp_` locator, the **device** and **address** that created it, when it was **last active**, its expiry, and a token's **description**. **Revoke** any one, or use **Revoke all sessions** / **Revoke all tokens** in the blade's kebab, to cut off a lost laptop or a leaked token without resetting the account. The revoke is audited with **you** as the actor. As with the password reset, an **owner's** credentials cannot be revoked by anyone: their list renders read-only (you can see where the account is signed in, not end it), and the affordance is hidden unless you hold the capability. From the CLI the same surface is `omniglass principal list` / `get` / `create` / `update` / `disable` / `enable` / `archive` / `restore` / `purge`, plus `principal session list ` / `principal session revoke ` / `principal session revokeAll ` (see the [CLI reference](/reference/cli/)). --- # Variables URL: /guides/admin/variables/ Plaintext typed free values owned at one scope and resolved down the cascade: the directory, create, and edit. A **variable** is the plaintext sibling of a [secret](/guides/admin/secrets/): a typed free value (a macro), owned at one scope and resolved down the [cascade](/architecture/cascade/) the same way, but shown in the clear because it is not sensitive. The model underneath is [config and credentials](/architecture/variables/). **Values > Variables** (with `variable:read`) is the directory of every [variable](/architecture/variables/). Each row shows its name, a **type badge** (`string`, `int`, `float`, `bool`, `json`), a **scope** label (Platform, or the location / system / component it attaches to), and the **value in the clear** (no mask, no reveal). ::screenshot{#variables} - **New variable** (with `variable:create`) opens a create **drawer**: name the key, pick a **type** and a **scope**, choose the entity from the shared tree picker, then enter the value in a **type-aware editor** (a number input, a toggle for a bool, a textarea for json). A **platform** variable is install-wide, so it needs an all-scope grant plus `platform:create` on top of `variable:create`. `variable:create` is on the **operator** role; `platform:*` starts at **admin**. Without `platform:create` the **Platform** scope is not offered at all, and the form says which capability that takes. - Pick a row to open its **detail blade**. The footer **Edit** pencil (with `variable:update`, also an operator permission) opens the type-aware value editor; **Delete** (with `variable:delete`, admin and owner) sits behind a confirm. On a **Platform** row both also need the matching `platform:`; holding only the variable half leaves the blade read-only with a note naming what is missing. From the CLI the same surface is `omniglass variable list` / `create` / `update` / `delete` (see the [CLI reference](/reference/cli/)). --- # Vendors URL: /guides/admin/vendors/ The Vendors catalog: the organizations behind products (name, display name, kind of manufacturer/integrator/developer, icon, support phone, website), seed-owned official rows read-only, admin-gated custom ones. **Catalog > Vendors** (`/vendors`, with `vendor:read`, covered by every viewer's `*:read` floor) is the directory of **vendors**: the organizations behind the products in the estate, on the same flat-registry pattern as [Types](/guides/admin/types/) and [Tags](/guides/admin/tags/). A vendor is not a device; it is the company a device comes from. Each row shows the **name** (the operator-facing kebab handle, for example `crestron`), the **display name**, its **kind** (**manufacturer**, **integrator**, or **developer**), an optional **icon** glyph key, and its **origin** (**official**, seed-owned, or **custom**). A vendor also carries an `id`, a uuid minted by the database, the internal address the handle resolves to ([ADR-0062](/architecture/decisions/)); the handle is what you type and read. A vendor is consumed by the [product](/guides/admin/products/) catalog: a `product` ("Acme 123A, by Acme") references its vendor through an optional `vendor_id`, chosen from a vendor picker on the product's create and edit forms, and a component then points at that product. Several shipped official products carry a vendor this way. See [core entities](/architecture/core-entities/) for where the vendor registry sits in the estate model, and [Drivers](/guides/admin/drivers/) and [Capabilities](/guides/admin/capabilities/) for the two leaf catalogs beside it. - **Kind** classifies the organization: a **manufacturer** builds hardware, an **integrator** assembles and installs systems, a **developer** ships software. It defaults to **manufacturer** and is a closed set; a value outside it is refused (422). - **New vendor** (with `vendor:create`, an admin permission) opens a create drawer: give it a **name** (the kebab handle, unique tenant-wide, e.g. `crestron`) and a **display name**; choose its **kind** (defaults to manufacturer); **icon** (a glyph key), **support phone**, and **website** are optional. - Pick a row to open its **detail blade**. The footer **Edit** pencil (with `vendor:update`) edits the display name, kind, icon, support phone, and website; the uuid `id` is database-minted and never edited. **Delete** (with `vendor: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 eight official vendors (Crestron, Biamp, QSC, Shure, Cisco, Extron, Sony, Samsung), all manufacturers, as a starter baseline, upserted idempotently at boot so the shared set cannot drift install to install; add a custom vendor for anything else. - **Website** is validated to an `http`/`https` URL, on both the create/edit form and the API: a value in another scheme (for example `javascript:`) is refused with a 422 rather than stored. A valid website renders as a live link on the blade; a value that fails the check (entered off console, through a raw API call that bypassed the client) still renders, as plain text, never as a dead or unsafe link. - **Delete** carries no in-use guard: a [product](/guides/admin/products/) references a `vendor` through its optional `vendor_id`, but that link is `on delete set null`, so deleting a vendor detaches it from those products (their vendor 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 vendor. Minting a vendor is admin-gated; the picker that consumes it lives on the [product](/guides/admin/products/) create and edit forms. The same operations are `omniglass vendor list/get/create/update/delete` from the CLI (see the [CLI reference](/reference/cli/)). --- # The CLI URL: /guides/cli/ The omniglass CLI: a generated client of the HTTP API, with a stable seam for hand-written commands. The `omniglass` binary is both the server and the client. Its data commands are **generated from the OpenAPI** (`make gen`, via `cmd/cligen`), so the CLI cannot drift from the API: a new route is a new command on the next regeneration. A small set of commands (the run modes and the trusted bootstrap) are hand-written and compose with the generated tree on the same root. This page is the mental model and the setup. For the exhaustive, generated list of every command, its flags, and an example, see the **[CLI reference](/reference/cli/)**. ## Running the full stack locally `make dev` brings up the whole stack for a browser session: a dev Postgres (docker compose, matching the default DSN), the migrations and boot seed, a bootstrapped `dev` owner whose token is printed once, a dev example estate (a few campuses with rooms, a few sign-in-able users, and their grants, so the console is not empty), and the server with the operator console at `http://localhost:8080/web`. Ctrl-C stops the server; `make down` stops Postgres (the named volume persists data between runs; `docker compose down -v` wipes it and re-mints a token next run). `make up` / `make down` manage just the database. Tests never touch this stack: they spin their own ephemeral Postgres via testcontainers. ## Connecting Every generated command is a client of a running server and takes two shared flags, each with an environment default: | Flag | Env | Default | |---|---|---| | `--server` | `OMNIGLASS_SERVER` | `http://localhost:8080` | | `--token` | `OMNIGLASS_TOKEN` | (none) | The token is a bearer credential (mint the first one with `omniglass bootstrap`; see [Authentication](#authentication) below). The server enforces the same capability and scope for the CLI as for any caller: the CLI is just another client, with no privileged path. ```sh export OMNIGLASS_SERVER=https://omniglass.example.com export OMNIGLASS_TOKEN=ogp_... omniglass location list omniglass location create --name hq --location-type campus omniglass location get hq ``` Output is JSON. A non-2xx response prints the server's error body and exits non-zero, so the CLI is safe in scripts. ## Authentication There are two ways to authenticate, both accepted on every request. A **bearer token** in the `Authorization` header (the `--token` flag above) is the path for services and the CLI. A **username and password** is the path for a human at the web console: the server verifies it and sets an httpOnly session cookie. The CLI itself always uses a bearer token. The first owner is created directly against the database with `bootstrap` (the trusted lane, no running server needed): ```sh # Mints a bearer credential (the token is printed once) and, with --password, a password # credential (argon2id) so the owner can sign in to the console. This is a trusted direct-DB # lane, so it is exempt from the password policy (unlike the console/API paths). The bootstrap # token expires after --ttl (default 90 days, hard maximum 365 days). omniglass bootstrap ops --password 'set-a-strong-one' --email ops@example.com --display-name "Ops Lead" # Mint a fresh bearer token for an existing user (direct-DB, owner lane). A --description # (required) names what the token is for. Every credential is time-bounded: the token expires # after --ttl (default 90 days, hard maximum 365 days; a --ttl above the cap is an error). A # web-login session cookie has its own, shorter fixed lifetime. omniglass token ops --description 'ci pipeline' omniglass token ops --description 'nightly backup' --ttl 720h # a 30-day token # A signed-in user can mint its own token over the API (the console Create token action), which # returns the secret once: omniglass auth create-token --description 'my laptop cli'. # Set or rotate a user's password (direct-DB, owner lane; also policy-exempt as the recovery path). # A break-glass reset also revokes the user's live SESSIONS, so a stolen login stops at once; API # tokens are kept unless --revoke-tokens is given (a full lockout of a compromised account). omniglass set-password ops 'set-a-strong-one' omniglass set-password ops 'set-a-strong-one' --revoke-tokens # full lockout: sessions and tokens ``` Once a server is running, a signed-in principal manages **its own** account through the generated self-scoped commands (each edits only the caller's own profile): ```sh omniglass auth me # your principal, permissions, and grants omniglass auth update-profile --display-name "Ops Lead" omniglass auth change-password --current-password 'orange-boat-42x' --new-password 'purple-canyon-7' omniglass auth set-avatar --image-base64 "$(base64 -w0 me.jpg)" # set your profile picture omniglass auth remove-avatar # clear it, falling back to initials omniglass auth avatar # read your picture back as { image_base64 } ``` `--image-base64` takes a plain base64 string, not a file path (base64-encode the image yourself, as the `$(base64 …)` above does); the server accepts JPEG, PNG, or WebP and normalizes it to a 256x256 JPEG. An administrator manages **any** principal's picture with `omniglass principal setAvatar --image-base64 …` and `omniglass principal removeAvatar ` (gated by `principal:set-avatar`), reading one back with `omniglass principal avatar list ` (gated by `principal:read:admin`). A principal with no picture is a 404. ## Collection commands The [collection](/architecture/collection/) surface regenerates into three top-level command groups, `node`, `interface`, and `task`; the composed reachability read hangs off the component that owns it (`component reachability`). They follow the same derivation as every other resource (`POST /interfaces` is `interface create`, `GET /tasks/{id}` is `task get `), so nothing here is special-cased. All examples require the matching permission on the running server. Register and enroll an edge node (the day-one handshake): ```sh omniglass node list omniglass node create --name edge-hq --display-name "HQ Edge Node" --location hq-west --description "HQ network closet" # needs node:create (all-scope) omniglass node get edge-hq omniglass node update edge-hq --display-name "HQ Edge" --location hq-west # needs node:update; the name is immutable omniglass node delete edge-hq # needs node:delete; decommissions the node (cascades its interfaces, tasks, and enrollment) ``` Every command above is new in practice, not only in the docs: a hand-written `node` run mode occupied the same name as the generated `node` group, so cobra resolved `omniglass node list` to the daemon and it failed asking for `--token`. The run mode is now `omniglass node run`, a verb beside the others: ```sh omniglass node run --name edge-hq --token ogp_... # the edge daemon: claim, pull the worklist, heartbeat ``` The node-facing `claim` exchange is public (the enrollment token is the authentication), so a node presents its name and token to receive its NATS credential: ```sh omniglass node claim --name edge-hq --token ogp_... ``` `omniglass node enroll ` mints (or re-mints) a node's enrollment token and prints it once (gated by `node:enroll`), the same action as the console's Enroll / Re-enroll. Author a reachability check by **creating an interface** (its poll task is derived automatically): ```sh # An interface owned by a component, placed on a node, with its probe target in params. # It is named by its protocol: --interface-type is the interface_type, there is no --name flag. omniglass interface create \ --interface-type tcp --component disp-1 --node edge-hq \ --params '{"target":"10.0.0.1:22"}' # needs interface:create omniglass interface list omniglass interface get # interfaces are addressed by id omniglass interface update --node edge-hq --params '{"target":"10.0.0.2:22"}' omniglass interface delete # refused (409) while its task references it # The poll task is derived from the interface, so the task surface is read-only. omniglass task list omniglass task get ``` The four built interface types are `icmp`, `tcp`, `ssh`, and `http`, and an interface is **named by its protocol** (the `--interface-type`), unique within its component. An interface `update` changes only its node placement and params. A **task** is **derived** when its interface is created, so there is no `task create`, `update`, or `delete`; its placement follows the interface's. A node purge cascades its interfaces and their derived tasks. Read a component's composed reachability (the verdict, the probe-layer signals, and the recent transitions the availability strip draws): ```sh omniglass component reachability list disp-1 # needs component:read ``` Read a component's property reconciliation, the want/told/is pivot (the declared value resolved live from the cascade, the intended value a command set, and the observed value from the latest-value cache) with drift computed on read: ```sh omniglass component reconciliation list disp-1 # needs component:read ``` ## Secrets The [secret](/architecture/variables/) commands are generated like every other resource. `secret` covers the encrypted values, `secret-type` lists the shape registry, and `component effective-secret` reads the masked cascade onto one component. Output is masked JSON, the same as the console; plaintext lives behind `reveal`, which the server audits and which only admin and owner may call. ```sh omniglass secret-type list # the shape registry (snmp-community, basic-auth) omniglass secret list # the all-scope admin directory (masked fields) omniglass secret create --name core-snmp --secret-type snmp-community \ --owner-kind location --owner hq --fields '{"community":"public"}' omniglass secret update --fields '{"community":"s3cret"}' # an omitted field keeps its value omniglass secret reveal # audited plaintext decrypt (secret:reveal) omniglass secret delete omniglass component effective-secret list codec-1 # the masked cascade resolved onto a component ``` The effective read answers **which** secret applies to a device and where it comes from, never what it contains: fields are masked exactly as the directory masks them, and plaintext is only ever `secret reveal`. It rides `secret:read`, which the viewer floor does not carry. `--owner-kind` is one of `platform | location | system | component`; `--owner` names the owning entity and is omitted for a `platform` secret (the install-wide tier, which needs an all-scope grant plus `platform:create`). Field maps pass as a JSON object to `--fields`, validated against the type's shape. ## Variables The [variable](/architecture/variables/) commands are generated the same way. `variable` covers the plaintext values and `component effective-variable` reads the cascade onto one component. There is no reveal: the value is shown in the clear. ```sh omniglass variable list # the all-scope admin directory omniglass variable create --name poll_interval --value-type int \ --owner-kind system --owner east-auditorium-av --value 30 omniglass variable create --name retry --value-type json --owner-kind platform \ --value '{"retries":3,"backoff":"1s"}' omniglass variable update --value 60 # validated against the fixed value_type omniglass variable delete omniglass component effective-variable list codec-1 # the cascade resolved onto a component ``` Both effective reads return the **winner and the candidates it beat**, with the tier each came from, so the answer explains itself rather than just asserting a value. The variable cascade resolves the system band from the component's **primary** membership; resolving against a named system is offered only by `component effective-tag list` today. `--value-type` is one of `string | int | float | bool | json`. `--value` is **parsed as JSON**, so a bare `30`, `true`, or `{"k":"v"}` sends the number, the boolean, or the object; a bare word like `HDMI1` falls back to a string, so the common case needs no quoting. A string value that would otherwise parse as JSON (`30`, `true`) is quoted to force a string: `--value '"30"'`. (`secret create --fields` parses the same way.) A signed-in principal manages its own account through the generated self-scoped commands (`omniglass auth me`, `auth update-profile`, `auth change-password`, `auth set-avatar` / `auth remove-avatar`); an administrator manages other principals, roles, groups, secrets, and variables through the resource commands, all covered in the [admin guide](/guides/admin/) and listed in full in the [CLI reference](/reference/cli/). ## Tags The [tag](/architecture/tags/) commands split along the governance line. The `tag` resource covers the **key vocabulary** (minting, editing, and deleting keys, plus the install-wide `platform` binding), while binding a value onto an entity is a **custom method on the entity** (`component setTag` and friends), so it needs only the write the operator already holds on that entity. `effective-tag` reads the resolved cascade onto one component. ```sh omniglass tag list # the governed key vocabulary omniglass tag create --name environment # mint a key (tag:create, admin) omniglass tag create --name rack_position --applies-to '["location"]' --propagates=false omniglass tag update environment --applies-to '["component","system"]' omniglass tag setPlatform environment --value prod # an install-wide default (tag:update + platform:update) omniglass tag clearPlatform environment omniglass tag delete environment # cascades its bindings omniglass component setTag codec-1 --key environment --value dev # component:update omniglass component listTags codec-1 # the bindings set directly on the component omniglass component removeTag codec-1 --key environment omniglass system setTag east-auditorium-av --key environment --value prod omniglass location setTag hq --key environment --value staging omniglass component effective-tag list codec-1 # the cascade resolved onto a component ``` Binding is a custom method on the entity (`component setTag`), like the principal lifecycle verbs, so it stays clear of the top-level `tag` commands. A key name is a normalized lowercase identifier (minting a bad name is a 422). `--applies-to` is an entity-kind allow-list passed as a JSON array (`'["component","system"]'`; empty means universal), checked when a value is bound. `--propagates` defaults true (the value cascades to descendants); `--propagates=false` binds a flat per-entity value that resolves only on its own entity. Resolving onto a component **unions** keys and **overrides** values most-specific-wins down the cascade. ## Component classification catalogs The [component-classification catalog](/architecture/core-entities/#catalog-reference-data-vendor-driver-capability) commands cover the `vendor`, `driver`, and `capability` registries: flat, official-vs-custom catalogs on the same pattern as the `type` registries. Each resource's `:read` sits on the viewer floor; the three writes (`:create`, `:update`, `:delete`) are admin-gated. A **vendor** names an organization, carrying a `--kind` of `manufacturer`, `integrator`, or `developer` (default `manufacturer`): ```sh omniglass vendor list # the vendor registry omniglass vendor create --name barco --display-name Barco --kind manufacturer \ --icon monitor --support-phone "+1-555-0100" --website https://www.barco.com omniglass vendor get barco omniglass vendor update barco --support-phone "+1-555-0199" omniglass vendor delete barco # refused (422) if official ``` A **driver** names the implementation that gets, emits, or sets a product's signals, with an optional `--version`. A **capability** names what a component can do: ```sh omniglass driver list # the driver registry omniglass driver create --name barco-snmp --display-name "Barco SNMP" --version 1.0.0 omniglass driver update barco-snmp --version 1.1.0 omniglass driver delete barco-snmp # refused (422) if official omniglass capability list # the capability registry omniglass capability create --name projector --display-name Projector omniglass capability delete projector # refused (422) if official ``` A seed-owned (**official**) row, for example the `crestron` vendor or the `microphone` capability, is read-only: `update` and `delete` both 422. A vendor's `website` is validated to an `http`/`https` scheme on write; any other scheme (for example `javascript:`) is a 422. ## Products The [product](/architecture/core-entities/#catalog-reference-data-product) commands cover the product registry: the concrete **SKU** that ties the vendor, driver, and capability catalogs together, and the thing a `component` points at. `product:read` sits on the viewer floor; the three writes (`product:create`, `product:update`, `product:delete`) are admin-gated. A product names its **kind** (`device`, `app`, `service`, or `vm`, default `device`), optionally its **vendor**, **driver**, and a **parent product** it is a variant of, and the **capabilities** it provides (a JSON array of capability ids): ```sh omniglass product list # the product registry omniglass product create --name barco-ub12 --display-name "Barco UB12" --kind device \ --vendor-id barco --driver-id barco-snmp --capabilities '["projector"]' omniglass product get barco-ub12 omniglass product update barco-ub12 --capabilities '["projector","speaker"]' # replaces the whole set omniglass product delete barco-ub12 # 422 if official, 409 if a component points at it ``` A seed-owned (**official**) product, for example `cisco-room-bar`, is read-only: `update` and `delete` both 422. A product still referenced by a **component** (`component.product_id`) cannot be deleted (409); an unknown vendor, driver, parent, or capability id is a 422. ## Standards The [standard](/architecture/core-entities/#catalog-reference-data-standard) commands cover the system-side counterpart of a product: the **blueprint a system conforms to**. `standard:read` sits on the viewer floor; `standard:create`, `standard:update`, and `standard:delete` are admin-gated. ```sh omniglass standard list # the standard catalog omniglass standard create --name lecture-hall --display-name "Lecture Hall" \ --parent-standard-id classroom # a variant of an existing standard omniglass standard get lecture-hall omniglass standard delete lecture-hall # 409 if a system still conforms to it ``` Unlike a seeded product, the **shipped standards are `official: false`**: they are forked from an in-code template once, with no inheritance, so they are yours to rename, re-parent, or delete, and the boot seed installs one **only if absent** rather than reasserting over your edit. The same holds for the shipped location types. See [the seed model](/architecture/core-entities/#the-seed-model-forked-templates-versus-canonical-catalogs). ## Event types The [event type](/architecture/events/#the-event_type-registry) commands cover the occurrence keyspace, the twin of the property catalog: the discrete happenings an event is typed by (`call.started`, `cable.unplugged`). `event_type:read` sits on the viewer floor; `event_type:create`, `event_type:update`, and `event_type:delete` are admin-gated. Official (seed-owned) event types are read-only. ```sh omniglass event-type list # the event type catalog omniglass event-type create --name cable.unplugged --display-name "Cable unplugged" omniglass event-type get call.started omniglass event-type delete cable.unplugged # 409 on an official type ``` ## Command types The [command type](/architecture/commands/#the-command_type-registry) commands cover the "do" catalog: what a component can be told, with a target property and a settle window. `command_type:read` sits on the viewer floor; create/update/delete are admin-gated, official types read-only. Issuing a command (`command:issue`) records the invocation, writes a caused event, and returns the computed settlement verdict. ```sh omniglass command-type list # the command type catalog omniglass command-type create --name set_input --target-property-type video.input --settle-window-seconds 15 omniglass command-type get reboot omniglass component command issue disp-1 --command-type set_input --value '"hdmi2"' # needs command:issue ``` ## Property contracts and values A classifier **declares** which properties its instances carry; an instance **sets** a value. Both sides are the same three verbs, and the contract commands hang off the classifier that owns them: ```sh omniglass product property list cisco-room-bar # a product's contract omniglass standard property list huddle-room # a standard's contract omniglass location-type property list room # a location type's contract omniglass standard property update huddle-room room_capacity --default-value 6 --required true omniglass standard property delete huddle-room room_capacity # systems keep any value they set omniglass component property list dsp-boardroom-3 # the effective read omniglass system property list boardroom # same shape, system side omniglass location property list east-campus # same shape, location side omniglass system property update boardroom room_capacity --value 12 # idempotent omniglass system property delete boardroom room_capacity # falls back to the contract default ``` The read resolves the classifier's contract against the instance's own values, so a **one-off system** (one conforming to no standard) and a **productless component** still resolve, to their off-contract values alone. The value commands are **scope-injected**: an instance outside your read scope is a non-disclosing 404 on the read and on the write. The registry and its contract share one noun: `omniglass location-type list` is the registry, `omniglass location-type property list ` its contract. ## Generated versus hand-written - **Generated** (`internal/cli/api_gen.go`, do not edit): one command per API operation. The resource and verb come from the AIP-style path (`POST /locations` is `location create`, `GET /locations/{name}` is `location get `, a `:verb` custom method is ` `, so the principal lifecycle is `principal disable `, `principal archive `, `principal restore `, and `principal purge `); path parameters are positional args, the request body is `--flags`, and OpenAPI query parameters become optional `--flags` (a set flag is appended to the request query string, an unset one keeps the server default), so `principal list --include-archived --kind service` filters the listing. A principal `` argument accepts either the uuid or a human's username (`omniglass principal archive alice`), resolved by the server, so you rarely need to look a uuid up first. `--help` plus the example come from the operation's summary and description. - **Hand-written** (`internal/cli/api_hooks.go` and the run-mode files): the client runtime the generated tree calls, plus commands that are not API operations, the `server`, `node run`, and `migrate` run modes, the trusted direct-DB owner lane (`bootstrap`, `token`, `set-password`), and the idempotent dev-estate seeder (`seed-dev`, the same trusted lane, never for production). To add a hand-written command, write a `newXxxCmd()` returning a `*cobra.Command` and add it in `newRoot`, exactly as `bootstrap` does. Regenerating the API commands never touches it. --- # Deployment URL: /guides/deployment/ Standing an Omniglass platform up: the single binary, the container image, the Helm chart, and per-PR preview environments. This is the how-to for **standing the platform up**, a different job from [operating the estate](/guides/operator/) or [administering it](/guides/admin/). Omniglass is a single self-contained Go binary with the operator console compiled in and a BYO PostgreSQL database, so a deployment is that one binary in one of its run modes (`server`, `node run`, `migrate`) plus a database. How it scales and the deploy model behind these pages is [scaling and deployment](/architecture/scaling/). Pick the path that fits where you are running it: - **[Install](/guides/install/)** downloads a prebuilt binary for your OS and architecture, the simplest way to run the server or drive it from the command line. - **[Container image](/guides/container-image/)** is the published distroless image: what it contains, where it ships, and how to run it with the `migrate` then `server` flow. - **[Deploying with Helm](/guides/helm/)** is the chart that serves both production (BYO Postgres) and disposable previews (bundled Postgres) from one set of values. - **[PR preview environments](/guides/pr-previews/)** stand up a live, isolated copy of the console for any pull request, so a change is reviewed against a running system. Once a server is up, the first owner is minted with the trusted direct-database lane (`omniglass bootstrap`), covered in [the CLI guide](/guides/cli/#authentication); from there, managing accounts and access moves to the [admin guide](/guides/admin/). --- # Install URL: /guides/install/ Download a prebuilt Omniglass binary for your OS and architecture, or run the container image. Omniglass ships as a single self-contained binary with the operator console compiled in, so one file is the whole app: every run mode (`server`, `node run`, `migrate`, `bootstrap`, `token`) is the same binary with a different first argument. You can download that binary directly, or run the [container image](/guides/container-image/) instead. ## Download a binary Each [GitHub Release](https://github.com/hyperscaleav/omniglass/releases) carries an archive per platform: | OS | Architectures | Archive | |----|---------------|---------| | Linux | `amd64`, `arm64` | `.tar.gz` | | macOS | `amd64` (Intel), `arm64` (Apple silicon) | `.tar.gz` | | Windows | `amd64` | `.zip` | Download the archive for your platform, verify it against `checksums.txt`, extract the `omniglass` binary, and put it on your `PATH`: ```bash VERSION=0.1.0 # the release tag, without the leading v BASE=https://github.com/hyperscaleav/omniglass/releases/download/v$VERSION curl -sSLO $BASE/omniglass_${VERSION}_linux_amd64.tar.gz curl -sSLO $BASE/checksums.txt sha256sum --check --ignore-missing checksums.txt tar xzf omniglass_${VERSION}_linux_amd64.tar.gz sudo install omniglass /usr/local/bin/ omniglass --version ``` Each archive also ships an SBOM (`.sbom.json`) alongside its checksum. ### macOS: first run The macOS binaries are not yet notarized ([#58](https://github.com/hyperscaleav/omniglass/issues/58)), so Gatekeeper quarantines a freshly downloaded binary. Clear the quarantine attribute once: ```bash xattr -d com.apple.quarantine omniglass ``` ## Run it The binary is the server, the collection node, and the CLI in one. To stand up the API and console you need Postgres and the same environment the image uses; see [Container image](/guides/container-image/#running-it) for the `OMNIGLASS_DSN` and `migrate`/`server`/`bootstrap`/`token` flow, which is identical for the binary. To drive a running server from the command line, see [the CLI](/guides/cli/). --- # Operator guide URL: /guides/operator/ Operating an Omniglass estate day to day: signing in, finding things, working with entities, and the scope that decides what you see. This is the how-to for **operating the estate**: reading and running the inventory of locations, systems, and components you are responsible for. It is a different job from **administering the platform** (managing who can sign in and what they can do), which is the [admin guide](/guides/admin/), and from **standing the platform up**, which is [deployment](/guides/deployment/). There are two ways to operate, and they are the same API with the same checks behind them: - **The web console**, served by the binary at `/web`, is the point-and-click surface these pages walk through, one task at a time. - **The [CLI](/guides/cli/)** is the `omniglass` binary as a client of a running server, for scripting and terminal work. Every command is in the [CLI reference](/reference/cli/). ## In this guide - **[Sign in and your profile](/guides/operator/sign-in/)**: getting in with a password or a bearer token, and managing your own display name, picture, and password. - **[Find things in your estate](/guides/operator/inventory/)**: the inventory pages, the chip filter, and the tree, list, and column controls. - **[Work with an entity](/guides/operator/entities/)**: opening a blade, drilling into children, and creating, editing, or deleting. - **[Nodes and reachability](/guides/operator/collection/)**: enrolling a collection node, adding a protocol-named interface to a component, and reading its reachability and events. ## Getting around - The **sidebar** is the information architecture: sections grouped into Inventory, Values, Catalog, and Admin. A live section is full strength; a section whose backend has not landed yet is dimmed with a **soon** tag (still clickable, with a short note on what it will do). - The **top bar** shows the current section and a **Search (⌘K)** button. The console is dark-only today; the theme is an [Admin > Settings](/guides/admin/) value resolved by the settings engine, not a per-session toggle. - Press **⌘K** (or Ctrl-K) to open the command palette and jump to any section by name. Arrow keys move the selection, Enter navigates, Esc closes. This is a global jump, distinct from a page's own [filter](/guides/operator/inventory/#filter). - You only see what you can use: a tab you have no read grant for is **hidden**, and an action you cannot perform does not render. The same permission map also **guards the route**, so a hidden tab is an unreachable URL: typing or bookmarking a page you cannot read redirects you to Home. The server is the authority on every request; the console only hides what it knows you cannot reach. ## What you see is your scope The data is filtered to **your scope** on the server: a campus-scoped operator sees only that campus's subtree, everywhere, automatically. You do not configure this; it follows your [grants](/guides/admin/access/). The console hides a surface you cannot read and the server refuses it regardless, so what you can see is exactly what you are allowed to act on. The model behind that is [identity and access](/architecture/identity-access/). (Surfacing your current scope in the UI is a later addition.) How the console is built is the [UI architecture](/architecture/ui/); how to add to it is the [design system](/contributing/design-system/). --- # Nodes and reachability URL: /guides/operator/collection/ Enrolling a collection node, adding a protocol-named interface to a component, and reading the per-interface reachability and the log events a node reports. Collection is how the estate learns whether a device is reachable and what it reports. An **edge node** runs the probes, a component's **interface** is the API the node reaches for, the component's **Reachability** panel shows the verdict, and its **Events** and **Logs** panels show recent occurrences the node ships back. This page walks the console surfaces; the model behind them is [data collection](/architecture/collection/), and every action here has the same [scope](/guides/operator/inventory/) and permission checks as the rest of the console. ## Nodes **Inventory > Nodes** (with `node:read`, which must be **all-scope**, since a node is estate-wide, so a location-scoped operator cannot list nodes) is the collection-daemon inventory. Each row is labelled by the node's **display name** (falling back to its name/key), with the key and its location as the subtitle, a **liveness pill** (up, down, or never, derived from its last heartbeat against the server's down window), and the relative last-heartbeat time. A row opens the node's detail. - With `node:create` and `node:enroll`, **New node** registers a node (the name is its estate address) and mints its **enrollment token**. The form also takes an optional **display name** and **location**. The token is a secret shown **once**, in a copy-to-clipboard field with a "shown once, cannot be retrieved again" warning. Copy it now and hand it to the node deployment; the node presents it to claim its NATS credential. The server stores only a hash of the token and never logs it. - The detail is **read-edit-save**, like a component or location. With `node:update`, **Edit** changes the node's **display name**, **description**, and **location** (a descriptive placement picked from the estate's locations, not a scope); the **name is immutable** (it is the estate address and enrollment identity). The location clears if that location is deleted. - The detail carries a **Tags** panel: with `node:update`, edit mode adds and removes governed [tags](/guides/operator/inventory/) (keys whose vocabulary allows nodes), the same tag editor the component and location details use. The node list shows a Tags column and filters by any tag key. - With `node:delete`, **Delete** (the destructive action, left of the footer) **decommissions** the node after a confirm: its interfaces, derived tasks, tags, and enrollment are removed. The telemetry it collected for components stays. - **Enroll** (or **Re-enroll**, if it is already enrolled) is a secondary action in the detail's kebab: it re-mints the token, invalidating the previous one. - The detail also shows whether the node is enrolled and when it last sent a heartbeat. The node detail also carries a read-only **Self-logs** panel (backed by `GET /nodes/{name}/logs`, gated by `node:read`): the node's own recent operational log lines, newest first, over the last 24 hours, so a node that is up but misbehaving explains itself without a shell on the box. ## Interfaces An interface is an **API on a component** that a node reaches for, and it lives **on the component**: there is no standalone Interfaces surface. Open a component from **Inventory > Components** (with `interface:read`) and its interfaces read as a panel on the detail, each showing the interface's protocol name, its reachability, its node placement, and its probed target. An interface is **named by its protocol**: you pick a **type** (the transport) and the interface takes that protocol as its name, unique within its component, so one component can have one `tcp` and one `http`. - With `interface:create`, **Add interface** on the component detail creates one: choose a **type** (the console picker offers `icmp` and `tcp` today; the `ssh` and `http` types exist through the API and CLI, probing as a tcp connect; there is no free-text name), a node placement, and a target (`host:port` for the tcp-family transports, `host` for icmp). The owning component is the one you are on. Creating an interface **derives its poll task** for you, so a fresh interface is a working reachability check with no second step. - With `interface:update`, editing an interface changes only its **node placement** and its **target**; the type (and so the protocol name) is fixed at creation. - With `interface:delete`, deleting an interface removes it and **cascades its derived task**. Because an interface belongs to a component, it inherits that component's scope: an interface on a component outside your scope is not shown. A node **purge cascades** its interfaces and their derived tasks. ## Tasks A task is the **collection work** a node runs, and it is **derived**, not authored: creating an interface creates its one poll task. A task has **no name**: it is a binding, a **function** running over an **interface**, so it reads as its interface (the anchor) plus that function, never a redundant label. There is no standalone Tasks surface. A node's derived tasks read as a **panel on the node's detail** (open a node from **Inventory > Nodes**, with `task:read`): each shows its interface, the function it runs (today the built-in **reachability** check, with a provisional marker since named collection functions arrive with device drivers), and an **enabled** state; the node it runs on follows its interface's placement. To change what a node collects, add or remove the **interface**; there is no task create, edit, or delete. ## Reachability Every component's detail carries an **Interfaces** panel showing composed reachability: is each of its interfaces reachable, and why. One row per interface shows the interface and its endpoint, a **verdict pill** (responding, down, stale, or unknown), an **availability strip** drawn from the verdict's up/down transitions over time, and an expandable **gate breakdown** (the L3/L4 ping and port probes this slice ships) with each probe's signal and timing, then the composed verdict (the interface is up only when every applicable probe passed). A down interface also shows a plain-language **why** line. Every value is a real reading from the node, and the panel is also the authoring surface: its header carries **Add interface** (with `interface:create`) and each row that maps to an interface a **Manage** affordance opening that interface's detail. To author a reachability check, add an **interface** to the component (above): a proper driver-based authoring flow is a later collection slice, so today a check is an interface plus its derived poll task, created from this panel on the component's own detail (there is no standalone Interfaces page). ## Events Alongside sampled readings, a component carries two occurrence panels, both read-only and gated by `component:read`. The **Events** panel shows the most recent typed **events**, newest first, over the last 24 hours (capped at 200): discrete things that *happened* (a `call.started`) that a component published natively or a rule derived, each row showing its **time**, the **event key**, the **message**, and any structured **attributes**. Below it, the **Logs** panel shows the component's raw **log lines** (the ingest lane, [ADR-0066](/architecture/decisions/#adr-0066-logs-are-a-raw-ingest-lane-not-events)): untyped device text a rule may later derive events from, each row showing its **time**, a **severity** badge, the **facility** and **source**, the **message**, and its structured **fields** on demand. Most log lines never become events. Where a reachability verdict is a sampled state, an event or a log line is a past occurrence, so the panels are read differently: the verdict answers "is it reachable *now*", the logs and events answer "what did it *say*, and when". Both land under the same [scope](/guides/operator/inventory/) and owner checks as every other reading, so an out-of-scope component's events and logs are a non-disclosing 404, exactly like its reachability. Every value is a real occurrence from the node. --- # Work with an entity URL: /guides/operator/entities/ Opening an entity's blade, drilling into its children, and creating, editing, or deleting through the footer action bar. Once you have [found an entity](/guides/operator/inventory/), you open it, read it, and change it the same way everywhere in the console. ## Open an entity Click a row to open its **blade**, a panel that slides in from the right with the entity's details. From a blade you can drill into a child (it stacks another blade behind the first), step back with the breadcrumb, or **Maximize** to the full detail page. The full page has its own URL, so it is shareable and bookmarkable; a blade is a quick look that does not change the URL. Rows are keyboard-operable: Tab to a row and press Enter to open it. ::screenshot{#entity-blade} The identity pages (Users, Groups, and Roles) use the same blade, and there drilling crosses entities: from a user you open a group's blade over it, and from a group you open a member's user blade, each stacking so you can trace where access comes from without leaving the page. Each page roots one entity and drills one direction (a user's groups, a group's members), so the stack stays shallow and the reverse relation on the far blade is a read-only reference. ## Edit through the footer action bar A detail blade opens **read-only**, and every entity is edited the same way through the **footer action bar**. The blade header is chrome only (back, full-page, close); the actions live in the bar at the foot of the blade. **Edit** (right) opens edit mode: the profile becomes inputs, the members and grants go live, and the right cluster swaps to **Cancel** and **Save**. Changes stage locally so you can check your work first; **Save** commits them together, **Cancel** discards them. The **destructive** action sits on the **left** and is always available, with no need to enter edit mode: a red **Delete** for a group (a user instead has the escalating [lifecycle](/guides/admin/users/) of **Disable**, then **Archive**, then **Purge**, so its left slot is **Disable / Enable** and the stronger steps sit in the kebab), each behind a confirm. Secondary actions like **Impersonate** fold into a **⋯** menu. Edit appears only if your grants allow it, and a read-only blade (a role) shows no bar at all. The same bar carries **create**. A form that opens in a slide-over (New user, New tag key, Upload file) or as its own blade (New interface) puts its **Create** button in the bar at the foot of the panel, never floating after the last field, and greys it out until the form is complete. **Cancel** sits beside it on the forms that offer one; where it does not, the header **x** closes the panel. ## Create, edit, delete - **New** opens a **draft** at the entity's own `/create` address (a form for name, classifier, placement, and where applicable a parent). The classifier is the entity's shape: a component picks its [product](/guides/admin/products/), a system the [standard](/guides/admin/standards/) it conforms to, a location its [type](/guides/admin/types/). On a component and a system the classifier is **optional**, so a one-off unit or a system that matches no blueprint is legitimate; a location's type is required, since for a location the type is the only shape-definer. The name is the entity's address: lowercase letters, digits, and hyphens (it can be changed later, see Edit). **Create** commits it and drops you straight into the new entity's detail in **edit mode**, so you can tag it and finish configuring in place instead of hunting for it back in the list. Bindings like tags need the entity to exist, so they unlock the moment it is created. On a location, the type you pick may restrict which parent types it can sit under (or require no parent at all); a placement outside that set is refused with a message naming both types, right on the create form. - **Edit** (the pencil on a row, or the button in the detail) flips that same detail into edit mode: the fields become inputs and the tag editor goes live. The **technical name** (the address) is editable here too, with an inline **Check** button that reports whether a proposed name is a valid slug and still free before you save; renaming changes the entity's URL, and existing links to the old name stop resolving. **Save** commits the changes, **Cancel** discards them. In **view** the detail is read-only, so tags and other bindings are shown but not editable until you enter edit. - A **location**'s edit mode also makes its **Parent** editable: the Placement section swaps its read-only fact for a picker narrowed to the location type's allowed parents (or, when unconstrained, every location), excluding the location's own subtree. Moving back to root is not offered; a move a stale picker still lets through is refused the same way as create, inline, naming both types. - A **component** and a **system** are just as mobile: edit re-opens their **Placement** (location and parent) and, for a component, its **Product**. Nothing here is fixed at create. A component can move rooms, re-parent under a different component, or be re-classified onto a different product; a system can move location or re-parent. Re-classifying a component keeps every property value you set by hand and lets the new product's contract defaults take over the rest, so a swap never silently loses your data. Clearing a field is a first-class move: an empty location unplaces it, an empty parent lifts it to a root, an empty product makes it a one-off. A re-parent that would put an entity under itself or one of its own children is refused. - **Delete** removes it, with a confirm. These actions appear only if your grants allow them. ## Properties on the detail A component, a system, and a location each carry a **Properties** panel on their detail: one row per property their classifier declares, resolved to the value set here or the classifier's default. Overrides are staged with the rest of the edit and committed by the same **Save changes**. It is one surface over one resolver, so the panel reads the same on all three; the full walkthrough is in the [Properties guide](/guides/admin/properties/#set-a-property-on-an-instance). ## Roles on a system A **system** carries one more panel: **Roles**, the slots it needs filled. A role is a slot (a room microphone, a main display), not a component, so the room can say what it needs before anything is assigned and an **empty slot stays visible**. These are slots in a room, not the [roles that grant people access](/guides/admin/access/); the two share only the word. Each row is one role with **where it came from**, **who fills it**, and **how many more it wants**: - **Inherited or declared here.** A role marked as coming from the standard is declared on the [standard](/guides/admin/standards/) this system conforms to, and every conforming system has it. A role declared on this system is this room's own. A **one-off system** (conforming to no standard) has only its own. - **Assigned and understaffed.** A role has a **quorum**, how many components should fill it. Two assigned against a quorum of two reads as staffed; one reads as short by one. That is true the moment you enter it, with nothing collecting: staffing is a fact about your model, not a measurement. - **Impact.** Each role also says what the room loses when the slot is not being filled properly: **outage**, **degraded**, or **none**. That is what turns a broken component into a room-level verdict further down this page, and it is declared on the [standard](/guides/admin/standards/#roles-what-a-conforming-system-needs-filled) or on the system. - **Assign** picks a component to fill the role; **unassign** takes it out and the role goes back to understaffed. Assigning the same component twice changes nothing. - **A component staffing a role cannot be deleted.** Unassign it first. The refusal is deliberate: a delete that silently emptied a slot would leave the room quietly wrong. **An assignment can be refused, and the refusal tells you why.** A role requires a set of [capabilities](/guides/admin/capabilities/), and a component must provide **every** one of them. Assign one that does not and you get the gap by name (`missing microphone, speaker`), which is either a fix on the component's **Capabilities** panel or a sign that it is the wrong component for the slot. Declaring the roles is on the [Standards guide](/guides/admin/standards/#roles-what-a-conforming-system-needs-filled); this panel is where they get staffed. ## Capabilities on a component A **component** carries a **Capabilities** panel: what it actually provides, resolved from its [product](/guides/admin/products/) plus what this unit adds and minus what it suppresses. It is the set every role assignment is checked against, and it is how a component with **no product** provides anything at all. The walkthrough is in the [Capabilities guide](/guides/admin/capabilities/#what-a-component-actually-provides). ## Alarms on a component An **alarm** says what is wrong with **one component**, and which of its capabilities the problem takes away. The component's **Alarms** panel lists the active ones newest first, with a **Recently cleared** group beneath them: what is wrong now on top, what was wrong underneath. Raising one takes three things: - a **severity**: `info`, `warning`, or `critical`. This is how loudly to treat it, and it sets the **component's own** state (any active alarm makes the component degraded, a critical one an outage); - a **message**, for whoever reads it later. Write it for the person who finds this at 8am, not for you; - the **capabilities it degrades**. This is the one that matters beyond the device. A component keeps its capabilities on paper, but a degraded one **does not count** toward any role that requires it. An alarm that degrades nothing is a note on the device and reaches no room, which is often exactly right. **Clearing keeps the row.** The alarm moves to the history with the time it was cleared, so what was wrong and when survives the fix. Clearing one twice is a plain miss rather than a silent success. Both writes take effect immediately and completely: the room's verdict, the location above it, and the recorded history all move in the same transaction as the alarm. There is no wait and no refresh cycle. From the CLI: `omniglass component alarm list [--include-cleared]`, `omniglass component alarm create --severity --message --capabilities `, and `omniglass component alarm delete `. ## Health on a system or location A **system** and a **location** each carry a **health verdict**, shown as a badge on the detail and in the systems list: | verdict | means | |---|---| | **healthy** | nothing the room depends on is impaired | | **degraded** | it is working, worse | | **outage** | it is not working | A location's verdict is the **worst** of every system placed anywhere beneath it, so a campus reads red when one room in one building is out. A system's verdict is the worst contribution among the **roles** it needs filled. **The Health panel is the answer to "why".** A bare "degraded" gives you nothing to do, so the panel names the whole chain instead, role by role: ```text alarm on mic-pod-2 (critical, "no audio on channel 1") -> degrades: microphone -> role room-mic requires microphone, and wants 2 -> only 1 assigned component can currently fill it -> role impaired, impact degraded -> hq-r1 is degraded ``` Read it bottom-up when you want the verdict and top-down when you want the fix. A role can also be impaired with **no alarm named**, which means it is **short-staffed** rather than broken: nobody is assigned, or what is assigned never provided what the role requires. Those are two different jobs, and the panel keeps them apart. **The History strip is the answer to "since when".** It is the same shape as the reachability availability strip: one segment per stretch the entity held a verdict, drawn from the **recorded edges** over the last 30 days. It is not a sample and not a redraw of what somebody happened to look at; each edge was written at the moment the estate changed, by the write that changed it. That is what makes "it broke Friday at 18:40 and came back Monday at 09:15" answerable on Tuesday. From the CLI: `omniglass system health list ` and `omniglass location health list `. ## The whole loop, end to end Once, in order, on a real room: 1. **Declare the roles with their impact.** On the room's [standard](/guides/admin/standards/#roles-what-a-conforming-system-needs-filled), give **Main Display** impact **outage** and **Room Microphone** impact **degraded** with quorum 2. Every conforming room inherits both immediately. 2. **Staff the system.** Assign components to each role from the system's **Roles** panel. A component that cannot fill the role is refused by name (`missing microphone, speaker`), so a wrong assignment never becomes a wrong verdict. 3. **Raise an alarm.** On one of the mic pods, raise a `critical` alarm degrading `microphone`. 4. **Watch the room move.** The system goes **degraded** (the `room-mic` role now has one satisfying component against a quorum of 2, and its impact is `degraded`), and the location above it follows. Had the alarm been on the main display instead, the room would be an **outage**, because that role says so. 5. **Read the Health panel** to find the cause: the impaired role, the capability it lost, and the alarm that took it, with its message and the time it was raised. Walk to the pod. 6. **Clear the alarm** once it is fixed. The room returns to **healthy** in the same transaction, and the alarm row stays in the component's history. 7. **Read the history afterwards.** The transition strip now shows the exact stretch the room was degraded, with the edge at the moment the alarm went up rather than the moment you opened this page. That is the whole point: come back in three weeks and the answer is still exact. --- # Find things in your estate URL: /guides/operator/inventory/ The inventory pages, the chip filter, and the tree, list, and column controls for locating a location, system, or component. Systems, Components, Locations, and Nodes are the live inventory pages. They share one shape, so once you know one you know them all. This page is how you **find** something in that inventory; [working with an entity](/guides/operator/entities/) is what you do once you have opened it. ::screenshot{#inventory} ## Filter The bar at the top of the table is a **chip filter**. Type a field name, then an operator, then a value; each commit becomes a chip: - Within one chip, multiple values are **OR** (match any). Across chips, the filters are **AND** (match all). - Click a chip's operator to cycle it; click its value to re-edit; the **x** removes it. Clicking an active summary facet (below) toggles the same chip. - A summary widget or a count card is just a one-click shortcut to a filter chip. - Filter by a [tag](/architecture/tags/) through the **tag** field: choose `tag`, then the tag key, then a value, to match its **effective** value (a component matches on a tag it inherits from its system or location, not only one set on it directly). Two operators, **is set** and **is absent**, take no value and find the rows that carry the tag at all or lack it entirely. Every tag in use is reachable this way, so the list tracks whatever your estate is tagged with. This is a page-local filter, distinct from the global **⌘K** jump that moves you between sections ([getting around](/guides/operator/#getting-around)). ## Tree, list, columns - Tree entities (Locations, and Systems/Components where they nest) show as a **tree**; use the expand/collapse controls, or switch to **list** view. Filtering also flattens to a list, with each row's place in the tree shown above its name. - The default list order is the **tree compressed to a flat list** (nesting preserved); click a column header to sort by it instead. - The **columns** menu shows or hides columns and lets you **drag to reorder** them. The layout is remembered per browser. - On Locations, each row wears its **type's icon** as a leading glyph (a campus, building, floor, and room each read differently at a glance), tinted the same hue as the type badge. - On Locations, a **summary board** at the top breaks the estate down by place type (a donut plus count cards); click any segment or card to filter to it. - A **Tags** column shows each row's **effective [tags](/architecture/tags/)**: the `key = value` labels that resolve onto it down the cascade, not only the ones set directly on it (so a component wears the tags of its location and system too). Each key gets its own consistent color, so the same tag reads the same everywhere. The chips stay on **one line**, fading at the edge when there are more than fit; **hover the row's tags to reveal the full set** in a popover. The column is on by default; hide it from the columns menu. Everything on these pages is already filtered to [your scope](/guides/operator/#what-you-see-is-your-scope): you are searching within the subtree your grants reach, not the whole estate. The **[Files](/guides/admin/files/)** directory (under Values) uses these same filter, column, and list controls, but it holds uploaded content, not the estate, so it is covered separately. --- # Sign in and your profile URL: /guides/operator/sign-in/ Getting into the console with a password or a bearer token, and managing your own display name, picture, and password. ## Signing in Sign in with your username and password. On success the server sets an httpOnly session cookie (the browser never exposes a token to scripts), and the cookie rides on every request for the rest of the session. Sign out from the menu in the sidebar footer, which revokes the session and clears the cookie. ::screenshot{#sign-in} The login screen also has a **"Use a bearer token instead"** toggle: paste a token (for a service account, or an operator who works from the CLI) and the console authenticates with the `Authorization` header rather than a password. Either path lands you in the same console. The first owner is created on the server with `omniglass bootstrap --password ` (see [the CLI guide](/guides/cli/)). ## Your profile Click your name in the sidebar footer to open **Your profile**. It is self-service: you edit only your own account, whatever your role. - **Profile.** Change your display name; it drives how you appear in the console (the sidebar label and the initials avatar). Your username and email are set by an administrator, not you, and are shown read-only. - **Profile picture.** The avatar at the top of the panel shows your picture when you have one and your initials when you do not. **Upload** picks an image file (JPEG, PNG, or WebP); the server crops and re-encodes it to a small square, so it reads the same everywhere you appear (the sidebar and the Users directory). **Remove** clears it and falls back to initials. Like the rest of the page it is self-service: you manage only your own picture. - **Change password.** Enter your current password and a new one. The new password must meet the **policy** (at least 12 characters, not a common password, and not containing your username); the field validates as you type, and **Generate** fills a strong random one you can **Copy**. A wrong current password is refused. Changing it **signs out your other sessions** (the one you are using stays, and your API tokens are kept, a token not being tied to your password), so the change takes effect on your logins at once. - **Access.** A read-only view of the identity model you operate under: your principal, the roles granted to you, and the flattened permissions those roles carry. The server enforces these on every request; the console only mirrors them. - **Sessions** and **API tokens.** Two sections listing every credential you hold: a **session** is a device you signed in from, a **token** one you minted for the CLI or API, both time-bounded and showing an expiry. Each row shows a **device** (parsed from the browser that created it), the **address** it came from, and when it was **last active**; a token also shows its **description**. The one you are using is marked **This session**. The secret is never shown, only its `ogp_` locator. **Revoke** any you do not recognize (revoking the one you are using is **Sign out**), or use **Revoke all** on either section to end every session or every token at once, keeping the one you are on. **Create token** on the API tokens section mints you a new token: give it a **description** (what it is for, required) and an optional lifetime in days (default 90, maximum 365). The token is shown **once**, so copy it then; it cannot be retrieved again. From the CLI the same actions are `omniglass auth update-profile`, `omniglass auth change-password`, `omniglass auth set-avatar` / `omniglass auth remove-avatar` for the picture, `omniglass session list` / `session revoke ` / `session revoke-all` for your sessions, and `omniglass auth create-token --description ` to mint one for yourself (see [the CLI guide](/guides/cli/)). ## After an administrator resets your password If an administrator resets your password, you sign in with the password they gave you and the console immediately gates you to a **Set a new password** screen: your account is on hold and every other page is refused (by the server, not just the console) until you choose a new password. Enter the temporary password as the current one and set a new policy-compliant password; once it is saved the hold clears and you land in the console. Signing out is the only other way off the screen. --- # PR preview environments URL: /guides/pr-previews/ How a pull request gets a live, isolated preview of the console, and how it is torn down. Any pull request can get its own live copy of the operator console, at a predictable URL, to review a change against a running system instead of reading the diff. ## Getting a preview Add the **`run:preview`** label to the PR. Within a minute or two a full instance comes up at: ``` https://og-pr-.preview.hyperscaleav.com/web ``` A bot comment on the PR carries the link. It is gated by Cloudflare Access, so you sign in with your reviewer identity first. Each push to the PR rolls the preview forward to that commit. Previews are **opt-in** (the label) to bound how many run at once, and only same-repo branches get one: CI never publishes an image for a fork PR, so labelling a fork PR does nothing until a maintainer pushes the branch to this repo (or builds and pushes an image by hand). ## What is inside Each preview is a throwaway, isolated instance in its own `og-pr-` namespace: the single binary with the console embedded, plus a bundled ephemeral Postgres (data does not survive a restart, which is the point). It runs the chart from *this PR's* commit, so schema migrations and API changes in the PR are exercised end to end. Sign in as the seeded owner: the bot comment carries only the link, never a credential; the credential is the bootstrap token printed once to the init container's logs (the [Helm guide](/guides/helm/) shows how to read it). ## Teardown Remove the `run:preview` label, or close the PR, and the environment is pruned. A reaper also sweeps any namespace left behind, so nothing lingers. ## How it works The image and chart are built here (see [Container image](/guides/container-image/) and [Deploying with Helm](/guides/helm/)); the per-PR wiring lives in the ops repo. An Argo CD PullRequest generator watches labeled PRs and renders the chart into a per-PR namespace, pinned to the commit's immutable image tag. Traffic reaches it through a Cloudflare tunnel to an in-cluster ingress controller that Host-routes `og-pr-.preview.hyperscaleav.com` to that preview. --- # CLI reference URL: /reference/cli/ Every omniglass CLI command, generated from the live command tree so it never drifts from the binary. Every `omniglass` command, generated from the binary's own command tree. The data commands are generated from the [API](/reference/api/) (`make gen`), so this reference is a faithful mirror of what the server exposes. For the narrative, connecting, authenticating, and the generated-versus- hand-written split, see [the CLI guide](/guides/cli/). ## Global flags Every command accepts these, each with an environment-variable default: | Flag | Type | Default | Description | |---|---|---|---| | `--server` | string | `http://localhost:8080` | Omniglass server base URL (env OMNIGLASS_SERVER) | | `--token` | string | (none) | bearer token (env OMNIGLASS_TOKEN) | ## `omniglass audit-log` Commands for the audit-log resource ### `omniglass audit-log list` List audit-trail events ``` omniglass audit-log list [flags] ``` Recent audit-trail events, newest first, each with the actor and, for an impersonated action, the real actor behind it. Read-only; gated by audit:read:admin (admin/owner only, since the audit trail is admin-sensitive). | Flag | Type | Default | Description | |---|---|---|---| | `--before` | string | (none) | Only events strictly older than this RFC3339 timestamp (paging backward) | | `--limit` | int | `0` | Max rows to return, newest first (default 100, capped at 500) | | `--resource` | string | (none) | Filter to one resource kind (e.g. auth, principal_grant) | | `--verb` | string | (none) | Filter to one verb (e.g. login, create) | Example: ```sh omniglass audit-log list ``` ## `omniglass auth` Commands for the auth resource ### `omniglass auth avatar` Get your own profile picture ``` omniglass auth avatar ``` Returns the caller's profile picture as a base64-encoded JPEG. Requires authentication; self-scoped. No picture is a 404. Example: ```sh omniglass auth avatar ``` ### `omniglass auth change-password` Change your own password ``` omniglass auth change-password [flags] ``` Verifies the current password and sets a new one. Requires authentication; self-scoped. | Flag | Type | Default | Description | |---|---|---|---| | `--current-password` | string | (none) | Your current password | | `--new-password` | string | (none) | The new password (at least 12 characters, not a common password, not containing the username) | Example: ```sh omniglass auth change-password --current-password current_password --new-password new_password ``` ### `omniglass auth create-token` Create one of your own API tokens ``` omniglass auth create-token [flags] ``` Mints a CLI/API token for the caller and returns it once (store it now; it cannot be retrieved again). A description is required (what the token is for); an optional ttl_days bounds its lifetime (default 90, maximum 365). Requires authentication; self-scoped (always issued for you). The token is stamped with the device and address that created it. | Flag | Type | Default | Description | |---|---|---|---| | `--description` | string | (none) | What the token is for (required) | | `--ttl-days` | string | (none) | Days until the token expires (default 90, maximum 365) | Example: ```sh omniglass auth create-token --description description ``` ### `omniglass auth login` Log in with a username and password ``` omniglass auth login [flags] ``` Verifies a human's password and sets an httpOnly session cookie. Public; a bad credential is a flat 401, and a correct password against a disabled account is a distinct 403 so the screen can explain it. | Flag | Type | Default | Description | |---|---|---|---| | `--password` | string | (none) | The account password; exchanged for a session cookie, never stored | | `--username` | string | (none) | The sign-in username | Example: ```sh omniglass auth login --password password --username username ``` ### `omniglass auth logout` Log out the current session ``` omniglass auth logout ``` Revokes the session token and clears the cookie. Public. Example: ```sh omniglass auth logout ``` ### `omniglass auth me` The authenticated principal, its permissions, and grants ``` omniglass auth me ``` Returns the caller's principal, flattened permissions (a UI hint and the fast-reject set), and grants. Requires authentication. Example: ```sh omniglass auth me ``` ### `omniglass auth remove-avatar` Remove your own profile picture ``` omniglass auth remove-avatar ``` Clears the caller's profile picture. Requires authentication; self-scoped. Example: ```sh omniglass auth remove-avatar ``` ### `omniglass auth set-avatar` Set your own profile picture ``` omniglass auth set-avatar [flags] ``` Sets the caller's profile picture (JPEG, PNG, or WebP, base64-encoded), normalized server-side to a 256x256 JPEG. Requires authentication; self-scoped. A bad or oversize image is a 422. | Flag | Type | Default | Description | |---|---|---|---| | `--image-base64` | string | (none) | The image (JPEG, PNG, or WebP), base64-encoded; normalized server-side to a 256x256 JPEG | Example: ```sh omniglass auth set-avatar --image-base64 image_base64 ``` ### `omniglass auth status` Commands for the status resource #### `omniglass auth status list` Whether the system has an owner yet ``` omniglass auth status list ``` Public: reports whether any owner has been bootstrapped, so the login screen can hide the bootstrap hint. Example: ```sh omniglass auth status list ``` ### `omniglass auth stop-impersonation` Stop the current impersonation session ``` omniglass auth stop-impersonation ``` Revokes the impersonation session presented by the request token, ending the view-as / act-as. Requires authentication with an impersonation token; self-scoped (ends only the session the token names). Example: ```sh omniglass auth stop-impersonation ``` ### `omniglass auth update-profile` Update your own profile ``` omniglass auth update-profile [flags] ``` Updates the caller's own display name (email is administrator-set). Requires authentication; self-scoped (edits only your own principal). | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | Your display name; empty clears it | Example: ```sh omniglass auth update-profile ``` ## `omniglass bootstrap` Create the first owner (idempotent per username) and mint its bearer token ``` omniglass bootstrap [flags] ``` | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | owner display name (optional) | | `--email` | string | (none) | owner email (optional) | | `--password` | string | (none) | owner password, so the owner can sign in to the console (optional) | | `--ttl` | duration | `2160h0m0s` | how long the bootstrap token is valid before it expires (max 365 days) | ## `omniglass capability` Commands for the capability resource ### `omniglass capability create` Create a capability ``` omniglass capability create [flags] ``` Creates a custom (non-official) capability. Gated by capability:create. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | What an operator reads in pickers and lists | | `--name` | string | (none) | The globally unique kebab handle; renameable | Example: ```sh omniglass capability create --display-name display_name --name name ``` ### `omniglass capability delete` Delete a capability ``` omniglass capability delete ``` Deletes a custom capability, refused if official (422). Gated by capability:delete. Example: ```sh omniglass capability delete ``` ### `omniglass capability get` Get a capability ``` omniglass capability get ``` Fetches a capability by id. Gated by capability:read. Example: ```sh omniglass capability get ``` ### `omniglass capability list` List capabilities ``` omniglass capability list ``` Lists the capability registry, ordered alphabetically by display name. Populates the capability picker on the product form. Gated by capability:read. Example: ```sh omniglass capability list ``` ### `omniglass capability update` Update a capability ``` omniglass capability update [flags] ``` Patches a custom capability's display_name. Official capabilities are read-only (422). Gated by capability:update. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | A new operator-facing label | Example: ```sh omniglass capability update ``` ## `omniglass command-type` Commands for the command-type resource ### `omniglass command-type create` Create a command type ``` omniglass command-type create [flags] ``` Registers a custom command type (official=false). The name must be a valid key; a target property, when set, must be registered. Gated by command_type:create. | Flag | Type | Default | Description | |---|---|---|---| | `--description` | string | (none) | What the command does | | `--display-name` | string | (none) | A human label | | `--name` | string | (none) | The command type name (lowercase, dot-hierarchied) | | `--params-schema` | string | (none) | A JSON Schema fragment for the params | | `--settle-window-seconds` | string | (none) | The actuation window in seconds (0 = fire-and-forget) | | `--target-property-type` | string | (none) | The property this command sets, for settlement | Example: ```sh omniglass command-type create --name name ``` ### `omniglass command-type delete` Delete a command type ``` omniglass command-type delete ``` Removes a custom command type by name. Official types are read-only. Gated by command_type:delete. Example: ```sh omniglass command-type delete ``` ### `omniglass command-type get` Get a command type ``` omniglass command-type get ``` Returns one command type by name. Gated by command_type:read. Example: ```sh omniglass command-type get ``` ### `omniglass command-type list` List command types ``` omniglass command-type list ``` Lists every registered command type (official and custom). Estate-wide reference data. Gated by command_type:read. Example: ```sh omniglass command-type list ``` ### `omniglass command-type update` Update a command type ``` omniglass command-type update [flags] ``` Patches a custom command type's label, description, params schema, settle window, or target (a nil field is unchanged; an empty target clears it). The name is fixed at creation. Official types are read-only. Gated by command_type:update. | Flag | Type | Default | Description | |---|---|---|---| | `--description` | string | (none) | What the command does | | `--display-name` | string | (none) | A human label | | `--params-schema` | string | (none) | A JSON Schema fragment (replaces wholesale) | | `--settle-window-seconds` | string | (none) | The actuation window in seconds | | `--target-property-type` | string | (none) | The property this command sets (empty clears it) | Example: ```sh omniglass command-type update ``` ## `omniglass component` Commands for the component resource ### `omniglass component alarm` Commands for the alarm resource #### `omniglass component alarm create` Raise an alarm on a component ``` omniglass component alarm create [flags] ``` Records a condition on this component and the capabilities it degrades, then recomputes health in the same transaction: any role requiring a degraded capability can no longer be filled by this component, and its system and location verdicts move with it. An unknown capability is a 422. Gated by component:update; an out-of-scope component is a non-disclosing 404. | Flag | Type | Default | Description | |---|---|---|---| | `--capabilities` | string | (none) | The capabilities this condition degrades; a role requiring one of them can no longer be filled by this component | | `--dedup-key` | string | (none) | The condition identity; defaults to the message. Raising an already-open (component, dedup_key) returns the existing open alarm instead of a duplicate | | `--message` | string | (none) | What is wrong, for the operator reading it later | | `--severity` | string | (none) | How bad it is; critical puts the component itself in outage | Example: ```sh omniglass component alarm create --severity severity ``` #### `omniglass component alarm delete` Clear an alarm ``` omniglass component alarm delete ``` Marks the alarm cleared and recomputes health in the same transaction, so the recovery is recorded as a transition at the moment it happened. The row is kept: what was wrong and when outlives the fix. Clearing an alarm that is already cleared or does not exist is a 404. Gated by component:update; an out-of-scope component is a non-disclosing 404. Example: ```sh omniglass component alarm delete ``` #### `omniglass component alarm list` List a component's alarms ``` omniglass component alarm list [flags] ``` What is currently wrong with this component, newest first, each with the capabilities it degrades. Pass include_cleared for the history rather than the active set. Gated by component:read; an out-of-scope component is a non-disclosing 404. | Flag | Type | Default | Description | |---|---|---|---| | `--include-cleared` | bool | `false` | Include cleared alarms, so the list is the history rather than what is wrong now | Example: ```sh omniglass component alarm list ``` ### `omniglass component capability` Commands for the capability resource #### `omniglass component capability delete` Clear a capability declaration on a component ``` omniglass component capability delete ``` Removes the component's own fact about the capability, so it falls back to whatever its product declares. Clearing a fact the component never declared is a 404. Gated by component:update; an out-of-scope component is a non-disclosing 404. Example: ```sh omniglass component capability delete ``` #### `omniglass component capability list` List a component's effective capabilities ``` omniglass component capability list ``` What this component actually provides: the capabilities its product declares, plus the ones the component adds, minus the ones it suppresses. This is the set the role-assignment guard checks, so a productless component that declares its own can still be staffed. Gated by component:read; an out-of-scope component is a non-disclosing 404. Example: ```sh omniglass component capability list ``` #### `omniglass component capability update` Declare a capability on a component ``` omniglass component capability update [flags] ``` Records this component's own fact about a capability: present true adds one its product does not claim, present false suppresses one it does. Idempotent. An unknown capability is a 422; an unknown or out-of-scope component is a non-disclosing 404 (the component is resolved in scope first). Gated by component:update. | Flag | Type | Default | Description | |---|---|---|---| | `--present` | string | (none) | True to add the capability, false to suppress one the product declares | Example: ```sh omniglass component capability update --present present ``` ### `omniglass component checkName` Check a component technical name ``` omniglass component checkName [flags] ``` Reports whether a proposed technical name is a valid slug and currently free. Advisory (Save is still gated by the unique constraint). Availability is scope-blind to match the global unique constraint. Gated by component:update. | Flag | Type | Default | Description | |---|---|---|---| | `--name` | string | (none) | The proposed technical name to check | Example: ```sh omniglass component checkName --name name ``` ### `omniglass component command` Commands for the command resource #### `omniglass component command issue` Issue a command to a component ``` omniglass component command issue [flags] ``` Records a command invocation, writes a caused event, and (for a settleable command) opens an intended value the observed value settles against. Returns the computed settlement verdict. Gated by command:issue; an out-of-scope component is a non-disclosing 404. | Flag | Type | Default | Description | |---|---|---|---| | `--command-type` | string | (none) | The command_type to invoke | | `--instance` | string | (none) | The series discriminator (e.g. an interface), when the target is instanced | | `--params` | string | (none) | The invocation params, stored on the command and the caused event | | `--value` | string | (none) | The intended value for the target property (a settleable command) | Example: ```sh omniglass component command issue --command-type command_type ``` ### `omniglass component create` Create a component ``` omniglass component create [flags] ``` Creates a component, optionally under a parent (a root needs an all-scoped grant), bound to a system and a location. Gated by component:create. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | What an operator reads; the technical name is the address | | `--location` | string | (none) | Location name this component is placed at | | `--name` | string | (none) | Globally unique name (the address; lowercase letters, digits, hyphens) | | `--parent` | string | (none) | Parent component name; omit for a root component | | `--product` | string | (none) | Product id (catalog SKU) this component is an instance of | | `--system` | string | (none) | Primary system name this component belongs to | Example: ```sh omniglass component create --name name ``` ### `omniglass component delete` Delete a component ``` omniglass component delete ``` Deletes a component, refused (409) while it still has child components or is still referenced elsewhere, such as by a system role it staffs. Gated by component:delete; read and delete scopes drive the 404 versus 403 split. Example: ```sh omniglass component delete ``` ### `omniglass component effective-secret` Commands for the effective-secret resource #### `omniglass component effective-secret list` Effective secrets for a component ``` omniglass component effective-secret list ``` Resolves the secrets that cascade onto a component (platform -> location -> component), with the winner and the shadowed candidates it overrode. There is NO system band: a secret is device-facing, and the room a component happens to serve is the wrong owner for a credential the device itself answers with. Fields are masked, as in the directory; plaintext is only ever the audited reveal. Gated by secret:read, which the viewer floor does not carry, and admin-sensitive secrets appear only to the admin tier. Example: ```sh omniglass component effective-secret list ``` ### `omniglass component effective-tag` Commands for the effective-tag resource #### `omniglass component effective-tag list` Effective tags for a component ``` omniglass component effective-tag list [flags] ``` Resolves the tags that cascade onto a component (platform -> location -> system -> component): keys union, values override most-specific-wins, with the winner and shadowed candidates. A non-propagating key resolves only from a binding on the component itself. The system band comes from MEMBERSHIP: pass ?system= to resolve against one the component belongs to (a shared device answers differently for each), or omit it to resolve against its primary membership. Gated by component:read; the component must be in the caller's component read scope. | Flag | Type | Default | Description | |---|---|---|---| | `--system` | string | (none) | Resolve against this system, which the component must be a member of. Omit to resolve against its primary membership, the default for a caller with no system in hand. | Example: ```sh omniglass component effective-tag list ``` ### `omniglass component effective-variable` Commands for the effective-variable resource #### `omniglass component effective-variable list` Effective variables for a component ``` omniglass component effective-variable list ``` Resolves the variables that cascade onto a component (platform -> location -> system -> component): names union, values override most-specific-wins, with the winner and the shadowed candidates it overrode. The system band comes from the component's PRIMARY membership; resolving against a named system is not offered here yet, unlike effective-tags. Gated by variable:read; the component must be in the caller's component read scope. Example: ```sh omniglass component effective-variable list ``` ### `omniglass component event` Commands for the event resource #### `omniglass component event list` List a component's recent events ``` omniglass component event list ``` Returns the component's recent log occurrences (the log-kind sink), newest first, bounded to the last 24 hours. Gated by component:read; an out-of-scope component is a non-disclosing 404. Example: ```sh omniglass component event list ``` ### `omniglass component get` Get a component ``` omniglass component get ``` Fetches a component by name within the caller's read scope. Out of scope is a non-disclosing 404. Gated by component:read. Example: ```sh omniglass component get ``` ### `omniglass component list` List components in scope ``` omniglass component list ``` Lists the components the caller may read, each filtered to its scope subtree. Gated by component:read. Example: ```sh omniglass component list ``` ### `omniglass component listTags` List tags on a component ``` omniglass component listTags ``` Lists the tags bound directly on a component (not the resolved cascade). Gated by component:read. Example: ```sh omniglass component listTags ``` ### `omniglass component log` Commands for the log resource #### `omniglass component log list` List a component's recent log lines ``` omniglass component log list ``` Returns the component's recent raw log lines (the ingest lane of ADR-0066, distinct from typed events), newest first, bounded to the last 24 hours. Gated by component:read; an out-of-scope component is a non-disclosing 404. Example: ```sh omniglass component log list ``` ### `omniglass component membership` Commands for the membership resource #### `omniglass component membership list` List the systems a component is in ``` omniglass component membership list ``` The systems this component is bound into, ordered by name. A component may belong to several: a rack DSP serving three rooms is a member of all three, and each of them depends on it. Exactly one membership may be marked primary, the default for a question asked without a system in hand. Gated by component:read; an out-of-scope component is a non-disclosing 404. Example: ```sh omniglass component membership list ``` ### `omniglass component property` Commands for the property resource #### `omniglass component property delete` Clear a property on a component ``` omniglass component property delete ``` Removes the component's declared value, so the property falls back to the product contract's default (or leaves the effective read entirely when it was off-contract). Clearing a property the component never set is a 404. Gated by component:update; an out-of-scope component is a non-disclosing 404. Example: ```sh omniglass component property delete ``` #### `omniglass component property list` List a component's effective properties ``` omniglass component property list ``` Every property the component's product declares, resolved to the component's own value or the contract default (is_set marks the override), plus any property set directly on the component (from_contract false). Gated by component:read; an out-of-scope component is a non-disclosing 404. Example: ```sh omniglass component property list ``` #### `omniglass component property update` Set a property on a component ``` omniglass component property update [flags] ``` Declares a value for the property on this component, overriding the product contract's default. 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). Gated by component:update; an out-of-scope component is a non-disclosing 404. | Flag | Type | Default | Description | |---|---|---|---| | `--value` | string | (none) | The value to declare, shape given by the property's data_type | Example: ```sh omniglass component property update --value value ``` ### `omniglass component reachability` Commands for the reachability resource #### `omniglass component reachability list` Read a component's per-interface reachability ``` omniglass component reachability list ``` Composes, per interface, the latest reachability verdict, the probe-layer signals that compose it, and the recent verdict transitions for the availability strip. Gated by component:read; an out-of-scope component is a non-disclosing 404. Example: ```sh omniglass component reachability list ``` ### `omniglass component reconciliation` Commands for the reconciliation resource #### `omniglass component reconciliation list` Read a component's property reconciliation (want/told/is) ``` omniglass component reconciliation list ``` Pivots, per declared property, the declared value (want, resolved live from the cascade), the intended value (told), and the observed value (is), with config-drift computed on read. Gated by component:read; an out-of-scope component is a non-disclosing 404. Example: ```sh omniglass component reconciliation list ``` ### `omniglass component removeTag` Remove a tag value from a component ``` omniglass component removeTag [flags] ``` Removes a key's value from a component. Gated by component:update. | Flag | Type | Default | Description | |---|---|---|---| | `--key` | string | (none) | The tag key to remove | Example: ```sh omniglass component removeTag --key key ``` ### `omniglass component setTag` Set a tag value on a component ``` omniglass component setTag [flags] ``` Binds a value for a key on a component. The key must exist and apply to this entity kind. Setting a value is the ordinary entity write, gated by component:update. | Flag | Type | Default | Description | |---|---|---|---| | `--key` | string | (none) | The tag key (must exist and apply to this kind) | | `--value` | string | (none) | The bound value | Example: ```sh omniglass component setTag --key key --value value ``` ### `omniglass component update` Update a component ``` omniglass component update [flags] ``` Patches a component's technical name, display_name, product, location, or parent. Placement and classification fields follow the three-state convention: an omitted field is unchanged, an explicit empty string clears, a name sets. A reparent is cycle-guarded and scope-injected. Gated by component:update; read and update scopes drive the 404 versus 403 split. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | A new operator-facing label | | `--location` | string | (none) | Relocates the component to this location name. An empty string clears its placement. | | `--name` | string | (none) | A new globally unique technical name (rename) | | `--parent` | string | (none) | Re-parents the component within the component tree to this component name; cycle-guarded and scope-injected. An empty string makes it a root component. | | `--product` | string | (none) | Re-classifies the component to this product (catalog SKU). An empty string clears it. Explicitly-set property values persist; the new product's contract defaults follow. | Example: ```sh omniglass component update ``` ## `omniglass driver` Commands for the driver resource ### `omniglass driver create` Create a driver ``` omniglass driver create [flags] ``` Creates a custom (non-official) driver. Gated by driver:create. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | What an operator reads in pickers and lists | | `--name` | string | (none) | The globally unique kebab handle; renameable | | `--version` | string | (none) | A free-form version string, e.g. 1.0.0 | Example: ```sh omniglass driver create --display-name display_name --name name ``` ### `omniglass driver delete` Delete a driver ``` omniglass driver delete ``` Deletes a custom driver, refused if official (422). Gated by driver:delete. Example: ```sh omniglass driver delete ``` ### `omniglass driver get` Get a driver ``` omniglass driver get ``` Fetches a driver by id. Gated by driver:read. Example: ```sh omniglass driver get ``` ### `omniglass driver list` List drivers ``` omniglass driver list ``` Lists the driver registry, ordered alphabetically by display name. Populates the driver picker on the product form. Gated by driver:read. Example: ```sh omniglass driver list ``` ### `omniglass driver update` Update a driver ``` omniglass driver update [flags] ``` Patches a custom driver's display_name or version. Official drivers are read-only (422). Gated by driver:update. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | A new operator-facing label | | `--version` | string | (none) | A new version string, e.g. 1.0.1 | Example: ```sh omniglass driver update ``` ## `omniglass event-type` Commands for the event-type resource ### `omniglass event-type create` Create an event type ``` omniglass event-type create [flags] ``` Registers a custom event type (official=false). The name must be a valid event key. Gated by event_type:create. | Flag | Type | Default | Description | |---|---|---|---| | `--description` | string | (none) | What the occurrence means | | `--display-name` | string | (none) | A human label | | `--name` | string | (none) | The event type name (lowercase, dot-hierarchied) | | `--payload-schema` | string | (none) | A JSON Schema fragment for the payload | Example: ```sh omniglass event-type create --name name ``` ### `omniglass event-type delete` Delete an event type ``` omniglass event-type delete ``` Removes a custom event type by name. Official event types are read-only. Gated by event_type:delete. Example: ```sh omniglass event-type delete ``` ### `omniglass event-type get` Get an event type ``` omniglass event-type get ``` Returns one event type by name. Gated by event_type:read. Example: ```sh omniglass event-type get ``` ### `omniglass event-type list` List event types ``` omniglass event-type list ``` Lists every registered event type (official and custom). The catalog is estate-wide reference data. Gated by event_type:read. Example: ```sh omniglass event-type list ``` ### `omniglass event-type update` Update an event type ``` omniglass event-type update [flags] ``` Patches a custom event type's label, description, or payload schema (a nil field is unchanged). The name is fixed at creation. Official event types are read-only. Gated by event_type:update. | Flag | Type | Default | Description | |---|---|---|---| | `--description` | string | (none) | What the occurrence means | | `--display-name` | string | (none) | A human label | | `--payload-schema` | string | (none) | A JSON Schema fragment (replaces wholesale) | Example: ```sh omniglass event-type update ``` ## `omniglass file` Commands for the file resource ### `omniglass file create` Create a file from an upload ``` omniglass file create [flags] ``` Stores the uploaded bytes as a content-addressed blob (identical bytes dedup to one blob) and writes the file handle pointing at it. Gated by file:create; a sensitive file additionally needs the admin tier (file:create:admin). | Flag | Type | Default | Description | |---|---|---|---| | `--content` | string | (none) | The file bytes, base64-encoded | | `--content-type` | string | (none) | The MIME type used to serve the file | | `--name` | string | (none) | The file's display name (a label, no path separators) | | `--sensitive` | string | (none) | Admin-only visibility; defaults false. Setting true requires the admin tier | Example: ```sh omniglass file create --content content --content-type content_type --name name ``` ### `omniglass file delete` Delete a file ``` omniglass file delete ``` Removes a file handle. The underlying blob is left in place (garbage collection is a later slice). A sensitive file is a non-disclosing 404 without the admin tier. Gated by file:delete. Example: ```sh omniglass file delete ``` ### `omniglass file download` Download a file's bytes ``` omniglass file download ``` Returns a file's bytes (base64-encoded) read from the blob it points at, the hash verified on read. A sensitive file is a non-disclosing 404 without the admin tier. Gated by file:read. Example: ```sh omniglass file download ``` ### `omniglass file get` Get a file's metadata ``` omniglass file get ``` Returns one file handle's searchable metadata (no bytes). A sensitive file is a non-disclosing 404 without the admin tier. Gated by file:read. Example: ```sh omniglass file get ``` ### `omniglass file list` List files ``` omniglass file list ``` Lists the file handles the caller may see (searchable metadata, no bytes). Sensitive files appear only to the admin tier. Gated by file:read. Example: ```sh omniglass file list ``` ## `omniglass healthz` Liveness and database-reachability probe ``` omniglass healthz ``` Reports process health and the database leg, pinged through the Storage Gateway. Public: the liveness probe carries no operator data. Example: ```sh omniglass healthz ``` ## `omniglass interface` Commands for the interface resource ### `omniglass interface create` Create an interface ``` omniglass interface create [flags] ``` Creates an interface owned by a component (or a server-hosted one, which needs an all-scoped grant). The create scope cascades through the owning component. Gated by interface:create. | Flag | Type | Default | Description | |---|---|---|---| | `--component` | string | (none) | Owning component, by name or id; omit for a server-hosted interface (needs an all-scoped grant) | | `--interface-type` | string | (none) | An interface_type name (the protocol); the interface is named by it, unique within the component | | `--node` | string | (none) | Node placement, by name or id | | `--params` | string | (none) | Endpoint/target settings (jsonb) | Example: ```sh omniglass interface create --interface-type interface_type ``` ### `omniglass interface delete` Delete an interface ``` omniglass interface delete ``` Deletes an interface, refused while a task still references it. Gated by interface:delete; read and delete scopes (through the component) drive the 404 versus 403 split. Example: ```sh omniglass interface delete ``` ### `omniglass interface get` Get an interface ``` omniglass interface get ``` Fetches an interface by id. An interface whose component is out of the caller's read scope is a non-disclosing 404. Gated by interface:read. Example: ```sh omniglass interface get ``` ### `omniglass interface list` List interfaces in scope ``` omniglass interface list ``` Lists the interfaces whose owning component the caller may read (the component cascade). Gated by interface:read. Example: ```sh omniglass interface list ``` ### `omniglass interface update` Update an interface ``` omniglass interface update [flags] ``` Patches an interface's node placement or params. Gated by interface:update; read and update scopes (through the component) drive the 404 versus 403 split. | Flag | Type | Default | Description | |---|---|---|---| | `--node` | string | (none) | Reassign the node placement, by name or id | | `--params` | string | (none) | Replace the endpoint/target settings (jsonb) | Example: ```sh omniglass interface update ``` ## `omniglass location` Commands for the location resource ### `omniglass location checkName` Check a location technical name ``` omniglass location checkName [flags] ``` Reports whether a proposed technical name is a valid slug and currently free. Advisory (Save is still gated by the unique constraint). Availability is scope-blind to match the global unique constraint. Gated by location:update. | Flag | Type | Default | Description | |---|---|---|---| | `--name` | string | (none) | The proposed technical name to check | Example: ```sh omniglass location checkName --name name ``` ### `omniglass location create` Create a location ``` omniglass location create [flags] ``` Creates a location, optionally under a parent (a root needs an all-scoped grant). Gated by location:create. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | What an operator reads; the technical name is the address | | `--location-type` | string | (none) | The location_type, by name or uuid (campus, building, ...) | | `--name` | string | (none) | Globally unique name (the address; lowercase letters, digits, hyphens) | | `--parent` | string | (none) | Parent location name; omit for a root location | Example: ```sh omniglass location create --location-type location_type --name name ``` ### `omniglass location delete` Delete a location ``` omniglass location delete ``` Deletes a location, refused (409) while it still has child locations or is still referenced elsewhere. Gated by location:delete; read and delete scopes drive the 404 versus 403 split. Example: ```sh omniglass location delete ``` ### `omniglass location get` Get a location ``` omniglass location get ``` Fetches a location by name within the caller's read scope. Out of scope is a non-disclosing 404. Gated by location:read. Example: ```sh omniglass location get ``` ### `omniglass location health` Commands for the health resource #### `omniglass location health list` Read a location's health ``` omniglass location health list ``` The location's current verdict, worst-wins over every system placed anywhere beneath it, with those systems and their verdicts as the drill-down (the system health read names the role, the capability, and the alarm). Transitions are the recorded edges over the last 30 days. Gated by location:read; an out-of-scope location is a non-disclosing 404. Example: ```sh omniglass location health list ``` ### `omniglass location list` List locations in scope ``` omniglass location list ``` Lists the locations the caller may read, each filtered to its scope subtree. Gated by location:read. Example: ```sh omniglass location list ``` ### `omniglass location listTags` List tags on a location ``` omniglass location listTags ``` Lists the tags bound directly on a location (not the resolved cascade). Gated by location:read. Example: ```sh omniglass location listTags ``` ### `omniglass location property` Commands for the property resource #### `omniglass location property delete` Clear a property on a location ``` omniglass location property delete ``` Removes the location's declared value, so the property falls back to the location type contract's default (or leaves the effective read entirely when it was off-contract). Clearing a property the location never set is a 404. Gated by location:update; an out-of-scope location is a non-disclosing 404. Example: ```sh omniglass location property delete ``` #### `omniglass location property list` List a location's effective properties ``` omniglass location property list ``` Every property the location's type declares, resolved to the location's own value or the contract default (is_set marks the override), plus any property set directly on the location (from_contract false). Gated by location:read; an out-of-scope location is a non-disclosing 404. Example: ```sh omniglass location property list ``` #### `omniglass location property update` Set a property on a location ``` omniglass location property update [flags] ``` Declares a value for the property on this location, overriding the location type contract's default. 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). Gated by location:update; an out-of-scope location is a non-disclosing 404. | Flag | Type | Default | Description | |---|---|---|---| | `--value` | string | (none) | The value to declare, shape given by the property's data_type | Example: ```sh omniglass location property update --value value ``` ### `omniglass location removeTag` Remove a tag value from a location ``` omniglass location removeTag [flags] ``` Removes a key's value from a location. Gated by location:update. | Flag | Type | Default | Description | |---|---|---|---| | `--key` | string | (none) | The tag key to remove | Example: ```sh omniglass location removeTag --key key ``` ### `omniglass location setTag` Set a tag value on a location ``` omniglass location setTag [flags] ``` Binds a value for a key on a location. The key must exist and apply to this entity kind. Setting a value is the ordinary entity write, gated by location:update. | Flag | Type | Default | Description | |---|---|---|---| | `--key` | string | (none) | The tag key (must exist and apply to this kind) | | `--value` | string | (none) | The bound value | Example: ```sh omniglass location setTag --key key --value value ``` ### `omniglass location update` Update a location ``` omniglass location update [flags] ``` Patches a location's display_name, location_type, or parent (a move). Gated by location:update; the read and update scopes drive the 404 versus 403 split. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | A new operator-facing label | | `--location-type` | string | (none) | Re-types the location: a location_type, by name or uuid | | `--name` | string | (none) | A new globally unique technical name (rename) | | `--parent` | string | (none) | Re-parents the location (a tree move) to this location name, cycle-guarded and placement-validated. Moving to root is not supported via update this slice. | Example: ```sh omniglass location update ``` ## `omniglass location-type` Commands for the location-type resource ### `omniglass location-type create` Create a location type ``` omniglass location-type create [flags] ``` Creates a custom (non-official) location_type. Gated by type:create. | Flag | Type | Default | Description | |---|---|---|---| | `--allowed-parent-types` | string | (none) | location_type names and/or the reserved root sentinel this type may be placed under; empty means unconstrained | | `--display-name` | string | (none) | What an operator reads in pickers and lists | | `--icon` | string | (none) | A glyph key; the console falls back to map-pin when empty | | `--name` | string | (none) | The globally unique kebab handle (e.g. wing); "root" is reserved | Example: ```sh omniglass location-type create --display-name display_name --name name ``` ### `omniglass location-type delete` Delete a location type ``` omniglass location-type delete ``` Deletes a custom location_type, refused if official (422) or still referenced by a location (409). Gated by type:delete. Example: ```sh omniglass location-type delete ``` ### `omniglass location-type list` List location types ``` omniglass location-type list ``` Lists the location_type registry (the shape-definers a location is classified by), ordered alphabetically by display name. Populates the type picker on the location form. Gated by type:read. Example: ```sh omniglass location-type list ``` ### `omniglass location-type property` Commands for the property resource #### `omniglass location-type property delete` Withdraw a property from a location type ``` omniglass location-type property delete ``` Removes one line from a custom location type's contract; locations of the type keep any value they set for it, now off-contract. A property the type does not declare is a 404, and an official type is read-only (422). Gated by type:delete. Example: ```sh omniglass location-type property delete ``` #### `omniglass location-type property list` List a location type's declared properties ``` omniglass location-type property list ``` Lists the location type's declared-property contract (what every location of the type exposes), ordered by property name, each with its optional default and required flag. Gated by type:read. Example: ```sh omniglass location-type property list ``` #### `omniglass location-type property update` Declare a property on a location type ``` omniglass location-type property update [flags] ``` Declares a catalog property on a custom location type, or revises the declaration in place (the line is addressed by name, so the write is idempotent). Official location types are read-only (422); an unknown type is a 404 and a property the catalog does not know is a 422. Gated by type:update. | Flag | Type | Default | Description | |---|---|---|---| | `--default-value` | string | (none) | The contract default, validated against the property's data_type; omit for no default | | `--required` | string | (none) | Whether every location of this type must set the property; defaults to false | Example: ```sh omniglass location-type property update ``` ### `omniglass location-type update` Update a location type ``` omniglass location-type update [flags] ``` Patches a custom location_type's display_name or icon. Official types are read-only (422). Gated by type:update. | Flag | Type | Default | Description | |---|---|---|---| | `--allowed-parent-types` | string | (none) | Replaces the allowed-parent set; omit to leave unchanged, [] to clear back to unconstrained | | `--display-name` | string | (none) | A new operator-facing label | | `--icon` | string | (none) | A new glyph key; the console falls back to map-pin when empty | Example: ```sh omniglass location-type update ``` ## `omniglass migrate` Apply embedded database migrations (dbmate) ``` omniglass migrate ``` ## `omniglass node` Commands for the node resource ### `omniglass node claim` Claim a node identity in exchange for its NATS credential ``` omniglass node claim [flags] ``` The node-facing exchange: a node presents its enrollment token and receives its NATS credential (url, username, password). Public (the token is the authentication); an invalid token is a 401. | Flag | Type | Default | Description | |---|---|---|---| | `--name` | string | (none) | The node name the enrollment was minted for | | `--token` | string | (none) | The one-time enrollment token from node create, exchanged here for the node's NATS credential | Example: ```sh omniglass node claim --name name --token token ``` ### `omniglass node create` Create a node ``` omniglass node create [flags] ``` Registers an edge node server-side (day-one enrollment: create, then :enroll to mint its token). Gated by node:create. | Flag | Type | Default | Description | |---|---|---|---| | `--description` | string | (none) | Free-form operator notes about the node | | `--display-name` | string | (none) | Operator label; falls back to the name when empty | | `--location` | string | (none) | Optional location the node sits in, by name or id (descriptive placement, not scope) | | `--name` | string | (none) | Globally unique node name (also its NATS subject token, so no dots or whitespace) | Example: ```sh omniglass node create --name name ``` ### `omniglass node delete` Delete a node ``` omniglass node delete ``` Decommissions a node: a hard delete that cascades its interfaces, their derived tasks, its node-owned tags and self-telemetry, and its enrollment credential. Component telemetry it collected is unaffected. Requires an all-scope action. Gated by node:delete. Example: ```sh omniglass node delete ``` ### `omniglass node enroll` Mint a node's enrollment token ``` omniglass node enroll ``` Mints (or re-mints) the node's enrollment token and returns it once. The token is stored only as a hash; it is never logged. Gated by node:enroll. Example: ```sh omniglass node enroll ``` ### `omniglass node get` Get a node ``` omniglass node get ``` Fetches a node by name. Requires an all-scope read. Gated by node:read. Example: ```sh omniglass node get ``` ### `omniglass node list` List nodes ``` omniglass node list ``` Lists the edge nodes. A node is estate-wide, so listing requires an all-scope read. Gated by node:read. Example: ```sh omniglass node list ``` ### `omniglass node listTags` List tags on a node ``` omniglass node listTags ``` Lists the tags bound directly on a node (not the resolved cascade). Gated by node:read. Example: ```sh omniglass node listTags ``` ### `omniglass node log` Commands for the log resource #### `omniglass node log list` List a node's recent self-logs ``` omniglass node log list ``` Returns the node's own recent operational log lines (the raw ingest lane of ADR-0066, owner-bound to the node), newest first, bounded to the last 24 hours. Gated by node:read; an out-of-scope node is a non-disclosing 404. Example: ```sh omniglass node log list ``` ### `omniglass node removeTag` Remove a tag value from a node ``` omniglass node removeTag [flags] ``` Removes a key's value from a node. Gated by node:update. | Flag | Type | Default | Description | |---|---|---|---| | `--key` | string | (none) | The tag key to remove | Example: ```sh omniglass node removeTag --key key ``` ### `omniglass node run` Run the edge node: claim, pull the worklist, and heartbeat over NATS ``` omniglass node run [flags] ``` | Flag | Type | Default | Description | |---|---|---|---| | `--heartbeat` | duration | `30s` | heartbeat interval | | `--name` | string | (none) | this node's registered name (env OMNIGLASS_NODE_NAME) | | `--once` | bool | `false` | run a single claim + pull + heartbeat cycle and exit | | `--server` | string | (none) | Omniglass server base URL (env OMNIGLASS_SERVER) | | `--token` | string | (none) | enrollment token from POST /nodes/{name}:enroll (env OMNIGLASS_NODE_TOKEN) | ### `omniglass node setTag` Set a tag value on a node ``` omniglass node setTag [flags] ``` Binds a value for a key on a node. The key must exist and apply to this entity kind. Setting a value is the ordinary entity write, gated by node:update. | Flag | Type | Default | Description | |---|---|---|---| | `--key` | string | (none) | The tag key (must exist and apply to this kind) | | `--value` | string | (none) | The bound value | Example: ```sh omniglass node setTag --key key --value value ``` ### `omniglass node update` Update a node ``` omniglass node update [flags] ``` Patches a node's display name, description, and location (a nil field is unchanged; a location of "" clears it). The name is immutable. Requires an all-scope action. Gated by node:update. | Flag | Type | Default | Description | |---|---|---|---| | `--description` | string | (none) | New free-form operator notes | | `--display-name` | string | (none) | A new operator-facing label | | `--location` | string | (none) | Set the node's location by name or id, or "" to clear it | Example: ```sh omniglass node update ``` ## `omniglass principal` Commands for the principal resource ### `omniglass principal archive` Archive a principal ``` omniglass principal archive ``` Soft-deletes a principal: it is hidden from the directory, can no longer authenticate, and its rows stay intact, reversibly (restore) until purged. Gated by principal:archive (all-scope). The last active owner cannot be archived. Example: ```sh omniglass principal archive ``` ### `omniglass principal avatar` Commands for the avatar resource #### `omniglass principal avatar list` Get a principal's profile picture ``` omniglass principal avatar list ``` Returns the principal's profile picture as a base64-encoded JPEG. Gated by principal:read:admin. A principal without a picture is a 404. Example: ```sh omniglass principal avatar list ``` ### `omniglass principal create` Create a human principal ``` omniglass principal create [flags] ``` Creates a human principal with an optional initial password. Gated by principal:create (all-scope). The new principal holds no grants; assign roles separately. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | What an operator reads in lists; falls back to the username | | `--email` | string | (none) | Contact email for the account | | `--password` | string | (none) | Optional initial password (at least 12 characters, not a common password, not containing the username); the user changes it after signing in | | `--username` | string | (none) | Unique sign-in name (lowercase letters, digits, and . _ -) | Example: ```sh omniglass principal create --username username ``` ### `omniglass principal disable` Disable a principal ``` omniglass principal disable ``` Soft-disables a principal so it can no longer authenticate; its audit trail is kept. Gated by principal:update (all-scope). The last active owner cannot be disabled. Example: ```sh omniglass principal disable ``` ### `omniglass principal enable` Enable a principal ``` omniglass principal enable ``` Re-enables a disabled principal, restoring its ability to authenticate. Gated by principal:update (all-scope). Example: ```sh omniglass principal enable ``` ### `omniglass principal get` Get a principal ``` omniglass principal get ``` Fetches one principal by id with its profile and grants. Gated by principal:read:admin. Example: ```sh omniglass principal get ``` ### `omniglass principal grant` Commands for the grant resource #### `omniglass principal grant create` Grant a role to a principal ``` omniglass principal grant create [flags] ``` Assigns a role at a scope to a principal. Gated by principal_grant:create (all-scope). Refused (403) when the granted role's capabilities exceed the granter's own (no promoting anyone, including yourself, to a higher tier such as owner). A duplicate is 409, an unknown role or bad scope 422. | Flag | Type | Default | Description | |---|---|---|---| | `--role` | string | (none) | A role id (viewer, operator, admin, owner, or a custom role) | | `--scope-id` | string | (none) | The scope root id; omit for the all scope | | `--scope-kind` | string | (none) | The scope kind; 'all' confers the whole estate (group-as-scope is unbuilt and not offered) | | `--scope-op` | string | (none) | How the scope root matches the tree: subtree (root + descendants, the default), subtree_excl_root (descendants only for update/delete, root kept for read/create), or self (the root row only). Moot for the all scope. | Example: ```sh omniglass principal grant create --role role --scope-kind scope_kind ``` #### `omniglass principal grant delete` Revoke a grant ``` omniglass principal grant delete ``` Removes one grant from a principal. Gated by principal_grant:delete (all-scope). The last owner grant cannot be revoked. Example: ```sh omniglass principal grant delete ``` ### `omniglass principal impersonate` Impersonate a principal (view-as or act-as) ``` omniglass principal impersonate [flags] ``` Mints a bounded, revocable token to view as (read-only) or act as (full) the target. Gated by principal:impersonate (all-scope). Refused on self, on an owner target (owners are un-impersonatable by anyone), when it would grant a capability the caller lacks (the escalation guard), or from within an existing impersonation. | Flag | Type | Default | Description | |---|---|---|---| | `--duration-minutes` | string | (none) | Session lifetime in minutes (default 30, max 1440) | | `--mode` | string | (none) | view_as is read-only; act_as is full, with mutations attributed to both the real actor and the impersonated principal | Example: ```sh omniglass principal impersonate --mode mode ``` ### `omniglass principal list` List principals ``` omniglass principal list [flags] ``` Lists all principals (humans and service accounts) with their grants. Gated by principal:read:admin. | Flag | Type | Default | Description | |---|---|---|---| | `--include-archived` | bool | `false` | Include archived (soft-deleted) principals, hidden by default | | `--kind` | string | (none) | Optionally filter by principal kind | Example: ```sh omniglass principal list ``` ### `omniglass principal purge` Purge a principal ``` omniglass principal purge ``` Hard-deletes an archived principal and its owned rows (profile, credentials, grants, memberships); the audit trail is preserved. Irreversible. Gated by principal:purge:admin (admin-sensitive, all-scope), and the principal must be archived first. Example: ```sh omniglass principal purge ``` ### `omniglass principal removeAvatar` Remove a principal's profile picture ``` omniglass principal removeAvatar ``` Clears another human principal's profile picture. Gated by principal:set-avatar (all-scope). Removing an absent picture is a no-op. Audited with the administrator as the actor. Example: ```sh omniglass principal removeAvatar ``` ### `omniglass principal resetPassword` Reset a principal's password ``` omniglass principal resetPassword [flags] ``` Sets a new password for another human principal (an administrator action; the target's current password is not required). Gated by principal:reset-password (all-scope). The new password must meet the password policy; a violation is a 422. Refused on yourself (change your own password from your profile, which verifies your current one), on an owner (owners cannot be reset by anyone), or when it would exceed the caller's own capabilities (the takeover guard, shared with impersonation). The action is audited with the administrator as the actor. | Flag | Type | Default | Description | |---|---|---|---| | `--password` | string | (none) | The new password (at least 12 characters, not a common password, not containing the username) | Example: ```sh omniglass principal resetPassword --password password ``` ### `omniglass principal restore` Restore a principal ``` omniglass principal restore ``` Reverses an archive: the account is restored to active and can authenticate again. Gated by principal:archive (all-scope). Example: ```sh omniglass principal restore ``` ### `omniglass principal session` Commands for the session resource #### `omniglass principal session list` List a principal's sessions ``` omniglass principal session list ``` Lists another principal's active bearer credentials (login sessions and API tokens) with their non-secret metadata, newest first, so an administrator can see where an account is signed in and revoke a session that should not be. Gated by principal:revoke-session (all-scope). The token secret is never returned, and current is always false (there is no "this request's own session" when viewing another principal). Example: ```sh omniglass principal session list ``` #### `omniglass principal session revoke` Revoke a principal's session ``` omniglass principal session revoke ``` Revokes one of another principal's sessions or tokens by id (an administrator action; the target is immediately signed out of that credential). Gated by principal:revoke-session (all-scope). Bounded to the target, so a credential id that is not theirs is a non-disclosing 404, never a cross-principal revoke. Refused (403) on an owner (an owner's sessions cannot be revoked by anyone, the takeover guard shared with impersonation and password reset) or when it would exceed the caller's own capabilities. Audited with the administrator as the actor. Example: ```sh omniglass principal session revoke ``` #### `omniglass principal session revokeAll` Revoke all of a principal's sessions or tokens ``` omniglass principal session revokeAll [flags] ``` Revokes every one of another principal's web-login sessions, or every one of its CLI/API tokens (chosen by purpose), in a single administrator action, returning how many were ended. Gated by principal:revoke-session (all-scope). Bounded to the target and never crosses purpose (revoking sessions leaves tokens, and vice versa). Refused (403) on an owner (the takeover guard shared with impersonation and the password reset) or when it would exceed the caller's own capabilities. Audited with the administrator as the actor. | Flag | Type | Default | Description | |---|---|---|---| | `--purpose` | string | (none) | Which credentials to revoke: all of the principal's web-login sessions, or all its CLI/API tokens | Example: ```sh omniglass principal session revokeAll --purpose purpose ``` ### `omniglass principal setAvatar` Set a principal's profile picture ``` omniglass principal setAvatar [flags] ``` Sets another human principal's profile picture (an administrator action). Gated by principal:set-avatar (all-scope). The image (JPEG, PNG, or WebP, base64-encoded) is normalized server-side to a 256x256 JPEG; a bad or oversize image is a 422. Audited with the administrator as the actor. | Flag | Type | Default | Description | |---|---|---|---| | `--image-base64` | string | (none) | The image (JPEG, PNG, or WebP), base64-encoded; normalized server-side to a 256x256 JPEG | Example: ```sh omniglass principal setAvatar --image-base64 image_base64 ``` ### `omniglass principal update` Update a principal ``` omniglass principal update [flags] ``` Updates a human principal's display name, email, and username. Gated by principal:update (all-scope). Renaming is safe: nothing keys on the username. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | Display name; empty clears it | | `--email` | string | (none) | Email; empty clears it | | `--username` | string | (none) | Sign-in name (lowercase letters, digits, and . _ -); renaming is safe | Example: ```sh omniglass principal update ``` ## `omniglass principal-group` Commands for the principal-group resource ### `omniglass principal-group create` Create a principal group ``` omniglass principal-group create [flags] ``` Creates a principal group. Gated by principal_group:create (all-scope). A duplicate name is 409. | Flag | Type | Default | Description | |---|---|---|---| | `--description` | string | (none) | Free-form notes on what the group is for | | `--display-name` | string | (none) | What an operator reads in lists | | `--name` | string | (none) | Unique group name (lowercase letters, digits, and . _ -) | Example: ```sh omniglass principal-group create --name name ``` ### `omniglass principal-group delete` Delete a principal group ``` omniglass principal-group delete ``` Removes a group and, by cascade, its memberships and grants. Gated by principal_group:delete (all-scope). Example: ```sh omniglass principal-group delete ``` ### `omniglass principal-group get` Get a principal group ``` omniglass principal-group get ``` One principal group by id. Gated by principal_group:read:admin. Example: ```sh omniglass principal-group get ``` ### `omniglass principal-group grant` Commands for the grant resource #### `omniglass principal-group grant create` Grant a role to a group ``` omniglass principal-group grant create [flags] ``` Assigns a role at a scope to a group; its members inherit it. Gated by principal_grant:create (all-scope). Refused (403) when the granted role's capabilities exceed the granter's own, exactly as for a direct grant. A duplicate is 409. | Flag | Type | Default | Description | |---|---|---|---| | `--role` | string | (none) | A role id (viewer, operator, admin, owner, or a custom role) | | `--scope-id` | string | (none) | The scope root id; omit for the all scope | | `--scope-kind` | string | (none) | The scope kind; 'all' confers the whole estate (group-as-scope is unbuilt and not offered) | | `--scope-op` | string | (none) | How the scope root matches the tree; moot for the all scope | Example: ```sh omniglass principal-group grant create --role role --scope-kind scope_kind ``` #### `omniglass principal-group grant delete` Revoke a group grant ``` omniglass principal-group grant delete ``` Removes one grant from a group. Gated by principal_grant:delete (all-scope). Example: ```sh omniglass principal-group grant delete ``` #### `omniglass principal-group grant list` List a group's grants ``` omniglass principal-group grant list ``` The role x scope grants a group confers on its members. Gated by principal_group:read:admin. Example: ```sh omniglass principal-group grant list ``` ### `omniglass principal-group list` List principal groups ``` omniglass principal-group list ``` Every principal group. Gated by principal_group:read:admin. Example: ```sh omniglass principal-group list ``` ### `omniglass principal-group member` Commands for the member resource #### `omniglass principal-group member create` Add a member to a group ``` omniglass principal-group member create [flags] ``` Adds a principal to a group; its members inherit the group's grants. Gated by principal_group:update (all-scope). Idempotent. | Flag | Type | Default | Description | |---|---|---|---| | `--principal-id` | string | (none) | The principal to add to the group | Example: ```sh omniglass principal-group member create --principal-id principal_id ``` #### `omniglass principal-group member delete` Remove a member from a group ``` omniglass principal-group member delete ``` Removes a principal from a group; it stops inheriting the group's grants. Gated by principal_group:update (all-scope). Example: ```sh omniglass principal-group member delete ``` #### `omniglass principal-group member list` List a group's members ``` omniglass principal-group member list ``` The principals in a group. Gated by principal_group:read:admin. Example: ```sh omniglass principal-group member list ``` ### `omniglass principal-group update` Update a principal group ``` omniglass principal-group update [flags] ``` Updates a group's name and presentational fields. Gated by principal_group:update (all-scope). A duplicate name is 409. | Flag | Type | Default | Description | |---|---|---|---| | `--description` | string | (none) | Description; empty clears it | | `--display-name` | string | (none) | Display name; empty clears it | | `--name` | string | (none) | Group name (lowercase letters, digits, and . _ -); renaming is safe | Example: ```sh omniglass principal-group update ``` ## `omniglass product` Commands for the product resource ### `omniglass product create` Create a product ``` omniglass product create [flags] ``` Creates a custom (non-official) product and sets its capabilities. Gated by product:create. | Flag | Type | Default | Description | |---|---|---|---| | `--capabilities` | string | (none) | Capability names the product provides (the default set its components inherit) | | `--display-name` | string | (none) | What an operator reads in pickers and lists | | `--driver-id` | string | (none) | The driver that talks to it, by handle or uuid | | `--kind` | string | (none) | What class of thing the product is | | `--name` | string | (none) | The globally unique kebab handle; renameable | | `--parent-product-id` | string | (none) | The parent product, by handle or uuid | | `--vendor-id` | string | (none) | The vendor, by handle or uuid | Example: ```sh omniglass product create --display-name display_name --name name ``` ### `omniglass product delete` Delete a product ``` omniglass product delete ``` Deletes a custom product, refused if official (422) or still referenced by a component (409). Gated by product:delete. Example: ```sh omniglass product delete ``` ### `omniglass product get` Get a product ``` omniglass product get ``` Fetches a product by id, with its capabilities. Gated by product:read. Example: ```sh omniglass product get ``` ### `omniglass product list` List products ``` omniglass product list ``` Lists the product registry, ordered alphabetically by display name. Each product carries its vendor, driver, kind, and capabilities. Gated by product:read. Example: ```sh omniglass product list ``` ### `omniglass product property` Commands for the property resource #### `omniglass product property delete` Withdraw a property from a product ``` omniglass product property delete ``` Removes one line from a custom product's contract; instances keep any value they set for it, now off-contract. A property the product does not declare is a 404, and an official product is read-only (422). Gated by product:delete. Example: ```sh omniglass product property delete ``` #### `omniglass product property list` List a product's declared properties ``` omniglass product property list ``` Lists the product's declared-property contract (what every instance of the product exposes), ordered by property name, each with its optional default and required flag. Gated by product:read. Example: ```sh omniglass product property list ``` #### `omniglass product property update` Declare a property on a product ``` omniglass product property update [flags] ``` Declares a catalog property on a custom product, or revises the declaration in place (the line is addressed by name, so the write is idempotent). Official products are read-only (422), and an unknown product or property is a 422. Gated by product:update. | Flag | Type | Default | Description | |---|---|---|---| | `--default-value` | string | (none) | The contract default, validated against the property's data_type; omit for no default | | `--required` | string | (none) | Whether every instance of this product must set the property; defaults to false | Example: ```sh omniglass product property update ``` ### `omniglass product update` Update a product ``` omniglass product update [flags] ``` Patches a custom product's display_name, vendor, driver, kind, or parent, and replaces its capabilities when provided. Official products are read-only (422). Gated by product:update. | Flag | Type | Default | Description | |---|---|---|---| | `--capabilities` | string | (none) | Replaces the capability-name set; omit to leave unchanged | | `--display-name` | string | (none) | A new operator-facing label | | `--driver-id` | string | (none) | A new driver, by handle or uuid | | `--kind` | string | (none) | A new product class | | `--parent-product-id` | string | (none) | A new parent product, by handle or uuid | | `--vendor-id` | string | (none) | A new vendor, by handle or uuid | Example: ```sh omniglass product update ``` ## `omniglass property-type` Commands for the property-type resource ### `omniglass property-type create` Create a property ``` omniglass property-type create [flags] ``` Registers a custom property (official=false). The name must be a valid property key. Gated by property_type:create. | Flag | Type | Default | Description | |---|---|---|---| | `--data-type` | string | (none) | The value type | | `--description` | string | (none) | What the property means | | `--display-name` | string | (none) | A human label | | `--kind` | string | (none) | The observed kind; omit for a declared-only property | | `--name` | string | (none) | The property name (lowercase, dot-hierarchied) | | `--unit` | string | (none) | A display unit (observed properties) | | `--validation` | string | (none) | A JSON Schema fragment constraining the value | Example: ```sh omniglass property-type create --data-type data_type --name name ``` ### `omniglass property-type delete` Delete a property ``` omniglass property-type delete ``` Removes a custom property by name. Official properties are read-only. Gated by property_type:delete. Example: ```sh omniglass property-type delete ``` ### `omniglass property-type get` Get a property ``` omniglass property-type get ``` Returns one property by name. Gated by property_type:read. Example: ```sh omniglass property-type get ``` ### `omniglass property-type list` List properties ``` omniglass property-type list ``` Lists every registered property (official and custom). The catalog is estate-wide reference data. Gated by property_type:read. Example: ```sh omniglass property-type list ``` ### `omniglass property-type update` Update a property ``` omniglass property-type update [flags] ``` Patches a custom property's label, description, unit, or validation (a nil field is unchanged). Data type and kind are fixed at creation. Official properties are read-only. Gated by property_type:update. | Flag | Type | Default | Description | |---|---|---|---| | `--description` | string | (none) | What the property means | | `--display-name` | string | (none) | A human label | | `--unit` | string | (none) | A display unit | | `--validation` | string | (none) | A JSON Schema fragment (replaces wholesale) | Example: ```sh omniglass property-type update ``` ## `omniglass role` Commands for the role resource ### `omniglass role list` List roles ``` omniglass role list ``` Lists the roles with their metadata and effective (flattened) permissions. Gated by the role:read:admin capability. Example: ```sh omniglass role list ``` ## `omniglass secret` Commands for the secret resource ### `omniglass secret copy` Decrypt a secret for clipboard copy ``` omniglass secret copy ``` Decrypts and returns a secret's field values for a clipboard copy, audited under the copy verb (distinct from an on-screen reveal). Same exposure and the same secret:reveal gate as reveal. Example: ```sh omniglass secret copy ``` ### `omniglass secret create` Create a secret ``` omniglass secret create [flags] ``` Seals a secret at an owner scope. Fields are validated and encrypted against the type shape. Gated by secret:create, plus platform:create when owner_kind is platform (the install-wide tier). | Flag | Type | Default | Description | |---|---|---|---| | `--admin-sensitive` | string | (none) | Admin-only visibility; omit to use the type default. Setting true requires the admin tier | | `--fields` | string | (none) | The operator field map, validated against the type shape | | `--name` | string | (none) | The cascade key; unique per owner | | `--owner` | string | (none) | The owning entity's name; omit for a platform secret | | `--owner-kind` | string | (none) | Which tier owns this secret (the system band is retired, ADR-0052) | | `--secret-type` | string | (none) | A secret_type id | Example: ```sh omniglass secret create --fields fields --name name --owner-kind owner_kind --secret-type secret_type ``` ### `omniglass secret delete` Delete a secret ``` omniglass secret delete ``` Removes a secret by id. Gated by secret:delete, plus platform:delete when the secret sits at the platform tier; read and delete scopes on the owner drive the 404 versus 403 split. Example: ```sh omniglass secret delete ``` ### `omniglass secret list` List secrets ``` omniglass secret list ``` Lists the secrets the caller may see, with masked fields, filtered to the read scope; admin-sensitive secrets appear only to the admin tier. Gated by secret:read, which the viewer floor does not carry (secret is a sensitive resource). Example: ```sh omniglass secret list ``` ### `omniglass secret reveal` Reveal a secret's plaintext ``` omniglass secret reveal ``` Decrypts and returns a secret's field values, auditing the decrypt. Gated by secret:reveal at the caller's scope; an admin-sensitive secret additionally needs the admin tier (secret:reveal:admin), so a scoped operator reveals device secrets but never a platform credential. Example: ```sh omniglass secret reveal ``` ### `omniglass secret update` Update a secret's field values ``` omniglass secret update [flags] ``` Replaces the given field values on a secret, re-sealing secret fields. Only values change; name, type, and owner are fixed at creation. An omitted field keeps its value. Gated by secret:update, plus platform:update when the secret sits at the platform tier. | Flag | Type | Default | Description | |---|---|---|---| | `--fields` | string | (none) | The field values to replace; an omitted field keeps its value | Example: ```sh omniglass secret update --fields fields ``` ## `omniglass secret-type` Commands for the secret-type resource ### `omniglass secret-type list` List secret types ``` omniglass secret-type list ``` Lists the secret_type shapes a secret can take, for the create form. Gated by secret:read. Example: ```sh omniglass secret-type list ``` ## `omniglass seed-dev` Seed a dev database with example locations, users, and grants (idempotent; never for production) ``` omniglass seed-dev ``` Populate a fresh dev database with a small example estate so `make dev` comes up with locations, sign-in-able users, and their grants instead of empty. The same trusted direct-DB lane as bootstrap, and idempotent, so it runs on every `make dev`. Not for production: these are operator rows, not ship-with reference data. ## `omniglass server` Run the control-plane server (HTTP API) ``` omniglass server ``` ## `omniglass session` Commands for the session resource ### `omniglass session list` List your own sessions and tokens ``` omniglass session list ``` Lists the caller's own active bearer credentials (time-bounded web-login sessions and CLI/API tokens) with their non-secret metadata; the current one is flagged. Requires authentication; self-scoped (never another principal's). The token secret is never returned. Example: ```sh omniglass session list ``` ### `omniglass session revoke` Revoke one of your own sessions ``` omniglass session revoke ``` Revokes one of the caller's own sessions or tokens by id (from the session list); revoking the current one signs it out. Requires authentication; self-scoped, so a credential id that is not yours is a 404. Example: ```sh omniglass session revoke ``` ### `omniglass session revoke-all` Revoke all of your own sessions or tokens ``` omniglass session revoke-all [flags] ``` Revokes every one of the caller's own web-login sessions, or every one of its CLI/API tokens (chosen by purpose), returning how many were ended. Requires authentication; self-scoped. Always keeps the credential that made this request, so you are never signed out of the one you are on; sessions and tokens never cross. | Flag | Type | Default | Description | |---|---|---|---| | `--purpose` | string | (none) | Which of your own credentials to revoke: all your web-login sessions, or all your CLI/API tokens | Example: ```sh omniglass session revoke-all --purpose purpose ``` ## `omniglass set-password` Set or rotate a user's console password, revoking their sessions (direct DB) ``` omniglass set-password [flags] ``` Installs or replaces a human's password credential (argon2id), addressed by username, and revokes the user's live SESSIONS so a break-glass reset locks out any stolen login at once. API tokens are a separate bearer secret, not tied to the password, and are kept unless --revoke-tokens is given (a full lockout of a compromised account). The same trusted direct-DB lane as bootstrap and token: dev setup, break-glass, or a password reset before the admin UI lands. | Flag | Type | Default | Description | |---|---|---|---| | `--revoke-tokens` | bool | `false` | also revoke the user's API tokens (a full lockout of a compromised account) | ## `omniglass setting` Commands for the setting resource ### `omniglass setting delete` Restore a settings namespace to defaults ``` omniglass setting delete ``` Drops the namespace's platform override, restoring the file layer and the declared defaults. Gated by settings:update and platform:update. Example: ```sh omniglass setting delete ``` ### `omniglass setting list` Get effective settings with provenance ``` omniglass setting list ``` The effective settings document plus per-key provenance (which level won) and lock state. Gated by settings:read (admin). Example: ```sh omniglass setting list ``` ### `omniglass setting me` Commands for the me resource #### `omniglass setting me list` Get the caller's effective settings ``` omniglass setting me list ``` The current principal's resolved settings, client-visible namespaces only, no provenance. Feeds the SPA at boot. Requires authentication. Example: ```sh omniglass setting me list ``` ### `omniglass setting restoreDefaults` Restore all settings to defaults ``` omniglass setting restoreDefaults ``` Removes every platform override (a factory reset). Gated by settings:update and platform:update. Example: ```sh omniglass setting restoreDefaults ``` ### `omniglass setting update` Update a settings namespace ``` omniglass setting update ``` Applies an RFC 7386 JSON Merge Patch to the namespace's platform override; null on a key restores it. Gated by settings:update and platform:update. Example: ```sh omniglass setting update ``` ## `omniglass standard` Commands for the standard resource ### `omniglass standard create` Create a standard ``` omniglass standard create [flags] ``` Creates a custom (non-official) standard, optionally as a variant of another. Gated by standard:create. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | What an operator reads in pickers and lists | | `--name` | string | (none) | The globally unique kebab handle; renameable | | `--parent-standard-id` | string | (none) | A standard this one is a variant of, by handle or uuid | Example: ```sh omniglass standard create --display-name display_name --name name ``` ### `omniglass standard delete` Delete a standard ``` omniglass standard delete ``` Deletes a custom standard, refused if official (422) or still referenced by a system (409). Gated by standard:delete. Example: ```sh omniglass standard delete ``` ### `omniglass standard get` Get a standard ``` omniglass standard get ``` Fetches a standard by id. Gated by standard:read. Example: ```sh omniglass standard get ``` ### `omniglass standard list` List standards ``` omniglass standard list ``` Lists the standard catalog, ordered alphabetically by display name. A standard is the blueprint a system conforms to. Gated by standard:read. Example: ```sh omniglass standard list ``` ### `omniglass standard property` Commands for the property resource #### `omniglass standard property delete` Withdraw a property from a standard ``` omniglass standard property delete ``` Removes one line from a custom standard's contract; conforming systems keep any value they set for it, now off-contract. A property the standard does not declare is a 404, and an official standard is read-only (422). Gated by standard:delete. Example: ```sh omniglass standard property delete ``` #### `omniglass standard property list` List a standard's declared properties ``` omniglass standard property list ``` Lists the standard's declared-property contract (what every system conforming to it exposes), ordered by property name, each with its optional default and required flag. Gated by standard:read. Example: ```sh omniglass standard property list ``` #### `omniglass standard property update` Declare a property on a standard ``` omniglass standard property update [flags] ``` Declares a catalog property on a custom standard, or revises the declaration in place (the line is addressed by name, so the write is idempotent). Official standards are read-only (422); an unknown standard is a 404 and a property the catalog does not know is a 422. Gated by standard:update. | Flag | Type | Default | Description | |---|---|---|---| | `--default-value` | string | (none) | The contract default, validated against the property's data_type; omit for no default | | `--required` | string | (none) | Whether every system conforming to this standard must set the property; defaults to false | Example: ```sh omniglass standard property update ``` ### `omniglass standard role` Commands for the role resource #### `omniglass standard role delete` Withdraw a role from a standard ``` omniglass standard role delete ``` Removes the role from the standard, and with it every assignment conforming systems made to it. A role the standard does not declare is a 404. Gated by standard:delete. Example: ```sh omniglass standard role delete ``` #### `omniglass standard role list` List a standard's declared roles ``` omniglass standard role list ``` Lists the roles this standard declares (every conforming system inherits them live), ordered by name, each with its quorum and the capabilities a component must provide to fill it. Gated by standard:read. Example: ```sh omniglass standard role list ``` #### `omniglass standard role update` Declare a role on a standard ``` omniglass standard role update [flags] ``` Declares a role every conforming system needs filled, or revises it in place (the role is addressed by name, so the write is idempotent). The capability list replaces the required set wholesale. An unknown standard or capability is a 422. Gated by standard:update. | Flag | Type | Default | Description | |---|---|---|---| | `--capabilities` | string | (none) | The capabilities a component must ALL provide; replaces the required set wholesale | | `--display-name` | string | (none) | The role's human label; defaults to the role name | | `--impact` | string | (none) | What an impaired role means for its system; omit for degraded. The same broken component matters differently depending on the slot it was filling: a dead confidence monitor is not a dead main display | | `--quorum` | string | (none) | How many components must fill the role; omit for one | Example: ```sh omniglass standard role update ``` ### `omniglass standard update` Update a standard ``` omniglass standard update [flags] ``` Patches a custom standard's display_name or parent. Official standards are read-only (422). Gated by standard:update. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | A new operator-facing label | | `--parent-standard-id` | string | (none) | A new variant parent, by handle or uuid | Example: ```sh omniglass standard update ``` ## `omniglass system` Commands for the system resource ### `omniglass system checkName` Check a system technical name ``` omniglass system checkName [flags] ``` Reports whether a proposed technical name is a valid slug and currently free. Advisory (Save is still gated by the unique constraint). Availability is scope-blind to match the global unique constraint. Gated by system:update. | Flag | Type | Default | Description | |---|---|---|---| | `--name` | string | (none) | The proposed technical name to check | Example: ```sh omniglass system checkName --name name ``` ### `omniglass system create` Create a system ``` omniglass system create [flags] ``` Creates a system, optionally under a parent (a root needs an all-scoped grant) and at a location. Gated by system:create. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | What an operator reads; the technical name is the address | | `--location` | string | (none) | Location name this system is placed at | | `--name` | string | (none) | Globally unique name (the address; lowercase letters, digits, hyphens) | | `--parent` | string | (none) | Parent system name; omit for a root system | | `--standard-id` | string | (none) | The standard it conforms to, by handle or uuid; omit for a one-off system | Example: ```sh omniglass system create --name name ``` ### `omniglass system delete` Delete a system ``` omniglass system delete ``` Deletes a system, refused (409) while it still has child systems or is still referenced elsewhere. Gated by system:delete; read and delete scopes drive the 404 versus 403 split. Example: ```sh omniglass system delete ``` ### `omniglass system get` Get a system ``` omniglass system get ``` Fetches a system by name within the caller's read scope. Out of scope is a non-disclosing 404. Gated by system:read. Example: ```sh omniglass system get ``` ### `omniglass system health` Commands for the health resource #### `omniglass system health list` Read a system's health ``` omniglass system health list ``` The system's current verdict and why: every role it needs filled, whether it is impaired, what an impaired role means for the system (impact), and for an impaired role the required capabilities an alarm has taken away plus the alarms that took them. Transitions are the recorded edges over the last 30 days, one entry per change. Gated by system:read; an out-of-scope system is a non-disclosing 404. Example: ```sh omniglass system health list ``` ### `omniglass system list` List systems in scope ``` omniglass system list ``` Lists the systems the caller may read, each filtered to its scope subtree. Gated by system:read. Example: ```sh omniglass system list ``` ### `omniglass system listTags` List tags on a system ``` omniglass system listTags ``` Lists the tags bound directly on a system (not the resolved cascade). Gated by system:read. Example: ```sh omniglass system listTags ``` ### `omniglass system member` Commands for the member resource #### `omniglass system member delete` Take a component out of a system ``` omniglass system member delete ``` Unbinds this component from the system. Refused with a 409 while it still fills a role here, since removing it would leave the system staffed by a non-member: unassign the role first. A component that was not a member is a 404. Gated by system:update; an out-of-scope system is a non-disclosing 404. Example: ```sh omniglass system member delete ``` #### `omniglass system member list` List the components in a system ``` omniglass system member list ``` The components bound into this system, ordered by name. Membership is what a role attaches to: every component staffing a role here is a member, and a member may also carry no role at all (a power conditioner is in the room without filling a declared slot). Gated by system:read; an out-of-scope system is a non-disclosing 404. Example: ```sh omniglass system member list ``` #### `omniglass system member setPrimary` Make this the component's default system ``` omniglass system member setPrimary ``` Moves the component's default to this membership. The default answers questions asked without a system in hand; it does not decide anything that names a system explicitly. A component that was not a member here is a 404. Gated by system:update; an out-of-scope system is a non-disclosing 404. Example: ```sh omniglass system member setPrimary ``` #### `omniglass system member update` Put a component in a system ``` omniglass system member update ``` Binds this component into the system. Idempotent. A component's first membership becomes its primary with nobody asking, so a component in exactly one system never has to think about the concept; a later membership does not take that default away. Gated by system:update; an out-of-scope system is a non-disclosing 404. Example: ```sh omniglass system member update ``` ### `omniglass system property` Commands for the property resource #### `omniglass system property delete` Clear a property on a system ``` omniglass system property delete ``` Removes the system's declared value, so the property falls back to the standard contract's default (or leaves the effective read entirely when it was off-contract). Clearing a property the system never set is a 404. Gated by system:update; an out-of-scope system is a non-disclosing 404. Example: ```sh omniglass system property delete ``` #### `omniglass system property list` List a system's effective properties ``` omniglass system property list ``` Every property the system's standard declares, resolved to the system's own value or the contract default (is_set marks the override), plus any property set directly on the system (from_contract false). Gated by system:read; an out-of-scope system is a non-disclosing 404. Example: ```sh omniglass system property list ``` #### `omniglass system property update` Set a property on a system ``` omniglass system property update [flags] ``` Declares a value for the property on this system, overriding the standard contract's default. 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). Gated by system:update; an out-of-scope system is a non-disclosing 404. | Flag | Type | Default | Description | |---|---|---|---| | `--value` | string | (none) | The value to declare, shape given by the property's data_type | Example: ```sh omniglass system property update --value value ``` ### `omniglass system removeTag` Remove a tag value from a system ``` omniglass system removeTag [flags] ``` Removes a key's value from a system. Gated by system:update. | Flag | Type | Default | Description | |---|---|---|---| | `--key` | string | (none) | The tag key to remove | Example: ```sh omniglass system removeTag --key key ``` ### `omniglass system role` Commands for the role resource #### `omniglass system role assignment` Commands for the assignment resource ##### `omniglass system role assignment delete` Unassign a component from a role ``` omniglass system role assignment delete ``` Takes this component out of the role, leaving the role understaffed until another fills it. A component that was not filling the role is a 404. Gated by system:update; an out-of-scope system is a non-disclosing 404. Example: ```sh omniglass system role assignment delete ``` ##### `omniglass system role assignment update` Assign a component to a role ``` omniglass system role assignment update ``` Puts this component in the role for this system. Refused with a 422 naming the missing capabilities when the component does not provide everything the role requires (its product's capabilities, plus what it adds, minus what it suppresses). Idempotent. Gated by system:update; an out-of-scope system is a non-disclosing 404. Example: ```sh omniglass system role assignment update ``` #### `omniglass system role delete` Withdraw a role from a system ``` omniglass system role delete ``` Removes a role declared on this system, and with it every assignment to it. A role the system does not declare itself is a 404 (a role inherited from its standard is withdrawn on the standard, not here). Gated by system:update; an out-of-scope system is a non-disclosing 404. Example: ```sh omniglass system role delete ``` #### `omniglass system role list` List a system's effective roles ``` omniglass system role list ``` Every role this system needs filled: those its standard declares (from_standard true) plus those declared directly on it, each with the capabilities it requires, the components filling it, and how many more it wants before quorum (understaffed). A one-off system shows only its own. Gated by system:read; an out-of-scope system is a non-disclosing 404. Example: ```sh omniglass system role list ``` #### `omniglass system role update` Declare a role on a system ``` omniglass system role update [flags] ``` Declares a role directly on this system (how a one-off system gets roles at all, and how a conforming one adds what its standard does not cover), or revises it in place. The capability list replaces the required set wholesale. Gated by system:update; an out-of-scope system is a non-disclosing 404. | Flag | Type | Default | Description | |---|---|---|---| | `--capabilities` | string | (none) | The capabilities a component must ALL provide; replaces the required set wholesale | | `--display-name` | string | (none) | The role's human label; defaults to the role name | | `--impact` | string | (none) | What an impaired role means for its system; omit for degraded. The same broken component matters differently depending on the slot it was filling: a dead confidence monitor is not a dead main display | | `--quorum` | string | (none) | How many components must fill the role; omit for one | Example: ```sh omniglass system role update ``` ### `omniglass system setTag` Set a tag value on a system ``` omniglass system setTag [flags] ``` Binds a value for a key on a system. The key must exist and apply to this entity kind. Setting a value is the ordinary entity write, gated by system:update. | Flag | Type | Default | Description | |---|---|---|---| | `--key` | string | (none) | The tag key (must exist and apply to this kind) | | `--value` | string | (none) | The bound value | Example: ```sh omniglass system setTag --key key --value value ``` ### `omniglass system update` Update a system ``` omniglass system update [flags] ``` Patches a system's display_name, standard, location, or parent. The classification and placement fields follow the three-state convention: an omitted field is unchanged, an explicit empty string clears (a one-off, an unplaced system, a root system), a name sets. A reparent is cycle-guarded and scope-injected. Gated by system:update; read and update scopes drive the 404 versus 403 split. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | A new operator-facing label | | `--location` | string | (none) | Relocates the system to this location name. An empty string clears its placement. | | `--name` | string | (none) | A new globally unique technical name (rename) | | `--parent` | string | (none) | Re-parents the system within the system tree to this system name; cycle-guarded and scope-injected. An empty string makes it a root system. | | `--standard-id` | string | (none) | A new standard, by handle or uuid; "" clears it (a one-off system) | Example: ```sh omniglass system update ``` ## `omniglass tag` Commands for the tag resource ### `omniglass tag clearPlatform` Clear a platform tag value ``` omniglass tag clearPlatform ``` Removes the platform-tier binding for a key. Gated by tag:update (all-scope) and platform:update. Example: ```sh omniglass tag clearPlatform ``` ### `omniglass tag create` Mint a tag key ``` omniglass tag create [flags] ``` Adds a key to the governed vocabulary. The name is normalized (a lowercase identifier). Gated by tag:create (all-scope, an admin action). | Flag | Type | Default | Description | |---|---|---|---| | `--allowed-values` | string | (none) | The value enum a bound value must belong to; omit for free text | | `--applies-to` | string | (none) | Entity kinds this key may bind to (component, system, location); omit for universal | | `--name` | string | (none) | The normalized key: a lowercase identifier, unique tenant-wide | | `--propagates` | string | (none) | Whether bindings cascade to descendants; defaults true | Example: ```sh omniglass tag create --name name ``` ### `omniglass tag delete` Delete a tag key ``` omniglass tag delete ``` Removes a key from the vocabulary, cascading its bindings. Gated by tag:delete (all-scope). Example: ```sh omniglass tag delete ``` ### `omniglass tag list` List tag keys ``` omniglass tag list ``` Lists the governed key vocabulary. Rides the tag:read floor. Example: ```sh omniglass tag list ``` ### `omniglass tag setPlatform` Set a platform tag value ``` omniglass tag setPlatform [flags] ``` Binds an install-wide default value for a key at the platform tier. Gated by tag:update (all-scope) and platform:update. | Flag | Type | Default | Description | |---|---|---|---| | `--value` | string | (none) | The bound value | Example: ```sh omniglass tag setPlatform --value value ``` ### `omniglass tag update` Update a tag key ``` omniglass tag update [flags] ``` Replaces a key's governance fields (applies_to, propagates); the name is fixed. Gated by tag:update (all-scope). | Flag | Type | Default | Description | |---|---|---|---| | `--allowed-values` | string | (none) | The value enum a bound value must belong to; omit for free text | | `--applies-to` | string | (none) | Entity kinds this key may bind to; omit for universal | | `--propagates` | string | (none) | Whether bindings cascade to descendants; defaults true | Example: ```sh omniglass tag update ``` ### `omniglass tag values` List the distinct values bound for a key ``` omniglass tag values ``` Returns the distinct values already bound for a key across the estate, for value autocomplete on a free-text key (an enum key carries its allowed set on the key itself). Rides the tag:read floor. Example: ```sh omniglass tag values ``` ## `omniglass task` Commands for the task resource ### `omniglass task get` Get a task ``` omniglass task get ``` Fetches a task by id. A task whose component is out of the caller's read scope is a non-disclosing 404. Gated by task:read. Example: ```sh omniglass task get ``` ### `omniglass task list` List tasks in scope ``` omniglass task list ``` Lists the tasks whose interface's owning component the caller may read (the component cascade). Tasks are derived from interfaces, not authored. Gated by task:read. Example: ```sh omniglass task list ``` ## `omniglass telemetry` Commands for the telemetry resource ### `omniglass telemetry push` Push telemetry for an owner ``` omniglass telemetry push [flags] ``` Accepts samples and raw log lines for one owner and publishes them onto the ingest lane. The registry decides where each sample lands (metric, state, or a caught event); an unregistered name is rejected and reported in the response rather than silently dropped. Gated by telemetry:push, and the caller's scope must cover the declared owner; an out-of-scope owner is a non-disclosing 404. | Flag | Type | Default | Description | |---|---|---|---| | `--logs` | string | (none) | Raw untyped log lines. No registry gate | | `--owner` | string | (none) | The entity every row in the batch lands under | | `--samples` | string | (none) | Registry-resolved observations. The registry decides which table each lands in | | `--source` | string | (none) | Who observed this batch (recorded as the provenance source on every row) | | `--ts` | string | (none) | Batch timestamp; a per-item timestamp overrides it | Example: ```sh omniglass telemetry push --owner owner ``` ## `omniglass token` Mint an additional bearer token for an existing principal (direct DB) ``` omniglass token [flags] ``` Issues a new bearer credential for an existing principal, addressed by username, and prints the token once. The same trusted direct-DB lane as bootstrap: token reissue, break-glass, or a fresh login token for `make dev` when the owner already exists. A --description (required) names what the token is for. The token expires after --ttl (default 90 days, hard maximum 365 days); every credential is time-bounded. | Flag | Type | Default | Description | |---|---|---|---| | `--description` | string | (none) | what the token is for (required) | | `--ttl` | duration | `2160h0m0s` | how long the token is valid before it expires (max 365 days) | ## `omniglass variable` Commands for the variable resource ### `omniglass variable create` Create a variable ``` omniglass variable create [flags] ``` Sets a variable at an owner scope. The value is validated against value_type. Gated by variable:create, plus platform:create when owner_kind is platform (the install-wide tier). | Flag | Type | Default | Description | |---|---|---|---| | `--name` | string | (none) | The cascade key; unique per owner | | `--owner` | string | (none) | The owning entity's name; omit for a platform variable | | `--owner-kind` | string | (none) | Which tier owns this variable | | `--value` | string | (none) | The value, validated against value_type | | `--value-type` | string | (none) | The declared value type | Example: ```sh omniglass variable create --name name --owner-kind owner_kind --value value --value-type value_type ``` ### `omniglass variable delete` Delete a variable ``` omniglass variable delete ``` Removes a variable by id. Gated by variable:delete, plus platform:delete when the variable sits at the platform tier; read and delete scopes on the owner drive the 404 versus 403 split. Example: ```sh omniglass variable delete ``` ### `omniglass variable list` List variables (admin directory) ``` omniglass variable list ``` Lists every variable. Requires an all-scope read. Gated by variable:read. Example: ```sh omniglass variable list ``` ### `omniglass variable update` Update a variable's value ``` omniglass variable update [flags] ``` Replaces a variable's value, validated against its fixed value_type. Only the value changes; name, type, and owner are fixed at creation. Gated by variable:update, plus platform:update when the variable sits at the platform tier. | Flag | Type | Default | Description | |---|---|---|---| | `--value` | string | (none) | The new value, validated against the fixed value_type | Example: ```sh omniglass variable update --value value ``` ## `omniglass vendor` Commands for the vendor resource ### `omniglass vendor create` Create a vendor ``` omniglass vendor create [flags] ``` Creates a custom (non-official) vendor. Gated by vendor:create. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | What an operator reads in pickers and lists | | `--icon` | string | (none) | A glyph key, e.g. crestron-logo | | `--kind` | string | (none) | The role the organization plays | | `--name` | string | (none) | The globally unique kebab handle; renameable | | `--support-phone` | string | (none) | The vendor's support line | | `--website` | string | (none) | The vendor's website (http or https) | Example: ```sh omniglass vendor create --display-name display_name --name name ``` ### `omniglass vendor delete` Delete a vendor ``` omniglass vendor delete ``` Deletes a custom vendor, refused if official (422). Gated by vendor:delete. Example: ```sh omniglass vendor delete ``` ### `omniglass vendor get` Get a vendor ``` omniglass vendor get ``` Fetches a vendor by id. Gated by vendor:read. Example: ```sh omniglass vendor get ``` ### `omniglass vendor list` List vendors ``` omniglass vendor list ``` Lists the vendor registry, ordered alphabetically by display name. Populates the vendor picker on the product form. Gated by vendor:read. Example: ```sh omniglass vendor list ``` ### `omniglass vendor update` Update a vendor ``` omniglass vendor update [flags] ``` Patches a custom vendor's display_name, kind, icon, support_phone, or website. Official vendors are read-only (422). Gated by vendor:update. | Flag | Type | Default | Description | |---|---|---|---| | `--display-name` | string | (none) | A new operator-facing label | | `--icon` | string | (none) | A new glyph key | | `--kind` | string | (none) | A new organization role | | `--support-phone` | string | (none) | A new support line | | `--website` | string | (none) | A new website (http or https) | Example: ```sh omniglass vendor update ```