API
PartialThe contract is two typed surfaces, one source of truth. The public HTTP / OpenAPI contract (this page) carries every operator action, integration, the SPA, the CLI, and the MCP server, and is the only caller of the Storage Gateway; the internal and edge transport is a sibling NATS subject contract, typed and versioned the same way (messaging). The doctrine and generation pipeline: API first; this page is the conventions every HTTP route honors.
Shape: resources and :verb methods
Section titled “Shape: resources and :verb methods”Everything lives under /api/v1. The path shape is derivable, not special-cased:
- Plural resource collections, standard methods addressed by the entity’s
namewhere it has one (the name-only forms are listed under every resource answers to its name) and by its uuid always (AIP-style):POSTcreates (409 when thenameis already taken in its placement bucket; the uuid primary key is the server’s to mint and cannot collide),GETreads,PATCHpartial-updates (AIP-134),DELETEremoves. No upsert shortcuts. Onlocation,system, andcomponent, the{ref}path parameter takes a third form beside uuid and bare name: a dotted address, a positional lookup resolved structurally before any scope or ambiguity check runs (ADR-0089). - Every name-bearing body carries both handles: a uuid
id(stable identity, the target every foreign key stores) and a unique, renameablename(the identifier an operator reads and types). A create takes thename; the uuid is the database’s to mint, and every write returns it, so a client stores the id and detects a rename by diffing the id it holds (ADR-0076). A path or a reference resolves whichever form it is given, since a kebab name can never look like a uuid. - A rename is a custom method, never a
PATCHfield.POST /components/{name}:rename(also/systems,/locations,/principal-groups/{group}) moves thename, gated by<entity>:renamerather than<entity>:update, and thePATCHbody of those four carries nonameat all. Renaming breaks every reference an operator stored outside the system (a bookmark, a runbook step, an integration’s config), so it is an act a grant can withhold on its own instead of a side effect of editing a label. - A move is a custom method too
(ADR-0088).
POST /components/{name}:moveand/systems/{name}:movetake{location?, parent?}(at least one required, 422 otherwise);/locations/{name}:movetakes{parent}only, with no clear-to-root branch (alocationhas never had one). Both follow the house three-state convention (omitted unchanged,""clears, a name sets), gated by<entity>:moverather than<entity>:update, and thePATCHbody of all three carries neitherparentnorlocationany more. Clearingparentto root under:moverequires an all-scoped grant, the same authorization creating a root row already requires: aPATCHused to skip that check entirely on the clear branch.:movewrites its own audit verb,move, and never recomputes health except where the rollup genuinely depends on the placement it changes: a system’s ownlocationfield, which keeps moving health at both ends exactly as its old combinedPATCHdid, and a location’sparent, which carries its whole subtree’s contribution between two ancestor chains and recomputes both (ADR-0092). - Custom methods carry a colon,
:verbnot/verb, for anything that is not CRUD:/components/{name}/commands:issue,/auth/me/sessions/{id}:revoke,/nodes:claim. The verb is also the permission::issueis gated bycommand:issue, so the route and the authorization check share one vocabulary. The self-scoped/auth/mefamily is the exception: authn-only, resolving the target from the session, never a path id, so it carries no capability and a credential id not the caller’s own is a 404; the admin counterparts on/principals/{id}do carry a capability and a scoped path id. - Each typed registry is its own plural collection:
/location-types,/metric-types,/property-types,/event-types(ADR-0060). - 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 }, gated by<entity>:update(the advisory reads a name’s availability; performing the rename needs<entity>:rename). Name uniqueness is scoped to placement, not the whole fleet (#627): the request carries the sameparent/locationfields a create would, and availability is checked against that specific placement bucket, not a single global fact. It stays blind to the caller’s own grant scope: a deploy principal scoped to one subtree still gets a correct answer for a placement it cannot read viaGET, because the check queries the target bucket directly rather than reading the row. - A principal is addressable by uuid, username, or service account name. Every
/principals/{id}route resolves any of them server-side (a value that parses as a uuid is used directly, else a username or a service account’s name, an unknown one a 404, and a word that is both a username and a service name a 404 listing both uuids). A name resolves only for a caller who may list the whole directory (principal:read:adminon an all-scope grant); anyone else gets the all-scope 403 for every name, so a name cannot be probed. The uuid stays the stable identity (a username is mutable, nothing keys on it).
Every operator resource answers to its name
Section titled “Every operator resource answers to its name”Every resource an operator types the name of accepts that name wherever its uuid is accepted, and every response still returns the uuid (ADR-1133). The forms, by what the name is unique within:
| Resource | Path parameter | Accepts beside the uuid | Example |
|---|---|---|---|
| location, system, component | {name} | a bare name, a dotted address | boi.17c.415a.$comp.display-1 |
| endpoint | {endpoint} | <component>/<endpoint name>, the component in any form above | boi.17c.415a.$comp.display-1/https |
| secret, variable | {secret}, {variable} | a bare name, or <owner>/<name>, the owner a location, system or component in any form above, or $platform | demo-panel-login, $platform/demo-panel-login |
| file | {file} | its filename | codec-firmware-2.1.4.txt |
| principal group | {group} | its name | night-shift |
| principal, group member | {id}, {principal} | a username, a service account’s name | alice |
| a grant’s scope root | scope_id (body) | a bare name, a dotted address | boi.17c |
The slash in an endpoint, secret or variable reference is one path segment, so a URL percent-encodes
it (/endpoints/boi.17c.415a.$comp.display-1%2Fhttps:retry); the generated CLI does that for you
(omniglass endpoint retry 'boi.17c.415a.$comp.display-1/https').
Scope decides before ambiguity does, for every form. A name’s candidates are narrowed to the rows
the caller may read first (for a secret, also to its admin tier), so a name an out-of-scope row shares
never makes an in-scope one ambiguous, and an absent row and an out-of-scope one give the same refusal.
A name that no visible row answers to, or that more than one does, is a 404 naming what was looked
up; when more than one answers, the 404 lists each in the spelling that does resolve
($platform/demo-panel-login, boi.17c/demo-panel-login). Resolution runs inside the request’s own
transaction and yields the uuid; the permission check, the read-then-action scope split, and the audit
row then run on that resolved row exactly as they do for a uuid. The tree entities keep their own
ambiguity refusal, a 409 listing candidates (#697; aligning the two is #1134). A write answers with
the row it reached (a secret’s or variable’s owner_kind, owner_id and owner_name), because a
name is a lookup: automation that must never drift addresses <owner>/<name> or stores the uuid.
What stays uuid-only, and why: an alarm, an audit row, a grant, a session or API token, and a task
have no name (a task’s id is its content hash); a component-less (server-hosted) endpoint’s name is not
unique, since only a component makes it so; a node principal on /principals is reached by its name on
/nodes instead.
Lists: filter, order, page
Section titled “Lists: filter, order, page”The built lists take a fixed query-param set (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: a list never returns a row
outside the caller’s visible set, and the page count is over visible rows only.
Partial responses: field masks
Section titled “Partial responses: field masks”Today the full resource is the only read behavior. The write mask is built, AIP-134 exactly (ADR-0091):
- Omit
update_maskand the mask is implied: the fields the body populated (a non-empty value) change, and an omitted field is left alone. This is what everyPATCHin the tree has always done, so it is the behavior of all of them. - Send
update_maskand exactly the fields it names change, populated or not, so a named field the body leaves empty is cleared. This is the only way to clear a field that has no empty-value sentinel of its own, an integer or a list where “empty” and “absent” look the same on the wire. update_mask: ["*"]is full replacement, the equivalent of aPUT: every patchable field is written, so one the body omits goes back to its default. It cannot be combined with named fields.- A mask naming a field the resource does not patch is a 422 naming the field, never a silent no-op.
The mask rides in the request body, not the query string: this API has no gRPC transcoding to force the split, Huma models a body as a typed struct, and a body field generates cleanly into both the typed client and a CLI flag. It is top-level field names only, spelled as the body spells them.
The three-state string convention (an omitted field unchanged, an explicit "" clears, a value
sets: moveComponentInput, a system’s standard) is untouched and stays the idiomatic way to clear a
string. The mask is what generalizes clearing to everything that is not a string; retiring the
sentinel in its favor is a separate ripple, not folded in here.
The inherited registry facts are where that pays off. A component_type or system_type stores
stem, abbrev, icon and label_rule as nullable strings where NULL means “inherit from the
nearest ancestor that sets one”, so each of the four has a clear state, and all four spell it the
same way (#716): omit the field and it is
unchanged, send "" and the column goes back to NULL so the walk resumes, send a value and it sets.
Both edit blades send "" for an empty box, which is what makes an emptied box mean what it
displays. The instrument is the sentinel rather than the mask because a nullable string has an empty
value to overload, the exact distinction ADR-0106 draws when it sends objects to the mask instead.
Two things the clear does not do. stem keeps its character rule on the patch, now written
^([a-z0-9][a-z0-9-]*)?$ with minLength gone, so "" is admitted and every malformed stem is
still a 422; only the clear got in, not a relaxation. And a root type cannot clear its stem:
there is no ancestor behind it, so the refusal that guards create guards the clear, 422 naming the
reason.
Clearing a nullable OBJECT field is the mask, always (name_rule is the first,
ADR-0106).
An object has no empty value to overload the way a string has "": {} is a rule with default
fields, not the absence of one, and an explicit null is indistinguishable from an omitted key
once the body is decoded into a typed struct. So the field goes in update_mask and the body
carries no value for it:
PATCH /location-types/{id}{ "update_mask": ["name_rule"], "name_rule": null }Sending the null is optional and reads well; the mask is what carries the intent. This is the
convention for every nullable object field that follows, not a name_rule special case. The mask
governs the whole write, so a caller changing other fields in the same request names those too
(["label", "icon", "allowed_parent_types", "name_rule"]) rather than the cleared field
alone, which would silently leave the rest unwritten. The console’s location type blade does exactly
that when an operator turns naming off while editing something else.
A name_rule also reads back with examples, the first two names it mints, produced by the same
mint a create allocates from. It is served rather than derived by a surface for the reason the
resolved icon and the drafted name are
(ADR-0104):
a shape implemented twice eventually disagrees, and here the wrong answer would be a name an operator
was promised that no create produces. It is a fact about the RULE and not the fleet, so it costs no
read, reserves no ordinal, and needs no scope.
Built on the role declarations (PATCH /standards/{id}/roles/{role}, PATCH /systems/{name}/roles/{role}) and adopted by PATCH /location-types/{id}. The other PATCH routes
accept no mask yet, and they do not need one to stay correct: an absent mask is the implied mask,
which is exactly what they already do.
Errors: one problem+json envelope
Section titled “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, so every
client renders every failure uniformly; the sketched custom envelope is retired
(ADR-0068). 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. (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) (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, the only 404 case, so the API never discloses that an entity exists outside the
caller’s visible set.
Long-running operations over the action row, tracked in #522
Long-running operations: the action is the handle
Section titled “Long-running operations: the action is the handle”A non-instantaneous operation (a command against a device, a reconcile :enforce, a credential
rotation) does not block the request and does not introduce a parallel operations resource:
the custom method returns an action row (its id and status),
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, but the handle is always returned. The action row is ABAC-owned
by its target’s exclusive-arc owner, so polling is read-scoped to whoever can see the target. The HTTP
method is the front door; dispatch is over NATS: the action fans out through
messaging and the result flows back to advance the row.
Writes are audited and scoped
Section titled “Writes are audited and scoped”- Every write emits an
audit_logrow 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 identity and access
invariants; the API is the gateway’s only caller, so there is no unscoped path. Each gated operation
carries an
x-omniglass-permissionextension (for examplerole:read:adminonGET /roles), makingapi/openapi.jsona machine-readable map of the authz contract; the set of stamps is the permission universe the Roles view reports.
The collection surface: nodes, endpoints, tasks
Section titled “The collection surface: nodes, endpoints, tasks”The collection authoring routes are the first concrete resources that
exercise every convention above at once: the standard family per resource (/nodes, /endpoints,
/tasks) plus the :verb custom methods, the per-component reads (reachability,
reconciliation, events), all component:read-gated, and the read-only GET /transports
(the code registry the endpoint picker consumes, endpoint:read-gated, #811). An endpoint
create takes either a transport (a bare probe endpoint) or a driver plus inputs (an
attach, #813: the driver’s spec derives the transport, params, and tasks), and the /drivers
family carries each driver’s declarative spec, validated at write. PATCH /endpoints/{endpoint} edits a
driver-attached endpoint’s inputs in place (the host, the port, an attached secret’s reference;
#1061), validated against the driver (a refused value is a 400 naming the field), re-resolving its
secrets under the editor’s scope, audited, its tasks re-derived in the same transaction.
POST /endpoints/{endpoint}:retry
(gated endpoint:retry, audited, #1049) moves the endpoint’s retry counter, part of its connection
fingerprint, so a gate a terminal refusal holds lifts and the endpoint is attempted again (ADR-0158); for an ssh endpoint whose host key changed, its optional host_key
body declares the new pin (#1032), and for a tls endpoint whose certificate’s public key changed,
host_key (the key the endpoint’s transport pins) or its tls-only twin certificate does the same
(#1035, ADR-0160); a key is refused (422) on a transport that pins none, as certificate is off a
tls endpoint, two different keys at once, and anything not a SHA256: fingerprint. While the
endpoint’s gate names an offered key, the key must be that key: any other is a 409 that writes
nothing, so a trust confirmed against a view the record has since moved past cannot pin a key nobody
read, and the audit row carries the pin replaced (old.host_key, or old.certificate for tls), the
pin set (new.host_key, new.certificate) and the actor (#1078,
ADR-0159). The routes live in the
generated API reference, rendered from the OpenAPI document on every build, each
operation’s description naming its permission gate verbatim (a guard test enforces that, alongside the
spec-contract test that every gated operation carries its x-omniglass-permission stamp and
POST /nodes:claim stays the deliberate public write).
The node custom methods are the day-one enrollment handshake. POST /nodes/{name}:enroll (gated
node: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. POST /nodes:claim is
the node-facing side: 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, and an invalid token is a 401 (a claim must not disclose which nodes exist). A node
is fleet-wide, so node:read and node:create require an all-scope grant.
A simulated device can be broken on purpose. GET and PUT /components/{name}/impairment
(gated simulation:read and simulation:update, component-scoped, the write audited) read and set
what is broken on a simulated component: unplugged, or latency, jitter and loss toward it and a rate
limit both ways. A set replaces the whole impairment (a field left out is zero, an empty body repairs
the device) and returns it as stored; jitter above latency or a value out of range is a 422. The
simulator beside the component’s node applies it (simulation).
A node’s simulator is a read beside it. GET /nodes/{name}/simulation (gated node:read, all-scope)
lists the components a simulator beside the node serves (each component with an
endpoint placed on the node and inside the caller’s component read scope, its product and target host),
joined to the simulator’s stated capabilities
and the hits the server has seen per device. Hits are a live signal kept in the server’s memory, not
stored, so they start from zero when the server restarts; a node with no simulator reads connected: false.
The simulated fleet has its own reads and two writes. GET /simulation (gated simulation:read)
is every node whose simulator has pulled since the server started and simulates a component in the caller’s
scope (an all-scope reader sees every one; no node read is needed), each
with its capabilities, its account of impairments and its simulated components in the caller’s component scope;
connected is true when any of them pulled recently, which is what shows the console’s Simulation page.
GET /simulation/profiles is the simulator’s embedded profile catalog, each profile with the products it
simulates. GET /components/{name}/simulation says whether a component is simulated and, when it is,
gives its node, address, live hits, impairment, the products its profile simulates, and its full API
surface from the profile (every protocol surface, with its HTTP routes, commands and SNMP OIDs, each port as the
simulator binds it). POST /components/{name}/simulation:fire (gated simulation:update, audited) asks
the simulator beside the component’s node, over the bus, to run one operation from that surface (an HTTP
call, a command line or an SNMP get) and returns the component’s answer: a component that is not simulated
is a 409, a surface it does not serve a 422, and no simulator answering a 503.
POST /nodes/{name}/simulation/components (gated simulation:update, and also needing component:create
and endpoint:create, each in the caller’s own scope) adds a simulated component: a component of a
simulated product at the lowest free address in 127.60.0.0/16, with the endpoint the collector reaches
it through placed on the node; a node with no simulator connected is a 409, a product no profile
simulates a 422. A whole simulated system is built to a standard: GET /simulation/standards/{standard}:plan
(gated simulation:read) previews each of the standard’s roles staffed with a simulated product that fits
(or left short, with why), and POST /nodes/{name}/simulation/systems (gated simulation:update, and also
needing system:create, system:update, system:read, component:create, endpoint:create and
node:read, plus location:read when it names a place) creates the system, a
simulated component per staffed role at its own free address with its endpoint on the node, and each
role assignment, in one transaction: it lands whole or not at all.
Alarms are raised by rules and graded by a level registry. /severity-levels (read with
severity_level:read on any grant, written with severity_level:* only on an all-scope grant, since a
level’s rank moves health fleet-wide) lists the severity levels least to most severe,
each with whether an alarm at it puts a component in outage; official levels are read-only. /event-rules
(gated rule:*; read like a registry, written only on an all-scope grant) authors the rules that raise alarms: a sample
key, fire and clear criteria, sustains, a selector, the event types it writes when its condition starts
(fire_event_type, required) and ends (clear_event_type, optional), and a severity level, which is
optional: a rule without one is informational and raises no alarm, and a PATCH with an empty severity
makes a rule informational (an empty clear_event_type drops the clear event; the fire type cannot be
emptied). Criteria are expressions (#957): a body gives {expr} or the short form {op, value}, which is
lowered to its expression, and a rule always reads back as {expr}. An unknown key, level or event
type, a missing fire event type, or a criterion that does not compile against its lane is a 422; for a
criterion the problem’s error is located at body.fire_criteria.expr (or clear_criteria), or at
body.fire_criteria when the body gave the short form, and carries the line and column of the
fault when it has one, and the code of the diagnostic
that refused it when one did. A stored rule whose criteria no longer compile carries criteria_error
(field, line, column, message, code) on the list, a get and its state, and holds until rewritten;
criteria that compile with a warning carry it in criteria_warnings (the same shape), a note that never
holds the rule or refuses its save. POST /expressions:validate (gated rule:read; omniglass expression validate) compiles a
source against a registered expression site (criteria, for which the sample key is required
and types the value, a metric a number and a property its data type, which so tells a rule:read
holder a key’s lane and data type, as the rule list does; or calc, for which key is the rule’s
output key, typing the result, and inputs with owner_kind give the bindings, an unknown or
misnamed input, or health as one, being a 422) and answers ok,
the value and result types, diagnostics (the refusal, or the warnings and hints an expression that
compiles carries, each with line, column, phase, severity and, from a registered diagnostic, its
code and a quick fix: a range and its replacement), and the site’s bindings,
builtins and budget, generated from the site’s declaration; it compiles without the engine’s
program cache, stores and runs nothing, and an unknown site or a missing or unknown key is a 422. POST /event-rules:preview (gated rule:read; omniglass event-rule preview) judges a candidate rule (sample key, selector, criteria) without writing anything, in a
read-only transaction, as the ingest would with no sustains (sustains_ignored is always true): over
each series it selects among the components the caller’s component read scope admits, from the state
its newest deciding sample in the 7 days before the window set (listed in state_unknown when there
is none), through every sample of the last 24 hours the ingest would have judged (a late sample older
than one before it decides nothing). It answers edges (fire or clear, on a component and instance,
at the sample) and in_force_now. Its reads are index probes per series; the window’s are capped at
row_cap samples a whole series at a time, reported as series_judged of series_total with
truncated. A selector naming a component the caller cannot read is the same 422 as one that does
not exist, and a criterion that does not compile is a 422 at its line and column. A rule edit is judged on what it changes from the stored rule:
a selector entry, key or criteria it restates unchanged is not re-resolved, so a rule whose component or
type was deleted stays editable (it matches nothing by the stale entry), while an entry new to the rule must
exist. POST /event-rules/{name}:rename (gated rule:update on an all-scope grant; omniglass event-rule rename) moves an operator rule’s name (#977) and answers the rule with its id; every reference inside
the platform holds that uuid (an alarm’s raised_by_rule_id and its dedup_key, rule:<uuid>[:<instance>],
a condition, a streak, an event’s derived_by_rule_id), so a rename bumps no version and leaves streaks
and conditions running. An event keeps the name it was written under (derived_by_rule). An official
rule keeps its name (409, the seed matches it by name), a taken name is a 409, an illegal or uuid-shaped
one a 422. The list and a get carry each rule’s live state as counts (conditions_in_force, open_alarms,
pending_streaks), read for the whole list in one query; GET /event-rules/{name}/state lists the
conditions in force, the alarms and the streaks themselves.
Both are fenced to the caller’s component read scope. The list, a get and the state also carry
last_judged_at, when the rule last judged an arriving sample, to within 5s (absent when it never has);
it names no component, so it is not fenced. Because writing a rule needs an all-scope grant,
GET /auth/me returns fleet_permissions beside permissions:
the permissions held on an all-scope grant, which the console checks before offering a rule write. An alarm body names its component by name and uuid, and a
rule-raised one its rule (raised_by_rule, raised_by_rule_id).
Calc rules derive a key from aliased inputs. /calc-rules (gated rule:*, read like a registry,
written only on an all-scope grant; omniglass calc-rule) authors the rules that derive an output key
on each component or system (calculations): an owner_kind, a
selector (a component rule’s component_types and components, or a system rule’s system_types and systems, subtypes included), inputs as {alias, key} pairs (the key by name or uuid, held by uuid; a read answers key,
key_id and lane), a reduce that is an expr over the aliases, each bound as the list of its
values (#1018), and an output_key, its lane answered as output_lane. A rule is addressed by name or uuid and every write answers the rule with its id. A
reduce that does not compile, or yields a type other than the output key’s, is a 422 located at
body.reduce.expr with the fault’s line and column (and unbound, the name, when it is one no
alias binds); any other refusal is a 422 located at the field
it names (body.inputs[0].key, body.inputs[0].alias, body.output_key, body.selector): an unknown
key, an alias that is not an identifier of at most 64, is reserved or repeats, health as an input or
output, an output of the reserved family (a native probe key, health), an output a driver’s declared
emits write or a command type targets, an output another enabled rule
writes (the refusal names it; enabling a rule is checked too). A list or get carries calc_error (field, line, column, message, code) when a stored reduce no
longer compiles against the registries, and such a rule cannot be enabled (422); a reduce that compiles
with a warning (an input it never names) carries it in calc_warnings, which never refuses a save. Deleting a property
or metric type a calc rule writes or reads is a 409 naming the rule. A metric or property type a calc
rule writes names it on /metric-types and /property-types (list and get) as written_by
(rule, rule_id): the enabled writer, else a disabled one, read with the type and scope-free as the
registry is (#1019). A rule that would close a cycle in the key graph is a 422 whose detail names the chain
(temp-twice -> avg-temp -> temp-twice), its error carrying the keys and rules along it. A patch
bumps the version unless it changes nothing; POST /calc-rules/{name}:rename moves the name, bumping no
version (a rename to the same name writes nothing), with the same 409 and 422 refusals as an event
rule’s rename. GET /calc-rules/{name}/state (gated rule:read; omniglass calc-rule state list) lists
what the rule last did on each owner the caller may read (a component by the component read scope, a
system by the system read scope), the owner named now: value, computed_at, rule_version,
missing, a system’s skipped members and the stale instances (named when the caller may read the
component, else counted in skipped_hidden and stale_hidden), error, no_members_since, pending
and a deferred mark’s due_at; failing owners first, then pending, missing, valued, each by name, at
most limit (100 unless asked, up to 1,000), beside the total and pending_depth over every owner the
caller may read. POST /calc-rules:preview
(gated rule:read; omniglass calc-rule preview) computes a candidate rule (owner_kind, selector,
inputs, reduce, output_key, and a saved rule’s name to preview an edit of it) on each selected
owner the caller may read, with the tick’s reduce, in a read-only transaction: per owner the value it
would write, the current output and whether it differs, and what was missing, skipped or stale
(the owners holding a value first; an expression that can read one member’s value over a system with
a member the caller may not read is withheld there, its error counting the members out of scope, while
one folding each list whole is previewed),
at most owner_cap owners (owners_judged of owners_total, truncated past it). A candidate a save
would refuse (a reduce that does not compile, a cycle, a second writer of the output, a collected
output, health) is the same 422.
The endpoint is authored; the task is derived. An endpoint is addressed by a surrogate id and
named by its protocol: name derives from transport, unique within its component
(create takes a type, not a name; a duplicate protocol on one component is a 409). Creating an
endpoint derives its one poll task, so the task surface is read-only (GET /tasks,
GET /tasks/{id}): no task write routes or grants. A task references its endpoint by
endpoint_id, its id content-addressed over endpoint, mode, and spec, with no node column:
placement projects from the endpoint. An endpoint belongs to a component (or is server-hosted,
needing an all-scoped grant), a task to an endpoint, so both inherit the component’s
scope: an out-of-read-scope component’s endpoint or task is a
non-disclosing 404.
Three per-component reads stand in until the ViewResult framework lands, each a hand-written
typed GET, gated component:read and scope-injected through the same GetComponent gate, so an
out-of-scope component is a non-disclosing 404 (a deliberate early exception to
reads beyond one resource are views):
GET /components/{name}/reachabilitycomposes, per endpoint, the latest verdict state (endpoint-reachable), the probe-layer signals that compose it (the rawicmp/tcpmetrics, and for audpendpoint arequestlayer: its request answered, offendpoint-responsive, timed byudp-response-timeonly while answered, #1037), the recent verdict transitions the availability strip reads, andlast_seen, when ingest last landed a batch the endpoint’s task delivered, by the server’s clock and to within a few seconds, whatever the batch carried. A verdict is stored only on a transition, so itstsis when it last changed, and a driver endpoint has no verdict at all;last_seenis the one freshness every endpoint has. Each row also carries the endpoint’sid(the address:retrytakes), its heldsessionwhile it is configured to hold one (a standing listen task, or a driver declaring a session: the latestsession-state, when it changed, and thesession-reasonbeside it, cut to 500 characters), and therejectiona terminal refusal holds (the rung and class ofendpoint-rejection; the connection fingerprint it carries is never served), which the console’s session pill and ladder render (#1059). Therejectionis served whether or not a session is held, since a reachability probe’s refusal gates an endpoint too, with itsreason(theendpoint-rejection-reasonthe node publishes beside the gate, cut to 500 characters like a session’s) andts(when the gate was last published), so the console’s refusal notice and its Retry read the same for a probe’s gate as for a rejected session (#1110, ADR-1110). A refusal of a changed key addspinned_keyandoffered_key, both or neither, read off the gate as data (#1078; a tls certificate refusal the same way, #1035): the console names them and trusts the exactoffered_keyit showed, never a fingerprint parsed out of the reason. A tls endpoint’s row also carriestls: its verification mode, the key it pins (pin mode alone), the latest handshake time, and the certificate’s expiry and days left, withheld while a certificate gate holds (#1035). Teardown publishes no state, which is why a session is served only while one is held. Every key this read composes is a native probe key, the node’s own observation: the verdict and its rungs, the probe metrics and timings, a session’s state and reason, the gate, a tls endpoint’s pin and certificate facts.POST /telemetry:pushrefuses each by name on every lane, whatever instance it names (#1080, ADR-0158 point 5), and the read serves each key’s observed series only, so no other writer or provenance can stand in for the node in any of it (ADR-0161).GET /components/{name}/eventsis the log-kind mirror: the component’s recent log occurrences (theeventlog sink), newest first, bounded to the last 24 hours and capped at 200 rows; each row carriests, theevent_typename it is typed by (on the wire askey, e.g.call-started) with itsevent_type_idbeside it,origin(caught/caused/derived/scheduled),instance,message, optionalattributes,provenance(observedfor direct collection), and thesourceendpoint type. An event a rule derived also carriesderived_by_rule(the rule’s name) and, when the same transaction opened or resolved an alarm, itsalarm_id(#949);GET /systems/{name}/eventscarries both the same way, and names each row’s owner by uuid as well (owner_id), since names repeat across a system’s members.GET /components/{name}/reconciliationpivots want/told/is over the series: per declared property, the want (the current declared value, with the contract default coalesced in), the told (theintendedvalue a command set), and the is (the latestobservedvalue), all three latest-series-row reads, with drift (observed present and disagreeing with declared) computed on read.
Three registries ride the /property-types shape (fleet-wide reference data, no scope injection;
official types are read-only, a 409). The metric_type catalog is the numeric keyspace:
GET/POST/PATCH/DELETE /metric-types[/{name}], gated metric_type:read / :create / :update /
:delete, each type carrying the numeric facts (unit, precision); the two sample catalogs and
event_type share one resolution namespace, so a create is refused when a sibling holds the name
(ADR-0079).
Every type on the three ingest registries carries its writer_class (node-probe, platform,
command-lifecycle, calc, collected: who may write the key,
ADR-0161),
read beside it and never set; a create naming a reserved key (a native probe key, collection-failed,
health, command-issued) is a 422 on every lane, whether or not its type is seeded. Any write the Storage Gateway
refuses for its writer (a declared value, a command’s target, a calc rule’s output, an event rule’s
event type, a driver spec’s emits on a key its route may not write) is a 422 naming the key and who
does write it.
The event_type registry is the occurrence keyspace:
GET/POST/PATCH/DELETE /event-types[/{name}], gated event_type:read / :create / :update /
:delete; an ingested occurrence is typed by a registered event_type name (the log-to-event
promotion, ADR-0063), the optional payload_schema a JSON Schema fragment for its payload. The
command_type registry is the do catalog:
GET/POST/PATCH/DELETE /command-types[/{name}], gated command_type:read / :create / :update /
:delete, each type carrying a settle_window_seconds and an optional target_property_type (the
property a settleable command sets; the metric target arm is storage-deep, its authoring surface
deferred). POST /components/{name}/commands:issue (gated command:issue, and fenced by that permission’s
own scope on the component tier rather than by component:read,
ADR-0117)
is the write: 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); the invocation then actuates over the per-node command queue
(commands, #815).
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 endpoints, their
derived tasks, its node-owned tags and self-telemetry, and its enrollment credential. An endpoint
PATCH changes only its node placement and its params (target); the type (and 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). The four built endpoint types are icmp, tcp, ssh, and http; there is no
transport list route yet.
Secrets: masked reads, an audited reveal
Section titled “Secrets: masked reads, an audited reveal”A secret is a typed, encrypted-at-rest operator value (config, credentials, and
variables); its routes (GET /secret-types, GET / POST /secrets,
PATCH / DELETE /secrets/{secret}, POST /secrets/{secret}:reveal, the secret by name, by
<owner>/<name>, or by uuid; shapes in the
reference) are a worked instance of the conventions above. Secret is a sensitive
resource
(ADR-0025),
off the viewer *:read floor: the registry and directory reads need an explicit secret:read grant
(seeded to operator, deploy, admin, and owner), the writes gate on
secret:create / secret:update / secret:delete, and the plaintext decrypt on secret:reveal,
held by operator and deploy (the device secrets in their scope), admin (via secret:>, which alone
reaches the admin-sensitive :admin tier), and owner (>). Every :reveal writes an
audit row (verb reveal) in the same call. The directory filters each
secret’s owner placement against the caller’s read scope, admin-sensitive secrets visible only to the
admin tier; every other read masks a secret field (••••••), only :reveal returns plaintext. A
create names its owner (owner_kind: platform|location|component; the system band is retired,
ADR-0052); a PATCH re-seals the given fields, merged over the stored value so an omitted field
keeps its value. A platform secret needs an all-scope grant and platform:create
(platform:update / platform:delete on later writes at the tier).
A variable is the plaintext sibling of a secret (config, secrets, and
variables): the same owner arc and cascade (owner_kind: platform|location|system|component), shown in the clear (no registry, no mask, no reveal), the value
polymorphic JSON typed by value_type (string|int|float|bool|json) and validated against it on
write. GET /variables is the all-scope admin directory (like the secret directory, a non-all
scope is a 403) on variable:read; POST /variables creates (201, variable:create),
PATCH /variables/{variable} replaces the value (variable:update), and DELETE /variables/{variable} removes
(204, variable:delete, admin and owner), with the same platform tier rule as a secret.
A tag (tags) is a key: value label, and its routes split along the
governance line: minting a tag is a tenant-wide governance action, setting a value is the
owning entity’s own write. The tag vocabulary and an entity’s tags read on the viewer floor
(tag:read, component:read).
GET /tagslists the governed tag vocabulary ({tags: [tag]},tag:read); atagbody is{id, name, applies_to, propagates}, thenameon the ordinary entity name rule (lowercase letters, digits, and hyphens, at most 100 characters, a uuid-shaped name refused), a 422 otherwise,applies_toan entity-kind allow-list (empty = universal),propagatesdefault true.POST /tagsmints a tag (201),PATCH /tags/{name}revises it (the name is fixed), andDELETE /tags/{name}removes it, cascading its bindings (204):tag:create/tag:update/tag:delete, all-scope.POST /tags/{name}:setPlatformsets the platform-tier value for a key from{value};POST /tags/{name}:clearPlatformremoves it (204). A platform binding has no owning entity, so it gates ontag:updateplusplatform:update(the install-wide tier permission below).GET /{components,systems,locations,nodes}/{name}:listTagslists the bindings set directly on one entity ({tags: [tagBinding]}, each{key, value, owner_kind, owner_id?, owner_name?}, the entity’s:read).POST /{components,systems,locations,nodes}/{name}:setTagbinds a value from{key, value}; the key must exist and itsapplies_tomust admit the kind (a 422 otherwise). Setting a value is the entity’s own write, so it gates on the entity’s:update(component:updateand friends), not a tag permission;POST /{...}/{name}:removeTagfrom{key}removes the binding (204). Bindings are custom methods on the entity rather than a nested collection, so the generated CLI stays collision-free.GET /components/{name}/effective-tagsis the cascade for one component: each aresolvedTag({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, read-scoped).- The directory list routes (
GET /components,/systems,/locations) each carry aneffective_tagsmap ({key: winning_value}) on every row, resolved for the whole page in one batched query. A component resolves the full arc; a location resolvesplatformplus its location tree; a system resolvesplatform, its system tree, and the location it is placed at. Provenance lives in the per-entity effective-tags detail, not the row.
The classification catalogs (core entities)
are official-vs-custom registries the inventory layer references, on the same pattern as the
*_type registries; vendor and driver are flat, component_type a hierarchy above product
(core entities), and
system_type its system-side counterpart, the coarse taxonomy of what kind of space a system is
(core entities). Each is its own
resource with one shared CRUD shape: the list routes
(GET /vendors, /drivers, /component-types, /products, /standards, /system-types) order
alphabetically by label, and each list and per-id GET sits on the viewer floor
(vendor:read / driver:read / component_type:read / product:read / standard:read /
system_type:read, which *:read carries); POST mints a custom
row (201) and PATCH updates and DELETE removes (204), gated <resource>:create /
<resource>:update / <resource>:delete, all at the admin tier. An official row refuses the write
(PATCH and DELETE both 422), except on a registry that has adopted the fork
(ADR-0095):
there a PATCH of a shipped row succeeds without writing it, storing the caller’s version over it and
answering 200 with forked: true under the same id, DELETE is still 422 (a fork is an overlay, not
ownership), and POST /component-types/{id}:restore discards the fork (409 when there is none) so later
releases reach the row again. component_type is the first adopter; official and forked together
are the row’s origin (shipped, yours, or yours overriding shipped). The {ref} grammar is unchanged
either way: one uuid and one name per logical row, forked or not, and the namespace never appears in a
URL.
A nested registry (component_type, system_type) returns the flat set with each row’s parent
link in both forms, so the console reconstructs the tree client-side; the row carries its own
stem / abbrev / icon, unresolved, because inheritance is the resolver’s job and a read that
silently filled a blank from an ancestor would make the override invisible. DELETE on one is refused
with a 409 while it still parents another node or still classifies a row (system_type counts both
sides).
A registry body carries both handles like every other name-bearing resource
(above,
ADR-0062):
a create takes the name, the response carries the minted uuid, and a reference to one (vendor,
driver, a parent) resolves whichever form it is given.
What each catalog adds over the shared shape (bodies in the reference):
- A vendor (Boreal, Polaris, …) names an organization, generalizing the former manufacturer-only
component_make: akindofmanufacturer/integrator/developer(defaultmanufacturer, a 422 otherwise), and awebsitevalidated to anhttp/httpsscheme on write (a 422 for any other scheme, for examplejavascript:). - A driver (Generic SNMP, Kestrel Device API, …) names the implementation that gets, emits, or sets a
product’s signals, with an optional
version. - A component_type (Mic, Camera, Wireless Mic, …) is the device-class genus a product is classified under, a hierarchy (a subtype falls within its ancestor’s subtree): what a system role’s typed-slot guard accepts.
- A product (core entities) is the
concrete SKU that ties the leaf catalogs together and the target of
component.product_id: a vendor, a driver, akindofdevice/app/service/vm(defaultdevice, a 422 otherwise), a requiredcomponent_type, and an optionalparent_product_idvariant, thevendoranddriverhandles reading the referenced registry’s current name beside its uuid. An unknown vendor / driver / parent / component_type reference is a 422, and a product still referenced by a component is refused (409). - A standard (core entities) 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: its own
standard:*resource at/standards, not/types/system, with an optionalparent_standard_id(an unknown parent a 422); a standard still referenced by a system is refused (409). The shipped standards areofficial: false, so unlike a seeded product they are fully editable (the seed model).
The install-wide tier permission
Section titled “The install-wide tier permission”The cascade’s least-specific tier is platform (cascade), and a write that
lands there needs two permissions: the resource’s own (secret:create, variable:update,
tag:update, settings:update) and platform:<action>, because fleet scope and install-wide
authority are different questions
(identity and access).
platform:* is seeded to admin (and to owner through >); operator and deploy do not hold it.
The tier gate is published in the spec like every primary gate: an
x-omniglass-platform-permission extension beside the route’s x-omniglass-permission stamp, both in
the route-derived permission universe. 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.
A second permission can also be conditional on the request rather than on a tier, and it is stamped
the same way, as x-omniglass-conditional-permission. There is one today: POST /components accepts a
system, and what it does with it is insert that component’s primary membership, the same row PUT /systems/{name}/members/{component} writes under system:update. Two routes writing one row must cost
the same permission, or the cheaper one is the way around the other, so a create that names a system
requires system:update and resolves it in that scope
(ADR-0107).
The check is in the handler rather than the middleware because the condition is a body field and
middleware cannot see the body; the stamp is what keeps the spec a faithful map of what the route
enforces.
Properties: a classifier declares, an instance sets
Section titled “Properties: a classifier declares, an instance sets”A contract is the set of properties a classifier’s instances expose
(core entities).
Each contract is a sub-collection of its classifier, addressed by property name, so the line is
idempotent: PUT declares it or revises it in place. Type and validation come from the
property catalog, not the body. Three classifiers carry a contract, on
identical route shapes:
GET /products/{id}/properties,PUT/DELETE /products/{id}/properties/{property}, gatedproduct:read/:update/:delete;GET /standards/{id}/properties,PUT/DELETE /standards/{id}/properties/{property}, gatedstandard:read/:update/:delete.GET /location-types/{id}/properties,PUT/DELETE /location-types/{id}/properties/{property}, gatedlocation_type:read/:update/:delete, the registry’s own resource.
The list returns the contract ordered by property name, each line
{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), and instances keep any value they
set for it, now off contract. An official classifier refuses the write (422), an unknown
classifier is a 404, and a property the catalog does not know is a 422, as is a reserved key (a
native probe key, health, command-issued) on this route or the metric contract’s, naming the key
and who writes it, since a contract default would stand in for its writer’s value
(ADR-1100).
An instance’s values are 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. Three owners are addressable
(GET /{components,systems,locations}/{name}/properties, PUT / DELETE .../{property}), each gated
by its own entity’s permission (component:read / component:update and friends): the catalog governs
the vocabulary, the owning entity its values, the same rule tag bindings follow.
The GET is the effective read: 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 off contract, each row carrying the
catalog’s display fields plus the value, the default, is_set (the override marker), from_contract,
required, and the value_id the surface clears. An instance with no classifier returns only its
off-contract values. PUT .../{property} sets the instance’s value from {value}, idempotently; the
property need not be on the contract, but must exist in the catalog (422 otherwise).
DELETE .../{property} clears it (204), falling back to the contract default or leaving the effective
read entirely when off contract; clearing a value the instance never set is a 404.
Roles: a system declares a slot, a component fills it
Section titled “Roles: a system declares a slot, a component fills it”A system role is a slot a
system needs filled, in three arcs: declaration (what a standard says every conforming system
needs, and what one system declares ad-hoc), resolution (the per-system read merging both with who
fills each role today), and staffing (assign and unassign). It is not the
IAM role: /roles is the RBAC catalog, these routes are the fleet model.
A role is addressed by name within its owner, so every declaration is a PATCH that declares or
revises in place. The body is {update_mask?, label?, quorum?, capacity?, position_labels?, accepted_types?, pinned_products?, impact?, alternate?}, patched under the
write mask: the fields the body populates change and the rest of
the declaration is left alone, and update_mask names the fields that change whether the body
populates them or not. accepted_types, pinned_products and position_labels each replace
their set wholesale when written, so clearing one means naming it in update_mask (an empty list is
not a populated field, so on its own it means unchanged). capacity is the most components the role
will accept (at least quorum), unbounded on first declare, and naming capacity in update_mask
with no value is what clears it back to unbounded. impact is outage / degraded / none
(degraded on first declare), what an impaired role does to its system’s
health; an unknown impact is a 422. alternate is the
choice-name/alternate-name this role joins, an empty string detaching it; every role read returns it
in the same form. Gating follows the owner:
GET /standards/{id}/rolesplusPATCH/DELETE /standards/{id}/roles/{role}, gatedstandard:read/:update/:delete. Withdrawing a role takes every assignment conforming systems made to it (a cascade); a role the standard does not declare is a 404.PATCH/DELETE /systems/{name}/roles/{role}, gatedsystem: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.GET /systems/{name}/rolesis the resolved read, gatedsystem:read: the declaration (includingimpact,capacity,position_labels,alternate) plusfrom_standard,assigned_to(the component names filling it here, in position order),positions(each entry’s own 1-based position, index for index withassigned_to; not assumed dense, since an unassign leaves a gap rather than compacting),assigned, andunderstaffed(how many more before quorum), the counts served, not computed by the client. A one-off system returns only its own roles.PUT /systems/{name}/roles/{role}/assignments/{component}puts a component in the role (204, idempotent);DELETEtakes it out (204; a component not filling the role is a 404). Both gate onsystem:update.POST /systems/{name}/roles/{role}:swapPositionsexchanges two occupants’ positions, from{position, with}(204,system:update): the only reorder primitive the API exposes, since a position is an ordering attribute of an assignment, not a second address for one, and there is no “move to index N” route.
Every system route resolves its owner within the caller’s scope first, so an out-of-scope system is a non-disclosing 404 on read and write alike, and every write is audited in the same transaction.
A refusal names both parties, and its status follows what it depends on. A component fills a
slot only when its product’s component_type falls within a type the role’s accepted_types names
(self or a descendant, any type if empty), and, if the role pins products, only when its product is
one of them:
component "panel-1" is a display; role "table-mic" wants a video-barThat guard, and a bad declaration (an unknown type, product, or impact), are 422: the request is
invalid on its own, regardless of anything else in the fleet. A component already staffing a
different role in the same system, or an assignment a role’s declared capacity cannot hold, are
409 instead:
component "bar-1" already fills "main-display" in "boardroom-a"; a component fills at most one role per systemthe request is fine in isolation, it conflicts with other rows (a component fills at most one role per system; lowering a capacity below the count already assigned is refused the same way). Both are the semantic-refusal case in the status table, not an authorization one: the message tells the operator the next move. Around it: an unknown role is a 404, and an unknown (or out-of-scope) system or component the same non-disclosing 404. The guard runs once, at assignment; afterward an occupant keeps its slot unless its own health verdict goes to outage (a lesser alarm degrades it but does not cost it the slot).
Health: the verdict, and why
Section titled “Health: the verdict, and why”Health is two shapes on this surface: the alarm (what is wrong with one
component) and the report (what that means for a system or a location); an alarm is
component-local, reaching a room only through the component’s own verdict. Only an alarm at or above high
takes that verdict to outage and impairs every role the component occupies while it is active; an
info or warning alarm degrades the component but leaves it occupying its roles.
An alarm hangs off its component and rides that component’s gating:
GET /components/{name}/alarmslists them newest first (component:read), the active set by default, the whole history withinclude_cleared, and the queue an operator actually works withunacknowledged(on its own, raised and unacknowledged; withinclude_cleared, also the incidents that came and went unattended).POST /components/{name}/alarmsraises one from{severity, message?, dedup_key?}(201,component:update).severitynames a severity level (GET /severity-levels, an unknown one a 422), driving the component’s own verdict (any active alarm degrades it, one at or abovehighis an outage);dedup_keyis the condition identity, defaulting to the message. 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 outlives the fix; clearing one already cleared, or another component’s, is a 404.
Raising and clearing both 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 fleet changed.
Acknowledgement is the one alarm write that does not, and it is the one that does not ride
component:update either:
POST /components/{name}/alarms/{id}:acknowledgerecords that a human has seen it (200,alarm:acknowledge). It changes nothing else:cleared_atis untouched, no health is recomputed, and the component stays exactly as broken as it was. Acknowledging is not fixing.
The permission is its own because recording that somebody looked is not editing the component, and a
role may hold one without the other. Its scope resolves on the component tier from
alarm:acknowledge itself, so a fleet-wide read never widens what may be acknowledged and a
narrow component write never narrows it; a component outside that scope is a non-disclosing 404.
Acknowledgement is orthogonal to cleared in both directions: an alarm can be acknowledged and
still raised, raised and unacknowledged, or cleared having never been acknowledged, and a cleared
alarm can still be acknowledged by whoever reviews the history. Acknowledging twice is idempotent:
the first person and the first time are what the row keeps, and the no-op writes no second audit row.
Un-acknowledging is deliberately not a verb yet
(#728); snooze and resolve are
out of scope with reasons.
GET /components/{name}/alarms/{id}:timeline(alarm:read, the component’s read scope injected, a non-disclosing 404 outside it or for an alarm not on the component) returns the alarm’s history oldest first:entriesof{at, kind (event|audit), what, actor?, rule?, instance?, message?}, the events carrying the alarm merged with its audit rows (the timeline).
The reports are one shape over two owners:
GET /systems/{name}/health(system:read) returns the verdict (healthy/incomplete/degraded/outage, in rank order, whereincompleteis a role short because its hardware was never installed rather than because installed hardware is failing) plusroles: every role the system needs filled, wheresatisfyingcounts the assigned components currently occupying it (their own verdict is not outage; a degraded one still counts),downnames the assigned components whose own verdict is outage, andalarmsthe active alarms on those. An impaired role with an emptydownis short-staffed, not broken, a different job for the operator.GET /locations/{name}/health(location:read) returns the same envelope withsystemsfilled instead: every system placed anywhere beneath the location, with its verdict, as the drill-down.transitionsis the recorded edges over the last 30 days, oldest first, each{ts, verdict}: one entry per change, never a sample.GET /systems/{name}/members(system:read) carries each member’s owncomponent_verdict, the same recorded value a fleet view dot reads. It is the per-system read of the members’ health, so a system’s view polls it with the system’s health report rather than the whole fleet view (#940).GET /systems/{name}/alarms(system:read) is every member’s alarms in one read, newest first, each naming its component: the active ones whatever their age and those cleared in the last 30 days, capped at 500. A system’s Activity explains its incidents with it, one request however many members the system has.
Both resolve their owner within the caller’s scope first (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 (ADR-0050).
Views: reads shaped for a surface
Section titled “Views: reads shaped for a surface”Every read above answers “what is this row”. A view answers “what does this screen need”, and it earns its own tier only when composing the entity reads in the browser would be the wrong shape.
GET /views/fleet(location:read) is the read behind Explore: every location flat with itsparentandverdict, every system with itslocationandverdict, and one dot per component in each system. A dot is{component, name, verdict, primary, shared}and nothing else. A renderer draws a mark per component across the whole fleet, so a component row per mark is a fleet-sized payload on every draw; the projection is the shape that avoids it, and the wire is pinned by test against being widened back into a component list.
Two properties are worth stating because they are easy to lose:
- Flat, not nested. Locations carry a parent id and the client assembles the tree. The server says what exists and how it is placed; the client decides how the fleet is grouped, which is what lets a grouping (by place, by standard, by vendor, by tag) be a mapper rather than a second endpoint. Explore’s outline of places is one such mapper (ADR-0137).
- Scoped per tier. The three tiers resolve their own read scope, so a principal who may read the place tree but not its components gets the shape of their fleet with no contents, and one with no fleet scope at all gets an empty fleet rather than a refusal: there is simply nothing of theirs to draw.
A view is a read and only a read. It adds no write path and no judgement of its own: the verdicts it serves are the ones the health rollup already recorded, so Explore and the detail page cannot land on different answers about the same room.
Files: content-addressed bytes behind a handle
Section titled “Files: content-addressed bytes behind a handle”A file is a searchable handle over a content-addressed blob: the metadata is
tenant-wide (no placement arc), so these routes take no scope, only the file:<action> permission
plus the per-file sensitive tier. Reading rides the viewer floor (file:read, which *:read
carries); a sensitive file is fenced to the :admin tier (file:read:admin), hidden from a lister
and a non-disclosing 404 to a reader without it, exactly the
secret sensitivity rule.
The bytes ride base64 in JSON on both create and download (the
avatar precedent),
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;
GET /files/{file} returns one handle’s metadata (the file by uuid, or by filename where exactly one
visible file carries it); GET /files/{file}:download returns
{name, content_type, content (base64)}, the blob read back and its hash verified (all file:read).
POST /files creates one from {name, content_type, content (base64), sensitive?} (201,
file:create): the server hashes the bytes, deduplicates the blob, and writes the handle
(sensitive: true additionally needs the admin tier). DELETE /files/{file} removes the handle (204,
file:delete); the blob is freed in the same transaction when no other handle references it. A file
body is {id, name, content_type, size, sha256, sensitive, created_at}; the sha256 is the content
address.
Views and the SSE live relay, tracked in #523
Reads beyond one resource are views
Section titled “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: a named query returning a
uniform ViewResult ({columns, rows}), bound by declared params at /views/{id}:run, executed through
the same scoped gateway; an operator never gets raw SQL. A live read may upgrade from polling :run
to a server-relayed SSE stream over the same seam: the subscribe is
capability fast-rejected at open (not authorized there), then the server holds the internal
subscription, re-runs the gateway scope per message against each message’s owner, and pushes only
visible deltas. The operator never connects to the bus, so the live path adds no second authorization
model.
Versioning and evolution
Section titled “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. Because the OpenAPI 3.1 document is generated from the Go
structs and the clients from that, the contract cannot drift from the implementation: a drift check fails
the PR on an unregenerated route change.
The MCP surface, tracked in #522
Also an MCP surface
Section titled “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 over the same gateway: every tool call is
the same route permission, the same gateway scope, the same same-transaction
audit, not a side channel. The tool catalog is curated, not a raw
one-method-per-tool dump: task-oriented tools, the views exposed as search and
query tools, errors shaped for a model. The MCP server runs under the authenticated human or
service principal’s credential, so its reach is exactly that principal’s grants
(AI).
The full node NATS contract, per ADR-0036
The node path is the NATS contract
Section titled “The node path is the NATS contract”Nodes do not speak HTTP. The edge is a NATS client over the WAN: telemetry to a JetStream stream, commands from a durable server-side JetStream queue, enrollment by NATS JWT/nkey, all on the sibling NATS subject contract, not this page’s routes. The old node HTTP custom methods are gone; the proto definitions survive as the NATS message schema. The contract lives in messaging and on the node page; the same AIP spirit, error envelope, and idempotency carry across.
Self-describing
Section titled “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; the internal NATS subject contract
(subjects, message schemas, stream and consumer definitions) is published from the running server the
same way, the sibling of OpenAPI for the bus.
Related: API first, messaging,
identity and access, audit,
UI (the views BFF and the renderer contract), and
expressions (the filter language).