Skip to content

API

Partial

The 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.

Everything lives under /api/v1. The path shape is derivable, not special-cased:

  • Plural resource collections, standard methods addressed by the entity’s name where it has one and by its uuid where it does not (AIP-style): POST creates (409 when the name is already taken in its placement bucket; the uuid primary key is the server’s to mint and cannot collide), GET reads, PATCH partial-updates (AIP-134), DELETE removes. No upsert shortcuts. On location, system, and component, 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, renameable name (the identifier an operator reads and types). A create takes the name; 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 PATCH field. POST /components/{name}:rename (also /systems, /locations, /principal-groups/{id}) moves the name, gated by <entity>:rename rather than <entity>:update, and the PATCH body of those four carries no name at 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}:move and /systems/{name}:move take {location?, parent?} (at least one required, 422 otherwise); /locations/{name}:move takes {parent} only, with no clear-to-root branch (a location has never had one). Both follow the house three-state convention (omitted unchanged, "" clears, a name sets), gated by <entity>:move rather than <entity>:update, and the PATCH body of all three carries neither parent nor location any more. Clearing parent to root under :move requires an all-scoped grant, the same authorization creating a root row already requires: a PATCH used to skip that check entirely on the clear branch. :move writes its own audit verb, move, and never recomputes health except where the rollup genuinely depends on the placement it changes: a system’s own location field, which keeps moving health at both ends exactly as its old combined PATCH did, and a location’s parent, which carries its whole subtree’s contribution between two ancestor chains and recomputes both (ADR-0092).
  • Custom methods carry a colon, :verb not /verb, for anything that is not CRUD: /components/{name}/commands:issue, /auth/me/sessions/{id}:revoke, /nodes:claim. The verb is also the permission: :issue is gated by command:issue, so the route and the authorization check share one vocabulary. The self-scoped /auth/me family 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 same parent/location fields 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 via GET, because the check queries the target bucket directly rather than reading the row.
  • A principal is addressable by uuid or username. Every /principals/{id} route resolves either server-side (a value that parses as a uuid is used directly, else a username lookup, an unknown one a 404). The uuid stays the stable identity (a username is mutable, nothing keys on it); service principals have no username and stay uuid-addressed.

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.

Today the full resource is the only read behavior. The write mask is built, AIP-134 exactly (ADR-0091):

  • Omit update_mask and the mask is implied: the fields the body populated (a non-empty value) change, and an omitted field is left alone. This is what every PATCH in the tree has always done, so it is the behavior of all of them.
  • Send update_mask and 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 a PUT: 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.

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:

StatusMeaning
400malformed request (bad JSON, an undeclared param)
401unauthenticated
403action denied on this target: the principal lacks the capability entirely, or can read the target but not perform this action on it (below)
404not found, including out-of-read-scope (below)
409conflict: PK collision, a stale conditional write, or an idempotency replay mismatch
422semantic validation (the :apply unmet-required-inputs case)
429throttled

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.

  • Every write emits an audit_log row in the same transaction as the change, a gateway responsibility, so it cannot be forgotten or bypassed.
  • Every route declares its permission (checked before the handler runs) and every query carries the caller’s scope (injected by the gateway), both 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-permission extension (for example role:read:admin on GET /roles), making api/openapi.json a machine-readable map of the authz contract; the set of 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: the standard family per resource (/nodes, /interfaces, /tasks) plus the :verb custom methods and the per-component reads (reachability, reconciliation, events), all component:read-gated. 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.

