API
PartialThe 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 server go through it, and it is the only caller of the Storage Gateway. 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. 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; this page is the conventions that doctrine points at.
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 by primary key (AIP-style):
POSTcreates (409 on PK collision),GETreads,PATCHpartial-updates (AIP-134),DELETEremoves. No upsert shortcuts. - Custom methods carry a colon,
:verbnot/verb, for anything that is not CRUD:/alarms/{id}:ack,/components/{name}:apply,/views/{id}:run. The verb is also the permission::ackis gated byalarm:ack, so the route and the authorization check share one vocabulary. The self-scoped/auth/mefamily 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}/sessionsandPOST /principals/{id}/sessions/{sid}:revoke(both gated byprincipal:revoke-session) let an administrator list and end another principal’s sessions, the revoke bounded to that target and behind the owner takeover guard.POST /principals/{id}/sessions:revokeAll(a{ purpose }body, same gate and guard) bulk-ends all of one kind at once, returning the count. - Singular kind sub-segments for the typed families:
/rules/calc,/datapoints/metric,/location-types. - Collection-level custom methods carry the colon on the collection, not a member:
POST /systems:checkName(also/components:checkName,/locations:checkName) is an advisory precheck for a technical-name rename, returning{ valid, available, reason }. It is gated by<entity>:updatelike a rename, but its availability answer is deliberately scope-blind: thenameuniqueness 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
Section titled “Lists: filter, order, page”A list takes filter, order_by, page_size (capped by a server maximum), page_token, and fields:
- Cursor pagination, never offset. A list returns a
next_page_token; the client echoes it on the next call. The token is opaque and stable under concurrent inserts, where an offset would skip or repeat rows. filteris one Omniglass expression over the resource’s fields, the same language as rule scopes and dynamic groups, so an operator learns it once.filter,order_by, andfieldsname 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).- Every list runs through the scoped gateway, so results are already scope-filtered: a list never returns a row outside the caller’s visible set, and the page count is over visible rows only.
Partial responses: field masks
Section titled “Partial responses: field masks”The fields parameter selects a subset of the response (a read field mask, AIP-157); the default is the
full resource. PATCH carries a write mask implicitly: only the fields present in the body change,
so a partial update never clobbers an omitted field.
Errors: one problem+json envelope
Section titled “Errors: one problem+json envelope”Every error is RFC 9457 application/problem+json: type, title, status, detail, instance,
plus an Omniglass code (a stable machine string) and, for validation, a violations array of
{field, message}. One shape, so the generated client and the CLI render every failure uniformly. The
status mapping:
| Status | Meaning |
|---|---|
| 400 | malformed request (bad JSON, an undeclared param) |
| 401 | unauthenticated |
| 403 | action denied on this target: the principal lacks the capability entirely, or can read the target but not perform this action on it (below) |
| 404 | not found, including out-of-read-scope (below) |
| 409 | conflict: PK collision, a stale conditional write, or an idempotency replay mismatch |
| 422 | semantic validation (the :apply unmet-required-inputs case) |
| 429 | throttled |
The 403/404 split is three-way, by where the target sits in the caller’s
per-action scope. (a) The action is in no grant the principal
holds: 403, capability missing entirely. (b) The target is in the caller’s read-scope but outside
visible_set(P, action) for the requested action (the principal can GET it but cannot :ack it):
403, which leaks nothing because the caller can already read the row. (c) The target is outside the
caller’s read-scope entirely: 404, so the API never discloses that an entity exists outside the
caller’s visible set. Out-of-read-scope is the only 404 case; a readable-but-not-actionable target is a
403, never a 404.
Idempotency and concurrency
Section titled “Idempotency and concurrency”Idempotency-Keyis accepted onPOSTand 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.
Long-running operations: the action is the handle
Section titled “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
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 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
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 are identity and 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-permissionextension (for examplerole:read:adminonGET /roles), soapi/openapi.jsonis a machine-readable map of the authz contract, and the set of all stamps is the permission universe the Roles view reports.
The collection surface: nodes, interfaces, tasks
Section titled “The collection surface: nodes, interfaces, tasks”The 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 section describes.
| Method | Path | Permission |
|---|---|---|
| GET | /nodes | node:read |
| GET | /nodes/{name} | node:read |
| POST | /nodes | node:create |
| PATCH | /nodes/{name} | node:update |
| DELETE | /nodes/{name} | node:delete |
| POST | /nodes/{name}:enroll | node:enroll |
| POST | /nodes:claim | none (public) |
| GET | /interfaces | interface:read |
| GET | /interfaces/{id} | interface:read |
| POST | /interfaces | interface:create |
| PATCH | /interfaces/{id} | interface:update |
| DELETE | /interfaces/{id} | interface:delete |
| GET | /tasks | task:read |
| GET | /tasks/{id} | task:read |
| GET | /components/{name}/reachability | component:read |
| GET | /components/{name}/events | component:read |
The node custom methods are the day-one enrollment handshake. POST /nodes/{name}:enroll mints (or
re-mints) the node’s enrollment token and returns it once; the server stores only its hash and never
logs it, so a re-enroll invalidates the previous token. It is gated by node:enroll, the verb-is-the-permission
rule. POST /nodes:claim is the node-facing side of the exchange: a node presents its token and receives
its NATS credential (url, username, password). It is the surface’s one public route, unauthenticated
because the token itself is the authentication, so it carries no permission and an invalid token is a
401 (a claim must not disclose which nodes exist). A node is estate-wide, so node:read and node:create
require an all-scope grant, not a tree-scoped one.
The interface is authored; the task is derived. An interface is addressed by a surrogate id and is
named by its protocol: its name derives from its interface_type and is unique within its component
(so create takes a type, not a name, and a duplicate protocol on one component is a 409). Creating an
interface derives its one poll task, so the task surface is read-only (GET /tasks, GET /tasks/{id}):
there are no task create, update, or delete routes and no task:create / task:update grants. A task references
its interface by interface_id, its id is content-addressed over its interface, mode, and spec, and it
carries no node column: its placement projects from the interface. An interface belongs to a component
(or is server-hosted, which needs an all-scoped grant), and a task belongs to an interface, so both inherit the
component’s scope: an out-of-read-scope component’s interface or task is a
non-disclosing 404, exactly the 403/404 split above.
The reachability read is a typed composed read, not yet a view. GET /components/{name}/reachability
composes, per interface, the latest verdict state (interface.reachable), the probe-layer signals that
compose it (the raw icmp/tcp metrics), and the recent verdict transitions the availability strip reads.
It is gated by component:read and scope-injected through the component, so an out-of-scope component is a
non-disclosing 404 and the datapoint reads only ever run on a verified, in-scope component. It is a
hand-written typed GET, an early and deliberate exception to reads beyond one resource are
views, 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),
newest first, bounded to the last 24 hours and capped at 200 rows. Each row carries its ts, the property
key (e.g. syslog.line), the instance discriminator, the message, optional structured attributes,
its provenance (observed for direct collection), and the source interface type. It is gated by
component:read and scope-injected through the same GetComponent gate as the reachability read, so an
out-of-scope component is the same non-disclosing 404 and the event read only ever runs on a verified,
in-scope component. Like reachability, it is a hand-written typed GET standing in until the ViewResult
framework lands.
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), and its routes are a worked instance of the conventions above:
the AIP resource plus a :verb custom method, the verb-is-the-permission rule, the implicit PATCH
write mask, same-transaction audit, and a scoped read. The registry and the directory read ride the
viewer read floor (secret:read, which *:read satisfies);
the three writes gate on secret:create / secret:update / secret:delete; the plaintext decrypt
gates on secret:reveal, a permission the *:read floor does not carry, so a plain “read
everything” grant sees only masks and only admin (secret:*) and owner (>) reveal. Every
:reveal writes an audit row (verb reveal) in the same call.
GET /secret-typeslists the shape registry, each{id, name, display_name, official, fields:[{name, type, secret, origin}]}(secret:read).GET /secretsis the all-scope admin directory ({secrets: [secret]}); like the principal directory it needs an all-scope grant, and a non-all scope is a 403 (secret:read).POST /secretscreates one from{name, secret_type, owner_kind: platform|location|system|component, owner?, fields}(201,secret:create); aplatformsecret needs an all-scope grant andplatform:create(the install-wide tier permission below).PATCHandDELETEon a secret that sits at the tier likewise takeplatform:update/platform:delete.PATCH /secrets/{id}re-seals the givenfields, 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}:revealreturns 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): 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 /variablesis 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 /variablescreates one from{name, value_type: string|int|float|bool|json, owner_kind: platform|location|system|component, owner?, value}(201,variable:create); aplatformvariable needs an all-scope grant andplatform:create, and thevalueis validated againstvalue_type.PATCHandDELETEon a variable at the tier likewise takeplatform:update/platform:delete.PATCH /variables/{id}replaces thevalue(validated against the fixedvalue_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) 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 /tagslists the governed key vocabulary ({tags: [tag]},tag:read); atagbody is{id, name, applies_to, propagates}.POST /tagsmints a key from{name, applies_to?, propagates?}(201,tag:create, all-scope); the name is normalized to a lowercase identifier (a 422 otherwise),applies_tois an entity-kind allow-list (empty = universal), andpropagatesdefaults 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}: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]}, the entity’s:read).POST /{components,systems,locations,nodes}/{name}:setTagbinds a value from{key, value}on the entity; 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 (like the principal lifecycle) 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; the component must be in the caller’s component read-scope).- The directory list routes (
GET /components,/systems,/locations) each carry aneffective_tagsmap ({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 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.
A tagBinding body is {key, value, owner_kind, owner_id?, owner_name?}.
The component-classification catalogs (core entities)
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 <resource>:create / <resource>:update /
<resource>: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):
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 /vendorslists the registry, ordered alphabetically by display name ({vendors: [vendor]},vendor:read).POST /vendorsmints 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 /driverslists the registry, ordered alphabetically by display name ({drivers: [driver]},driver:read).POST /driversmints 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, and a system role requires a set of them.
GET /capabilitieslists the registry, ordered alphabetically by display name ({capabilities: [capability]},capability:read).POST /capabilitiesmints 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) 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 /productslists the registry, ordered alphabetically by display name ({products: [product]},product:read). Each row carries its vendor, driver, kind, and capabilities.POST /productsmints a custom product from{id, display_name, kind?, vendor_id?, driver_id?, parent_product_id?, capabilities?}(201,product:create, admin).GET /products/{id}reads one, with its capabilities (product:read).PATCH /products/{id}updates{display_name?, kind?, vendor_id?, driver_id?, parent_product_id?, capabilities?}(product:update, admin);capabilities, when given, replaces the whole set.DELETE /products/{id}removes a custom product (204,product:delete, admin); an official row is refused (422), and a product still referenced by a component is refused (409).
A product body is
{id, name, display_name, kind, vendor, vendor_id, driver, driver_id, parent_product_id, capabilities, official}.
The vendor and driver handles read the referenced registry’s current name beside its uuid. An unknown
vendor / driver / parent / capability reference is a 422.
A standard (core entities) 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 /standardslists the catalog, ordered alphabetically by display name ({standards: [standard]},standard:read).POST /standardsmints 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).
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>. 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). 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
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, 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. 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}, gatedtype:read/:update/:delete(the location type registry is still atyperegistry, 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-typescollection.
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
Section titled “Roles: a system declares a slot, a component fills it”A system role 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: /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. An unknown impact is a 422. Gating follows the owner:
GET /standards/{id}/rolesplusPUT/DELETE /standards/{id}/roles/{role}, gatedstandard: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}, 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, not on the system that inherits it.GET /systems/{name}/rolesis the resolved read ({system, roles: [effectiveRole]}), gatedsystem:read. Each row is the declaration (including itsimpact) plusfrom_standard(inherited, or declared on the system),assigned_to(the component names filling it here),assigned, andunderstaffed(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), andDELETEtakes it out (204; a component that was not filling the role is a 404). Both gate onsystem:update.GET /components/{name}/capabilitiesreturns the resolved set ({component, capabilities}, gatedcomponent: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), andDELETEclears the fact so the component falls back to its product (204; clearing a fact it never declared is a 404). Both writes gate oncomponent: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, speakerThe 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, 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
Section titled “Health: the verdict, and why”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}/alarmslists them newest first ({component, alarms: [alarm]},component:read), the active set by default and the whole history withinclude_cleared. Analarmbody is{id, component, severity, message, capabilities, raised_at, cleared_at?, active}.POST /components/{name}/alarmsraises one from{severity, message?, capabilities?}(201,component:update).severityisinfo/warning/critical;capabilitiesis 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}.verdictishealthy/degraded/outage.rolesis every role the system needs filled, each{name, display_name, impact, required, quorum, satisfying, impaired, assigned_to, degraded, alarms}:satisfyingcounts the assigned components that can currently fill it,degradednames the required capabilities an active alarm has taken away, andalarmsis the alarms that took them. An impaired role with an emptydegradedis short-staffed, not broken, which is 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. The system read explains the rest, so the location report stays a map.transitionsis 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).
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 unlike a secret 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, 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.
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 /filesis the directory ({files: [file]}), sensitive files omitted below the admin tier (file:read).POST /filescreates 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. Asensitive: truefile 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}:downloadreturns{name, content_type, content (base64)}, the blob read back and its hash verified (file:read).DELETE /files/{id}removes the handle (204,file:delete); the blob is freed in the same transaction when no other handle references it (dedup-aware, so storage is reclaimed), and a blob still shared by another handle is kept.
A file body is {id, name, content_type, size, sha256, sensitive, created_at}; the sha256 is the
content address of the blob it points at, so two handles over identical bytes share one blob.
Reads beyond one resource are views
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. 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
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
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, not a silent edit. Because the OpenAPI 3.1 document is generated
from the Go structs and the clients are generated from that, the contract cannot drift from the
implementation: a drift check fails the PR if a route changed without regenerating.
Also an MCP surface
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, so an AI agent 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. 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 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), so its reach is exactly that principal’s grants,
scoped and audited like any caller (AI).
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: 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 and on the node 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
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, 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 (the doctrine and the generation pipeline),
messaging (the sibling NATS subject contract and the bus),
identity and access (permission + scope), audit
(the write-time record), UI (the views BFF and the renderer contract), and
expressions (the filter language).