Infrastructure nodes
packs:infrastructure
An infrastructure node is one machine-shaped row in the estate tree:
a host, hypervisor, network device or link registered under a project,
environment or asset, living at <parent path>/infrastructure/<code>.
Where the estate nodes themselves (opschain docs getting-started/1-1-projects-environments-assets-and-hosts) are the things changes target and
properties converge over, an infrastructure node is an inventory
record with a lifecycle: identity (code, name), indexed address
columns, a closed type, an estate-membership state, discovered facts and
its own properties document. It exists so a host operated by name has
one home — the connection profile, the facts and the lifecycle state —
instead of user@host strings scattered through workflows. The kind is
engineered for tens of thousands of nodes per instance: hot columns are
first-class and indexed, lists are keyset-paginated with server-side
filters, and registration arrives in bulk.
The node, attribute by attribute
| Attribute | Meaning |
|---|---|
node_path / parent_path | The node's own path (…/infrastructure/<code>) and its parent — a project, environment or asset. |
code | Lowercase letters, numbers and underscores, at most 50 characters; unique among the parent's infrastructure. |
name / description | Display name (max 50) and description (max 1000). |
infrastructure_type | One of the closed registry: host, hypervisor, network_device, link. |
address / fqdn | Network address and fully qualified domain name. Indexed but unconstrained: a same-environment duplicate address warns, never refuses (duplicate_address on the row, a warning on reads). |
lifecycle_state | registered or deregistered (the manual membership states), or one of the reserved integration-asserted states provisioned / destroyed. |
archived | Shelved but still a member — set and cleared by update. |
last_facts_at | When discovered facts last landed; reads render freshness as last-observed ("facts last collected …"), never a live judgement. |
last_used_at | When a run step last reached the node through a transport session — stamped at dispatch when a host_ref-bound step's session is admitted (a refused session is not a use); unset while no run has touched the node. Last-observed like facts: it says when work last reached the host, never whether the host is up. |
properties_id / settings_id | The node's own properties and settings document ids. The long tail of configuration lives in the properties document, served on the get. |
The properties document is schema-checked on every write:
- No credential material, anywhere. Keys like
password,private_key,tokenorcredentialrefuse by name — connection custody belongs to transport providers, and facts writes apply the same refusal. - A flat per-type reference vocabulary. A hypervisor's
host_ref, a device'smgmt_host_refand a link'sendpoint_a_ref/endpoint_b_refname sibling infrastructure node paths (the host-targeting keys must name ahost); any other*_refkey refuses by name.rpc.provider_ref— the transport-provider binding, a provider code string — is recognised on every type.
The duplicate-address warning scope is the environment subtree for environment- and asset-level nodes, the project for project-level ones.
Lifecycle
Assertion follows authority. The manual lane — API, CLI and the pack's hand-over actions alike — asserts estate membership only:
- Register creates the node
registeredunder its parent. Registering a code a deregistered node holds revives the same row (same id, history intact) rather than creating a new one. - Update changes attributes in place; omitted fields preserve stored
values, and a replacement properties document is validated like
registration. Facts land the discovered-facts object under the
properties document's
factskey and stamplast_facts_at. - Deregister closes membership — the soft-close posture: hidden from list defaults and estate reads, full history and events retained, revivable by re-registering the code.
- Bulk upsert registers or updates a batch under one parent in one set-based write — the lane sync jobs use, since inventories arrive in batches. New codes are created, existing codes updated in place; a per-item validation failure lands in that item's result without failing the batch, and a batch over 10,000 items refuses by name.
The provisioned and destroyed states stay in the schema as reserved
slots for the integration that performs those operations; nothing in the
manual lane writes them. What the infrastructure
pack's hand-over automates is exactly this
membership lane: an action that makes a machine exist calls
infrastructure:host_register (the ordinary register verb, so the
machine becomes estate content), and infrastructure:host_deregister
closes membership when it leaves — never an operational state.
How a change targets a host
A change still targets an estate node; the host enters through the
host_ref input on a transport-session step (the infrastructure
pack's dialing actions). At dispatch the engine resolves host_ref to
the registered host node and re-points the whole transport seam at it:
the provider is selected at the host's path (its own
rpc.provider_ref, else the deepest-wins scope binding there, else the
single enabled provider), and the caller's transport grant is enforced
against the host node's path — the grant covers the host, not the
declaring scope. Every miss refuses by name: an unresolved expression,
an unknown node, a non-host type, and a deregistered or destroyed host
each fail the step loudly. The dial profile defaults from the host's
rpc.target, rpc.hops and rpc.tofu_store properties.
Expressions enumerate the registry: estate(<path>).infrastructure
lists a node's infrastructure, and the two-argument
estate('<path>', '<filter>') form is a filtered enumerate over the
hot-column fields (code, name, path, type, address, fqdn,
lifecycle_state — never the property bag), the fan-out idiom for
"every host under prod". See opschain docs expressions.
Why hosts carry no catalog
An infrastructure node never carries an action catalog of its own.
There is no opschain actions list against a host, no per-host action
files, and no way to create a change on an infrastructure node. That
is a deliberate shape, not a gap, and it rests on four facts about how
the platform governs execution.
One governed door into execution. Everything OpsChain runs is a change (or a workflow spawning changes), created against an estate node whose actions come from its pinned template version — and run creation is where every control attaches: approval policies hold matching runs before they start, declared inputs are typed and validated before anything exists, entitlement gates refuse by name, plans preview the step tree, and the whole run lands in the events feed. A second catalog hanging off every host would be a second door into execution, and every one of those controls would have to be built, configured and audited twice. Hosts have no door of their own, so work can only reach a host through the one lane that is already governed.
What runs is kept separate from where it reaches. A template
version pins an exact commit of a repository, so what an action does is
a reviewable git fact — diffable in review, resolved to a stored commit
at write time, lockable for release. An infrastructure node answers the
other question: where work lands — the address and connection profile
(rpc.target, rpc.hops), the transport-provider selection at the
host's path, and the caller's transport grant enforced against that
same path. Folding a catalog into the host would collapse that
separation and put executable content on what is, by design, an
inventory record.
A host has no pinned source to derive a catalog from. An asset's catalog is derived from its template version's resolved commit — remove the pin and there is nothing reviewable left to derive. An infrastructure node carries identity, address columns, a lifecycle and a validated properties document, but no template identity and no repository behind it. A host catalog would therefore have no versioned, reviewable source: nothing to diff, nothing to lock, nothing an approver could read before it ran. The properties document refuses credential material by name for the same reason — an inventory record holds facts about the machine, never the means to act on it.
The host still governs — as an authorisation point. Host-lifecycle
operations (patch a fleet, reboot a box, move files, gather facts) are
expressed as host-targeted pack actions: the change still runs from an
asset's catalog, and the host_ref input names the host. At dispatch
the transport provider is resolved at the host's path and the
caller's transport grant is enforced against the host node's own path —
so the host contributes authority, identity and audit (path RBAC on
reads, the transport session events, the registry's own event family)
to every operation that touches it. It contributes everything except
executable content.
The practical consequence: "run something on this host" is always "run
a host-targeted action from an asset's catalog, with host_ref naming
the host". The worked exercise in opschain docs getting-started/6-4-hosts-without-a-runner builds exactly that
shape end to end — a template, an asset pinned to it, and a fleet of
registered hosts the actions fan out over.
The inventory summary
The questions an operator or an auditor asks of an inventory are
aggregate ones — how many hosts per project, which are destroyed but
still present, whose facts are stale, which addresses collide, which
registered hosts no run has touched in a month, what an integration
provisioned or destroyed this week. SummarizeInfrastructure answers
them in one read, computed set-based in the database so a large estate
never crosses the wire; the GUI dashboard's Infrastructure panel is
its consumer, shown only under the packs:infrastructure entitlement.
The summary covers a scope — the whole estate the caller may read when
scope_path is empty (a principal whose read grant covers / sees
everything; anyone else sees exactly the projects their grant covers),
or one project, environment or asset subtree rolled up (an unknown or
unreadable scope answers not found). It serves:
-
The matrix — one cell per project ×
infrastructure_type×lifecycle_statewith a non-zero count, archived rows counted in cells of their own so they can be shown apart. Deregistered rows stay in the matrix (they are history, not exceptions). -
The exceptions — totals over the scope's unarchived, never-deregistered rows, each with a list filter that reproduces it exactly, so a count is always a click away from its rows:
Exception Counts List filter stale_factsregistered/provisioned rows whose last_facts_atis older thaninfrastructure.stale_facts_after, or never setlast_facts_beforeduplicate_addressesregistered/provisioned rows whose address another same-environment node carries duplicate_address = trueunusedregistered/provisioned rows whose last_used_atis older thaninfrastructure.unused_after, or never setlast_used_beforedestroyed_presentrows asserted destroyedthat were never deregisteredlifecycle_state = destroyedrecently_changedrows with an api:infrastructure:provisionedorapi:infrastructure:destroyedevent in the last 7 dayschanged_since -
The thresholds it judged against (
stale_facts_after,unused_after, the fixed 7-dayrecently_changed_within) and the instant of judgement (computed_at), so a client rebuilds the matching filters without guessing the server's clock.
Every figure is last-observed: stale means "facts last collected before the threshold", unused means "no run step reached the node within the window" — never a live probe of the machine.
Settings and configuration
Two stored global settings tune the summary's exceptions (opschain docs settings-reference), both Go duration strings read live on every
summary; an absent, malformed or non-positive value falls back to the
default:
| Key | Default | Meaning |
|---|---|---|
infrastructure.stale_facts_after | 168h (7 days) | Facts older than this count as stale. |
infrastructure.unused_after | 720h (30 days) | A registered node no run step reached within this counts as unused. |
Nothing else tunes the component: the bulk-batch cap (10,000 items), the
list page caps (default 100, maximum 1000 rows) and the summary's 7-day
recently-changed window are built in. The estate read model lives in
PostgreSQL: on the embedded SQLite store every infrastructure RPC
answers the typed ERROR_REASON_STORE_POSTGRES_REQUIRED error.
Governance and audit
Authorisation is ordinary path RBAC, 404-on-deny throughout. Lists apply
per-row read pushdown (an unreadable node never appears); gets need read
on the node's path; the summary pushes the caller's read grant into the
aggregate (literal path prefixes bound the SQL; a grant no prefix set
reproduces falls back to deciding the scope's rows one by one), so a
count never includes a node the caller could not list. Registering (and bulk upsert) needs update on the
parent's …/infrastructure rule path and on each new node's own path;
update, facts and deregister need update on the node's path. List and
get responses carry the caller's own standing (authorisations) so
read-only surfaces can drop their edit affordances honestly.
Every write is audited as an ordinary event (opschain docs events):
api:infrastructure:registered— a node registered (a revive records the same event).api:nodes:update— a node updated (the same family the estate node writes record).api:infrastructure:bulk_upsert— a batch ran, with item, created, updated and failed counts.api:infrastructure:facts— discovered facts landed.api:infrastructure:deregistered— membership closed.api:infrastructure:provisioned/api:infrastructure:destroyed— reserved for the integration that asserts those states; nothing currently emits them. The inventory summary's recently-changed exception reads exactly these two types, so it stays at zero until an integration asserts a state.
The registry itself is base platform — registering, reading, updating
and deregistering nodes carries no entitlement gate, so inventory stays
fully available on licence lapse. What is gated is operating hosts:
the infrastructure:* actions require packs:infrastructure at run
creation, with the polite-lapse posture described on
the infrastructure pack page.
Related CLI and API
The CLI family (opschain docs cli/infrastructure):
opschain docs cli/infrastructure-list— filters and pagination.opschain docs cli/infrastructure-show— one node with its properties.opschain docs cli/infrastructure-register— register one node.opschain docs cli/infrastructure-update— attributes or properties.opschain docs cli/infrastructure-bulk-upsert— batch from a YAML file.opschain docs cli/infrastructure-deregister— close membership.opschain docs cli/infrastructure-facts— record discovered facts.
The API service is InfrastructureService (v2 — opschain docs api-v2): ListInfrastructureNodes, GetInfrastructureNode,
SummarizeInfrastructure, RegisterInfrastructureNode,
BulkUpsertInfrastructureNodes, UpdateInfrastructureNode,
DeregisterInfrastructureNode and RecordInfrastructureFacts. The GUI's
Infrastructure page registers in bulk through Import nodes… — one
line per node as code,type[,address[,fqdn[,name]]] or a JSON items
array — over BulkUpsertInfrastructureNodes, each item's outcome and
warnings reported after the batch.