The interface is authored; the task is derived. An interface is addressed by a surrogate id and named by its protocol: name derives from interface_type, unique within its component (create takes a type, not a name; 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}): no task write routes or grants. A task references its interface by interface_id, its id content-addressed over interface, mode, and spec, with no node column: placement projects from the interface. An interface belongs to a component (or is server-hosted, needing an all-scoped grant), a task 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.

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}/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.
  • GET /components/{name}/events is the log-kind mirror: 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 ts, the event_type name it is typed by (on the wire as key, e.g. call-started) with its event_type_id beside it, origin (caught/caused/derived/scheduled), instance, message, optional attributes, provenance (observed for direct collection), and the source interface type.
  • GET /components/{name}/reconciliation pivots want/told/is over the series: per declared property, the want (the current declared value, with the contract default coalesced in), the told (the intended value a command set), and the is (the latest observed value), 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). 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).

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/{id}, POST /secrets/{id}:reveal; 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/{id} replaces the value (variable:update), and DELETE /variables/{id} 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 /tags lists the governed tag vocabulary ({tags: [tag]}, tag:read); a tag body is {id, name, applies_to, propagates}, the name on the ordinary entity name rule (lowercase letters, digits, and hyphens, at most 100 characters, a uuid-shaped name refused), a 422 otherwise, applies_to an entity-kind allow-list (empty = universal), propagates default true. POST /tags mints a tag (201), PATCH /tags/{name} revises it (the name is fixed), and DELETE /tags/{name} removes it, cascading its bindings (204): tag:create / tag:update / tag:delete, all-scope.
  • POST /tags/{name}:setPlatform sets the platform-tier value for a key from {value}; POST /tags/{name}:clearPlatform removes it (204). A platform binding has no owning entity, so it gates on tag:update plus platform:update (the install-wide tier permission below).
  • GET /{components,systems,locations,nodes}/{name}:listTags lists the bindings set directly on one entity ({tags: [tagBinding]}, each {key, value, owner_kind, owner_id?, owner_name?}, the entity’s :read).
  • POST /{components,systems,locations,nodes}/{name}:setTag binds a value from {key, value}; the key must exist and its applies_to must admit the kind (a 422 otherwise). Setting a value is the entity’s own write, so it gates on the entity’s :update (component:update and friends), not a tag permission; POST /{...}/{name}:removeTag from {key} removes the binding (204). Bindings are custom methods on the entity rather than a nested collection, so the generated CLI stays collision-free.
  • GET /components/{name}/effective-tags is the cascade for one component: each a resolvedTag ({key, value, owner_kind, owner_id?, owner_name?, band, depth, winner}), keys unioning and values overriding most-specific-wins, with the winner and shadowed candidates. A non-propagating key resolves only from a binding on the component itself (component:read, read-scoped).
  • The directory list routes (GET /components, /systems, /locations) each carry an effective_tags map ({key: winning_value}) on every row, resolved for the whole page in one batched query. A component resolves the full arc; a location resolves platform plus its location tree; a system resolves platform, its system tree, and the location it is placed at. Provenance lives in the per-entity effective-tags detail, not the row.

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: a kind of manufacturer / integrator / developer (default manufacturer, a 422 otherwise), and a website validated to an http/https scheme on write (a 422 for any other scheme, for example javascript:).
  • 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, a kind of device / app / service / vm (default device, a 422 otherwise), a required component_type, and an optional parent_product_id variant, the vendor and driver handles 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 optional parent_standard_id (an unknown parent a 422); a standard still referenced by a system is refused (409). The shipped standards are official: false, so unlike a seeded product they are fully editable (the seed model).

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}, gated product:read / :update / :delete; GET /standards/{id}/properties, PUT / DELETE /standards/{id}/properties/{property}, gated standard:read / :update / :delete.
  • GET /location-types/{id}/properties, PUT / DELETE /location-types/{id}/properties/{property}, gated location_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.

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}/roles plus PATCH / DELETE /standards/{id}/roles/{role}, gated standard: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}, gated system:update, for a role declared directly on one system. A role the system does not declare itself is a 404 here: an inherited role is withdrawn on the standard.
  • GET /systems/{name}/roles is the resolved read, gated system:read: the declaration (including impact, capacity, position_labels, alternate) plus from_standard, assigned_to (the component names filling it here, in position order), positions (each entry’s own 1-based position, index for index with assigned_to; not assumed dense, since an unassign leaves a gap rather than compacting), assigned, and understaffed (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); DELETE takes it out (204; a component not filling the role is a 404). Both gate on system:update.
  • POST /systems/{name}/roles/{role}:swapPositions exchanges 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-bar

That 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 system

the 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 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 a critical alarm 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}/alarms lists them newest first (component:read), the active set by default, the whole history with include_cleared, and the queue an operator actually works with unacknowledged (on its own, raised and unacknowledged; with include_cleared, also the incidents that came and went unattended).
  • POST /components/{name}/alarms raises one from {severity, message?, dedup_key?} (201, component:update). severity is info / warning / critical, driving the component’s own verdict (any active alarm degrades it, a critical one is an outage); dedup_key is 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}:acknowledge records that a human has seen it (200, alarm:acknowledge). It changes nothing else: cleared_at is 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.

The reports are one shape over two owners:

  • GET /systems/{name}/health (system:read) returns the verdict (healthy / incomplete / degraded / outage, in rank order, where incomplete is a role short because its hardware was never installed rather than because installed hardware is failing) plus roles: every role the system needs filled, where satisfying counts the assigned components currently occupying it (their own verdict is not outage; a degraded one still counts), down names the assigned components whose own verdict is outage, and alarms the active alarms on those. An impaired role with an empty down is short-staffed, not broken, a different job for the operator.
  • GET /locations/{name}/health (location:read) returns the same envelope with systems filled instead: every system placed anywhere beneath the location, with its verdict, as the drill-down.
  • transitions is the recorded edges over the last 30 days, oldest first, each {ts, verdict}: one entry per change, never a sample.

Both resolve their owner within the caller’s scope first (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).

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 the fleet canvas: every location flat with its parent and verdict, every system with its location and verdict, and one dot per component in each system. A dot is {component, name, verdict, primary, shared} and nothing else. The canvas paints a square per component across the whole fleet, so a component row per square is a fleet-sized payload on every paint; 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 gathered into bands, which is what lets a future grouping (by standard, by vendor, by tag) be a mapper rather than a second endpoint.
  • 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 canvas 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 the canvas 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/{id} returns one handle’s metadata; GET /files/{id}: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/{id} 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.

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 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).