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 (the name-only forms are listed under every resource answers to its name) and by its uuid always (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/{group}) 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, 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:admin on 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:

ResourcePath parameterAccepts beside the uuidExample
location, system, component{name}a bare name, a dotted addressboi.17c.415a.$comp.display-1
endpoint{endpoint}<component>/<endpoint name>, the component in any form aboveboi.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 $platformdemo-panel-login, $platform/demo-panel-login
file{file}its filenamecodec-firmware-2.1.4.txt
principal group{group}its namenight-shift
principal, group member{id}, {principal}a username, a service account’s namealice
a grant’s scope rootscope_id (body)a bare name, a dotted addressboi.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.

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, 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}/reachability composes, per endpoint, the latest verdict state (endpoint-reachable), the probe-layer signals that compose it (the raw icmp/tcp metrics, and for a udp endpoint a request layer: its request answered, off endpoint-responsive, timed by udp-response-time only while answered, #1037), the recent verdict transitions the availability strip reads, and last_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 its ts is when it last changed, and a driver endpoint has no verdict at all; last_seen is the one freshness every endpoint has. Each row also carries the endpoint’s id (the address :retry takes), its held session while it is configured to hold one (a standing listen task, or a driver declaring a session: the latest session-state, when it changed, and the session-reason beside it, cut to 500 characters), and the rejection a terminal refusal holds (the rung and class of endpoint-rejection; the connection fingerprint it carries is never served), which the console’s session pill and ladder render (#1059). The rejection is served whether or not a session is held, since a reachability probe’s refusal gates an endpoint too, with its reason (the endpoint-rejection-reason the node publishes beside the gate, cut to 500 characters like a session’s) and ts (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 adds pinned_key and offered_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 exact offered_key it showed, never a fingerprint parsed out of the reason. A tls endpoint’s row also carries tls: 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:push refuses 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}/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 endpoint type. An event a rule derived also carries derived_by_rule (the rule’s name) and, when the same transaction opened or resolved an alarm, its alarm_id (#949); GET /systems/{name}/events carries 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}/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). 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).

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 /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, 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}/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 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}/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 names 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 above high 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.

  • 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: entries of {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, 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.
  • GET /systems/{name}/members (system:read) carries each member’s own component_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).

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

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